Work on Tauri settings

This commit is contained in:
Peter Steinberger 2025-06-23 16:56:02 +02:00
parent 3351cc08c2
commit 2af9257562
11 changed files with 1988 additions and 557 deletions

View file

@ -2208,6 +2208,122 @@ pub async fn test_terminal(terminal: String, state: State<'_, AppState>) -> Resu
Ok(())
}
// Welcome flow specific commands
#[derive(Serialize)]
pub struct VtInstallationStatus {
pub installed: bool,
pub path: Option<String>,
}
#[tauri::command]
pub async fn check_vt_installation() -> Result<VtInstallationStatus, String> {
let installed = crate::cli_installer::check_cli_installed()
.unwrap_or(false);
let path = if installed {
Some("/usr/local/bin/vt".to_string())
} else {
None
};
Ok(VtInstallationStatus { installed, path })
}
#[tauri::command]
pub async fn install_vt() -> Result<(), String> {
crate::cli_installer::install_cli()?;
Ok(())
}
#[derive(Serialize)]
pub struct PermissionsStatus {
pub automation: bool,
pub accessibility: bool,
}
#[tauri::command]
pub async fn check_permissions(state: State<'_, AppState>) -> Result<PermissionsStatus, String> {
let permissions_manager = &state.permissions_manager;
// Check terminal access permission (closest to automation)
let automation_status = permissions_manager
.check_permission_silent(crate::permissions::PermissionType::TerminalAccess)
.await;
// Check accessibility permission
let accessibility_status = permissions_manager
.check_permission_silent(crate::permissions::PermissionType::Accessibility)
.await;
Ok(PermissionsStatus {
automation: automation_status == crate::permissions::PermissionStatus::Granted,
accessibility: accessibility_status == crate::permissions::PermissionStatus::Granted,
})
}
#[tauri::command]
pub async fn request_automation_permission(state: State<'_, AppState>) -> Result<(), String> {
let permissions_manager = &state.permissions_manager;
permissions_manager
.request_permission(crate::permissions::PermissionType::TerminalAccess)
.await?;
Ok(())
}
#[tauri::command]
pub async fn request_accessibility_permission(state: State<'_, AppState>) -> Result<(), String> {
let permissions_manager = &state.permissions_manager;
permissions_manager
.request_permission(crate::permissions::PermissionType::Accessibility)
.await?;
Ok(())
}
#[tauri::command]
pub async fn save_dashboard_password(password: String) -> Result<(), String> {
// Save password to keychain
crate::keychain::KeychainManager::set_dashboard_password(&password)
.map_err(|e| e.message)?;
// Update settings to enable password
let mut settings = crate::settings::Settings::load().unwrap_or_default();
settings.dashboard.enable_password = true;
settings.save()?;
Ok(())
}
#[tauri::command]
pub async fn open_dashboard(state: State<'_, AppState>, app: tauri::AppHandle) -> Result<(), String> {
// Check if server is running
if !state.backend_manager.is_running().await {
// Start server if not running
start_server(state.clone(), app).await?;
}
// Get server port from settings
let settings = crate::settings::Settings::load().unwrap_or_default();
let url = format!("http://127.0.0.1:{}", settings.dashboard.server_port);
// Open URL in default browser
open::that(url).map_err(|e| format!("Failed to open dashboard: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn finish_welcome(state: State<'_, AppState>) -> Result<(), String> {
// Mark welcome as completed
state.welcome_manager.skip_tutorial().await?;
// Update settings to not show welcome on startup
let mut settings = crate::settings::Settings::load().unwrap_or_default();
settings.general.show_welcome_on_startup = Some(false);
settings.save()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -394,6 +394,14 @@ fn main() {
// Welcome flow commands
request_all_permissions,
test_terminal,
check_vt_installation,
install_vt,
check_permissions,
request_automation_permission,
request_accessibility_permission,
save_dashboard_password,
open_dashboard,
finish_welcome,
])
.setup(|app| {
// Set app handle in managers

View file

@ -211,9 +211,11 @@ impl WelcomeManager {
tauri::WebviewUrl::App("welcome.html".into()),
)
.title("Welcome to VibeTunnel")
.inner_size(800.0, 600.0)
.inner_size(640.0, 560.0)
.center()
.resizable(false)
.decorations(false)
.transparent(true)
.build()
.map_err(|e| e.to_string())?;
}

View file

@ -0,0 +1,139 @@
import { html, css, LitElement } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('glowing-app-icon')
export class GlowingAppIcon extends LitElement {
static override styles = css`
:host {
display: inline-block;
position: relative;
}
.icon-container {
position: relative;
cursor: pointer;
transition: transform 0.2s ease;
}
.icon-container:hover {
transform: scale(1.05);
}
.icon-container:active {
transform: scale(0.98);
}
.app-icon {
width: var(--size, 128px);
height: var(--size, 128px);
border-radius: 27.2%;
position: relative;
z-index: 2;
transition: all 0.3s ease;
}
.glow {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: var(--size, 128px);
height: var(--size, 128px);
border-radius: 27.2%;
opacity: var(--glow-opacity, 0.3);
filter: blur(var(--glow-blur, 20px));
z-index: 1;
pointer-events: none;
animation: breathing 3s ease-in-out infinite;
}
.floating {
animation: float 3s ease-in-out infinite;
}
@keyframes breathing {
0%, 100% {
transform: translate(-50%, -50%) scale(1.15);
opacity: 0.2;
}
50% {
transform: translate(-50%, -50%) scale(1.25);
opacity: 0.4;
}
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
/* Light theme adjustments */
:host-context(.light) .glow {
opacity: 0.2;
filter: blur(15px);
}
:host-context(.light) .app-icon {
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
/* Dark theme adjustments */
:host-context(.dark) .app-icon {
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
`;
@property({ type: Number })
size = 128;
@property({ type: Boolean })
enableFloating = true;
@property({ type: Boolean })
enableInteraction = true;
@property({ type: Number })
glowIntensity = 0.3;
@state()
private iconSrc = '';
override connectedCallback() {
super.connectedCallback();
// Use the app icon from the public directory
this.iconSrc = '/icon_512x512.png';
this.style.setProperty('--size', `${this.size}px`);
this.style.setProperty('--glow-opacity', `${this.glowIntensity}`);
this.style.setProperty('--glow-blur', `${this.size * 0.15}px`);
}
private handleClick(): void {
if (this.enableInteraction) {
this.dispatchEvent(new CustomEvent('icon-click', {
bubbles: true,
composed: true
}));
}
}
override render() {
return html`
<div
class="icon-container ${this.enableFloating ? 'floating' : ''}"
@click=${this.handleClick}
>
<img
src=${this.iconSrc}
alt="VibeTunnel"
class="app-icon"
/>
<img
src=${this.iconSrc}
alt=""
class="glow"
aria-hidden="true"
/>
</div>
`;
}
}

View file

@ -4,6 +4,8 @@ import { TauriBase } from './base/tauri-base';
import { formStyles } from './shared/styles';
import './settings-tab';
import './settings-checkbox';
import './settings-select';
import './glowing-app-icon';
interface SettingsData {
general?: {
@ -13,9 +15,18 @@ interface SettingsData {
theme?: 'system' | 'light' | 'dark';
default_terminal?: string;
};
dashboard?: Record<string, unknown>;
dashboard?: {
password_enabled?: boolean;
password?: string;
access_mode?: 'localhost' | 'network';
port?: string;
ngrok_enabled?: boolean;
ngrok_token?: string;
};
advanced?: {
debug_mode?: boolean;
cleanup_on_startup?: boolean;
preferred_terminal?: string;
};
}
@ -192,6 +203,172 @@ export class SettingsApp extends TauriBase {
}
}
/* Form Elements */
.form-group {
margin-bottom: 16px;
}
.form-input {
width: 100%;
padding: 8px 12px;
font-size: 14px;
color: var(--text-primary);
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 6px;
transition: all 0.2s ease;
}
.form-input:hover {
border-color: var(--border-secondary);
background: var(--bg-hover);
}
.form-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.form-input[type="number"] {
width: 120px;
}
.form-text {
display: block;
font-size: 12px;
color: var(--text-tertiary);
margin-top: 4px;
}
.password-section {
margin-top: 12px;
}
label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 8px;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* About Tab Styles */
.about-content {
max-width: 600px;
margin: 0 auto;
text-align: center;
padding: 40px 20px;
}
.app-info-section {
margin-bottom: 32px;
}
.app-name {
font-size: 36px;
font-weight: 500;
margin: 24px 0 8px 0;
letter-spacing: -0.5px;
}
.app-version {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
}
.description-section {
margin-bottom: 32px;
}
.description-section p {
font-size: 16px;
color: var(--text-secondary);
line-height: 1.5;
margin: 0;
}
.links-section {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 48px;
}
.link-item {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
color: var(--accent);
text-decoration: none;
border-radius: 8px;
transition: all 0.2s ease;
width: fit-content;
margin: 0 auto;
}
.link-item:hover {
background: var(--bg-hover);
text-decoration: underline;
transform: translateX(4px);
}
.link-item svg {
flex-shrink: 0;
}
.credits-section {
padding-top: 24px;
border-top: 1px solid var(--border-primary);
}
.credits-label {
font-size: 12px;
color: var(--text-secondary);
margin: 0 0 8px 0;
}
.credits-links {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 12px;
}
.credit-link {
font-size: 12px;
color: var(--accent);
text-decoration: none;
transition: all 0.2s ease;
}
.credit-link:hover {
text-decoration: underline;
}
.separator {
font-size: 12px;
color: var(--text-secondary);
}
.copyright {
font-size: 11px;
color: var(--text-tertiary);
margin: 0;
}
/* Theme Variables */
:host {
/* Dark theme (default) */
@ -390,24 +567,18 @@ export class SettingsApp extends TauriBase {
<div class="setting-card">
<h3>Terminal</h3>
<div class="form-group">
<label for="terminal-select">Default Terminal</label>
<select
id="terminal-select"
class="form-select"
@change=${(e: Event) => {
const target = e.target as HTMLSelectElement;
this.handleSettingChange(new CustomEvent('change', {
detail: { settingKey: 'general.default_terminal', value: target.value }
}) as SettingChangeEvent);
}}
>
<option value="terminal" ?selected=${!this.settings.general?.default_terminal || this.settings.general.default_terminal === 'terminal'}>Terminal.app</option>
<option value="iterm2" ?selected=${this.settings.general?.default_terminal === 'iterm2'}>iTerm2</option>
<option value="warp" ?selected=${this.settings.general?.default_terminal === 'warp'}>Warp</option>
</select>
<small class="form-text">Choose your preferred terminal application</small>
</div>
<settings-select
label="Default Terminal"
help="Choose your preferred terminal application"
settingKey="general.default_terminal"
.value=${this.settings.general?.default_terminal || 'terminal'}
.options=${[
{ value: 'terminal', label: 'Terminal.app' },
{ value: 'iterm2', label: 'iTerm2' },
{ value: 'warp', label: 'Warp' }
]}
@change=${this.handleSettingChange}
></settings-select>
</div>
<div class="setting-card">
@ -420,25 +591,117 @@ export class SettingsApp extends TauriBase {
@change=${this.handleSettingChange}
></settings-checkbox>
<settings-select
label="Theme"
help="Choose your preferred color scheme"
settingKey="general.theme"
.value=${this.settings.general?.theme || 'system'}
.options=${[
{ value: 'system', label: 'System' },
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' }
]}
@change=${this.handleSettingChange}
></settings-select>
</div>
</div>
</div>
`;
}
private _renderDashboardTab(): TemplateResult {
return html`
<div class="tab-content ${this.activeTab === 'dashboard' ? 'active' : ''}" id="dashboard">
<h2>Dashboard</h2>
<div class="settings-grid">
<div class="setting-card">
<h3>Security</h3>
<settings-checkbox
.checked=${this.settings.dashboard?.password_enabled || false}
label="Password protect dashboard"
help="Require a password to access the dashboard from remote connections"
settingKey="dashboard.password_enabled"
@change=${this.handleSettingChange}
></settings-checkbox>
${this.settings.dashboard?.password_enabled ? html`
<div class="password-section">
<input
type="password"
placeholder="Enter password"
class="form-input"
@input=${(e: Event) => {
const input = e.target as HTMLInputElement;
this.handleSettingChange(new CustomEvent('change', {
detail: { settingKey: 'dashboard.password', value: input.value }
}) as SettingChangeEvent);
}}
/>
</div>
` : ''}
</div>
<div class="setting-card">
<h3>Server Configuration</h3>
<settings-select
label="Allow dashboard access from"
help="Control where the dashboard can be accessed from"
settingKey="dashboard.access_mode"
.value=${this.settings.dashboard?.access_mode || 'localhost'}
.options=${[
{ value: 'localhost', label: 'This Mac only' },
{ value: 'network', label: 'Local network' }
]}
@change=${this.handleSettingChange}
></settings-select>
<div class="form-group">
<label for="theme-select">Theme</label>
<select
id="theme-select"
class="form-select"
@change=${(e: Event) => {
const target = e.target as HTMLSelectElement;
<label>Server port</label>
<input
type="number"
class="form-input"
.value=${this.settings.dashboard?.port || '4022'}
@input=${(e: Event) => {
const input = e.target as HTMLInputElement;
this.handleSettingChange(new CustomEvent('change', {
detail: { settingKey: 'general.theme', value: target.value }
detail: { settingKey: 'dashboard.port', value: input.value }
}) as SettingChangeEvent);
}}
>
<option value="system" ?selected=${!this.settings.general?.theme || this.settings.general.theme === 'system'}>System</option>
<option value="light" ?selected=${this.settings.general?.theme === 'light'}>Light</option>
<option value="dark" ?selected=${this.settings.general?.theme === 'dark'}>Dark</option>
</select>
<small class="form-text">Choose your preferred color scheme</small>
/>
<small class="form-text">The server will automatically restart when the port is changed</small>
</div>
</div>
<div class="setting-card">
<h3>ngrok Integration</h3>
<settings-checkbox
.checked=${this.settings.dashboard?.ngrok_enabled || false}
label="Enable ngrok tunnel"
help="Expose VibeTunnel to the internet using ngrok"
settingKey="dashboard.ngrok_enabled"
@change=${this.handleSettingChange}
></settings-checkbox>
${this.settings.dashboard?.ngrok_enabled ? html`
<div class="form-group">
<label>Auth token</label>
<input
type="password"
placeholder="Enter ngrok auth token"
class="form-input"
.value=${this.settings.dashboard?.ngrok_token || ''}
@input=${(e: Event) => {
const input = e.target as HTMLInputElement;
this.handleSettingChange(new CustomEvent('change', {
detail: { settingKey: 'dashboard.ngrok_token', value: input.value }
}) as SettingChangeEvent);
}}
/>
<small class="form-text">Get your free auth token at <a href="https://ngrok.com" target="_blank">ngrok.com</a></small>
</div>
` : ''}
</div>
</div>
</div>
`;
@ -455,6 +718,118 @@ export class SettingsApp extends TauriBase {
`;
}
private _renderAdvancedTab(): TemplateResult {
return html`
<div class="tab-content ${this.activeTab === 'advanced' ? 'active' : ''}" id="advanced">
<h2>Advanced</h2>
<div class="settings-grid">
<div class="setting-card">
<h3>Terminal</h3>
<settings-select
label="Preferred Terminal"
help="Select which application to use when creating new sessions"
settingKey="advanced.preferred_terminal"
.value=${this.settings.advanced?.preferred_terminal || 'terminal'}
.options=${[
{ value: 'terminal', label: 'Terminal.app' },
{ value: 'iterm2', label: 'iTerm2' },
{ value: 'warp', label: 'Warp' }
]}
@change=${this.handleSettingChange}
></settings-select>
</div>
<div class="setting-card">
<h3>Advanced Options</h3>
<settings-checkbox
.checked=${this.settings.advanced?.cleanup_on_startup !== false}
label="Clean up old sessions on startup"
help="Automatically remove terminated sessions when the app starts"
settingKey="advanced.cleanup_on_startup"
@change=${this.handleSettingChange}
></settings-checkbox>
<settings-checkbox
.checked=${this.settings.advanced?.debug_mode || false}
label="Debug mode"
help="Enable additional logging and debugging features"
settingKey="advanced.debug_mode"
@change=${this.handleSettingChange}
></settings-checkbox>
</div>
</div>
</div>
`;
}
private _renderAboutTab(): TemplateResult {
return html`
<div class="tab-content ${this.activeTab === 'about' ? 'active' : ''}" id="about">
<div class="about-content">
<div class="app-info-section">
<glowing-app-icon
.size=${128}
.enableFloating=${true}
.enableInteraction=${true}
.glowIntensity=${0.3}
@icon-click=${() => window.open('https://vibetunnel.sh', '_blank')}
></glowing-app-icon>
<h1 class="app-name">VibeTunnel</h1>
<p class="app-version">Version ${this.systemInfo.version || '1.0.0'}</p>
</div>
<div class="description-section">
<p>Turn any browser into your terminal & command your agents on the go.</p>
</div>
<div class="links-section">
<a href="https://vibetunnel.sh" target="_blank" class="link-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.94-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" fill="currentColor"/>
</svg>
<span>Website</span>
</a>
<a href="https://github.com/amantus-ai/vibetunnel" target="_blank" class="link-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z" fill="currentColor"/>
</svg>
<span>View on GitHub</span>
</a>
<a href="https://github.com/amantus-ai/vibetunnel/issues" target="_blank" class="link-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" fill="currentColor"/>
</svg>
<span>Report an Issue</span>
</a>
<a href="https://x.com/VibeTunnel" target="_blank" class="link-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" fill="currentColor"/>
</svg>
<span>Follow @VibeTunnel</span>
</a>
</div>
<div class="credits-section">
<p class="credits-label">Brought to you by</p>
<div class="credits-links">
<a href="https://mariozechner.at/" target="_blank" class="credit-link">@badlogic</a>
<span class="separator"></span>
<a href="https://lucumr.pocoo.org/" target="_blank" class="credit-link">@mitsuhiko</a>
<span class="separator"></span>
<a href="https://steipete.me" target="_blank" class="credit-link">@steipete</a>
</div>
<p class="copyright">© 2025 MIT Licensed</p>
</div>
</div>
</div>
`;
}
override render() {
const tabs: TabConfig[] = [
{ id: 'general', name: 'General', icon: html`<path d="M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5a3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97c0-.33-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1c0 .33.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.66Z"/>` },
@ -487,20 +862,11 @@ export class SettingsApp extends TauriBase {
${this._renderDebugTab()}
<!-- Other tabs will be added here -->
<div class="tab-content ${this.activeTab === 'dashboard' ? 'active' : ''}" id="dashboard">
<h2>Dashboard</h2>
<p>Dashboard settings coming soon...</p>
</div>
${this._renderDashboardTab()}
<div class="tab-content ${this.activeTab === 'advanced' ? 'active' : ''}" id="advanced">
<h2>Advanced</h2>
<p>Advanced settings coming soon...</p>
</div>
${this._renderAdvancedTab()}
<div class="tab-content ${this.activeTab === 'about' ? 'active' : ''}" id="about">
<h2>About</h2>
<p>VibeTunnel v${this.systemInfo.version || '1.0.0'}</p>
</div>
${this._renderAboutTab()}
</div>
</div>
`;

View file

@ -5,11 +5,15 @@ import { customElement, property } from 'lit/decorators.js';
export class SettingsCheckbox extends LitElement {
static override styles = css`
:host {
display: block;
padding: 8px 0;
}
label {
display: flex;
align-items: flex-start;
cursor: pointer;
padding: 8px 0;
position: relative;
width: 100%;
}
input[type="checkbox"] {
@ -20,15 +24,15 @@ export class SettingsCheckbox extends LitElement {
}
.checkbox-indicator {
width: 20px;
height: 20px;
width: 16px;
height: 16px;
background: var(--bg-hover, rgba(255, 255, 255, 0.05));
border: 2px solid var(--border-secondary, rgba(255, 255, 255, 0.12));
border-radius: 6px;
border: 1.5px solid var(--border-secondary, rgba(255, 255, 255, 0.12));
border-radius: 4px;
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
position: relative;
flex-shrink: 0;
margin-right: 12px;
margin-right: 10px;
margin-top: 2px;
}
@ -42,15 +46,19 @@ export class SettingsCheckbox extends LitElement {
border-color: var(--accent, #10b981);
}
input[type="checkbox"]:checked + .checkbox-indicator::after {
content: '✓';
.checkbox-indicator svg {
position: absolute;
color: var(--text-primary, #fff);
font-size: 14px;
font-weight: bold;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
opacity: 0;
transition: opacity 0.2s ease;
}
input[type="checkbox"]:checked + .checkbox-indicator svg {
opacity: 1;
}
.setting-info {
@ -104,7 +112,11 @@ export class SettingsCheckbox extends LitElement {
.checked=${this.checked}
@change=${this._handleChange}
>
<span class="checkbox-indicator"></span>
<span class="checkbox-indicator">
<svg viewBox="0 0 12 12" fill="none">
<path d="M2 6L5 9L10 3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<div class="setting-info">
<span class="label">${this.label}</span>
${this.help ? html`<span class="help">${this.help}</span>` : ''}

View file

@ -0,0 +1,131 @@
import { html, css, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
export interface SelectChangeEvent extends CustomEvent {
detail: {
settingKey: string;
value: string;
};
}
@customElement('settings-select')
export class SettingsSelect extends LitElement {
static override styles = css`
:host {
display: block;
}
.form-group {
margin-bottom: 16px;
}
label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 8px;
}
.select-wrapper {
position: relative;
display: inline-block;
width: 100%;
}
select {
appearance: none;
-webkit-appearance: none;
width: 100%;
padding: 8px 32px 8px 12px;
font-size: 14px;
color: var(--text-primary);
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
select:hover {
border-color: var(--border-secondary);
background: var(--bg-hover);
}
select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.arrow {
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%);
pointer-events: none;
color: var(--text-secondary);
}
.help {
display: block;
font-size: 12px;
color: var(--text-tertiary);
margin-top: 4px;
}
`;
@property({ type: String })
label = '';
@property({ type: String })
value = '';
@property({ type: String })
settingKey = '';
@property({ type: String })
help = '';
@property({ type: Array })
options: Array<{ value: string; label: string }> = [];
private handleChange(e: Event): void {
const select = e.target as HTMLSelectElement;
this.value = select.value;
this.dispatchEvent(new CustomEvent('change', {
detail: {
settingKey: this.settingKey,
value: select.value
},
bubbles: true,
composed: true
}) as SelectChangeEvent);
}
override render() {
return html`
<div class="form-group">
${this.label ? html`<label for="${this.settingKey}">${this.label}</label>` : ''}
<div class="select-wrapper">
<select
id="${this.settingKey}"
.value=${this.value}
@change=${this.handleChange}
>
${this.options.map(option => html`
<option value="${option.value}" ?selected=${this.value === option.value}>
${option.label}
</option>
`)}
</select>
<svg class="arrow" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
${this.help ? html`<small class="help">${this.help}</small>` : ''}
</div>
`;
}
}

View file

@ -1,108 +1,183 @@
import { LitElement, html, css } from 'lit';
import { html, css, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { buttonStyles } from './styles';
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
type ButtonSize = 'small' | 'medium' | 'large';
@customElement('vt-button')
export class VTButton extends LitElement {
static override styles = [
buttonStyles,
css`
:host {
display: inline-block;
}
export class VtButton extends LitElement {
static override styles = css`
:host {
display: inline-block;
}
.btn {
width: 100%;
}
button {
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif);
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
outline: none;
position: relative;
}
.loading-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
button:focus-visible {
box-shadow: 0 0 0 3px var(--accent-glow, rgba(16, 185, 129, 0.5));
}
@keyframes spin {
to { transform: rotate(360deg); }
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Sizes */
button.small {
padding: 6px 12px;
font-size: 12px;
min-width: 64px;
}
button.medium {
padding: 10px 20px;
font-size: 14px;
min-width: 80px;
}
button.large {
padding: 14px 28px;
font-size: 16px;
min-width: 120px;
}
/* Variants */
button.primary {
background: var(--accent, #10b981);
color: white;
}
button.primary:hover:not(:disabled) {
background: var(--accent-hover, #0ea671);
}
button.primary:active:not(:disabled) {
transform: translateY(1px);
}
button.secondary {
background: var(--bg-secondary, rgba(255, 255, 255, 0.08));
color: var(--text-primary, #fff);
border: 1px solid var(--border-primary, rgba(255, 255, 255, 0.1));
}
button.secondary:hover:not(:disabled) {
background: var(--bg-hover, rgba(255, 255, 255, 0.12));
border-color: var(--border-secondary, rgba(255, 255, 255, 0.15));
}
button.danger {
background: var(--error, #ef4444);
color: white;
}
button.danger:hover:not(:disabled) {
background: var(--error-hover, #dc2626);
}
button.ghost {
background: transparent;
color: var(--text-primary, #fff);
}
button.ghost:hover:not(:disabled) {
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
}
/* Loading state */
.loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: inherit;
border-radius: inherit;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
`
];
}
.content {
visibility: hidden;
}
:host([loading]) .content {
visibility: hidden;
}
:host(:not([loading])) .loading {
display: none;
}
`;
@property({ type: String })
variant: ButtonVariant = 'primary';
@property({ type: String })
size: ButtonSize = 'md';
size: ButtonSize = 'medium';
@property({ type: Boolean })
disabled = false;
@property({ type: Boolean })
@property({ type: Boolean, reflect: true })
loading = false;
@property({ type: Boolean })
icon = false;
@property({ type: String })
href?: string;
private _handleClick(e: Event): void {
if (this.loading || this.disabled) {
e.preventDefault();
e.stopPropagation();
return;
}
if (this.href) {
e.preventDefault();
window.open(this.href, '_blank', 'noopener,noreferrer');
}
}
override render() {
const classes = {
'btn': true,
[`btn-${this.variant}`]: true,
[`btn-${this.size}`]: this.size !== 'md',
'btn-icon': this.icon
};
const content = this.loading
? html`<span class="loading-spinner"></span>`
: html`<slot></slot>`;
const isDisabled = this.disabled || this.loading;
if (this.href) {
return html`
<a
href=${this.href}
class=${classMap(classes)}
?disabled=${isDisabled}
@click=${this._handleClick}
tabindex=${isDisabled ? '-1' : '0'}
aria-disabled=${isDisabled ? 'true' : 'false'}
>
${content}
</a>
`;
}
return html`
<button
class=${classMap(classes)}
?disabled=${isDisabled}
@click=${this._handleClick}
aria-busy=${this.loading ? 'true' : 'false'}
<button
class="${this.variant} ${this.size}"
?disabled=${this.disabled || this.loading}
@click=${this.handleClick}
>
${content}
<span class="content">
<slot></slot>
</span>
${this.loading ? html`
<div class="loading">
<div class="spinner"></div>
</div>
` : ''}
</button>
`;
}
}
private handleClick(e: Event) {
if (this.disabled || this.loading) {
e.preventDefault();
e.stopPropagation();
}
}
}
declare global {
interface HTMLElementTagNameMap {
'vt-button': VtButton;
}
}
EOF < /dev/null

View file

@ -0,0 +1,315 @@
import { html, css, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { TauriBase } from '../base/tauri-base';
@customElement('window-header')
export class WindowHeader extends TauriBase {
static override styles = css`
:host {
display: block;
background: var(--bg-primary, #000);
border-bottom: 1px solid var(--border-primary, rgba(255, 255, 255, 0.08));
user-select: none;
-webkit-user-select: none;
}
.header {
display: flex;
align-items: center;
height: 38px;
padding: 0 16px;
position: relative;
}
/* Draggable region - covers most of the header */
.drag-region {
position: absolute;
top: 0;
left: 80px; /* Leave space for traffic lights */
right: 0;
bottom: 0;
-webkit-app-region: drag;
app-region: drag;
}
/* Window controls container */
.window-controls {
display: flex;
gap: 8px;
align-items: center;
position: relative;
z-index: 10;
-webkit-app-region: no-drag;
app-region: no-drag;
}
/* Traffic light buttons */
.traffic-light {
width: 12px;
height: 12px;
border-radius: 50%;
border: none;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}
.traffic-light:hover::before {
content: '';
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.1);
}
.traffic-light:active {
transform: scale(0.95);
}
.traffic-light.close {
background: #ff5f57;
}
.traffic-light.close:hover {
background: #ff6058;
}
.traffic-light.minimize {
background: #ffbd2e;
}
.traffic-light.minimize:hover {
background: #ffbe2f;
}
.traffic-light.maximize {
background: #28ca42;
}
.traffic-light.maximize:hover {
background: #29cb43;
}
/* When window is not focused, gray out the buttons */
:host(.inactive) .traffic-light {
background: #4a4a4a;
}
/* Title */
.title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 13px;
font-weight: 500;
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
pointer-events: none;
z-index: 5;
}
/* Content slot */
.content {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
position: relative;
z-index: 5;
-webkit-app-region: no-drag;
app-region: no-drag;
}
/* For non-macOS, use different controls */
@media not all and (hover: hover) {
.traffic-light {
display: none;
}
}
/* Windows/Linux style controls */
.windows-controls {
display: none;
gap: 0;
}
.windows-control {
width: 46px;
height: 38px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
.windows-control:hover {
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
}
.windows-control.close:hover {
background: #e81123;
color: white;
}
.windows-control svg {
width: 10px;
height: 10px;
}
/* Show Windows controls on non-macOS */
:host([platform="windows"]) .traffic-light,
:host([platform="linux"]) .traffic-light {
display: none;
}
:host([platform="windows"]) .windows-controls,
:host([platform="linux"]) .windows-controls {
display: flex;
}
`;
@property({ type: String })
title = '';
@property({ type: Boolean })
showMaximize = true;
@property({ type: String, reflect: true })
platform = 'macos';
override async connectedCallback() {
super.connectedCallback();
// Detect platform
if (this.tauriAvailable) {
const os = await this.safeInvoke<string>('get_os');
this.platform = os || 'macos';
}
// Listen for window focus/blur events
window.addEventListener('blur', this._handleBlur);
window.addEventListener('focus', this._handleFocus);
}
override disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener('blur', this._handleBlur);
window.removeEventListener('focus', this._handleFocus);
}
private _handleBlur = () => {
this.classList.add('inactive');
};
private _handleFocus = () => {
this.classList.remove('inactive');
};
private async _closeWindow() {
if (this.tauriAvailable) {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().close();
}
}
private async _minimizeWindow() {
if (this.tauriAvailable) {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().minimize();
}
}
private async _maximizeWindow() {
if (this.tauriAvailable) {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const window = getCurrentWindow();
const isMaximized = await window.isMaximized();
if (isMaximized) {
await window.unmaximize();
} else {
await window.maximize();
}
}
}
override render() {
return html`
<div class="header">
${this.platform === 'macos' ? html`
<div class="window-controls">
<button
class="traffic-light close"
@click=${this._closeWindow}
aria-label="Close window"
></button>
<button
class="traffic-light minimize"
@click=${this._minimizeWindow}
aria-label="Minimize window"
></button>
${this.showMaximize ? html`
<button
class="traffic-light maximize"
@click=${this._maximizeWindow}
aria-label="Maximize window"
></button>
` : ''}
</div>
` : ''}
<div class="drag-region" data-tauri-drag-region></div>
${this.title ? html`
<div class="title">${this.title}</div>
` : ''}
<div class="content">
<slot></slot>
</div>
${this.platform !== 'macos' ? html`
<div class="windows-controls">
<button
class="windows-control minimize"
@click=${this._minimizeWindow}
aria-label="Minimize window"
>
<svg viewBox="0 0 10 10" fill="currentColor">
<path d="M0 5h10v1H0z"/>
</svg>
</button>
${this.showMaximize ? html`
<button
class="windows-control maximize"
@click=${this._maximizeWindow}
aria-label="Maximize window"
>
<svg viewBox="0 0 10 10" fill="none" stroke="currentColor">
<rect x="0.5" y="0.5" width="9" height="9" stroke-width="1"/>
</svg>
</button>
` : ''}
<button
class="windows-control close"
@click=${this._closeWindow}
aria-label="Close window"
>
<svg viewBox="0 0 10 10" fill="currentColor">
<path d="M0 0l10 10M10 0L0 10" stroke="currentColor" stroke-width="1.5"/>
</svg>
</button>
</div>
` : ''}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'window-header': WindowHeader;
}
}

File diff suppressed because it is too large Load diff

44
tauri/welcome.html Normal file
View file

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Welcome to VibeTunnel</title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
/* Theme detection */
@media (prefers-color-scheme: dark) {
html { color-scheme: dark; }
}
@media (prefers-color-scheme: light) {
html { color-scheme: light; }
html.light { color-scheme: light; }
}
</style>
</head>
<body>
<welcome-app></welcome-app>
<script type="module">
import './src/components/welcome-app.ts';
// Apply theme class to html element
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('light', !isDark);
// Listen for theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
document.documentElement.classList.toggle('light', !e.matches);
});
</script>
</body>
</html>