feat(tauri): Implement full feature parity with Mac app (#213)
43
.dockerignore
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Ignore build artifacts
|
||||
target/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
web/native/vibetunnel
|
||||
web/native/*.node
|
||||
web/native/spawn-helper
|
||||
web/public/bundle/
|
||||
|
||||
# Ignore node_modules - CRITICAL for cross-platform builds
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
web/node_modules/
|
||||
tauri/node_modules/
|
||||
mac/node_modules/
|
||||
ios/node_modules/
|
||||
|
||||
# Ignore git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Ignore macOS files
|
||||
.DS_Store
|
||||
|
||||
# Ignore IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Ignore test files
|
||||
*.test.*
|
||||
*.spec.*
|
||||
|
||||
# Ignore documentation
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# Ignore cache directories
|
||||
.cache/
|
||||
.npm/
|
||||
.pnpm-store/
|
||||
174
.github/workflows/build-tauri.yml
vendored
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
name: Build Tauri App
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'tauri/**'
|
||||
- '.github/workflows/build-tauri.yml'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'tauri/**'
|
||||
- '.github/workflows/build-tauri.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# Check if we should run the build
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tauri: ${{ steps.filter.outputs.tauri }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
tauri:
|
||||
- 'tauri/**'
|
||||
- '.github/workflows/build-tauri.yml'
|
||||
|
||||
build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.tauri == 'true' || github.event_name == 'workflow_dispatch' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: 'macos-latest'
|
||||
target: 'x86_64-apple-darwin'
|
||||
- platform: 'macos-latest'
|
||||
target: 'aarch64-apple-darwin'
|
||||
- platform: 'blacksmith-8vcpu-ubuntu-2404-arm'
|
||||
target: 'aarch64-unknown-linux-gnu'
|
||||
- platform: 'windows-latest'
|
||||
target: 'x86_64-pc-windows-msvc'
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: web/pnpm-lock.yaml
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install Python setuptools
|
||||
run: pip install setuptools
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Setup Rust target
|
||||
if: startsWith(matrix.platform, 'blacksmith') || startsWith(matrix.platform, 'ubuntu')
|
||||
run: |
|
||||
rustup target add ${{ matrix.target }}
|
||||
# Set appropriate linker based on target
|
||||
if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then
|
||||
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=gcc" >> $GITHUB_ENV
|
||||
else
|
||||
echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=gcc" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './tauri/src-tauri -> target'
|
||||
|
||||
- name: Install dependencies (Linux)
|
||||
if: startsWith(matrix.platform, 'blacksmith') || startsWith(matrix.platform, 'ubuntu')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev librsvg2-dev libpam0g-dev
|
||||
|
||||
- name: Install dependencies (Windows)
|
||||
if: matrix.platform == 'windows-latest'
|
||||
run: |
|
||||
choco install llvm -y
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: ${{ startsWith(matrix.platform, 'blacksmith') && 'useblacksmith/cache@v5' || 'actions/cache@v3' }}
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install web dependencies
|
||||
working-directory: ./web
|
||||
run: |
|
||||
# Install dependencies (without frozen lockfile due to config differences)
|
||||
pnpm install
|
||||
# Rebuild native modules for the target platform
|
||||
pnpm rebuild
|
||||
|
||||
- name: Build web assets
|
||||
working-directory: ./web
|
||||
run: pnpm run build
|
||||
|
||||
- name: Cache tauri-cli
|
||||
id: cache-tauri
|
||||
uses: ${{ startsWith(matrix.platform, 'blacksmith') && 'useblacksmith/cache@v5' || 'actions/cache@v3' }}
|
||||
with:
|
||||
path: ~/.cargo/bin/cargo-tauri
|
||||
key: ${{ runner.os }}-tauri-cli-2.0
|
||||
|
||||
- name: Install tauri-cli
|
||||
if: steps.cache-tauri.outputs.cache-hit != 'true'
|
||||
run: cargo install tauri-cli --version "^2.0.0" --locked
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: ./tauri/src-tauri
|
||||
run: cargo tauri build --target ${{ matrix.target }} --ci
|
||||
|
||||
- name: Upload artifacts (macOS)
|
||||
if: matrix.platform == 'macos-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vibetunnel-${{ matrix.target }}
|
||||
path: |
|
||||
tauri/src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
|
||||
tauri/src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload artifacts (Linux)
|
||||
if: startsWith(matrix.platform, 'blacksmith') || startsWith(matrix.platform, 'ubuntu')
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vibetunnel-${{ matrix.target }}
|
||||
path: |
|
||||
tauri/src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb
|
||||
tauri/src-tauri/target/${{ matrix.target }}/release/bundle/appimage/*.AppImage
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload artifacts (Windows)
|
||||
if: matrix.platform == 'windows-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vibetunnel-${{ matrix.target }}
|
||||
path: |
|
||||
tauri/src-tauri/target/${{ matrix.target }}/release/bundle/msi/*.msi
|
||||
tauri/src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*.exe
|
||||
if-no-files-found: error
|
||||
|
|
@ -98,11 +98,16 @@ final class WindowFocuser {
|
|||
if let tabRef = windowInfo.tabReference {
|
||||
// Use stored tab reference to select the tab
|
||||
// The tabRef format is "tab id X of window id Y"
|
||||
// Escape the tab reference to prevent injection
|
||||
let escapedTabRef = tabRef.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
.replacingOccurrences(of: "\n", with: "\\n")
|
||||
.replacingOccurrences(of: "\r", with: "\\r")
|
||||
|
||||
let script = """
|
||||
tell application "Terminal"
|
||||
activate
|
||||
set selected of \(tabRef) to true
|
||||
set frontmost of window id \(windowInfo.windowID) to true
|
||||
set selected of \(escapedTabRef) to true
|
||||
set frontmost of window id \(AppleScriptSecurity.escapeNumber(windowInfo.windowID)) to true
|
||||
end tell
|
||||
"""
|
||||
|
||||
|
|
@ -121,7 +126,7 @@ final class WindowFocuser {
|
|||
activate
|
||||
set allWindows to windows
|
||||
repeat with w in allWindows
|
||||
if id of w is \(windowInfo.windowID) then
|
||||
if id of w is \(AppleScriptSecurity.escapeNumber(windowInfo.windowID)) then
|
||||
set frontmost of w to true
|
||||
exit repeat
|
||||
end if
|
||||
|
|
@ -146,6 +151,11 @@ final class WindowFocuser {
|
|||
let sessionInfo = SessionMonitor.shared.sessions[windowInfo.sessionID]
|
||||
let workingDir = sessionInfo?.workingDir ?? ""
|
||||
let dirName = (workingDir as NSString).lastPathComponent
|
||||
|
||||
// Escape all user-provided values to prevent injection
|
||||
let escapedSessionID = AppleScriptSecurity.escapeString(windowInfo.sessionID)
|
||||
let escapedDirName = AppleScriptSecurity.escapeString(dirName)
|
||||
let escapedTabID = windowInfo.tabID.map { AppleScriptSecurity.escapeString($0) } ?? ""
|
||||
|
||||
// Try to find and focus the tab with matching content
|
||||
let script = """
|
||||
|
|
@ -162,7 +172,7 @@ final class WindowFocuser {
|
|||
set sessionName to name of s
|
||||
|
||||
-- Try to match by session content
|
||||
if sessionName contains "\(windowInfo.sessionID)" or sessionName contains "\(dirName)" then
|
||||
if sessionName contains "\(escapedSessionID)" or sessionName contains "\(escapedDirName)" then
|
||||
-- Found it! Select this tab and window
|
||||
select w
|
||||
select t
|
||||
|
|
@ -174,9 +184,9 @@ final class WindowFocuser {
|
|||
end repeat
|
||||
|
||||
-- If we have a window ID, at least focus that window
|
||||
if "\(windowInfo.tabID ?? "")" is not "" then
|
||||
if "\(escapedTabID)" is not "" then
|
||||
try
|
||||
tell window id "\(windowInfo.tabID ?? "")"
|
||||
tell window id "\(escapedTabID)"
|
||||
select
|
||||
end tell
|
||||
end try
|
||||
|
|
|
|||
88
mac/VibeTunnel/Core/Utilities/AppleScriptSecurity.swift
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import Foundation
|
||||
import CoreGraphics
|
||||
|
||||
/// Security utilities for AppleScript execution
|
||||
enum AppleScriptSecurity {
|
||||
|
||||
/// Escapes a string for safe use in AppleScript
|
||||
///
|
||||
/// This function properly escapes all special characters that could be used
|
||||
/// for AppleScript injection attacks, including:
|
||||
/// - Double quotes (")
|
||||
/// - Backslashes (\)
|
||||
/// - Newlines and carriage returns
|
||||
/// - Tabs
|
||||
/// - Other control characters
|
||||
///
|
||||
/// - Parameter string: The string to escape
|
||||
/// - Returns: The escaped string safe for use in AppleScript
|
||||
static func escapeString(_ string: String) -> String {
|
||||
var escaped = string
|
||||
|
||||
// Order matters: escape backslashes first
|
||||
escaped = escaped.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
escaped = escaped.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
escaped = escaped.replacingOccurrences(of: "\n", with: "\\n")
|
||||
escaped = escaped.replacingOccurrences(of: "\r", with: "\\r")
|
||||
escaped = escaped.replacingOccurrences(of: "\t", with: "\\t")
|
||||
|
||||
// Remove any other control characters that could cause issues
|
||||
let controlCharacterSet = CharacterSet.controlCharacters
|
||||
escaped = escaped.components(separatedBy: controlCharacterSet)
|
||||
.joined(separator: " ")
|
||||
|
||||
return escaped
|
||||
}
|
||||
|
||||
/// Validates an identifier (like an application name) for safe use in AppleScript
|
||||
///
|
||||
/// This function ensures the identifier only contains safe characters and
|
||||
/// isn't trying to inject AppleScript commands.
|
||||
///
|
||||
/// - Parameter identifier: The identifier to validate
|
||||
/// - Returns: The validated identifier, or nil if invalid
|
||||
static func validateIdentifier(_ identifier: String) -> String? {
|
||||
// Allow alphanumeric, spaces, dots, hyphens, and underscores
|
||||
let allowedCharacterSet = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .-_")
|
||||
let identifierCharacterSet = CharacterSet(charactersIn: identifier)
|
||||
|
||||
guard allowedCharacterSet.isSuperset(of: identifierCharacterSet) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Additional check: ensure it doesn't contain AppleScript keywords that could be dangerous
|
||||
let dangerousKeywords = ["tell", "end", "do", "script", "run", "activate", "quit", "delete", "set", "get"]
|
||||
let lowercased = identifier.lowercased()
|
||||
for keyword in dangerousKeywords {
|
||||
if lowercased.contains(keyword) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return identifier
|
||||
}
|
||||
|
||||
/// Escapes a numeric value for safe use in AppleScript
|
||||
///
|
||||
/// - Parameter value: The numeric value
|
||||
/// - Returns: The string representation of the number
|
||||
static func escapeNumber(_ value: Int) -> String {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/// Escapes a numeric value for safe use in AppleScript
|
||||
///
|
||||
/// - Parameter value: The numeric value (UInt32/CGWindowID)
|
||||
/// - Returns: The string representation of the number
|
||||
static func escapeNumber(_ value: UInt32) -> String {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/// Creates a safe AppleScript string literal
|
||||
///
|
||||
/// - Parameter string: The string to make into a literal
|
||||
/// - Returns: A properly quoted and escaped AppleScript string literal
|
||||
static func createStringLiteral(_ string: String) -> String {
|
||||
return "\"\(escapeString(string))\""
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ struct AdvancedSettingsView: View {
|
|||
@AppStorage("cleanupOnStartup")
|
||||
private var cleanupOnStartup = true
|
||||
@AppStorage("showInDock")
|
||||
private var showInDock = false
|
||||
private var showInDock = true
|
||||
@State private var cliInstaller = CLIInstaller()
|
||||
@State private var showingVtConflictAlert = false
|
||||
|
||||
|
|
|
|||
|
|
@ -159,6 +159,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser
|
|||
}
|
||||
#endif
|
||||
|
||||
// Register default values
|
||||
UserDefaults.standard.register(defaults: [
|
||||
"showInDock": true // Default to showing in dock
|
||||
])
|
||||
|
||||
// Initialize Sparkle updater manager
|
||||
sparkleUpdaterManager = SparkleUpdaterManager.shared
|
||||
|
||||
|
|
|
|||
5
tauri/.cargo/config.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
[env]
|
||||
CARGO_UNSTABLE_EDITION2024 = "true"
|
||||
|
||||
[build]
|
||||
rustflags = ["-Z", "unstable-options"]
|
||||
29
tauri/.dockerignore
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Ignore node_modules to avoid platform conflicts
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
**/target/
|
||||
**/.next/
|
||||
**/.cache/
|
||||
**/.turbo/
|
||||
**/.git/
|
||||
**/.gitignore
|
||||
**/Dockerfile*
|
||||
**/.dockerignore
|
||||
**/.DS_Store
|
||||
**/npm-debug.log*
|
||||
**/yarn-debug.log*
|
||||
**/yarn-error.log*
|
||||
**/pnpm-debug.log*
|
||||
|
||||
# Ignore build artifacts
|
||||
web/public/bundle/
|
||||
web/dist/
|
||||
mac/
|
||||
ios/
|
||||
|
||||
# Keep the source files we need
|
||||
!web/src/
|
||||
!web/public/
|
||||
!web/package.json
|
||||
!web/pnpm-lock.yaml
|
||||
!tauri/
|
||||
51
tauri/Dockerfile.linux
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Dockerfile for building Linux binaries
|
||||
FROM rust:latest
|
||||
|
||||
# Install required dependencies for Tauri
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libwebkit2gtk-4.0-dev \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
libssl-dev \
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libpam0g-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Node.js
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Install nightly toolchain for edition2024 support
|
||||
RUN rustup toolchain install nightly && \
|
||||
rustup default nightly
|
||||
|
||||
# Set environment variable to enable edition2024
|
||||
ENV CARGO_UNSTABLE_EDITION2024=true
|
||||
|
||||
# Install latest tauri-cli
|
||||
RUN cargo install tauri-cli --locked
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the entire project
|
||||
COPY . .
|
||||
|
||||
# Remove any existing node_modules to avoid platform conflicts
|
||||
RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' + || true
|
||||
|
||||
# Install web dependencies and rebuild native modules for Linux
|
||||
WORKDIR /app/web
|
||||
RUN pnpm install --frozen-lockfile && pnpm rebuild
|
||||
|
||||
# Go back to main directory
|
||||
WORKDIR /app
|
||||
|
||||
# Build the Linux binary
|
||||
CMD ["cargo", "tauri", "build"]
|
||||
48
tauri/Dockerfile.windows
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Dockerfile for building Windows binaries
|
||||
FROM rust:latest
|
||||
|
||||
# Install Windows cross-compilation tools
|
||||
RUN apt-get update && apt-get install -y \
|
||||
mingw-w64 \
|
||||
nsis \
|
||||
wine64 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Node.js
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# Install nightly toolchain for edition2024 support
|
||||
RUN rustup toolchain install nightly && \
|
||||
rustup default nightly
|
||||
|
||||
# Add Windows target
|
||||
RUN rustup target add x86_64-pc-windows-gnu
|
||||
|
||||
# Set environment variable to enable edition2024
|
||||
ENV CARGO_UNSTABLE_EDITION2024=true
|
||||
|
||||
# Install latest tauri-cli
|
||||
RUN cargo install tauri-cli --locked
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the entire project
|
||||
COPY . .
|
||||
|
||||
# Remove any existing node_modules to avoid platform conflicts
|
||||
RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' + || true
|
||||
|
||||
# Install web dependencies and rebuild native modules for Windows target
|
||||
WORKDIR /app/web
|
||||
RUN pnpm install --frozen-lockfile && pnpm rebuild
|
||||
|
||||
# Go back to main directory
|
||||
WORKDIR /app
|
||||
|
||||
# Build the Windows binary
|
||||
CMD ["cargo", "tauri", "build", "--target", "x86_64-pc-windows-gnu"]
|
||||
44
tauri/build-cross-platform.sh
Executable file
|
|
@ -0,0 +1,44 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Build script for cross-platform Tauri builds
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Building VibeTunnel for all platforms..."
|
||||
|
||||
# Colors for output
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Build for macOS (native)
|
||||
echo -e "${BLUE}Building for macOS...${NC}"
|
||||
cargo tauri build
|
||||
echo -e "${GREEN}✅ macOS build complete${NC}"
|
||||
|
||||
# Build for Linux using Docker
|
||||
echo -e "${BLUE}Building for Linux...${NC}"
|
||||
if command -v docker &> /dev/null; then
|
||||
docker build -f Dockerfile.linux -t vibetunnel-linux-builder ..
|
||||
docker run --rm -v "$(pwd)/..:/app" vibetunnel-linux-builder
|
||||
echo -e "${GREEN}✅ Linux build complete${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Docker not found. Skipping Linux build.${NC}"
|
||||
fi
|
||||
|
||||
# Build for Windows using Docker
|
||||
echo -e "${BLUE}Building for Windows...${NC}"
|
||||
if command -v docker &> /dev/null; then
|
||||
docker build -f Dockerfile.windows -t vibetunnel-windows-builder ..
|
||||
docker run --rm -v "$(pwd)/..:/app" vibetunnel-windows-builder
|
||||
echo -e "${GREEN}✅ Windows build complete${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Docker not found. Skipping Windows build.${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}🎉 All builds complete!${NC}"
|
||||
echo "Build artifacts can be found in:"
|
||||
echo " - macOS: src-tauri/target/release/bundle/"
|
||||
echo " - Linux: src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/"
|
||||
echo " - Windows: src-tauri/target/x86_64-pc-windows-gnu/release/bundle/"
|
||||
4
tauri/rust-toolchain.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[toolchain]
|
||||
channel = "nightly"
|
||||
components = ["rustfmt", "clippy"]
|
||||
profile = "minimal"
|
||||
|
|
@ -12,4 +12,27 @@ rustflags = [
|
|||
"-A", "clippy::too_many_lines",
|
||||
"-A", "clippy::cargo_common_metadata",
|
||||
"-A", "clippy::multiple_crate_versions",
|
||||
]
|
||||
]
|
||||
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "x86_64-w64-mingw32-gcc"
|
||||
ar = "x86_64-w64-mingw32-ar"
|
||||
|
||||
[target.x86_64-pc-windows-gnu.env]
|
||||
CC_x86_64_pc_windows_gnu = "x86_64-w64-mingw32-gcc"
|
||||
CXX_x86_64_pc_windows_gnu = "x86_64-w64-mingw32-g++"
|
||||
AR_x86_64_pc_windows_gnu = "x86_64-w64-mingw32-ar"
|
||||
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "x86_64-unknown-linux-gnu-gcc"
|
||||
ar = "x86_64-unknown-linux-gnu-ar"
|
||||
|
||||
[target.x86_64-unknown-linux-gnu.env]
|
||||
CC_x86_64_unknown_linux_gnu = "x86_64-unknown-linux-gnu-gcc"
|
||||
CXX_x86_64_unknown_linux_gnu = "x86_64-unknown-linux-gnu-g++"
|
||||
AR_x86_64_unknown_linux_gnu = "x86_64-unknown-linux-gnu-ar"
|
||||
PKG_CONFIG_ALLOW_CROSS = "1"
|
||||
PKG_CONFIG_PATH = "/opt/homebrew/opt/x86_64-unknown-linux-gnu/lib/pkgconfig"
|
||||
|
||||
[target.x86_64-unknown-linux-musl]
|
||||
linker = "x86_64-linux-musl-gcc"
|
||||
|
|
@ -32,7 +32,6 @@ tauri-plugin-window-state = "2.2.3"
|
|||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Terminal handling
|
||||
|
|
@ -59,6 +58,10 @@ toml = "0.8"
|
|||
|
||||
# Utilities
|
||||
open = "5"
|
||||
url = "2"
|
||||
urlencoding = "2"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
lazy_static = "1.5"
|
||||
|
||||
# File system
|
||||
dirs = "6"
|
||||
|
|
@ -99,6 +102,13 @@ windows = { version = "0.61", features = ["Win32_Foundation", "Win32_Security",
|
|||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-single-instance = "2.2.4"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cocoa = "0.26"
|
||||
objc = "0.2"
|
||||
core-graphics = "0.24"
|
||||
core-foundation = "0.10"
|
||||
libc = "0.2"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
|
|
|
|||
17
tauri/src-tauri/Info.plist
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.vibetunnel.app</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>vibetunnel</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
],
|
||||
"definitions": {
|
||||
"Capability": {
|
||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"identifier",
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.",
|
||||
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -3152,6 +3152,12 @@
|
|||
"const": "core:webview:allow-reparent",
|
||||
"markdownDescription": "Enables the reparent command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_webview_auto_resize command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:webview:allow-set-webview-auto-resize",
|
||||
"markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_webview_background_color command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
|
@ -3254,6 +3260,12 @@
|
|||
"const": "core:webview:deny-reparent",
|
||||
"markdownDescription": "Denies the reparent command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_webview_auto_resize command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:webview:deny-set-webview-auto-resize",
|
||||
"markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_webview_background_color command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
],
|
||||
"definitions": {
|
||||
"Capability": {
|
||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||
"description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"identifier",
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.",
|
||||
"description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.",
|
||||
"default": "",
|
||||
"type": "string"
|
||||
},
|
||||
|
|
@ -3152,6 +3152,12 @@
|
|||
"const": "core:webview:allow-reparent",
|
||||
"markdownDescription": "Enables the reparent command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_webview_auto_resize command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:webview:allow-set-webview-auto-resize",
|
||||
"markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set_webview_background_color command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
|
@ -3254,6 +3260,12 @@
|
|||
"const": "core:webview:deny-reparent",
|
||||
"markdownDescription": "Denies the reparent command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_webview_auto_resize command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "core:webview:deny-set-webview-auto-resize",
|
||||
"markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set_webview_background_color command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 944 B After Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 325 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 325 KiB |
|
Before Width: | Height: | Size: 954 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 954 KiB After Width: | Height: | Size: 2.7 MiB |
277
tauri/src-tauri/src/applescript.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
use std::process::Command;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
/// AppleScript integration for advanced terminal control on macOS
|
||||
pub struct AppleScriptRunner;
|
||||
|
||||
impl AppleScriptRunner {
|
||||
/// Run an AppleScript command
|
||||
pub fn run_script(script: &str) -> Result<String, String> {
|
||||
debug!("Running AppleScript: {}", script);
|
||||
|
||||
let output = Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(script)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute AppleScript: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
let result = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
debug!("AppleScript output: {}", result);
|
||||
Ok(result)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
error!("AppleScript error: {}", error);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Launch Terminal.app with a new window for a session
|
||||
pub fn launch_terminal_app(session_id: &str, command: Option<&str>) -> Result<String, String> {
|
||||
let default_cmd = format!("vt connect localhost:4022/{}", session_id);
|
||||
let cmd = command.unwrap_or(&default_cmd);
|
||||
|
||||
let script = format!(
|
||||
r#"tell application "Terminal"
|
||||
activate
|
||||
do script "{}"
|
||||
set newWindow to front window
|
||||
set newTab to selected tab of newWindow
|
||||
return "tab id " & (id of newTab) & " of window id " & (id of newWindow)
|
||||
end tell"#,
|
||||
cmd.replace("\"", "\\\"")
|
||||
);
|
||||
|
||||
Self::run_script(&script)
|
||||
}
|
||||
|
||||
/// Launch iTerm2 with a new window/tab for a session
|
||||
pub fn launch_iterm2(session_id: &str, command: Option<&str>, new_window: bool) -> Result<String, String> {
|
||||
let default_cmd = format!("vt connect localhost:4022/{}", session_id);
|
||||
let cmd = command.unwrap_or(&default_cmd);
|
||||
|
||||
let script = if new_window {
|
||||
format!(
|
||||
r#"tell application "iTerm"
|
||||
activate
|
||||
set newWindow to (create window with default profile)
|
||||
tell current session of newWindow
|
||||
write text "{}"
|
||||
end tell
|
||||
return id of newWindow as string
|
||||
end tell"#,
|
||||
cmd.replace("\"", "\\\"")
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r#"tell application "iTerm"
|
||||
activate
|
||||
tell current window
|
||||
set newTab to (create tab with default profile)
|
||||
tell current session of newTab
|
||||
write text "{}"
|
||||
end tell
|
||||
return id of newTab as string
|
||||
end tell
|
||||
end tell"#,
|
||||
cmd.replace("\"", "\\\"")
|
||||
)
|
||||
};
|
||||
|
||||
Self::run_script(&script)
|
||||
}
|
||||
|
||||
/// Focus a specific Terminal.app window/tab
|
||||
pub fn focus_terminal_window(window_id: u64, tab_id: Option<u64>) -> Result<(), String> {
|
||||
let script = if let Some(tid) = tab_id {
|
||||
format!(
|
||||
r#"tell application "Terminal"
|
||||
activate
|
||||
set targetWindow to window id {}
|
||||
set selected tab of targetWindow to tab id {} of targetWindow
|
||||
set frontmost of targetWindow to true
|
||||
end tell"#,
|
||||
window_id, tid
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r#"tell application "Terminal"
|
||||
activate
|
||||
set targetWindow to window id {}
|
||||
set frontmost of targetWindow to true
|
||||
end tell"#,
|
||||
window_id
|
||||
)
|
||||
};
|
||||
|
||||
Self::run_script(&script)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Focus iTerm2 window
|
||||
pub fn focus_iterm2_window(window_id: &str) -> Result<(), String> {
|
||||
let script = format!(
|
||||
r#"tell application "iTerm"
|
||||
activate
|
||||
set targetWindow to (first window whose id is {})
|
||||
select targetWindow
|
||||
end tell"#,
|
||||
window_id
|
||||
);
|
||||
|
||||
Self::run_script(&script)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get list of Terminal.app windows with their titles
|
||||
pub fn get_terminal_windows() -> Result<Vec<(u32, String)>, String> {
|
||||
let script = r#"tell application "Terminal"
|
||||
set windowList to {}
|
||||
repeat with w in windows
|
||||
set windowInfo to (id of w as string) & "|" & (name of w as string)
|
||||
set end of windowList to windowInfo
|
||||
end repeat
|
||||
return windowList
|
||||
end tell"#;
|
||||
|
||||
let output = Self::run_script(script)?;
|
||||
let mut windows = Vec::new();
|
||||
|
||||
for line in output.lines() {
|
||||
if let Some((id_str, title)) = line.split_once('|') {
|
||||
if let Ok(id) = id_str.trim().parse::<u32>() {
|
||||
windows.push((id, title.trim().to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(windows)
|
||||
}
|
||||
|
||||
/// Check if an application is running
|
||||
pub fn is_app_running(app_name: &str) -> Result<bool, String> {
|
||||
let script = format!(
|
||||
r#"tell application "System Events"
|
||||
return exists (processes where name is "{}")
|
||||
end tell"#,
|
||||
app_name
|
||||
);
|
||||
|
||||
let output = Self::run_script(&script)?;
|
||||
Ok(output.trim() == "true")
|
||||
}
|
||||
|
||||
/// Launch an application by path
|
||||
pub fn launch_app(app_path: &str) -> Result<(), String> {
|
||||
let script = format!(
|
||||
r#"tell application "Finder"
|
||||
open POSIX file "{}"
|
||||
end tell"#,
|
||||
app_path
|
||||
);
|
||||
|
||||
Self::run_script(&script)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send keystrokes to the frontmost application
|
||||
pub fn send_keystrokes(text: &str) -> Result<(), String> {
|
||||
let script = format!(
|
||||
r#"tell application "System Events"
|
||||
keystroke "{}"
|
||||
end tell"#,
|
||||
text.replace("\"", "\\\"")
|
||||
);
|
||||
|
||||
Self::run_script(&script)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the frontmost application name
|
||||
pub fn get_frontmost_app() -> Result<String, String> {
|
||||
let script = r#"tell application "System Events"
|
||||
return name of first application process whose frontmost is true
|
||||
end tell"#;
|
||||
|
||||
Self::run_script(script)
|
||||
}
|
||||
}
|
||||
|
||||
/// Enhanced terminal launching with AppleScript
|
||||
pub struct AppleScriptTerminalLauncher;
|
||||
|
||||
impl AppleScriptTerminalLauncher {
|
||||
/// Launch a terminal with enhanced AppleScript control
|
||||
pub async fn launch_terminal(
|
||||
terminal_type: &str,
|
||||
session_id: &str,
|
||||
command: Option<&str>,
|
||||
working_directory: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
info!("Launching {} for session {} via AppleScript", terminal_type, session_id);
|
||||
|
||||
// Build the full command with working directory
|
||||
let full_command = if let Some(cwd) = working_directory {
|
||||
if let Some(cmd) = command {
|
||||
format!("cd '{}' && {}", cwd, cmd)
|
||||
} else {
|
||||
format!("cd '{}' && vt connect localhost:4022/{}", cwd, session_id)
|
||||
}
|
||||
} else {
|
||||
command.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| format!("vt connect localhost:4022/{}", session_id))
|
||||
};
|
||||
|
||||
match terminal_type {
|
||||
"Terminal" | "Terminal.app" => {
|
||||
AppleScriptRunner::launch_terminal_app(session_id, Some(&full_command))
|
||||
}
|
||||
"iTerm2" | "iTerm" => {
|
||||
AppleScriptRunner::launch_iterm2(session_id, Some(&full_command), true)
|
||||
}
|
||||
_ => {
|
||||
// For other terminals, try to launch via open command
|
||||
let mut cmd = Command::new("open");
|
||||
cmd.arg("-a").arg(terminal_type);
|
||||
|
||||
if let Some(cwd) = working_directory {
|
||||
cmd.arg("--args").arg("--working-directory").arg(cwd);
|
||||
}
|
||||
|
||||
cmd.output()
|
||||
.map_err(|e| format!("Failed to launch {}: {}", terminal_type, e))?;
|
||||
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus a terminal window using AppleScript
|
||||
pub async fn focus_terminal_window(
|
||||
terminal_type: &str,
|
||||
window_info: &str,
|
||||
) -> Result<(), String> {
|
||||
match terminal_type {
|
||||
"Terminal" | "Terminal.app" => {
|
||||
// Parse window ID from window_info
|
||||
if let Ok(window_id) = window_info.parse::<u64>() {
|
||||
AppleScriptRunner::focus_terminal_window(window_id, None)
|
||||
} else {
|
||||
Err("Invalid window ID".to_string())
|
||||
}
|
||||
}
|
||||
"iTerm2" | "iTerm" => {
|
||||
AppleScriptRunner::focus_iterm2_window(window_info)
|
||||
}
|
||||
_ => {
|
||||
// For other terminals, just activate the app
|
||||
let script = format!(
|
||||
r#"tell application "{}" to activate"#,
|
||||
terminal_type
|
||||
);
|
||||
AppleScriptRunner::run_script(&script)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ use tokio::io::{AsyncBufReadExt, BufReader};
|
|||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{error, info, warn};
|
||||
use crate::log_collector::SERVER_LOG_COLLECTOR;
|
||||
|
||||
/// Server state enumeration
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -414,14 +415,24 @@ impl NodeJsServer {
|
|||
/// Log server output
|
||||
fn log_output(line: &str, is_error: bool) {
|
||||
let line_lower = line.to_lowercase();
|
||||
|
||||
if is_error || line_lower.contains("error") || line_lower.contains("failed") {
|
||||
|
||||
let level = if is_error || line_lower.contains("error") || line_lower.contains("failed") {
|
||||
error!("Server: {}", line);
|
||||
"error"
|
||||
} else if line_lower.contains("warn") {
|
||||
warn!("Server: {}", line);
|
||||
"warn"
|
||||
} else {
|
||||
info!("Server: {}", line);
|
||||
}
|
||||
"info"
|
||||
};
|
||||
|
||||
// Add to log collector
|
||||
let collector = SERVER_LOG_COLLECTOR.clone();
|
||||
let line_owned = line.to_string();
|
||||
tokio::spawn(async move {
|
||||
collector.add_log(level, line_owned).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Monitor process for unexpected termination
|
||||
|
|
|
|||
|
|
@ -753,6 +753,35 @@ pub async fn show_welcome_window(state: State<'_, AppState>) -> Result<(), Strin
|
|||
welcome_manager.show_welcome_window().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_folder(path: String) -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open folder: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("explorer")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open folder: {}", e))?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open folder: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Advanced Settings Commands
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -1006,6 +1035,30 @@ pub async fn get_permission_stats(
|
|||
Ok(stats)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn register_permission_monitoring(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.permissions_manager.register_for_monitoring().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn unregister_permission_monitoring(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.permissions_manager.unregister_from_monitoring().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn show_permission_alert(
|
||||
state: State<'_, AppState>,
|
||||
permission_type: crate::permissions::PermissionType,
|
||||
) -> Result<(), String> {
|
||||
state.permissions_manager.show_permission_alert(permission_type).await
|
||||
}
|
||||
|
||||
// Update Manager Commands
|
||||
#[tauri::command]
|
||||
pub async fn check_for_updates(
|
||||
|
|
@ -2072,22 +2125,26 @@ pub struct ServerLog {
|
|||
|
||||
#[tauri::command]
|
||||
pub async fn get_server_logs(limit: usize) -> Result<Vec<ServerLog>, String> {
|
||||
// TODO: Implement actual log collection from the server
|
||||
// For now, return dummy logs for the UI
|
||||
let logs = vec![
|
||||
ServerLog {
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
level: "info".to_string(),
|
||||
message: "Server started on port 4022".to_string(),
|
||||
},
|
||||
ServerLog {
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
level: "info".to_string(),
|
||||
message: "Health check endpoint accessed".to_string(),
|
||||
},
|
||||
];
|
||||
let logs = crate::log_collector::SERVER_LOG_COLLECTOR.get_logs().await;
|
||||
|
||||
// Convert LogEntry to ServerLog
|
||||
let server_logs: Vec<ServerLog> = logs
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|entry| ServerLog {
|
||||
timestamp: entry.timestamp,
|
||||
level: entry.level,
|
||||
message: entry.message,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(server_logs)
|
||||
}
|
||||
|
||||
Ok(logs.into_iter().take(limit).collect())
|
||||
#[tauri::command]
|
||||
pub async fn clear_server_logs() -> Result<(), String> {
|
||||
crate::log_collector::SERVER_LOG_COLLECTOR.clear().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -2355,6 +2412,209 @@ pub async fn finish_welcome(state: State<'_, AppState>) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// Tailscale commands
|
||||
#[tauri::command]
|
||||
pub async fn get_tailscale_status(state: State<'_, AppState>) -> Result<crate::tailscale::TailscaleStatus, String> {
|
||||
Ok(state.tailscale_service.check_tailscale_status().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_tailscale_monitoring(state: State<'_, AppState>) -> Result<(), String> {
|
||||
state.tailscale_service.start_monitoring().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_tailscale_app() -> Result<(), String> {
|
||||
crate::tailscale::TailscaleService::open_tailscale_app()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_tailscale_download() -> Result<(), String> {
|
||||
crate::tailscale::TailscaleService::open_download_page()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_tailscale_setup_guide() -> Result<(), String> {
|
||||
crate::tailscale::TailscaleService::open_setup_guide()
|
||||
}
|
||||
|
||||
// Window tracking commands
|
||||
#[tauri::command]
|
||||
pub async fn register_terminal_window(
|
||||
session_id: String,
|
||||
terminal_app: String,
|
||||
tab_reference: Option<String>,
|
||||
tab_id: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.window_tracker
|
||||
.register_window(session_id, terminal_app, tab_reference, tab_id)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn unregister_terminal_window(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.window_tracker.unregister_window(&session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn focus_terminal_window(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.window_tracker.focus_window(&session_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_terminal_window_info(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<crate::window_tracker::WindowInfo>, String> {
|
||||
Ok(state.window_tracker.window_info(&session_id).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_window_tracking(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let sessions = state.api_client.list_sessions().await?;
|
||||
state.window_tracker.update_from_sessions(&sessions).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// AppleScript commands (macOS only)
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tauri::command]
|
||||
pub async fn run_applescript(script: String) -> Result<String, String> {
|
||||
crate::applescript::AppleScriptRunner::run_script(&script)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tauri::command]
|
||||
pub async fn launch_terminal_with_applescript(
|
||||
terminal_type: String,
|
||||
session_id: String,
|
||||
command: Option<String>,
|
||||
working_directory: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
crate::applescript::AppleScriptTerminalLauncher::launch_terminal(
|
||||
&terminal_type,
|
||||
&session_id,
|
||||
command.as_deref(),
|
||||
working_directory.as_deref(),
|
||||
).await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tauri::command]
|
||||
pub async fn focus_terminal_with_applescript(
|
||||
terminal_type: String,
|
||||
window_info: String,
|
||||
) -> Result<(), String> {
|
||||
crate::applescript::AppleScriptTerminalLauncher::focus_terminal_window(
|
||||
&terminal_type,
|
||||
&window_info,
|
||||
).await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tauri::command]
|
||||
pub async fn get_terminal_windows_applescript() -> Result<Vec<(u32, String)>, String> {
|
||||
crate::applescript::AppleScriptRunner::get_terminal_windows()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tauri::command]
|
||||
pub async fn is_app_running_applescript(app_name: String) -> Result<bool, String> {
|
||||
crate::applescript::AppleScriptRunner::is_app_running(&app_name)
|
||||
}
|
||||
|
||||
// Git commands
|
||||
#[tauri::command]
|
||||
pub async fn get_git_repository(
|
||||
file_path: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<crate::git_repository::GitRepository>, String> {
|
||||
Ok(state
|
||||
.git_monitor
|
||||
.find_repository(&file_path)
|
||||
.await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_cached_git_repository(
|
||||
file_path: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<crate::git_repository::GitRepository>, String> {
|
||||
Ok(state.git_monitor.get_cached_repository(&file_path).await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_git_cache(state: State<'_, AppState>) -> Result<(), String> {
|
||||
state.git_monitor.clear_cache().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_git_monitoring(state: State<'_, AppState>, app: tauri::AppHandle) -> Result<(), String> {
|
||||
state.git_monitor.start_monitoring(app).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Dock Manager Commands
|
||||
#[tauri::command]
|
||||
pub fn set_dock_visible(state: State<'_, AppState>, visible: bool) -> Result<(), String> {
|
||||
super::dock_manager::set_dock_visible(state, visible)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_dock_visible(state: State<'_, AppState>) -> bool {
|
||||
super::dock_manager::get_dock_visible(state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_dock_visibility(state: State<'_, AppState>) -> Result<(), String> {
|
||||
super::dock_manager::update_dock_visibility(state)
|
||||
}
|
||||
|
||||
// Status Indicator Commands
|
||||
#[tauri::command]
|
||||
pub async fn update_status_indicator(
|
||||
state: State<'_, AppState>,
|
||||
server_running: bool,
|
||||
active_sessions: usize,
|
||||
total_sessions: usize,
|
||||
) -> Result<(), String> {
|
||||
super::status_indicator::update_status_indicator(state, server_running, active_sessions, total_sessions).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn flash_activity_indicator(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
super::status_indicator::flash_activity_indicator(state).await
|
||||
}
|
||||
|
||||
// Power Manager Commands
|
||||
#[tauri::command]
|
||||
pub fn prevent_sleep(state: State<'_, AppState>) -> Result<(), String> {
|
||||
super::power_manager::prevent_sleep(state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn allow_sleep(state: State<'_, AppState>) -> Result<(), String> {
|
||||
super::power_manager::allow_sleep(state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn is_sleep_prevented(state: State<'_, AppState>) -> bool {
|
||||
super::power_manager::is_sleep_prevented(state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
142
tauri/src-tauri/src/dock_manager.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Manages dock icon visibility on macOS
|
||||
pub struct DockManager {
|
||||
should_show_in_dock: Arc<AtomicBool>,
|
||||
#[cfg(target_os = "macos")]
|
||||
window_count: Arc<std::sync::Mutex<usize>>,
|
||||
}
|
||||
|
||||
impl DockManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
should_show_in_dock: Arc::new(AtomicBool::new(true)),
|
||||
#[cfg(target_os = "macos")]
|
||||
window_count: Arc::new(std::sync::Mutex::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the user preference for dock visibility
|
||||
pub fn set_show_in_dock(&self, show: bool) {
|
||||
self.should_show_in_dock.store(show, Ordering::Relaxed);
|
||||
debug!("Dock preference updated: {}", show);
|
||||
}
|
||||
|
||||
/// Gets the current dock preference
|
||||
pub fn get_show_in_dock(&self) -> bool {
|
||||
self.should_show_in_dock.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Updates dock visibility based on window count and user preference
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn update_dock_visibility(&self, app_handle: &AppHandle) {
|
||||
use tauri::ActivationPolicy;
|
||||
|
||||
let user_wants_dock_hidden = !self.should_show_in_dock.load(Ordering::Relaxed);
|
||||
|
||||
// Count visible windows
|
||||
let visible_window_count = app_handle
|
||||
.webview_windows()
|
||||
.values()
|
||||
.filter(|window| {
|
||||
window.is_visible().unwrap_or(false) &&
|
||||
!window.is_minimized().unwrap_or(false)
|
||||
})
|
||||
.count();
|
||||
|
||||
// Update stored window count
|
||||
{
|
||||
let mut count = self.window_count.lock().unwrap();
|
||||
*count = visible_window_count;
|
||||
}
|
||||
|
||||
// Show dock if user wants it shown OR if any windows are open
|
||||
if !user_wants_dock_hidden || visible_window_count > 0 {
|
||||
debug!("Showing dock icon (windows: {}, user_hidden: {})", visible_window_count, user_wants_dock_hidden);
|
||||
let _ = app_handle.set_activation_policy(ActivationPolicy::Regular);
|
||||
} else {
|
||||
debug!("Hiding dock icon (windows: {}, user_hidden: {})", visible_window_count, user_wants_dock_hidden);
|
||||
let _ = app_handle.set_activation_policy(ActivationPolicy::Accessory);
|
||||
}
|
||||
}
|
||||
|
||||
/// Force shows the dock icon temporarily
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn temporarily_show_dock(&self, app_handle: &AppHandle) {
|
||||
use tauri::ActivationPolicy;
|
||||
info!("Temporarily showing dock icon");
|
||||
let _ = app_handle.set_activation_policy(ActivationPolicy::Regular);
|
||||
}
|
||||
|
||||
/// Called when a window is created
|
||||
pub fn on_window_created(&self, app_handle: &AppHandle) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.temporarily_show_dock(app_handle);
|
||||
self.update_dock_visibility(app_handle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when a window is closed
|
||||
pub fn on_window_closed(&self, app_handle: &AppHandle) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Add a small delay to let window state settle
|
||||
let app_handle = app_handle.clone();
|
||||
let dock_manager = DockManager::new();
|
||||
dock_manager.set_show_in_dock(self.get_show_in_dock());
|
||||
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
dock_manager.update_dock_visibility(&app_handle);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when window visibility changes
|
||||
pub fn on_window_visibility_changed(&self, app_handle: &AppHandle) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.update_dock_visibility(app_handle);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn update_dock_visibility(&self, _app_handle: &AppHandle) {
|
||||
// No-op on non-macOS platforms
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn temporarily_show_dock(&self, _app_handle: &AppHandle) {
|
||||
// No-op on non-macOS platforms
|
||||
}
|
||||
}
|
||||
|
||||
// Public functions for commands (without tauri::command attribute)
|
||||
pub fn set_dock_visible(state: tauri::State<crate::state::AppState>, visible: bool) -> Result<(), String> {
|
||||
state.dock_manager.set_show_in_dock(visible);
|
||||
|
||||
// Update immediately on the app handle
|
||||
if let Some(app_handle) = state.get_app_handle() {
|
||||
state.dock_manager.update_dock_visibility(&app_handle);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_dock_visible(state: tauri::State<crate::state::AppState>) -> bool {
|
||||
state.dock_manager.get_show_in_dock()
|
||||
}
|
||||
|
||||
pub fn update_dock_visibility(state: tauri::State<crate::state::AppState>) -> Result<(), String> {
|
||||
if let Some(app_handle) = state.get_app_handle() {
|
||||
state.dock_manager.update_dock_visibility(&app_handle);
|
||||
Ok(())
|
||||
} else {
|
||||
Err("App handle not available".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
351
tauri/src-tauri/src/git_app_launcher.rs
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum GitApp {
|
||||
Cursor,
|
||||
Fork,
|
||||
GitHubDesktop,
|
||||
GitUp,
|
||||
SourceTree,
|
||||
SublimeMerge,
|
||||
Tower,
|
||||
VSCode,
|
||||
Windsurf,
|
||||
}
|
||||
|
||||
impl GitApp {
|
||||
pub fn all() -> Vec<Self> {
|
||||
vec![
|
||||
Self::Cursor,
|
||||
Self::Fork,
|
||||
Self::GitHubDesktop,
|
||||
Self::GitUp,
|
||||
Self::SourceTree,
|
||||
Self::SublimeMerge,
|
||||
Self::Tower,
|
||||
Self::VSCode,
|
||||
Self::Windsurf,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn raw_value(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Cursor => "Cursor",
|
||||
Self::Fork => "Fork",
|
||||
Self::GitHubDesktop => "GitHub Desktop",
|
||||
Self::GitUp => "GitUp",
|
||||
Self::SourceTree => "SourceTree",
|
||||
Self::SublimeMerge => "Sublime Merge",
|
||||
Self::Tower => "Tower",
|
||||
Self::VSCode => "Visual Studio Code",
|
||||
Self::Windsurf => "Windsurf",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_raw_value(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"Cursor" => Some(Self::Cursor),
|
||||
"Fork" => Some(Self::Fork),
|
||||
"GitHub Desktop" => Some(Self::GitHubDesktop),
|
||||
"GitUp" => Some(Self::GitUp),
|
||||
"SourceTree" => Some(Self::SourceTree),
|
||||
"Sublime Merge" => Some(Self::SublimeMerge),
|
||||
"Tower" => Some(Self::Tower),
|
||||
"Visual Studio Code" => Some(Self::VSCode),
|
||||
"Windsurf" => Some(Self::Windsurf),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn bundle_identifier(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Cursor => "com.todesktop.230313mzl4w4u92",
|
||||
Self::Fork => "com.DanPristupov.Fork",
|
||||
Self::GitHubDesktop => "com.github.GitHubClient",
|
||||
Self::GitUp => "co.gitup.mac",
|
||||
Self::SourceTree => "com.torusknot.SourceTreeNotMAS",
|
||||
Self::SublimeMerge => "com.sublimemerge",
|
||||
Self::Tower => "com.fournova.Tower3",
|
||||
Self::VSCode => "com.microsoft.VSCode",
|
||||
Self::Windsurf => "com.codeiumapp.windsurf",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detection_priority(&self) -> u8 {
|
||||
match self {
|
||||
Self::Cursor => 70,
|
||||
Self::Fork => 75,
|
||||
Self::GitHubDesktop => 90,
|
||||
Self::GitUp => 60,
|
||||
Self::SourceTree => 80,
|
||||
Self::SublimeMerge => 85,
|
||||
Self::Tower => 100,
|
||||
Self::VSCode => 95,
|
||||
Self::Windsurf => 65,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
self.raw_value()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn is_installed(&self) -> bool {
|
||||
// Check if app is installed using mdfind
|
||||
let output = Command::new("mdfind")
|
||||
.arg(format!("kMDItemCFBundleIdentifier == '{}'", self.bundle_identifier()))
|
||||
.output()
|
||||
.ok();
|
||||
|
||||
output
|
||||
.map(|o| !o.stdout.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn is_installed(&self) -> bool {
|
||||
// Check common installation paths on Windows
|
||||
match self {
|
||||
Self::VSCode => {
|
||||
// Check if VS Code is in PATH
|
||||
Command::new("code")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.is_ok()
|
||||
}
|
||||
Self::GitHubDesktop => {
|
||||
// Check for GitHub Desktop in AppData
|
||||
let app_data = std::env::var("LOCALAPPDATA").unwrap_or_default();
|
||||
std::path::Path::new(&app_data)
|
||||
.join("GitHubDesktop")
|
||||
.join("GitHubDesktop.exe")
|
||||
.exists()
|
||||
}
|
||||
Self::Fork => {
|
||||
// Check for Fork in Program Files
|
||||
let program_files = std::env::var("ProgramFiles").unwrap_or_default();
|
||||
std::path::Path::new(&program_files)
|
||||
.join("Fork")
|
||||
.join("Fork.exe")
|
||||
.exists()
|
||||
}
|
||||
Self::SourceTree => {
|
||||
// Check for SourceTree in AppData
|
||||
let app_data = std::env::var("LOCALAPPDATA").unwrap_or_default();
|
||||
std::path::Path::new(&app_data)
|
||||
.join("SourceTree")
|
||||
.join("SourceTree.exe")
|
||||
.exists()
|
||||
}
|
||||
Self::SublimeMerge => {
|
||||
// Check if Sublime Merge is in PATH
|
||||
Command::new("smerge")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.is_ok()
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn is_installed(&self) -> bool {
|
||||
// Check if application is available in PATH
|
||||
match self {
|
||||
Self::VSCode => Command::new("code").arg("--version").output().is_ok(),
|
||||
Self::SublimeMerge => Command::new("smerge").arg("--version").output().is_ok(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn installed_apps() -> Vec<Self> {
|
||||
Self::all()
|
||||
.into_iter()
|
||||
.filter(|app| app.is_installed())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GitAppLauncher;
|
||||
|
||||
impl GitAppLauncher {
|
||||
/// Open a repository in the preferred Git app
|
||||
pub fn open_repository(path: &str) -> Result<(), String> {
|
||||
let git_app = Self::get_preferred_git_app();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Some(app) = git_app {
|
||||
// Use open command with bundle identifier
|
||||
let output = Command::new("open")
|
||||
.arg("-b")
|
||||
.arg(app.bundle_identifier())
|
||||
.arg(path)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to launch Git app: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to open Git app: {}", stderr));
|
||||
}
|
||||
} else {
|
||||
// Fallback to opening in Finder
|
||||
Command::new("open")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open in Finder: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(app) = git_app {
|
||||
match app {
|
||||
GitApp::VSCode => {
|
||||
Command::new("code")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch VS Code: {}", e))?;
|
||||
}
|
||||
GitApp::GitHubDesktop => {
|
||||
let app_data = std::env::var("LOCALAPPDATA").unwrap_or_default();
|
||||
let github_desktop = std::path::Path::new(&app_data)
|
||||
.join("GitHubDesktop")
|
||||
.join("GitHubDesktop.exe");
|
||||
|
||||
Command::new(github_desktop)
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch GitHub Desktop: {}", e))?;
|
||||
}
|
||||
_ => {
|
||||
// Fallback to Explorer
|
||||
Command::new("explorer")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open in Explorer: {}", e))?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to Explorer
|
||||
Command::new("explorer")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open in Explorer: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(app) = git_app {
|
||||
match app {
|
||||
GitApp::VSCode => {
|
||||
Command::new("code")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch VS Code: {}", e))?;
|
||||
}
|
||||
_ => {
|
||||
// Fallback to file manager
|
||||
Command::new("xdg-open")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open in file manager: {}", e))?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to file manager
|
||||
Command::new("xdg-open")
|
||||
.arg(path)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open in file manager: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the preferred Git app from settings
|
||||
pub fn get_preferred_git_app() -> Option<GitApp> {
|
||||
if let Ok(settings) = crate::settings::Settings::load() {
|
||||
if let Some(app_name) = settings.advanced.preferred_git_app {
|
||||
return GitApp::from_raw_value(&app_name);
|
||||
}
|
||||
}
|
||||
|
||||
// If no preference set, auto-detect the best available
|
||||
Self::auto_detect_git_app()
|
||||
}
|
||||
|
||||
/// Auto-detect the best available Git app
|
||||
pub fn auto_detect_git_app() -> Option<GitApp> {
|
||||
let installed = GitApp::installed_apps();
|
||||
|
||||
// Sort by priority and return the highest priority app
|
||||
installed
|
||||
.into_iter()
|
||||
.max_by_key(|app| app.detection_priority())
|
||||
}
|
||||
|
||||
/// Set the preferred Git app and save to settings
|
||||
pub fn set_preferred_git_app(app: Option<&GitApp>) -> Result<(), String> {
|
||||
let mut settings = crate::settings::Settings::load().unwrap_or_default();
|
||||
settings.advanced.preferred_git_app = app.map(|a| a.raw_value().to_string());
|
||||
settings.save().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Verify that the preferred Git app is still installed
|
||||
pub fn verify_preferred_git_app() -> Result<(), String> {
|
||||
if let Some(app) = Self::get_preferred_git_app() {
|
||||
if !app.is_installed() {
|
||||
// Clear the preference if app is no longer installed
|
||||
Self::set_preferred_git_app(None)?;
|
||||
info!("Cleared preferred Git app as it's no longer installed");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Commands
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct GitAppOption {
|
||||
pub value: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_installed_git_apps() -> Vec<GitAppOption> {
|
||||
GitApp::installed_apps()
|
||||
.into_iter()
|
||||
.map(|app| GitAppOption {
|
||||
value: app.raw_value().to_string(),
|
||||
label: app.raw_value().to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_preferred_git_app() -> Option<String> {
|
||||
GitAppLauncher::get_preferred_git_app()
|
||||
.map(|app| app.raw_value().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_preferred_git_app(app: Option<String>) -> Result<(), String> {
|
||||
let git_app = app.and_then(|name| GitApp::from_raw_value(&name));
|
||||
GitAppLauncher::set_preferred_git_app(git_app.as_ref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_repository_in_git_app(path: String) -> Result<(), String> {
|
||||
GitAppLauncher::open_repository(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn verify_git_app_installation() -> Result<(), String> {
|
||||
GitAppLauncher::verify_preferred_git_app()
|
||||
}
|
||||
291
tauri/src-tauri/src/git_monitor.rs
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
use crate::git_repository::GitRepository;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tauri::async_runtime::Mutex;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::time::interval;
|
||||
|
||||
pub struct GitMonitor {
|
||||
// Cache for repository information by repository path
|
||||
repository_cache: Arc<RwLock<HashMap<String, GitRepository>>>,
|
||||
// Cache mapping file paths to their repository paths
|
||||
file_to_repo_cache: Arc<RwLock<HashMap<String, String>>>,
|
||||
// Cache for GitHub URLs by repository path
|
||||
github_url_cache: Arc<RwLock<HashMap<String, String>>>,
|
||||
// Track in-progress GitHub URL fetches
|
||||
github_url_fetches: Arc<Mutex<std::collections::HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl GitMonitor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
repository_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
file_to_repo_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
github_url_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
github_url_fetches: Arc::new(Mutex::new(std::collections::HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached repository information synchronously
|
||||
pub async fn get_cached_repository(&self, file_path: &str) -> Option<GitRepository> {
|
||||
let file_to_repo = self.file_to_repo_cache.read().await;
|
||||
if let Some(repo_path) = file_to_repo.get(file_path) {
|
||||
let repos = self.repository_cache.read().await;
|
||||
return repos.get(repo_path).cloned();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find Git repository for a given file path and return its status
|
||||
pub async fn find_repository(&self, file_path: &str) -> Option<GitRepository> {
|
||||
// Validate path first
|
||||
if !Self::validate_path(file_path) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if let Some(cached) = self.get_cached_repository(file_path).await {
|
||||
return Some(cached);
|
||||
}
|
||||
|
||||
// Find the Git repository root
|
||||
let repo_path = Self::find_git_root(file_path)?;
|
||||
|
||||
// Check if we already have this repository cached
|
||||
{
|
||||
let repos = self.repository_cache.read().await;
|
||||
if let Some(cached_repo) = repos.get(&repo_path) {
|
||||
// Cache the file->repo mapping
|
||||
let mut file_to_repo = self.file_to_repo_cache.write().await;
|
||||
file_to_repo.insert(file_path.to_string(), repo_path.clone());
|
||||
return Some(cached_repo.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Get repository status
|
||||
let repository = self.get_repository_status(&repo_path).await?;
|
||||
|
||||
// Cache the result
|
||||
self.cache_repository(&repository, Some(file_path)).await;
|
||||
|
||||
Some(repository)
|
||||
}
|
||||
|
||||
/// Clear all caches
|
||||
pub async fn clear_cache(&self) {
|
||||
self.repository_cache.write().await.clear();
|
||||
self.file_to_repo_cache.write().await.clear();
|
||||
self.github_url_cache.write().await.clear();
|
||||
self.github_url_fetches.lock().await.clear();
|
||||
}
|
||||
|
||||
/// Start monitoring and refreshing all cached repositories
|
||||
pub async fn start_monitoring(&self, app_handle: AppHandle) {
|
||||
let cache = self.repository_cache.clone();
|
||||
let github_cache = self.github_url_cache.clone();
|
||||
let fetches = self.github_url_fetches.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut refresh_interval = interval(Duration::from_secs(5));
|
||||
loop {
|
||||
refresh_interval.tick().await;
|
||||
Self::refresh_all_cached(&cache, &github_cache, &fetches).await;
|
||||
// Emit event to update UI
|
||||
let _ = app_handle.emit("git-repos-updated", ());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Validate and sanitize paths
|
||||
fn validate_path(path: &str) -> bool {
|
||||
let path = Path::new(path);
|
||||
path.is_absolute() && path.exists()
|
||||
}
|
||||
|
||||
/// Find the Git repository root starting from a given path
|
||||
fn find_git_root(path: &str) -> Option<String> {
|
||||
let mut current_path = PathBuf::from(path);
|
||||
|
||||
// If it's a file, start from its directory
|
||||
if current_path.is_file() {
|
||||
current_path = current_path.parent()?.to_path_buf();
|
||||
}
|
||||
|
||||
// Search up the directory tree to the root
|
||||
loop {
|
||||
let git_path = current_path.join(".git");
|
||||
if git_path.exists() {
|
||||
return current_path.to_str().map(|s| s.to_string());
|
||||
}
|
||||
|
||||
if !current_path.pop() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Get repository status by running git status
|
||||
async fn get_repository_status(&self, repo_path: &str) -> Option<GitRepository> {
|
||||
// Get basic git status
|
||||
let mut repository = Self::get_basic_git_status(repo_path)?;
|
||||
|
||||
// Check if we have a cached GitHub URL
|
||||
let github_urls = self.github_url_cache.read().await;
|
||||
if let Some(url) = github_urls.get(repo_path) {
|
||||
repository.github_url = Some(url.clone());
|
||||
} else {
|
||||
// Fetch GitHub URL in background
|
||||
let repo_path_clone = repo_path.to_string();
|
||||
let github_cache = self.github_url_cache.clone();
|
||||
let fetches = self.github_url_fetches.clone();
|
||||
tokio::spawn(async move {
|
||||
Self::fetch_github_url_background(repo_path_clone, github_cache, fetches).await;
|
||||
});
|
||||
}
|
||||
|
||||
Some(repository)
|
||||
}
|
||||
|
||||
/// Get basic repository status without GitHub URL
|
||||
fn get_basic_git_status(repo_path: &str) -> Option<GitRepository> {
|
||||
let output = Command::new("git")
|
||||
.args(&["status", "--porcelain", "--branch"])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let output_str = String::from_utf8(output.stdout).ok()?;
|
||||
Some(Self::parse_git_status(&output_str, repo_path))
|
||||
}
|
||||
|
||||
/// Parse git status --porcelain output
|
||||
fn parse_git_status(output: &str, repo_path: &str) -> GitRepository {
|
||||
let lines: Vec<&str> = output.lines().collect();
|
||||
let mut current_branch = None;
|
||||
let mut modified_count = 0;
|
||||
let mut added_count = 0;
|
||||
let mut deleted_count = 0;
|
||||
let mut untracked_count = 0;
|
||||
|
||||
for line in &lines {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Parse branch information (first line with --branch flag)
|
||||
if trimmed.starts_with("##") {
|
||||
let branch_info = trimmed[2..].trim();
|
||||
// Extract branch name (format: "branch...tracking" or just "branch")
|
||||
if let Some(dot_index) = branch_info.find('.') {
|
||||
current_branch = Some(branch_info[..dot_index].to_string());
|
||||
} else {
|
||||
current_branch = Some(branch_info.to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip empty lines
|
||||
if trimmed.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get status code (first two characters)
|
||||
let status_code = &trimmed[..2];
|
||||
|
||||
// Count files based on status codes
|
||||
match status_code {
|
||||
"??" => untracked_count += 1,
|
||||
code if code.contains('M') => modified_count += 1,
|
||||
code if code.contains('A') => added_count += 1,
|
||||
code if code.contains('D') => deleted_count += 1,
|
||||
code if code.contains('R') || code.contains('C') => modified_count += 1,
|
||||
code if code.contains('U') => modified_count += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
GitRepository {
|
||||
path: repo_path.to_string(),
|
||||
modified_count,
|
||||
added_count,
|
||||
deleted_count,
|
||||
untracked_count,
|
||||
current_branch,
|
||||
github_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch GitHub URL in background and cache it
|
||||
async fn fetch_github_url_background(
|
||||
repo_path: String,
|
||||
github_cache: Arc<RwLock<HashMap<String, String>>>,
|
||||
fetches: Arc<Mutex<std::collections::HashSet<String>>>,
|
||||
) {
|
||||
// Check if already fetching
|
||||
{
|
||||
let mut fetches_guard = fetches.lock().await;
|
||||
if fetches_guard.contains(&repo_path) {
|
||||
return;
|
||||
}
|
||||
fetches_guard.insert(repo_path.clone());
|
||||
}
|
||||
|
||||
// Fetch GitHub URL
|
||||
if let Some(github_url) = GitRepository::get_github_url(&repo_path) {
|
||||
github_cache.write().await.insert(repo_path.clone(), github_url);
|
||||
}
|
||||
|
||||
// Remove from fetches
|
||||
fetches.lock().await.remove(&repo_path);
|
||||
}
|
||||
|
||||
/// Refresh all cached repositories
|
||||
async fn refresh_all_cached(
|
||||
cache: &Arc<RwLock<HashMap<String, GitRepository>>>,
|
||||
github_cache: &Arc<RwLock<HashMap<String, String>>>,
|
||||
_fetches: &Arc<Mutex<std::collections::HashSet<String>>>,
|
||||
) {
|
||||
let repo_paths: Vec<String> = {
|
||||
let repos = cache.read().await;
|
||||
repos.keys().cloned().collect()
|
||||
};
|
||||
|
||||
for repo_path in repo_paths {
|
||||
if let Some(mut fresh) = Self::get_basic_git_status(&repo_path) {
|
||||
// Add GitHub URL if cached
|
||||
let github_urls = github_cache.read().await;
|
||||
if let Some(url) = github_urls.get(&repo_path) {
|
||||
fresh.github_url = Some(url.clone());
|
||||
}
|
||||
|
||||
cache.write().await.insert(repo_path, fresh);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache repository information
|
||||
async fn cache_repository(&self, repository: &GitRepository, original_file_path: Option<&str>) {
|
||||
self.repository_cache
|
||||
.write()
|
||||
.await
|
||||
.insert(repository.path.clone(), repository.clone());
|
||||
|
||||
// Also map the original file path if different from repository path
|
||||
if let Some(file_path) = original_file_path {
|
||||
if file_path != repository.path {
|
||||
self.file_to_repo_cache
|
||||
.write()
|
||||
.await
|
||||
.insert(file_path.to_string(), repository.path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
113
tauri/src-tauri/src/git_repository.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub struct GitRepository {
|
||||
pub path: String,
|
||||
pub modified_count: usize,
|
||||
pub added_count: usize,
|
||||
pub deleted_count: usize,
|
||||
pub untracked_count: usize,
|
||||
pub current_branch: Option<String>,
|
||||
pub github_url: Option<String>,
|
||||
}
|
||||
|
||||
impl GitRepository {
|
||||
pub fn new(path: String) -> Self {
|
||||
Self {
|
||||
path,
|
||||
modified_count: 0,
|
||||
added_count: 0,
|
||||
deleted_count: 0,
|
||||
untracked_count: 0,
|
||||
current_branch: None,
|
||||
github_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_changes(&self) -> bool {
|
||||
self.modified_count > 0
|
||||
|| self.added_count > 0
|
||||
|| self.deleted_count > 0
|
||||
|| self.untracked_count > 0
|
||||
}
|
||||
|
||||
pub fn total_changed_files(&self) -> usize {
|
||||
self.modified_count + self.added_count + self.deleted_count + self.untracked_count
|
||||
}
|
||||
|
||||
pub fn folder_name(&self) -> &str {
|
||||
Path::new(&self.path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
pub fn status_text(&self) -> String {
|
||||
if !self.has_changes() {
|
||||
return "clean".to_string();
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
if self.modified_count > 0 {
|
||||
parts.push(format!("{}M", self.modified_count));
|
||||
}
|
||||
if self.added_count > 0 {
|
||||
parts.push(format!("{}A", self.added_count));
|
||||
}
|
||||
if self.deleted_count > 0 {
|
||||
parts.push(format!("{}D", self.deleted_count));
|
||||
}
|
||||
if self.untracked_count > 0 {
|
||||
parts.push(format!("{}U", self.untracked_count));
|
||||
}
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
/// Extract GitHub URL from a repository path
|
||||
pub fn get_github_url(repo_path: &str) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(&["remote", "get-url", "origin"])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let remote_url = String::from_utf8(output.stdout)
|
||||
.ok()?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
Self::parse_github_url(&remote_url)
|
||||
}
|
||||
|
||||
/// Parse GitHub URL from git remote output
|
||||
fn parse_github_url(remote_url: &str) -> Option<String> {
|
||||
// Handle HTTPS URLs: https://github.com/user/repo.git
|
||||
if remote_url.starts_with("https://github.com/") {
|
||||
let clean_url = if remote_url.ends_with(".git") {
|
||||
&remote_url[..remote_url.len() - 4]
|
||||
} else {
|
||||
remote_url
|
||||
};
|
||||
return Some(clean_url.to_string());
|
||||
}
|
||||
|
||||
// Handle SSH URLs: git@github.com:user/repo.git
|
||||
if remote_url.starts_with("git@github.com:") {
|
||||
let path_part = &remote_url["git@github.com:".len()..];
|
||||
let clean_path = if path_part.ends_with(".git") {
|
||||
&path_part[..path_part.len() - 4]
|
||||
} else {
|
||||
path_part
|
||||
};
|
||||
return Some(format!("https://github.com/{}", clean_path));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +1,35 @@
|
|||
pub mod api_client;
|
||||
pub mod api_testing;
|
||||
pub mod app_mover;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod applescript;
|
||||
pub mod auth_cache;
|
||||
pub mod auto_launch;
|
||||
pub mod backend_manager;
|
||||
pub mod cli_installer;
|
||||
pub mod commands;
|
||||
pub mod debug_features;
|
||||
pub mod dock_manager;
|
||||
pub mod errors;
|
||||
pub mod fs_api;
|
||||
pub mod git_app_launcher;
|
||||
pub mod git_monitor;
|
||||
pub mod git_repository;
|
||||
pub mod keychain;
|
||||
pub mod log_collector;
|
||||
pub mod menubar_popover;
|
||||
pub mod network_utils;
|
||||
pub mod ngrok;
|
||||
pub mod notification_manager;
|
||||
pub mod permissions;
|
||||
pub mod port_conflict;
|
||||
pub mod power_manager;
|
||||
pub mod process_tracker;
|
||||
pub mod status_indicator;
|
||||
pub mod session_monitor;
|
||||
pub mod settings;
|
||||
pub mod state;
|
||||
pub mod tailscale;
|
||||
pub mod terminal;
|
||||
pub mod terminal_detector;
|
||||
pub mod terminal_integrations;
|
||||
|
|
@ -27,7 +39,11 @@ pub mod tty_forward;
|
|||
#[cfg(unix)]
|
||||
pub mod unix_socket_server;
|
||||
pub mod updater;
|
||||
pub mod url_scheme;
|
||||
pub mod welcome;
|
||||
pub mod window_enumerator;
|
||||
pub mod window_matcher;
|
||||
pub mod window_tracker;
|
||||
|
||||
#[cfg(mobile)]
|
||||
pub fn init() {
|
||||
|
|
|
|||
73
tauri/src-tauri/src/log_collector.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
use std::collections::VecDeque;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LogEntry {
|
||||
pub timestamp: String,
|
||||
pub level: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub struct LogCollector {
|
||||
buffer: Arc<RwLock<VecDeque<LogEntry>>>,
|
||||
max_size: usize,
|
||||
app_handle: Arc<RwLock<Option<AppHandle>>>,
|
||||
}
|
||||
|
||||
impl LogCollector {
|
||||
pub fn new(max_size: usize) -> Self {
|
||||
Self {
|
||||
buffer: Arc::new(RwLock::new(VecDeque::with_capacity(max_size))),
|
||||
max_size,
|
||||
app_handle: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_app_handle(&self, app_handle: AppHandle) {
|
||||
*self.app_handle.write().await = Some(app_handle);
|
||||
}
|
||||
|
||||
pub async fn add_log(&self, level: &str, message: String) {
|
||||
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f").to_string();
|
||||
let entry = LogEntry {
|
||||
timestamp,
|
||||
level: level.to_string(),
|
||||
message,
|
||||
};
|
||||
|
||||
// Emit to frontend if app handle is available
|
||||
if let Some(ref app) = *self.app_handle.read().await {
|
||||
let _ = app.emit("server-log", &entry);
|
||||
}
|
||||
|
||||
// Add to buffer
|
||||
let mut buffer = self.buffer.write().await;
|
||||
if buffer.len() >= self.max_size {
|
||||
buffer.pop_front();
|
||||
}
|
||||
buffer.push_back(entry);
|
||||
}
|
||||
|
||||
pub async fn get_logs(&self) -> Vec<LogEntry> {
|
||||
self.buffer.read().await.iter().cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn clear(&self) {
|
||||
self.buffer.write().await.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Global log collector instance
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref SERVER_LOG_COLLECTOR: Arc<LogCollector> = Arc::new(LogCollector::new(1000));
|
||||
}
|
||||
|
||||
// These functions are exposed through commands.rs which already has the tauri::command attributes
|
||||
|
||||
// Initialize the log collector with app handle
|
||||
pub async fn init_log_collector(app_handle: AppHandle) {
|
||||
SERVER_LOG_COLLECTOR.set_app_handle(app_handle).await;
|
||||
}
|
||||
|
|
@ -11,23 +11,35 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
|||
mod api_client;
|
||||
mod api_testing;
|
||||
mod app_mover;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod applescript;
|
||||
mod auth_cache;
|
||||
mod auto_launch;
|
||||
mod backend_manager;
|
||||
mod cli_installer;
|
||||
mod commands;
|
||||
mod debug_features;
|
||||
mod dock_manager;
|
||||
mod errors;
|
||||
mod fs_api;
|
||||
mod git_app_launcher;
|
||||
pub mod git_monitor;
|
||||
pub mod git_repository;
|
||||
mod keychain;
|
||||
mod log_collector;
|
||||
mod menubar_popover;
|
||||
mod network_utils;
|
||||
mod ngrok;
|
||||
mod notification_manager;
|
||||
mod permissions;
|
||||
mod port_conflict;
|
||||
mod power_manager;
|
||||
mod process_tracker;
|
||||
mod session_monitor;
|
||||
mod status_indicator;
|
||||
mod settings;
|
||||
mod state;
|
||||
mod tailscale;
|
||||
mod terminal;
|
||||
mod terminal_detector;
|
||||
mod terminal_integrations;
|
||||
|
|
@ -37,7 +49,11 @@ mod tty_forward;
|
|||
#[cfg(unix)]
|
||||
mod unix_socket_server;
|
||||
mod updater;
|
||||
mod url_scheme;
|
||||
mod welcome;
|
||||
mod window_enumerator;
|
||||
mod window_matcher;
|
||||
mod window_tracker;
|
||||
|
||||
use commands::ServerStatus;
|
||||
use commands::*;
|
||||
|
|
@ -92,7 +108,7 @@ fn open_settings_window(app: AppHandle, tab: Option<String>) -> Result<(), Strin
|
|||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn focus_terminal_window(session_id: String) -> Result<(), String> {
|
||||
fn focus_terminal_window_legacy(session_id: String) -> Result<(), String> {
|
||||
// Focus the terminal window for the given session
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
|
|
@ -224,7 +240,7 @@ fn main() {
|
|||
show_main_window,
|
||||
open_settings_window,
|
||||
open_session_detail_window,
|
||||
focus_terminal_window,
|
||||
focus_terminal_window_legacy,
|
||||
quit_app,
|
||||
settings::get_settings,
|
||||
settings::save_settings,
|
||||
|
|
@ -291,6 +307,9 @@ fn main() {
|
|||
all_required_permissions_granted,
|
||||
open_system_permission_settings,
|
||||
get_permission_stats,
|
||||
register_permission_monitoring,
|
||||
unregister_permission_monitoring,
|
||||
show_permission_alert,
|
||||
check_for_updates,
|
||||
download_update,
|
||||
install_update,
|
||||
|
|
@ -405,6 +424,61 @@ fn main() {
|
|||
save_dashboard_password,
|
||||
open_dashboard,
|
||||
finish_welcome,
|
||||
open_folder,
|
||||
// Tailscale commands
|
||||
get_tailscale_status,
|
||||
start_tailscale_monitoring,
|
||||
open_tailscale_app,
|
||||
open_tailscale_download,
|
||||
open_tailscale_setup_guide,
|
||||
// Window tracking commands
|
||||
register_terminal_window,
|
||||
unregister_terminal_window,
|
||||
focus_terminal_window,
|
||||
get_terminal_window_info,
|
||||
update_window_tracking,
|
||||
// AppleScript commands (macOS only)
|
||||
#[cfg(target_os = "macos")]
|
||||
run_applescript,
|
||||
#[cfg(target_os = "macos")]
|
||||
launch_terminal_with_applescript,
|
||||
#[cfg(target_os = "macos")]
|
||||
focus_terminal_with_applescript,
|
||||
#[cfg(target_os = "macos")]
|
||||
get_terminal_windows_applescript,
|
||||
#[cfg(target_os = "macos")]
|
||||
is_app_running_applescript,
|
||||
// Git commands
|
||||
get_git_repository,
|
||||
get_cached_git_repository,
|
||||
clear_git_cache,
|
||||
start_git_monitoring,
|
||||
// Git app launcher commands
|
||||
git_app_launcher::get_installed_git_apps,
|
||||
git_app_launcher::get_preferred_git_app,
|
||||
git_app_launcher::set_preferred_git_app,
|
||||
git_app_launcher::open_repository_in_git_app,
|
||||
git_app_launcher::verify_git_app_installation,
|
||||
// URL scheme commands
|
||||
url_scheme::handle_url_scheme,
|
||||
url_scheme::parse_url_scheme,
|
||||
// Menubar popover commands
|
||||
menubar_popover::show_menubar_popover,
|
||||
menubar_popover::hide_menubar_popover,
|
||||
menubar_popover::toggle_menubar_popover,
|
||||
// Server log commands
|
||||
clear_server_logs,
|
||||
// Dock manager commands
|
||||
set_dock_visible,
|
||||
get_dock_visible,
|
||||
update_dock_visibility,
|
||||
// Status indicator commands
|
||||
update_status_indicator,
|
||||
flash_activity_indicator,
|
||||
// Power manager commands
|
||||
prevent_sleep,
|
||||
allow_sleep,
|
||||
is_sleep_prevented,
|
||||
])
|
||||
.setup(|app| {
|
||||
// Set app handle in managers
|
||||
|
|
@ -414,6 +488,11 @@ fn main() {
|
|||
let app_handle3 = app.handle().clone();
|
||||
let app_handle4 = app.handle().clone();
|
||||
let app_handle_for_move = app.handle().clone();
|
||||
let app_handle_for_url_scheme = app.handle().clone();
|
||||
let app_handle_for_log_collector = app.handle().clone();
|
||||
let app_handle_for_state = app.handle().clone();
|
||||
let app_handle_for_dock = app.handle().clone();
|
||||
let app_handle_for_status = app.handle().clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let state = state_clone;
|
||||
|
|
@ -421,7 +500,15 @@ fn main() {
|
|||
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;
|
||||
|
||||
// Set app handles for new managers
|
||||
state.set_app_handle(app_handle_for_state).await;
|
||||
state.dock_manager.on_window_created(&app_handle_for_dock);
|
||||
state.status_indicator.set_app_handle(app_handle_for_status);
|
||||
|
||||
// Initialize log collector
|
||||
log_collector::init_log_collector(app_handle_for_log_collector).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;
|
||||
|
|
@ -460,7 +547,32 @@ fn main() {
|
|||
// Load updater settings and start auto-check
|
||||
let _ = state.update_manager.load_settings().await;
|
||||
state.update_manager.clone().start_auto_check().await;
|
||||
|
||||
// Start Git repository monitoring
|
||||
let git_app_handle = app_handle_for_move.clone();
|
||||
state.git_monitor.start_monitoring(git_app_handle).await;
|
||||
|
||||
// Start Tailscale monitoring
|
||||
state.tailscale_service.start_monitoring().await;
|
||||
// Check initial status
|
||||
let _ = state.tailscale_service.check_tailscale_status().await;
|
||||
|
||||
// Start window tracking updates
|
||||
let window_tracker = state.window_tracker.clone();
|
||||
let api_client = state.api_client.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Ok(sessions) = api_client.list_sessions().await {
|
||||
window_tracker.update_from_sessions(&sessions).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Set up URL scheme handler for deep links
|
||||
url_scheme::URLSchemeHandler::setup_deep_link_handler(app_handle_for_url_scheme);
|
||||
|
||||
// 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() {
|
||||
|
|
@ -503,27 +615,38 @@ fn main() {
|
|||
handle_tray_menu_event(app, event.id.as_ref());
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
// Get server status and open dashboard in browser
|
||||
let app = tray.app_handle();
|
||||
let state = app.state::<AppState>();
|
||||
if state.backend_manager.blocking_is_running() {
|
||||
let settings = crate::settings::Settings::load().unwrap_or_default();
|
||||
let url = format!("http://127.0.0.1:{}", settings.dashboard.server_port);
|
||||
let _ = open::that(url);
|
||||
match event {
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
position,
|
||||
..
|
||||
} => {
|
||||
// Show custom menubar popover on left click
|
||||
let app = tray.app_handle();
|
||||
|
||||
// Update popover position based on tray icon location
|
||||
let _ = menubar_popover::MenubarPopover::update_position(&app, position.x, position.y);
|
||||
|
||||
// Toggle the popover
|
||||
let _ = menubar_popover::MenubarPopover::toggle(&app);
|
||||
}
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Right,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} => {
|
||||
// Right click shows the traditional menu
|
||||
// The menu is already set, so it will show automatically
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
}
|
||||
|
||||
// Load settings to determine initial dock icon visibility
|
||||
let settings = settings::Settings::load().unwrap_or_default();
|
||||
let _settings = settings::Settings::load().unwrap_or_default();
|
||||
|
||||
// Set initial dock icon visibility on macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
|
|
|
|||
143
tauri/src-tauri/src/menubar_popover.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
use tauri::{AppHandle, LogicalPosition, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
use tracing::{debug, error};
|
||||
|
||||
/// Manages the menubar popover window
|
||||
pub struct MenubarPopover;
|
||||
|
||||
impl MenubarPopover {
|
||||
/// Show the menubar popover window
|
||||
pub fn show(app: &AppHandle) -> Result<(), String> {
|
||||
debug!("Showing menubar popover");
|
||||
|
||||
// Check if popover already exists
|
||||
if let Some(window) = app.get_webview_window("menubar-popover") {
|
||||
window.show().map_err(|e| e.to_string())?;
|
||||
window.set_focus().map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Get the current mouse position to position the popover
|
||||
// On macOS, we want to position it near the menu bar
|
||||
let position = Self::calculate_popover_position();
|
||||
|
||||
// Create the popover window
|
||||
let window = WebviewWindowBuilder::new(
|
||||
app,
|
||||
"menubar-popover",
|
||||
WebviewUrl::App("menubar.html".into())
|
||||
)
|
||||
.title("")
|
||||
.decorations(false)
|
||||
.always_on_top(true)
|
||||
.skip_taskbar(true)
|
||||
.resizable(false)
|
||||
.inner_size(360.0, 700.0)
|
||||
.position(position.0, position.1)
|
||||
.visible(false) // Start hidden, show after setup
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
error!("Failed to create menubar popover: {}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
// Configure window for popover behavior
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// For now, skip the macOS-specific configuration
|
||||
// This would require proper cocoa integration which is complex with Tauri v2
|
||||
// The window will still work but won't have the exact native popover behavior
|
||||
}
|
||||
|
||||
// Handle window events
|
||||
let app_handle = app.clone();
|
||||
window.on_window_event(move |event| {
|
||||
match event {
|
||||
tauri::WindowEvent::Focused(false) => {
|
||||
// Hide popover when it loses focus
|
||||
if let Some(window) = app_handle.get_webview_window("menubar-popover") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
// Show the window after configuration
|
||||
window.show().map_err(|e| e.to_string())?;
|
||||
window.set_focus().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hide the menubar popover window
|
||||
pub fn hide(app: &AppHandle) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("menubar-popover") {
|
||||
window.hide().map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Toggle the menubar popover window
|
||||
pub fn toggle(app: &AppHandle) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("menubar-popover") {
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
Self::hide(app)
|
||||
} else {
|
||||
Self::show(app)
|
||||
}
|
||||
} else {
|
||||
Self::show(app)
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the position for the popover based on screen and menu bar
|
||||
fn calculate_popover_position() -> (f64, f64) {
|
||||
// Default position near the top-right of the screen (where menu bar items typically are)
|
||||
// This is a simplified implementation - in a real app, you'd get the actual
|
||||
// tray icon position
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Position near the right side of the menu bar
|
||||
// Menu bar is typically 24px tall on macOS
|
||||
let x = 100.0; // This should be calculated based on actual tray icon position
|
||||
let y = 30.0; // Just below the menu bar
|
||||
return (x, y);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
// For other platforms, position at top-right
|
||||
(100.0, 30.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the popover position based on the tray icon location
|
||||
pub fn update_position(app: &AppHandle, x: f64, y: f64) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("menubar-popover") {
|
||||
// Adjust position to center the popover on the tray icon
|
||||
let popover_width = 360.0;
|
||||
let adjusted_x = x - (popover_width / 2.0);
|
||||
let adjusted_y = y + 10.0; // Small gap below the menu bar
|
||||
|
||||
window.set_position(LogicalPosition::new(adjusted_x, adjusted_y))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands for menubar popover
|
||||
#[tauri::command]
|
||||
pub fn show_menubar_popover(app: AppHandle) -> Result<(), String> {
|
||||
MenubarPopover::show(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn hide_menubar_popover(app: AppHandle) -> Result<(), String> {
|
||||
MenubarPopover::hide(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn toggle_menubar_popover(app: AppHandle) -> Result<(), String> {
|
||||
MenubarPopover::toggle(&app)
|
||||
}
|
||||
|
|
@ -2,8 +2,10 @@ use chrono::{DateTime, Utc};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::AppHandle;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Permission type enumeration
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
|
|
@ -65,6 +67,8 @@ pub struct PermissionsManager {
|
|||
permissions: Arc<RwLock<HashMap<PermissionType, PermissionInfo>>>,
|
||||
app_handle: Arc<RwLock<Option<AppHandle>>>,
|
||||
notification_manager: Option<Arc<crate::notification_manager::NotificationManager>>,
|
||||
monitor_registration_count: Arc<AtomicUsize>,
|
||||
monitor_handle: Arc<RwLock<Option<tokio::task::JoinHandle<()>>>>,
|
||||
}
|
||||
|
||||
impl Default for PermissionsManager {
|
||||
|
|
@ -80,6 +84,8 @@ impl PermissionsManager {
|
|||
permissions: Arc::new(RwLock::new(Self::initialize_permissions())),
|
||||
app_handle: Arc::new(RwLock::new(None)),
|
||||
notification_manager: None,
|
||||
monitor_registration_count: Arc::new(AtomicUsize::new(0)),
|
||||
monitor_handle: Arc::new(RwLock::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -390,6 +396,114 @@ impl PermissionsManager {
|
|||
}
|
||||
}
|
||||
|
||||
/// Register for permission monitoring (call when a view appears)
|
||||
pub async fn register_for_monitoring(&self) {
|
||||
let prev_count = self.monitor_registration_count.fetch_add(1, Ordering::SeqCst);
|
||||
debug!("Registered for monitoring, count: {}", prev_count + 1);
|
||||
|
||||
if prev_count == 0 {
|
||||
// First registration, start monitoring
|
||||
self.start_monitoring().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Unregister from permission monitoring (call when a view disappears)
|
||||
pub async fn unregister_from_monitoring(&self) {
|
||||
let prev_count = self.monitor_registration_count.fetch_sub(1, Ordering::SeqCst);
|
||||
debug!("Unregistered from monitoring, count: {}", prev_count.saturating_sub(1));
|
||||
|
||||
if prev_count == 1 {
|
||||
// No more registrations, stop monitoring
|
||||
self.stop_monitoring().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start monitoring permissions
|
||||
async fn start_monitoring(&self) {
|
||||
info!("Starting permission monitoring");
|
||||
|
||||
// Initial check
|
||||
let _ = self.check_all_permissions().await;
|
||||
|
||||
// Clone necessary references for the monitoring task
|
||||
let permissions = self.permissions.clone();
|
||||
let app_handle = self.app_handle.clone();
|
||||
let _notification_manager = self.notification_manager.clone();
|
||||
|
||||
// Start monitoring task
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// Check permissions
|
||||
let mut perms = permissions.write().await;
|
||||
let mut changed = false;
|
||||
|
||||
for (permission_type, info) in perms.iter_mut() {
|
||||
let old_status = info.status;
|
||||
|
||||
// Use platform-specific checks
|
||||
let platform = std::env::consts::OS;
|
||||
let new_status = match (platform, permission_type) {
|
||||
#[cfg(target_os = "macos")]
|
||||
("macos", PermissionType::ScreenRecording) => {
|
||||
check_screen_recording_permission_macos_static().await
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
("macos", PermissionType::Accessibility) => {
|
||||
check_accessibility_permission_macos_static().await
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
("macos", PermissionType::NotificationAccess) => {
|
||||
PermissionStatus::Granted // Tauri handles this
|
||||
}
|
||||
_ => info.status, // Keep existing status for other permissions
|
||||
};
|
||||
|
||||
if old_status != new_status {
|
||||
info.status = new_status;
|
||||
info.last_checked = Some(Utc::now());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit permission change event if any changed
|
||||
if changed {
|
||||
if let Some(app) = app_handle.read().await.as_ref() {
|
||||
let _ = app.emit("permissions_updated", ());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
*self.monitor_handle.write().await = Some(handle);
|
||||
}
|
||||
|
||||
/// Stop monitoring permissions
|
||||
async fn stop_monitoring(&self) {
|
||||
info!("Stopping permission monitoring");
|
||||
|
||||
if let Some(handle) = self.monitor_handle.write().await.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// Show permission alert dialog
|
||||
pub async fn show_permission_alert(&self, permission_type: PermissionType) -> Result<(), String> {
|
||||
let permission_info = self.get_permission_info(permission_type).await
|
||||
.ok_or_else(|| "Permission not found".to_string())?;
|
||||
|
||||
if let Some(app_handle) = self.app_handle.read().await.as_ref() {
|
||||
// Emit event for frontend to show dialog
|
||||
app_handle.emit("show_permission_dialog", &permission_info)
|
||||
.map_err(|e| format!("Failed to emit permission dialog event: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Platform-specific implementations
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn check_screen_recording_permission_macos(&self) -> PermissionStatus {
|
||||
|
|
@ -591,3 +705,62 @@ pub struct PermissionStats {
|
|||
pub missing_required: usize,
|
||||
pub platform: String,
|
||||
}
|
||||
|
||||
// Static permission check functions for use in monitoring task
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn check_screen_recording_permission_macos_static() -> PermissionStatus {
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg("tell application \"System Events\" to get properties")
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) if output.status.success() => PermissionStatus::Granted,
|
||||
_ => PermissionStatus::NotDetermined,
|
||||
}
|
||||
}
|
||||
|
||||
// Add enhanced screen recording check for macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
fn check_screen_recording_with_cg() -> PermissionStatus {
|
||||
use core_graphics::display::CGDisplay;
|
||||
|
||||
// Try to get display information - this requires screen recording permission
|
||||
match CGDisplay::active_displays() {
|
||||
Ok(displays) => {
|
||||
if displays.is_empty() {
|
||||
// No displays found could mean no permission
|
||||
PermissionStatus::NotDetermined
|
||||
} else {
|
||||
PermissionStatus::Granted
|
||||
}
|
||||
}
|
||||
Err(_) => PermissionStatus::Denied,
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri commands for enhanced permission functionality are in commands.rs
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn check_accessibility_permission_macos_static() -> PermissionStatus {
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg("tell application \"System Events\" to get UI elements enabled")
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) if output.status.success() => {
|
||||
let result = String::from_utf8_lossy(&output.stdout);
|
||||
if result.trim() == "true" {
|
||||
PermissionStatus::Granted
|
||||
} else {
|
||||
PermissionStatus::Denied
|
||||
}
|
||||
}
|
||||
_ => PermissionStatus::NotDetermined,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
243
tauri/src-tauri/src/power_manager.rs
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use core_foundation::base::TCFType;
|
||||
#[cfg(target_os = "macos")]
|
||||
use core_foundation::string::CFString;
|
||||
|
||||
/// Manages system power assertions to prevent the system from sleeping
|
||||
pub struct PowerManager {
|
||||
is_prevented: Arc<AtomicBool>,
|
||||
#[cfg(target_os = "macos")]
|
||||
assertion_id: std::sync::Mutex<Option<u32>>,
|
||||
#[cfg(target_os = "windows")]
|
||||
_previous_state: std::sync::Mutex<Option<u32>>,
|
||||
}
|
||||
|
||||
impl PowerManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
is_prevented: Arc::new(AtomicBool::new(false)),
|
||||
#[cfg(target_os = "macos")]
|
||||
assertion_id: std::sync::Mutex::new(None),
|
||||
#[cfg(target_os = "windows")]
|
||||
_previous_state: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Prevents the system from sleeping
|
||||
pub fn prevent_sleep(&self) -> Result<(), String> {
|
||||
if self.is_prevented.load(Ordering::Relaxed) {
|
||||
debug!("Sleep is already prevented");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.prevent_sleep_macos()?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
self.prevent_sleep_windows()?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
self.prevent_sleep_linux()?;
|
||||
}
|
||||
|
||||
self.is_prevented.store(true, Ordering::Relaxed);
|
||||
info!("System sleep prevention enabled");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Allows the system to sleep normally
|
||||
pub fn allow_sleep(&self) -> Result<(), String> {
|
||||
if !self.is_prevented.load(Ordering::Relaxed) {
|
||||
debug!("Sleep is already allowed");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.allow_sleep_macos()?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
self.allow_sleep_windows()?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
self.allow_sleep_linux()?;
|
||||
}
|
||||
|
||||
self.is_prevented.store(false, Ordering::Relaxed);
|
||||
info!("System sleep prevention disabled");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns whether sleep is currently prevented
|
||||
pub fn is_sleep_prevented(&self) -> bool {
|
||||
self.is_prevented.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn prevent_sleep_macos(&self) -> Result<(), String> {
|
||||
use std::os::raw::c_void;
|
||||
|
||||
#[link(name = "IOKit", kind = "framework")]
|
||||
extern "C" {
|
||||
fn IOPMAssertionCreateWithName(
|
||||
assertion_type: *const c_void,
|
||||
assertion_level: u32,
|
||||
reason: *const c_void,
|
||||
assertion_id: *mut u32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
const kIOPMAssertionLevelOn: u32 = 255;
|
||||
const kIOReturnSuccess: i32 = 0;
|
||||
|
||||
let assertion_type = CFString::from("NoIdleSleepAssertion");
|
||||
let reason = CFString::from("VibeTunnel is running terminal sessions");
|
||||
|
||||
let mut assertion_id: u32 = 0;
|
||||
let result = unsafe {
|
||||
IOPMAssertionCreateWithName(
|
||||
assertion_type.as_concrete_TypeRef() as *const c_void,
|
||||
kIOPMAssertionLevelOn,
|
||||
reason.as_concrete_TypeRef() as *const c_void,
|
||||
&mut assertion_id,
|
||||
)
|
||||
};
|
||||
|
||||
if result == kIOReturnSuccess {
|
||||
let mut guard = self.assertion_id.lock().unwrap();
|
||||
*guard = Some(assertion_id);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Failed to create power assertion: {}", result))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn allow_sleep_macos(&self) -> Result<(), String> {
|
||||
|
||||
#[link(name = "IOKit", kind = "framework")]
|
||||
extern "C" {
|
||||
fn IOPMAssertionRelease(assertion_id: u32) -> i32;
|
||||
}
|
||||
|
||||
const kIOReturnSuccess: i32 = 0;
|
||||
|
||||
let mut guard = self.assertion_id.lock().unwrap();
|
||||
if let Some(assertion_id) = guard.take() {
|
||||
let result = unsafe { IOPMAssertionRelease(assertion_id) };
|
||||
|
||||
if result == kIOReturnSuccess {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Failed to release power assertion: {}", result))
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn prevent_sleep_windows(&self) -> Result<(), String> {
|
||||
use std::os::raw::c_uint;
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
fn SetThreadExecutionState(flags: c_uint) -> c_uint;
|
||||
}
|
||||
|
||||
const ES_CONTINUOUS: c_uint = 0x80000000;
|
||||
const ES_SYSTEM_REQUIRED: c_uint = 0x00000001;
|
||||
const ES_DISPLAY_REQUIRED: c_uint = 0x00000002;
|
||||
|
||||
let flags = ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED;
|
||||
let result = unsafe { SetThreadExecutionState(flags) };
|
||||
|
||||
if result == 0 {
|
||||
Err("Failed to set thread execution state".to_string())
|
||||
} else {
|
||||
let mut guard = self._previous_state.lock().unwrap();
|
||||
*guard = Some(result);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn allow_sleep_windows(&self) -> Result<(), String> {
|
||||
use std::os::raw::c_uint;
|
||||
|
||||
#[link(name = "kernel32")]
|
||||
extern "system" {
|
||||
fn SetThreadExecutionState(flags: c_uint) -> c_uint;
|
||||
}
|
||||
|
||||
const ES_CONTINUOUS: c_uint = 0x80000000;
|
||||
|
||||
let result = unsafe { SetThreadExecutionState(ES_CONTINUOUS) };
|
||||
|
||||
if result == 0 {
|
||||
Err("Failed to reset thread execution state".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn prevent_sleep_linux(&self) -> Result<(), String> {
|
||||
// On Linux, we can use systemd-inhibit or DBus to prevent sleep
|
||||
// For now, we'll use a simple implementation
|
||||
debug!("Linux sleep prevention not implemented");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn allow_sleep_linux(&self) -> Result<(), String> {
|
||||
debug!("Linux sleep allowance not implemented");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PowerManager {
|
||||
fn drop(&mut self) {
|
||||
if self.is_prevented.load(Ordering::Relaxed) {
|
||||
let _ = self.allow_sleep();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public functions for commands (without tauri::command attribute)
|
||||
pub fn prevent_sleep(state: tauri::State<crate::state::AppState>) -> Result<(), String> {
|
||||
state.power_manager.prevent_sleep()
|
||||
}
|
||||
|
||||
pub fn allow_sleep(state: tauri::State<crate::state::AppState>) -> Result<(), String> {
|
||||
state.power_manager.allow_sleep()
|
||||
}
|
||||
|
||||
pub fn is_sleep_prevented(state: tauri::State<crate::state::AppState>) -> bool {
|
||||
state.power_manager.is_sleep_prevented()
|
||||
}
|
||||
|
||||
// Integration with session monitoring
|
||||
impl PowerManager {
|
||||
/// Updates sleep prevention based on session count and user preferences
|
||||
pub async fn update_for_sessions(&self, session_count: usize, prevent_sleep_enabled: bool) {
|
||||
if prevent_sleep_enabled && session_count > 0 {
|
||||
let _ = self.prevent_sleep();
|
||||
} else {
|
||||
let _ = self.allow_sleep();
|
||||
}
|
||||
}
|
||||
}
|
||||
287
tauri/src-tauri/src/process_tracker.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Process information
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProcessInfo {
|
||||
pub pid: u32,
|
||||
pub ppid: u32,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Handles process tree traversal and process information extraction
|
||||
pub struct ProcessTracker;
|
||||
|
||||
impl ProcessTracker {
|
||||
/// Get the parent process ID of a given process
|
||||
pub fn get_parent_process_id(pid: u32) -> Option<u32> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Self::get_parent_pid_macos(pid)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Self::get_parent_pid_windows(pid)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Self::get_parent_pid_linux(pid)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get process info including name and parent PID
|
||||
pub fn get_process_info(pid: u32) -> Option<ProcessInfo> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Self::get_process_info_macos(pid)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Self::get_process_info_windows(pid)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Self::get_process_info_linux(pid)
|
||||
}
|
||||
}
|
||||
|
||||
/// Log the process tree for debugging
|
||||
pub fn log_process_tree(pid: u32) {
|
||||
debug!("Process tree for PID {}:", pid);
|
||||
|
||||
let mut current_pid = pid;
|
||||
let mut depth = 0;
|
||||
|
||||
while depth < 20 {
|
||||
if let Some(info) = Self::get_process_info(current_pid) {
|
||||
let indent = " ".repeat(depth);
|
||||
debug!("{}PID {}: {} (parent: {})", indent, current_pid, info.name, info.ppid);
|
||||
|
||||
if info.ppid == 0 || info.ppid == 1 {
|
||||
break;
|
||||
}
|
||||
|
||||
current_pid = info.ppid;
|
||||
depth += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the terminal process in the ancestry of a given PID
|
||||
pub fn find_terminal_ancestor(pid: u32, max_depth: usize) -> Option<u32> {
|
||||
let mut current_pid = pid;
|
||||
let mut depth = 0;
|
||||
|
||||
while depth < max_depth {
|
||||
if let Some(parent_pid) = Self::get_parent_process_id(current_pid) {
|
||||
debug!("Checking ancestor process PID: {} at depth {}", parent_pid, depth + 1);
|
||||
|
||||
// Check if this is a terminal process
|
||||
if let Some(info) = Self::get_process_info(parent_pid) {
|
||||
let terminal_processes = vec![
|
||||
"Terminal", "iTerm2", "alacritty", "kitty", "wezterm",
|
||||
"gnome-terminal", "konsole", "xterm", "cmd.exe", "powershell.exe",
|
||||
"WindowsTerminal.exe"
|
||||
];
|
||||
|
||||
if terminal_processes.iter().any(|&tp| info.name.contains(tp)) {
|
||||
info!("Found terminal ancestor: {} (PID: {})", info.name, parent_pid);
|
||||
return Some(parent_pid);
|
||||
}
|
||||
}
|
||||
|
||||
current_pid = parent_pid;
|
||||
depth += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn get_parent_pid_macos(pid: u32) -> Option<u32> {
|
||||
use std::process::Command;
|
||||
use std::str;
|
||||
|
||||
// Use ps command which is more reliable and doesn't require unsafe kernel structs
|
||||
let output = Command::new("ps")
|
||||
.args(&["-o", "ppid=", "-p", &pid.to_string()])
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if output.status.success() {
|
||||
let ppid_str = str::from_utf8(&output.stdout).ok()?.trim();
|
||||
ppid_str.parse::<u32>().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn get_process_info_macos(pid: u32) -> Option<ProcessInfo> {
|
||||
use std::process::Command;
|
||||
|
||||
// Use ps command as a fallback for process info
|
||||
match Command::new("ps")
|
||||
.args(&["-p", &pid.to_string(), "-o", "ppid=,comm="])
|
||||
.output()
|
||||
{
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
let parts: Vec<&str> = output_str.trim().split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
let ppid = parts[0].parse::<u32>().unwrap_or(0);
|
||||
let name = parts[1..].join(" ");
|
||||
return Some(ProcessInfo { pid, ppid, name });
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to run ps command: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to at least get parent PID
|
||||
if let Some(ppid) = Self::get_parent_pid_macos(pid) {
|
||||
Some(ProcessInfo {
|
||||
pid,
|
||||
ppid,
|
||||
name: format!("Process {}", pid),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn get_parent_pid_windows(pid: u32) -> Option<u32> {
|
||||
use windows::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows::Win32::Foundation::HANDLE;
|
||||
|
||||
unsafe {
|
||||
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()?;
|
||||
|
||||
let mut process_entry = PROCESSENTRY32 {
|
||||
dwSize: std::mem::size_of::<PROCESSENTRY32>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if Process32First(snapshot, &mut process_entry).is_ok() {
|
||||
loop {
|
||||
if process_entry.th32ProcessID == pid {
|
||||
let _ = windows::Win32::Foundation::CloseHandle(snapshot);
|
||||
return Some(process_entry.th32ParentProcessID);
|
||||
}
|
||||
|
||||
if !Process32Next(snapshot, &mut process_entry).is_ok() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = windows::Win32::Foundation::CloseHandle(snapshot);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn get_process_info_windows(pid: u32) -> Option<ProcessInfo> {
|
||||
use windows::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()?;
|
||||
|
||||
let mut process_entry = PROCESSENTRY32 {
|
||||
dwSize: std::mem::size_of::<PROCESSENTRY32>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if Process32First(snapshot, &mut process_entry).is_ok() {
|
||||
loop {
|
||||
if process_entry.th32ProcessID == pid {
|
||||
let name = String::from_utf16_lossy(
|
||||
&process_entry.szExeFile
|
||||
.iter()
|
||||
.take_while(|&&c| c != 0)
|
||||
.copied()
|
||||
.collect::<Vec<u16>>()
|
||||
);
|
||||
|
||||
let _ = windows::Win32::Foundation::CloseHandle(snapshot);
|
||||
return Some(ProcessInfo {
|
||||
pid,
|
||||
ppid: process_entry.th32ParentProcessID,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
if !Process32Next(snapshot, &mut process_entry).is_ok() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = windows::Win32::Foundation::CloseHandle(snapshot);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn get_parent_pid_linux(pid: u32) -> Option<u32> {
|
||||
use std::fs;
|
||||
|
||||
// Read /proc/[pid]/stat
|
||||
let stat_path = format!("/proc/{}/stat", pid);
|
||||
match fs::read_to_string(&stat_path) {
|
||||
Ok(contents) => {
|
||||
// Format: pid (comm) state ppid ...
|
||||
// Find the closing parenthesis to skip the command name
|
||||
if let Some(close_paren) = contents.rfind(')') {
|
||||
let after_name = &contents[close_paren + 1..];
|
||||
let fields: Vec<&str> = after_name.split_whitespace().collect();
|
||||
|
||||
// ppid is the second field after the command name
|
||||
if fields.len() > 1 {
|
||||
return fields[1].parse::<u32>().ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Failed to read {}: {}", stat_path, e);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn get_process_info_linux(pid: u32) -> Option<ProcessInfo> {
|
||||
use std::fs;
|
||||
|
||||
// Read /proc/[pid]/stat for ppid
|
||||
let ppid = Self::get_parent_pid_linux(pid)?;
|
||||
|
||||
// Read /proc/[pid]/comm for process name
|
||||
let comm_path = format!("/proc/{}/comm", pid);
|
||||
let name = match fs::read_to_string(&comm_path) {
|
||||
Ok(contents) => contents.trim().to_string(),
|
||||
Err(_) => format!("Process {}", pid),
|
||||
};
|
||||
|
||||
Some(ProcessInfo { pid, ppid, name })
|
||||
}
|
||||
}
|
||||
|
||||
// Platform-specific dependencies
|
||||
#[cfg(target_os = "macos")]
|
||||
extern crate libc;
|
||||
|
|
@ -19,6 +19,8 @@ pub struct SessionInfo {
|
|||
pub last_activity: String,
|
||||
pub is_active: bool,
|
||||
pub client_count: usize,
|
||||
pub working_directory: Option<String>,
|
||||
pub git_repository: Option<crate::git_repository::GitRepository>,
|
||||
}
|
||||
|
||||
/// Session state change event
|
||||
|
|
@ -76,6 +78,8 @@ impl SessionMonitor {
|
|||
last_activity: Utc::now().to_rfc3339(),
|
||||
is_active: true,
|
||||
client_count: 0, // TODO: Track actual client count
|
||||
working_directory: None, // TODO: Get from session API when available
|
||||
git_repository: None, // Will be populated by Git monitor separately
|
||||
};
|
||||
|
||||
// Check if this is a new session
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ pub struct AdvancedSettings {
|
|||
pub ngrok_subdomain: Option<String>,
|
||||
pub enable_telemetry: Option<bool>,
|
||||
pub experimental_features: Option<bool>,
|
||||
pub preferred_git_app: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
|
|
@ -223,6 +224,7 @@ impl Default for Settings {
|
|||
ngrok_subdomain: None,
|
||||
enable_telemetry: Some(false),
|
||||
experimental_features: Some(false),
|
||||
preferred_git_app: None,
|
||||
},
|
||||
tty_forward: Some(TTYForwardSettings {
|
||||
enabled: false,
|
||||
|
|
|
|||
|
|
@ -3,10 +3,15 @@ use crate::api_testing::APITestingManager;
|
|||
use crate::auth_cache::AuthCacheManager;
|
||||
use crate::backend_manager::BackendManager;
|
||||
use crate::debug_features::DebugFeaturesManager;
|
||||
use crate::dock_manager::DockManager;
|
||||
use crate::git_monitor::GitMonitor;
|
||||
use crate::ngrok::NgrokManager;
|
||||
use crate::notification_manager::NotificationManager;
|
||||
use crate::permissions::PermissionsManager;
|
||||
use crate::power_manager::PowerManager;
|
||||
use crate::session_monitor::SessionMonitor;
|
||||
use crate::status_indicator::StatusIndicator;
|
||||
use crate::tailscale::TailscaleService;
|
||||
use crate::terminal::TerminalManager;
|
||||
use crate::terminal_integrations::TerminalIntegrationsManager;
|
||||
use crate::terminal_spawn_service::TerminalSpawnService;
|
||||
|
|
@ -15,8 +20,10 @@ use crate::tty_forward::TTYForwardManager;
|
|||
use crate::unix_socket_server::UnixSocketServer;
|
||||
use crate::updater::UpdateManager;
|
||||
use crate::welcome::WelcomeManager;
|
||||
use crate::window_tracker::WindowTracker;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
use tauri::AppHandle;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -38,6 +45,13 @@ pub struct AppState {
|
|||
pub auth_cache_manager: Arc<AuthCacheManager>,
|
||||
pub terminal_integrations_manager: Arc<TerminalIntegrationsManager>,
|
||||
pub terminal_spawn_service: Arc<TerminalSpawnService>,
|
||||
pub git_monitor: Arc<GitMonitor>,
|
||||
pub tailscale_service: Arc<TailscaleService>,
|
||||
pub window_tracker: Arc<WindowTracker>,
|
||||
pub dock_manager: Arc<DockManager>,
|
||||
pub status_indicator: Arc<StatusIndicator>,
|
||||
pub power_manager: Arc<PowerManager>,
|
||||
app_handle: Arc<RwLock<Option<AppHandle>>>,
|
||||
#[cfg(unix)]
|
||||
pub unix_socket_server: Arc<UnixSocketServer>,
|
||||
}
|
||||
|
|
@ -104,10 +118,27 @@ impl AppState {
|
|||
auth_cache_manager: Arc::new(auth_cache_manager),
|
||||
terminal_integrations_manager,
|
||||
terminal_spawn_service,
|
||||
git_monitor: Arc::new(GitMonitor::new()),
|
||||
tailscale_service: Arc::new(TailscaleService::new()),
|
||||
window_tracker: Arc::new(WindowTracker::new()),
|
||||
dock_manager: Arc::new(DockManager::new()),
|
||||
status_indicator: Arc::new(StatusIndicator::new()),
|
||||
power_manager: Arc::new(PowerManager::new()),
|
||||
app_handle: Arc::new(RwLock::new(None)),
|
||||
#[cfg(unix)]
|
||||
unix_socket_server,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the app handle for managers that need it
|
||||
pub async fn set_app_handle(&self, app_handle: AppHandle) {
|
||||
*self.app_handle.write().await = Some(app_handle);
|
||||
}
|
||||
|
||||
/// Get the app handle if available
|
||||
pub fn get_app_handle(&self) -> Option<AppHandle> {
|
||||
self.app_handle.blocking_read().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -135,6 +166,12 @@ mod tests {
|
|||
assert!(Arc::strong_count(&state.terminal_integrations_manager) >= 1);
|
||||
assert!(Arc::strong_count(&state.terminal_spawn_service) >= 1);
|
||||
assert!(Arc::strong_count(&state.tty_forward_manager) >= 1);
|
||||
assert!(Arc::strong_count(&state.git_monitor) >= 1);
|
||||
assert!(Arc::strong_count(&state.tailscale_service) >= 1);
|
||||
assert!(Arc::strong_count(&state.window_tracker) >= 1);
|
||||
assert!(Arc::strong_count(&state.dock_manager) >= 1);
|
||||
assert!(Arc::strong_count(&state.status_indicator) >= 1);
|
||||
assert!(Arc::strong_count(&state.power_manager) >= 1);
|
||||
|
||||
#[cfg(unix)]
|
||||
assert!(Arc::strong_count(&state.unix_socket_server) >= 1);
|
||||
|
|
|
|||
173
tauri/src-tauri/src/status_indicator.rs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tracing::{debug, error};
|
||||
|
||||
/// Visual status indicators for the tray icon
|
||||
pub struct StatusIndicator {
|
||||
app_handle: Arc<std::sync::Mutex<Option<AppHandle>>>,
|
||||
}
|
||||
|
||||
impl StatusIndicator {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
app_handle: Arc::new(std::sync::Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the app handle for the indicator
|
||||
pub fn set_app_handle(&self, handle: AppHandle) {
|
||||
let mut guard = self.app_handle.lock().unwrap();
|
||||
*guard = Some(handle);
|
||||
}
|
||||
|
||||
/// Update the tray icon based on server and session status
|
||||
pub fn update_tray_icon(&self, server_running: bool, active_sessions: usize, total_sessions: usize) {
|
||||
let guard = self.app_handle.lock().unwrap();
|
||||
if let Some(app_handle) = guard.as_ref() {
|
||||
if let Some(tray) = app_handle.tray_by_id("main") {
|
||||
// Update icon based on status
|
||||
let icon_name = if server_running {
|
||||
if active_sessions > 0 {
|
||||
"tray-icon-active" // Green/active indicator
|
||||
} else if total_sessions > 0 {
|
||||
"tray-icon-idle" // Yellow/idle indicator
|
||||
} else {
|
||||
"tray-icon" // Normal running state
|
||||
}
|
||||
} else {
|
||||
"tray-icon-inactive" // Gray/inactive state
|
||||
};
|
||||
|
||||
// Try to load the appropriate icon
|
||||
match Self::load_icon_data(app_handle, icon_name) {
|
||||
Ok(icon_data) => {
|
||||
match tauri::image::Image::from_bytes(&icon_data) {
|
||||
Ok(image) => {
|
||||
if let Err(e) = tray.set_icon(Some(image)) {
|
||||
error!("Failed to update tray icon: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to create image from icon data: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Fall back to default icon with tooltip to indicate status
|
||||
debug!("Icon {} not found, using default", icon_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Update tooltip with session information
|
||||
let tooltip = self.format_tooltip(server_running, active_sessions, total_sessions);
|
||||
let _ = tray.set_tooltip(Some(&tooltip));
|
||||
|
||||
// Update title (visible on macOS) with session count
|
||||
if total_sessions > 0 {
|
||||
let title = self.format_title(active_sessions, total_sessions);
|
||||
let _ = tray.set_title(Some(&title));
|
||||
} else {
|
||||
let _ = tray.set_title::<&str>(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format the tray tooltip
|
||||
fn format_tooltip(&self, server_running: bool, active_sessions: usize, total_sessions: usize) -> String {
|
||||
if !server_running {
|
||||
return "VibeTunnel - Server Stopped".to_string();
|
||||
}
|
||||
|
||||
if total_sessions == 0 {
|
||||
return "VibeTunnel - No Sessions".to_string();
|
||||
}
|
||||
|
||||
if active_sessions == 0 {
|
||||
format!("VibeTunnel - {} idle session{}", total_sessions, if total_sessions == 1 { "" } else { "s" })
|
||||
} else if active_sessions == total_sessions {
|
||||
format!("VibeTunnel - {} active session{}", active_sessions, if active_sessions == 1 { "" } else { "s" })
|
||||
} else {
|
||||
format!("VibeTunnel - {} active, {} idle", active_sessions, total_sessions - active_sessions)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format the tray title (visible text on macOS)
|
||||
fn format_title(&self, active_sessions: usize, total_sessions: usize) -> String {
|
||||
if active_sessions == 0 {
|
||||
total_sessions.to_string()
|
||||
} else if active_sessions == total_sessions {
|
||||
format!("● {}", active_sessions)
|
||||
} else {
|
||||
format!("{} | {}", active_sessions, total_sessions - active_sessions)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load icon data from resources
|
||||
fn load_icon_data(app_handle: &AppHandle, name: &str) -> Result<Vec<u8>, String> {
|
||||
// Try to load from different icon paths
|
||||
let icon_paths = vec![
|
||||
format!("icons/{}.png", name),
|
||||
format!("icons/{}@2x.png", name),
|
||||
format!("{}.png", name),
|
||||
];
|
||||
|
||||
for path in icon_paths {
|
||||
if let Ok(icon_path) = app_handle.path().resolve(&path, tauri::path::BaseDirectory::Resource) {
|
||||
if let Ok(contents) = std::fs::read(&icon_path) {
|
||||
return Ok(contents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("Icon {} not found", name))
|
||||
}
|
||||
|
||||
/// Animate the tray icon for notifications or activity
|
||||
pub async fn animate_activity(&self) {
|
||||
// Simple animation: briefly change icon to indicate activity
|
||||
let app_handle = {
|
||||
let guard = self.app_handle.lock().unwrap();
|
||||
guard.clone()
|
||||
};
|
||||
|
||||
if let Some(app_handle) = app_handle {
|
||||
if let Some(tray) = app_handle.tray_by_id("main") {
|
||||
// Flash the icon by changing it briefly
|
||||
if let Ok(active_icon_data) = Self::load_icon_data(&app_handle, "tray-icon-flash") {
|
||||
if let Ok(active_image) = tauri::image::Image::from_bytes(&active_icon_data) {
|
||||
let _ = tray.set_icon(Some(active_image));
|
||||
|
||||
// Restore after a short delay
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
|
||||
if let Ok(normal_icon_data) = Self::load_icon_data(&app_handle, "tray-icon") {
|
||||
if let Ok(normal_image) = tauri::image::Image::from_bytes(&normal_icon_data) {
|
||||
let _ = tray.set_icon(Some(normal_image));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public functions for commands (without tauri::command attribute)
|
||||
pub async fn update_status_indicator(
|
||||
state: tauri::State<'_, crate::state::AppState>,
|
||||
server_running: bool,
|
||||
active_sessions: usize,
|
||||
total_sessions: usize,
|
||||
) -> Result<(), String> {
|
||||
state.status_indicator.update_tray_icon(server_running, active_sessions, total_sessions);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn flash_activity_indicator(
|
||||
state: tauri::State<'_, crate::state::AppState>,
|
||||
) -> Result<(), String> {
|
||||
state.status_indicator.animate_activity().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
205
tauri/src-tauri/src/tailscale.rs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TailscaleStatus {
|
||||
pub is_installed: bool,
|
||||
pub is_running: bool,
|
||||
pub hostname: Option<String>,
|
||||
pub ip_address: Option<String>,
|
||||
pub status_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct TailscaleAPIResponse {
|
||||
status: String,
|
||||
device_name: String,
|
||||
tailnet_name: String,
|
||||
#[serde(rename = "IPv4")]
|
||||
ipv4: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TailscaleService {
|
||||
status: Arc<RwLock<TailscaleStatus>>,
|
||||
}
|
||||
|
||||
impl TailscaleService {
|
||||
const TAILSCALE_API_ENDPOINT: &'static str = "http://100.100.100.100/api/data";
|
||||
const API_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
status: Arc::new(RwLock::new(TailscaleStatus {
|
||||
is_installed: false,
|
||||
is_running: false,
|
||||
hostname: None,
|
||||
ip_address: None,
|
||||
status_error: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current Tailscale status
|
||||
pub async fn get_status(&self) -> TailscaleStatus {
|
||||
self.status.read().await.clone()
|
||||
}
|
||||
|
||||
/// Check if Tailscale app is installed
|
||||
fn check_app_installation() -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::path::Path::new("/Applications/Tailscale.app").exists()
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Check common Linux installation paths
|
||||
std::path::Path::new("/usr/bin/tailscale").exists()
|
||||
|| std::path::Path::new("/usr/local/bin/tailscale").exists()
|
||||
|| std::path::Path::new("/opt/tailscale/tailscale").exists()
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Check Windows installation
|
||||
std::path::Path::new("C:\\Program Files\\Tailscale\\tailscale.exe").exists()
|
||||
|| std::path::Path::new("C:\\Program Files (x86)\\Tailscale\\tailscale.exe").exists()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch Tailscale status from the API
|
||||
async fn fetch_tailscale_status() -> Option<TailscaleAPIResponse> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Self::API_TIMEOUT)
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
match client.get(Self::TAILSCALE_API_ENDPOINT).send().await {
|
||||
Ok(response) if response.status().is_success() => {
|
||||
response.json::<TailscaleAPIResponse>().await.ok()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the current Tailscale status and update properties
|
||||
pub async fn check_tailscale_status(&self) -> TailscaleStatus {
|
||||
let is_installed = Self::check_app_installation();
|
||||
|
||||
if !is_installed {
|
||||
let status = TailscaleStatus {
|
||||
is_installed: false,
|
||||
is_running: false,
|
||||
hostname: None,
|
||||
ip_address: None,
|
||||
status_error: Some("Tailscale is not installed".to_string()),
|
||||
};
|
||||
*self.status.write().await = status.clone();
|
||||
return status;
|
||||
}
|
||||
|
||||
// Try to fetch status from API
|
||||
match Self::fetch_tailscale_status().await {
|
||||
Some(api_response) => {
|
||||
let is_running = api_response.status.to_lowercase() == "running";
|
||||
|
||||
let (hostname, ip_address, status_error) = if is_running {
|
||||
// Extract hostname from device name and tailnet name
|
||||
let device_name = api_response.device_name
|
||||
.to_lowercase()
|
||||
.replace(' ', "-");
|
||||
let tailnet_name = api_response.tailnet_name
|
||||
.replace(".ts.net", "")
|
||||
.replace(".tailscale.net", "");
|
||||
|
||||
let hostname = format!("{}.{}.ts.net", device_name, tailnet_name);
|
||||
|
||||
(Some(hostname), api_response.ipv4, None)
|
||||
} else {
|
||||
(None, None, Some("Tailscale is not running".to_string()))
|
||||
};
|
||||
|
||||
let status = TailscaleStatus {
|
||||
is_installed,
|
||||
is_running,
|
||||
hostname,
|
||||
ip_address,
|
||||
status_error,
|
||||
};
|
||||
*self.status.write().await = status.clone();
|
||||
status
|
||||
}
|
||||
None => {
|
||||
// API not responding - Tailscale not running
|
||||
let status = TailscaleStatus {
|
||||
is_installed,
|
||||
is_running: false,
|
||||
hostname: None,
|
||||
ip_address: None,
|
||||
status_error: Some("Please start the Tailscale app".to_string()),
|
||||
};
|
||||
*self.status.write().await = status.clone();
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start monitoring Tailscale status
|
||||
pub async fn start_monitoring(&self) {
|
||||
let status = self.status.clone();
|
||||
let service = Self::new();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let new_status = service.check_tailscale_status().await;
|
||||
*status.write().await = new_status;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the Tailscale app
|
||||
pub fn open_tailscale_app() -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
open::that("/Applications/Tailscale.app")
|
||||
.map_err(|e| format!("Failed to open Tailscale app: {}", e))
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Try to launch via desktop file or command
|
||||
std::process::Command::new("tailscale")
|
||||
.arg("up")
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start Tailscale: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
open::that("C:\\Program Files\\Tailscale\\tailscale.exe")
|
||||
.or_else(|_| open::that("C:\\Program Files (x86)\\Tailscale\\tailscale.exe"))
|
||||
.map_err(|e| format!("Failed to open Tailscale app: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the Tailscale download page
|
||||
pub fn open_download_page() -> Result<(), String> {
|
||||
let url = if cfg!(target_os = "macos") {
|
||||
"https://tailscale.com/download/macos"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"https://tailscale.com/download/windows"
|
||||
} else {
|
||||
"https://tailscale.com/download/linux"
|
||||
};
|
||||
|
||||
open::that(url).map_err(|e| format!("Failed to open download page: {}", e))
|
||||
}
|
||||
|
||||
/// Open the Tailscale setup guide
|
||||
pub fn open_setup_guide() -> Result<(), String> {
|
||||
open::that("https://tailscale.com/kb/1017/install/")
|
||||
.map_err(|e| format!("Failed to open setup guide: {}", e))
|
||||
}
|
||||
}
|
||||
|
|
@ -470,6 +470,35 @@ impl TerminalIntegrationsManager {
|
|||
) -> Result<(), String> {
|
||||
let emulator = emulator.unwrap_or(*self.default_terminal.read().await);
|
||||
|
||||
// Use AppleScript for enhanced terminal control on macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
match emulator {
|
||||
TerminalEmulator::Terminal | TerminalEmulator::ITerm2 => {
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let command = options.command.as_deref();
|
||||
let working_dir = options.working_directory
|
||||
.as_ref()
|
||||
.and_then(|p| p.to_str());
|
||||
|
||||
let terminal_name = match emulator {
|
||||
TerminalEmulator::Terminal => "Terminal",
|
||||
TerminalEmulator::ITerm2 => "iTerm2",
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
return crate::applescript::AppleScriptTerminalLauncher::launch_terminal(
|
||||
terminal_name,
|
||||
&session_id,
|
||||
command,
|
||||
working_dir,
|
||||
).await
|
||||
.map(|_| ());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match emulator {
|
||||
TerminalEmulator::SystemDefault => self.launch_system_terminal(options).await,
|
||||
_ => self.launch_specific_terminal(emulator, options).await,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ impl TrayMenuManager {
|
|||
session_count: usize,
|
||||
access_mode: Option<String>,
|
||||
) -> Result<Menu<tauri::Wry>, tauri::Error> {
|
||||
Self::create_menu_with_sessions(app, server_running, port, session_count, access_mode, None)
|
||||
Self::create_menu_with_sessions(app, server_running, port, session_count, access_mode, None, None)
|
||||
}
|
||||
|
||||
pub fn create_menu_with_sessions(
|
||||
|
|
@ -27,6 +27,7 @@ impl TrayMenuManager {
|
|||
session_count: usize,
|
||||
access_mode: Option<String>,
|
||||
sessions: Option<Vec<SessionInfo>>,
|
||||
tailscale_status: Option<crate::tailscale::TailscaleStatus>,
|
||||
) -> Result<Menu<tauri::Wry>, tauri::Error> {
|
||||
// Server status
|
||||
let status_text = if server_running {
|
||||
|
|
@ -92,7 +93,19 @@ impl TrayMenuManager {
|
|||
dir_name.to_string()
|
||||
};
|
||||
|
||||
let session_text = format!(" • {} (PID: {})", display_name, session.pid);
|
||||
// Add Git info if available
|
||||
let session_text = if let Some(git_repo) = &session.git_repository {
|
||||
let branch = git_repo.current_branch.as_deref().unwrap_or("main");
|
||||
let status = if git_repo.has_changes() {
|
||||
format!(" ({})", git_repo.status_text())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(" • {} [{}{}] (PID: {})", display_name, branch, status, session.pid)
|
||||
} else {
|
||||
format!(" • {} (PID: {})", display_name, session.pid)
|
||||
};
|
||||
|
||||
let session_item = MenuItemBuilder::new(&session_text)
|
||||
.id(format!("session_{}", session.id))
|
||||
.build(app)?;
|
||||
|
|
@ -165,6 +178,18 @@ impl TrayMenuManager {
|
|||
if let Some(network_item) = network_info {
|
||||
menu_builder = menu_builder.item(&network_item);
|
||||
}
|
||||
|
||||
// Add Tailscale info if available
|
||||
if let Some(ts_status) = &tailscale_status {
|
||||
if ts_status.is_running && ts_status.hostname.is_some() {
|
||||
let tailscale_text = format!("Tailscale: {}", ts_status.hostname.as_ref().unwrap());
|
||||
let tailscale_item = MenuItemBuilder::new(&tailscale_text)
|
||||
.id("tailscale_info")
|
||||
.enabled(false)
|
||||
.build(app)?;
|
||||
menu_builder = menu_builder.item(&tailscale_item);
|
||||
}
|
||||
}
|
||||
|
||||
// Build menu with sessions
|
||||
let mut menu_builder = menu_builder
|
||||
|
|
@ -208,6 +233,9 @@ impl TrayMenuManager {
|
|||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Get Tailscale status
|
||||
let tailscale_status = state.tailscale_service.get_status().await;
|
||||
|
||||
// Rebuild menu with new state and sessions
|
||||
if let Ok(menu) = Self::create_menu_with_sessions(
|
||||
|
|
@ -217,6 +245,7 @@ impl TrayMenuManager {
|
|||
session_count,
|
||||
access_mode,
|
||||
Some(sessions),
|
||||
Some(tailscale_status),
|
||||
) {
|
||||
if let Err(e) = tray.set_menu(Some(menu)) {
|
||||
tracing::error!("Failed to update tray menu: {}", e);
|
||||
|
|
@ -250,6 +279,9 @@ impl TrayMenuManager {
|
|||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Get Tailscale status
|
||||
let tailscale_status = state.tailscale_service.get_status().await;
|
||||
|
||||
// Rebuild menu with new state and sessions
|
||||
if let Ok(menu) = Self::create_menu_with_sessions(
|
||||
|
|
@ -259,6 +291,7 @@ impl TrayMenuManager {
|
|||
count,
|
||||
access_mode,
|
||||
Some(sessions),
|
||||
Some(tailscale_status),
|
||||
) {
|
||||
if let Err(e) = tray.set_menu(Some(menu)) {
|
||||
tracing::error!("Failed to update tray menu: {}", e);
|
||||
|
|
|
|||
241
tauri/src-tauri/src/url_scheme.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{Emitter, Listener, Manager};
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
/// URL scheme handler for vibetunnel:// URLs
|
||||
pub struct URLSchemeHandler;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum URLSchemeAction {
|
||||
OpenSession { session_id: String },
|
||||
CreateSession { name: Option<String>, command: Option<String> },
|
||||
OpenSettings { tab: Option<String> },
|
||||
ShowWelcome,
|
||||
}
|
||||
|
||||
impl URLSchemeHandler {
|
||||
/// Parse a vibetunnel:// URL into an action
|
||||
pub fn parse_url(url: &str) -> Result<URLSchemeAction, String> {
|
||||
debug!("Parsing URL scheme: {}", url);
|
||||
|
||||
// Remove the scheme prefix
|
||||
let url = url.strip_prefix("vibetunnel://")
|
||||
.ok_or_else(|| "Invalid URL scheme: must start with vibetunnel://".to_string())?;
|
||||
|
||||
// Parse the path and query
|
||||
let parts: Vec<&str> = url.split('?').collect();
|
||||
let path = parts.get(0).unwrap_or(&"");
|
||||
let query = parts.get(1).unwrap_or(&"");
|
||||
|
||||
// Parse query parameters
|
||||
let params = Self::parse_query(query);
|
||||
|
||||
// Route based on path
|
||||
match *path {
|
||||
"session" | "sessions" => {
|
||||
if let Some(session_id) = params.get("id") {
|
||||
Ok(URLSchemeAction::OpenSession {
|
||||
session_id: session_id.clone(),
|
||||
})
|
||||
} else {
|
||||
Err("Missing session ID parameter".to_string())
|
||||
}
|
||||
}
|
||||
"create" | "new" => {
|
||||
Ok(URLSchemeAction::CreateSession {
|
||||
name: params.get("name").cloned(),
|
||||
command: params.get("command").cloned(),
|
||||
})
|
||||
}
|
||||
"settings" | "preferences" => {
|
||||
Ok(URLSchemeAction::OpenSettings {
|
||||
tab: params.get("tab").cloned(),
|
||||
})
|
||||
}
|
||||
"welcome" => {
|
||||
Ok(URLSchemeAction::ShowWelcome)
|
||||
}
|
||||
"" => {
|
||||
// Default action - show welcome or main window
|
||||
Ok(URLSchemeAction::ShowWelcome)
|
||||
}
|
||||
_ => {
|
||||
Err(format!("Unknown URL path: {}", path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse query string into key-value pairs
|
||||
fn parse_query(query: &str) -> std::collections::HashMap<String, String> {
|
||||
let mut params = std::collections::HashMap::new();
|
||||
|
||||
for pair in query.split('&') {
|
||||
if let Some((key, value)) = pair.split_once('=') {
|
||||
if let Ok(decoded_value) = urlencoding::decode(value) {
|
||||
params.insert(key.to_string(), decoded_value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
params
|
||||
}
|
||||
|
||||
/// Handle a URL scheme action
|
||||
pub async fn handle_action(
|
||||
action: URLSchemeAction,
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
info!("Handling URL scheme action: {:?}", action);
|
||||
|
||||
match action {
|
||||
URLSchemeAction::OpenSession { session_id } => {
|
||||
// Open session detail window
|
||||
app_handle.emit("open-session", &session_id)
|
||||
.map_err(|e| format!("Failed to emit open-session event: {}", e))?;
|
||||
|
||||
// Show main window if needed (synchronous)
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
} else {
|
||||
// Create main window
|
||||
let window = tauri::WebviewWindowBuilder::new(app_handle, "main", tauri::WebviewUrl::App("index.html".into()))
|
||||
.title("VibeTunnel")
|
||||
.inner_size(1200.0, 800.0)
|
||||
.center()
|
||||
.resizable(true)
|
||||
.decorations(true)
|
||||
.build();
|
||||
|
||||
if let Ok(window) = window {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
URLSchemeAction::CreateSession { name, command } => {
|
||||
// Create new session
|
||||
let state = app_handle.state::<crate::state::AppState>();
|
||||
let req = crate::api_client::CreateSessionRequest {
|
||||
name,
|
||||
rows: None,
|
||||
cols: None,
|
||||
cwd: None,
|
||||
env: None,
|
||||
shell: command,
|
||||
};
|
||||
|
||||
if let Ok(session) = state.api_client.create_session(req).await {
|
||||
// Emit event to open the new session
|
||||
app_handle.emit("open-session", &session.id)
|
||||
.map_err(|e| format!("Failed to emit open-session event: {}", e))?;
|
||||
}
|
||||
|
||||
// Show main window (synchronous)
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
} else {
|
||||
// Create main window
|
||||
let window = tauri::WebviewWindowBuilder::new(app_handle, "main", tauri::WebviewUrl::App("index.html".into()))
|
||||
.title("VibeTunnel")
|
||||
.inner_size(1200.0, 800.0)
|
||||
.center()
|
||||
.resizable(true)
|
||||
.decorations(true)
|
||||
.build();
|
||||
|
||||
if let Ok(window) = window {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
URLSchemeAction::OpenSettings { tab } => {
|
||||
// Open settings window
|
||||
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_handle.get_webview_window("settings") {
|
||||
// Navigate to the URL with the tab parameter if window exists
|
||||
let _ = window.eval(&format!("window.location.href = '{}'", url));
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
} else {
|
||||
// Create new settings window
|
||||
let window = tauri::WebviewWindowBuilder::new(app_handle, "settings", tauri::WebviewUrl::App(url.into()))
|
||||
.title("VibeTunnel Settings")
|
||||
.inner_size(1200.0, 800.0)
|
||||
.resizable(true)
|
||||
.decorations(true)
|
||||
.center()
|
||||
.build();
|
||||
|
||||
if let Ok(window) = window {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
URLSchemeAction::ShowWelcome => {
|
||||
// Show welcome window through the welcome manager
|
||||
let state = app_handle.state::<crate::state::AppState>();
|
||||
let welcome_manager = state.welcome_manager.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = welcome_manager.show_welcome_window().await {
|
||||
error!("Failed to show welcome window: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set up URL scheme handling for deep links
|
||||
pub fn setup_deep_link_handler(app_handle: tauri::AppHandle) {
|
||||
// Clone app_handle for use in the closure
|
||||
let app_handle_for_closure = app_handle.clone();
|
||||
|
||||
// Set up listener for deep link events
|
||||
app_handle.listen("tauri://deep-link", move |event| {
|
||||
// In Tauri v2, the payload is already a string
|
||||
let payload = event.payload();
|
||||
if let Ok(urls) = serde_json::from_str::<Vec<String>>(payload) {
|
||||
for url in urls {
|
||||
debug!("Received deep link: {}", url);
|
||||
|
||||
match Self::parse_url(&url) {
|
||||
Ok(action) => {
|
||||
let app_handle_clone = app_handle_for_closure.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = Self::handle_action(action, &app_handle_clone).await {
|
||||
error!("Failed to handle URL scheme action: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to parse URL scheme: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Commands for testing URL scheme handling
|
||||
#[tauri::command]
|
||||
pub async fn handle_url_scheme(url: String, app: tauri::AppHandle) -> Result<(), String> {
|
||||
let action = URLSchemeHandler::parse_url(&url)?;
|
||||
URLSchemeHandler::handle_action(action, &app).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn parse_url_scheme(url: String) -> Result<URLSchemeAction, String> {
|
||||
URLSchemeHandler::parse_url(&url)
|
||||
}
|
||||
271
tauri/src-tauri/src/window_enumerator.rs
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tracing::debug;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WindowInfo {
|
||||
pub window_id: u64,
|
||||
pub owner_pid: u32,
|
||||
pub terminal_app: String,
|
||||
pub session_id: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub tab_reference: Option<String>,
|
||||
pub tab_id: Option<String>,
|
||||
pub bounds: Option<WindowBounds>,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WindowBounds {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
/// Window enumerator for finding and tracking terminal windows
|
||||
pub struct WindowEnumerator;
|
||||
|
||||
impl WindowEnumerator {
|
||||
/// Get all terminal windows currently visible on screen
|
||||
pub fn get_all_terminal_windows() -> Vec<WindowInfo> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Self::get_terminal_windows_macos()
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Self::get_terminal_windows_windows()
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Self::get_terminal_windows_linux()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn get_terminal_windows_macos() -> Vec<WindowInfo> {
|
||||
use std::process::Command;
|
||||
let mut terminal_windows = Vec::new();
|
||||
|
||||
// Use AppleScript to get window information as a simpler approach
|
||||
let script = r#"
|
||||
tell application "System Events"
|
||||
set terminalApps to {"Terminal", "iTerm2", "Alacritty", "kitty", "WezTerm", "Hyper"}
|
||||
set windowList to {}
|
||||
|
||||
repeat with appName in terminalApps
|
||||
if exists application process appName then
|
||||
tell application process appName
|
||||
repeat with w in windows
|
||||
set windowInfo to {appName, (id of w), (name of w), (position of w), (size of w)}
|
||||
set end of windowList to windowInfo
|
||||
end repeat
|
||||
end tell
|
||||
end if
|
||||
end repeat
|
||||
|
||||
return windowList
|
||||
end tell
|
||||
"#;
|
||||
|
||||
if let Ok(output) = Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(script)
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
// Parse the AppleScript output
|
||||
// This is a simplified version - real implementation would parse the structured output
|
||||
debug!("Window enumeration via AppleScript completed");
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Use ps to find terminal processes
|
||||
if let Ok(output) = Command::new("ps")
|
||||
.args(&["-eo", "pid,comm"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
for line in output_str.lines() {
|
||||
let parts: Vec<&str> = line.trim().split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
if let Ok(pid) = parts[0].parse::<u32>() {
|
||||
let process_name = parts[1..].join(" ");
|
||||
for terminal in &["Terminal", "iTerm2", "Alacritty", "kitty", "WezTerm", "Hyper"] {
|
||||
if process_name.contains(terminal) {
|
||||
terminal_windows.push(WindowInfo {
|
||||
window_id: pid as u64,
|
||||
owner_pid: pid,
|
||||
terminal_app: terminal.to_string(),
|
||||
session_id: String::new(),
|
||||
created_at: chrono::Utc::now(),
|
||||
tab_reference: None,
|
||||
tab_id: None,
|
||||
bounds: None,
|
||||
title: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
terminal_windows
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn get_terminal_windows_windows() -> Vec<WindowInfo> {
|
||||
use windows::Win32::Foundation::{BOOL, HWND, LPARAM, RECT};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
EnumWindows, GetClassNameW, GetWindowRect, GetWindowTextW, GetWindowThreadProcessId, IsWindowVisible,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
|
||||
let terminal_windows = Arc::new(Mutex::new(Vec::new()));
|
||||
let terminal_windows_clone = terminal_windows.clone();
|
||||
|
||||
unsafe {
|
||||
let _ = EnumWindows(
|
||||
Some(enum_window_callback),
|
||||
LPARAM(&terminal_windows_clone as *const _ as isize),
|
||||
);
|
||||
}
|
||||
|
||||
unsafe extern "system" fn enum_window_callback(hwnd: HWND, lparam: LPARAM) -> BOOL {
|
||||
let terminal_windows = &*(lparam.0 as *const Arc<Mutex<Vec<WindowInfo>>>);
|
||||
|
||||
if IsWindowVisible(hwnd).as_bool() {
|
||||
let mut class_name = [0u16; 256];
|
||||
let class_len = GetClassNameW(hwnd, &mut class_name);
|
||||
|
||||
if class_len > 0 {
|
||||
let class_str = String::from_utf16_lossy(&class_name[..class_len as usize]);
|
||||
|
||||
// Check for known terminal window classes
|
||||
let terminal_classes = vec![
|
||||
"ConsoleWindowClass", // Windows Terminal, CMD
|
||||
"CASCADIA_HOSTING_WINDOW_CLASS", // Windows Terminal
|
||||
"VirtualConsoleClass", // ConEmu
|
||||
"PuTTY", // PuTTY
|
||||
];
|
||||
|
||||
if terminal_classes.iter().any(|&tc| class_str.contains(tc)) {
|
||||
let mut title = [0u16; 512];
|
||||
let title_len = GetWindowTextW(hwnd, &mut title);
|
||||
let title_str = if title_len > 0 {
|
||||
Some(String::from_utf16_lossy(&title[..title_len as usize]))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut process_id: u32 = 0;
|
||||
GetWindowThreadProcessId(hwnd, Some(&mut process_id));
|
||||
|
||||
let mut rect = RECT::default();
|
||||
let bounds = if GetWindowRect(hwnd, &mut rect).is_ok() {
|
||||
Some(WindowBounds {
|
||||
x: rect.left as f64,
|
||||
y: rect.top as f64,
|
||||
width: (rect.right - rect.left) as f64,
|
||||
height: (rect.bottom - rect.top) as f64,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
terminal_windows.lock().unwrap().push(WindowInfo {
|
||||
window_id: hwnd.0 as u64,
|
||||
owner_pid: process_id,
|
||||
terminal_app: class_str,
|
||||
session_id: String::new(),
|
||||
created_at: chrono::Utc::now(),
|
||||
tab_reference: None,
|
||||
tab_id: None,
|
||||
bounds,
|
||||
title: title_str,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BOOL(1) // Continue enumeration
|
||||
}
|
||||
|
||||
let result = terminal_windows.lock().unwrap().clone();
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn get_terminal_windows_linux() -> Vec<WindowInfo> {
|
||||
// Use wmctrl or xwininfo to enumerate windows
|
||||
let mut terminal_windows = Vec::new();
|
||||
|
||||
// Try using wmctrl first
|
||||
match Command::new("wmctrl").arg("-lp").output() {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
for line in output_str.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 4 {
|
||||
let window_id = u64::from_str_radix(parts[0].trim_start_matches("0x"), 16).unwrap_or(0);
|
||||
let pid = parts[2].parse::<u32>().unwrap_or(0);
|
||||
let title = parts[4..].join(" ");
|
||||
|
||||
// Check if it's a terminal by title or other heuristics
|
||||
let terminal_keywords = vec!["terminal", "konsole", "gnome-terminal", "xterm", "alacritty", "kitty"];
|
||||
if terminal_keywords.iter().any(|&kw| title.to_lowercase().contains(kw)) {
|
||||
terminal_windows.push(WindowInfo {
|
||||
window_id,
|
||||
owner_pid: pid,
|
||||
terminal_app: "Unknown".to_string(),
|
||||
session_id: String::new(),
|
||||
created_at: chrono::Utc::now(),
|
||||
tab_reference: None,
|
||||
tab_id: None,
|
||||
bounds: None,
|
||||
title: Some(title),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to run wmctrl: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
terminal_windows
|
||||
}
|
||||
|
||||
/// Extract window ID from terminal tab reference
|
||||
pub fn extract_window_id(tab_reference: &str) -> Option<u64> {
|
||||
// Extract window ID from tab reference (format: "tab id X of window id Y")
|
||||
if let Some(pos) = tab_reference.find("window id ") {
|
||||
let id_str = &tab_reference[pos + 10..];
|
||||
if let Some(end_pos) = id_str.find(|c: char| !c.is_numeric()) {
|
||||
return id_str[..end_pos].parse().ok();
|
||||
} else {
|
||||
return id_str.parse().ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if a window title contains a specific identifier
|
||||
pub fn window_title_contains(window: &WindowInfo, identifier: &str) -> bool {
|
||||
if let Some(ref title) = window.title {
|
||||
title.contains(identifier)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Platform-specific imports
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::sync::Arc;
|
||||
279
tauri/src-tauri/src/window_matcher.rs
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
use crate::process_tracker::ProcessTracker;
|
||||
use crate::window_enumerator::{WindowEnumerator, WindowInfo};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionInfo {
|
||||
pub id: String,
|
||||
pub pid: Option<u32>,
|
||||
pub working_dir: String,
|
||||
pub name: Option<String>,
|
||||
pub activity_status: Option<String>,
|
||||
}
|
||||
|
||||
/// Handles window matching and session-to-window mapping algorithms
|
||||
pub struct WindowMatcher {
|
||||
/// Cache of session to window mappings
|
||||
session_window_cache: HashMap<String, u64>,
|
||||
}
|
||||
|
||||
impl WindowMatcher {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
session_window_cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a window for a specific terminal and session
|
||||
pub fn find_window<'a>(
|
||||
&mut self,
|
||||
terminal_app: &str,
|
||||
session_id: &str,
|
||||
session_info: Option<&SessionInfo>,
|
||||
tab_reference: Option<&str>,
|
||||
tab_id: Option<&str>,
|
||||
terminal_windows: &'a [WindowInfo],
|
||||
) -> Option<&'a WindowInfo> {
|
||||
// Check cache first
|
||||
if let Some(&cached_window_id) = self.session_window_cache.get(session_id) {
|
||||
if let Some(window) = terminal_windows.iter().find(|w| w.window_id == cached_window_id) {
|
||||
debug!("Found cached window for session {}: {}", session_id, cached_window_id);
|
||||
return Some(window);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter windows for the specific terminal
|
||||
let filtered_windows: Vec<&WindowInfo> = terminal_windows
|
||||
.iter()
|
||||
.filter(|w| w.terminal_app == terminal_app)
|
||||
.collect();
|
||||
|
||||
// First try to find window by process PID traversal
|
||||
if let Some(session_info) = session_info {
|
||||
if let Some(session_pid) = session_info.pid {
|
||||
debug!("Attempting to find window by process PID: {}", session_pid);
|
||||
|
||||
// Log the process tree for debugging
|
||||
ProcessTracker::log_process_tree(session_pid);
|
||||
|
||||
// Try to find the parent process (shell) that owns this session
|
||||
if let Some(parent_pid) = ProcessTracker::get_parent_process_id(session_pid) {
|
||||
debug!("Found parent process PID: {}", parent_pid);
|
||||
|
||||
// Look for a window owned by the parent process
|
||||
if let Some(matching_window) = filtered_windows.iter().find(|window| {
|
||||
window.owner_pid == parent_pid
|
||||
}) {
|
||||
info!("Found window by parent process match: PID {}", parent_pid);
|
||||
self.session_window_cache.insert(session_id.to_string(), matching_window.window_id);
|
||||
return Some(matching_window);
|
||||
}
|
||||
|
||||
// If direct parent match fails, try to find grandparent or higher ancestors
|
||||
let mut current_pid = parent_pid;
|
||||
let mut depth = 0;
|
||||
while depth < 10 {
|
||||
if let Some(grandparent_pid) = ProcessTracker::get_parent_process_id(current_pid) {
|
||||
debug!("Checking ancestor process PID: {} at depth {}", grandparent_pid, depth + 2);
|
||||
|
||||
if let Some(matching_window) = filtered_windows.iter().find(|window| {
|
||||
window.owner_pid == grandparent_pid
|
||||
}) {
|
||||
info!("Found window by ancestor process match: PID {} at depth {}", grandparent_pid, depth + 2);
|
||||
self.session_window_cache.insert(session_id.to_string(), matching_window.window_id);
|
||||
return Some(matching_window);
|
||||
}
|
||||
|
||||
current_pid = grandparent_pid;
|
||||
depth += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to find window by title containing session path or command
|
||||
if let Some(session_info) = session_info {
|
||||
let working_dir = &session_info.working_dir;
|
||||
let dir_name = std::path::Path::new(working_dir)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Look for windows whose title contains the directory name
|
||||
if let Some(matching_window) = filtered_windows.iter().find(|window| {
|
||||
WindowEnumerator::window_title_contains(window, dir_name) ||
|
||||
WindowEnumerator::window_title_contains(window, working_dir)
|
||||
}) {
|
||||
debug!("Found window by directory match: {}", dir_name);
|
||||
self.session_window_cache.insert(session_id.to_string(), matching_window.window_id);
|
||||
return Some(matching_window);
|
||||
}
|
||||
}
|
||||
|
||||
// For Terminal.app with specific tab reference
|
||||
if terminal_app == "Terminal" {
|
||||
if let Some(tab_ref) = tab_reference {
|
||||
if let Some(window_id) = WindowEnumerator::extract_window_id(tab_ref) {
|
||||
if let Some(matching_window) = filtered_windows.iter().find(|w| {
|
||||
w.window_id == window_id
|
||||
}) {
|
||||
debug!("Found Terminal.app window by ID: {}", window_id);
|
||||
self.session_window_cache.insert(session_id.to_string(), matching_window.window_id);
|
||||
return Some(matching_window);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For iTerm2 with tab ID
|
||||
if terminal_app == "iTerm2" {
|
||||
if let Some(tab_id) = tab_id {
|
||||
// Try to match by window title which often includes the window ID
|
||||
if let Some(matching_window) = filtered_windows.iter().find(|window| {
|
||||
WindowEnumerator::window_title_contains(window, tab_id)
|
||||
}) {
|
||||
debug!("Found iTerm2 window by ID in title: {}", tab_id);
|
||||
self.session_window_cache.insert(session_id.to_string(), matching_window.window_id);
|
||||
return Some(matching_window);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return the most recently created window (highest window ID)
|
||||
if let Some(latest_window) = filtered_windows.iter().max_by_key(|w| w.window_id) {
|
||||
debug!("Using most recent window as fallback for session: {}", session_id);
|
||||
self.session_window_cache.insert(session_id.to_string(), latest_window.window_id);
|
||||
return Some(latest_window);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Find a terminal window for a session that was attached via `vt`
|
||||
pub fn find_window_for_session<'a>(
|
||||
&mut self,
|
||||
session_id: &str,
|
||||
session_info: &SessionInfo,
|
||||
all_windows: &'a [WindowInfo],
|
||||
) -> Option<&'a WindowInfo> {
|
||||
// Check cache first
|
||||
if let Some(&cached_window_id) = self.session_window_cache.get(session_id) {
|
||||
if let Some(window) = all_windows.iter().find(|w| w.window_id == cached_window_id) {
|
||||
debug!("Found cached window for session {}: {}", session_id, cached_window_id);
|
||||
return Some(window);
|
||||
}
|
||||
}
|
||||
|
||||
// First try to find window by process PID traversal
|
||||
if let Some(session_pid) = session_info.pid {
|
||||
debug!("Scanning for window by process PID: {} for session {}", session_pid, session_id);
|
||||
|
||||
// Log the process tree for debugging
|
||||
ProcessTracker::log_process_tree(session_pid);
|
||||
|
||||
// Try to traverse up the process tree to find a terminal window
|
||||
let mut current_pid = session_pid;
|
||||
let mut depth = 0;
|
||||
let max_depth = 20;
|
||||
|
||||
while depth < max_depth {
|
||||
// Check if any window is owned by this PID
|
||||
if let Some(matching_window) = all_windows.iter().find(|window| {
|
||||
window.owner_pid == current_pid
|
||||
}) {
|
||||
info!("Found window by PID {} at depth {} for session {}", current_pid, depth, session_id);
|
||||
self.session_window_cache.insert(session_id.to_string(), matching_window.window_id);
|
||||
return Some(matching_window);
|
||||
}
|
||||
|
||||
// Move up to parent process
|
||||
if let Some(parent_pid) = ProcessTracker::get_parent_process_id(current_pid) {
|
||||
if parent_pid == 0 || parent_pid == 1 {
|
||||
// Reached root process
|
||||
break;
|
||||
}
|
||||
current_pid = parent_pid;
|
||||
depth += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Process traversal completed at depth {} without finding window", depth);
|
||||
}
|
||||
|
||||
// Fallback: Find by working directory
|
||||
let working_dir = &session_info.working_dir;
|
||||
let dir_name = std::path::Path::new(working_dir)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
debug!("Trying to match by directory: {} or full path: {}", dir_name, working_dir);
|
||||
|
||||
// Look for windows whose title contains the directory name
|
||||
if let Some(matching_window) = all_windows.iter().find(|window| {
|
||||
if let Some(ref title) = window.title {
|
||||
let matches = title.contains(dir_name) || title.contains(working_dir);
|
||||
if matches {
|
||||
debug!("Window title '{}' matches directory", title);
|
||||
}
|
||||
matches
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
info!("Found window by directory match: {} for session {}", dir_name, session_id);
|
||||
self.session_window_cache.insert(session_id.to_string(), matching_window.window_id);
|
||||
return Some(matching_window);
|
||||
}
|
||||
|
||||
// Try to match by activity status (for sessions with specific activities)
|
||||
if let Some(ref activity) = session_info.activity_status {
|
||||
if !activity.is_empty() {
|
||||
debug!("Trying to match by activity: {}", activity);
|
||||
|
||||
if let Some(matching_window) = all_windows.iter().find(|window| {
|
||||
if let Some(ref title) = window.title {
|
||||
title.contains(activity)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
info!("Found window by activity match: {} for session {}", activity, session_id);
|
||||
self.session_window_cache.insert(session_id.to_string(), matching_window.window_id);
|
||||
return Some(matching_window);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("Could not find window for session {} after all attempts", session_id);
|
||||
debug!("Available windows: {}", all_windows.len());
|
||||
for (index, window) in all_windows.iter().enumerate() {
|
||||
debug!(
|
||||
" Window {}: PID={}, Terminal={}, Title={}",
|
||||
index,
|
||||
window.owner_pid,
|
||||
window.terminal_app,
|
||||
window.title.as_deref().unwrap_or("<no title>")
|
||||
);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Clear cached window mapping for a session
|
||||
pub fn clear_session_cache(&mut self, session_id: &str) {
|
||||
self.session_window_cache.remove(session_id);
|
||||
}
|
||||
|
||||
/// Clear all cached window mappings
|
||||
pub fn clear_all_cache(&mut self) {
|
||||
self.session_window_cache.clear();
|
||||
}
|
||||
}
|
||||
341
tauri/src-tauri/src/window_tracker.rs
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
use crate::window_enumerator::WindowEnumerator;
|
||||
use crate::window_matcher::{SessionInfo as MatcherSessionInfo, WindowMatcher};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WindowInfo {
|
||||
pub window_id: u64, // Changed from u32 to u64 to support all platforms
|
||||
pub owner_pid: u32,
|
||||
pub terminal_app: String,
|
||||
pub session_id: String,
|
||||
pub created_at: String,
|
||||
pub tab_reference: Option<String>,
|
||||
pub tab_id: Option<String>,
|
||||
pub bounds: Option<WindowBounds>,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WindowBounds {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
pub struct WindowTracker {
|
||||
// Maps session IDs to their terminal window information
|
||||
session_window_map: Arc<RwLock<HashMap<String, WindowInfo>>>,
|
||||
// Window matcher for advanced window finding
|
||||
window_matcher: Arc<RwLock<WindowMatcher>>,
|
||||
}
|
||||
|
||||
impl WindowTracker {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
session_window_map: Arc::new(RwLock::new(HashMap::new())),
|
||||
window_matcher: Arc::new(RwLock::new(WindowMatcher::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a terminal window for a session
|
||||
pub async fn register_window(
|
||||
&self,
|
||||
session_id: String,
|
||||
terminal_app: String,
|
||||
tab_reference: Option<String>,
|
||||
tab_id: Option<String>,
|
||||
) {
|
||||
info!("Registering window for session: {}, terminal: {}", session_id, terminal_app);
|
||||
|
||||
// For terminals with explicit window/tab info, register immediately
|
||||
if (terminal_app == "Terminal" && tab_reference.is_some()) ||
|
||||
(terminal_app == "iTerm2" && tab_id.is_some()) {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
if let Some(window_info) = self.find_window(&terminal_app, &session_id, &tab_reference, &tab_id).await {
|
||||
self.session_window_map.write().await.insert(session_id.clone(), window_info);
|
||||
info!("Successfully registered window for session {} with explicit ID", session_id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For other terminals, use progressive delays to find the window
|
||||
let delays = [0.5, 1.0, 2.0, 3.0];
|
||||
for (index, delay) in delays.iter().enumerate() {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs_f64(*delay)).await;
|
||||
|
||||
if let Some(window_info) = self.find_window(&terminal_app, &session_id, &tab_reference, &tab_id).await {
|
||||
self.session_window_map.write().await.insert(session_id.clone(), window_info);
|
||||
info!("Successfully registered window for session {} after {} attempts", session_id, index + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
warn!("Failed to register window for session {} after all attempts", session_id);
|
||||
}
|
||||
|
||||
/// Unregister a window for a session
|
||||
pub async fn unregister_window(&self, session_id: &str) {
|
||||
if self.session_window_map.write().await.remove(session_id).is_some() {
|
||||
info!("Unregistered window for session: {}", session_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get window information for a specific session
|
||||
pub async fn window_info(&self, session_id: &str) -> Option<WindowInfo> {
|
||||
self.session_window_map.read().await.get(session_id).cloned()
|
||||
}
|
||||
|
||||
/// Get all tracked windows
|
||||
pub async fn all_tracked_windows(&self) -> Vec<WindowInfo> {
|
||||
self.session_window_map.read().await.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Focus the terminal window for a specific session
|
||||
pub async fn focus_window(&self, session_id: &str) -> Result<(), String> {
|
||||
let window_info = self.window_info(session_id).await
|
||||
.ok_or_else(|| format!("No window registered for session: {}", session_id))?;
|
||||
|
||||
info!("Focusing window for session: {}, terminal: {}", session_id, window_info.terminal_app);
|
||||
|
||||
// Platform-specific window focusing
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.focus_window_macos(&window_info).await
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
self.focus_window_windows(&window_info).await
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
self.focus_window_linux(&window_info).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Update window tracking based on current sessions
|
||||
pub async fn update_from_sessions(&self, sessions: &[crate::api_client::SessionResponse]) {
|
||||
let session_ids: std::collections::HashSet<String> = sessions.iter()
|
||||
.map(|s| s.id.clone())
|
||||
.collect();
|
||||
|
||||
// Remove windows for sessions that no longer exist
|
||||
let mut window_map = self.session_window_map.write().await;
|
||||
let tracked_sessions: Vec<String> = window_map.keys().cloned().collect();
|
||||
|
||||
for session_id in tracked_sessions {
|
||||
if !session_ids.contains(&session_id) {
|
||||
window_map.remove(&session_id);
|
||||
info!("Removed window tracking for terminated session: {}", session_id);
|
||||
}
|
||||
}
|
||||
drop(window_map);
|
||||
|
||||
// Try to find windows for sessions without registered windows
|
||||
for session in sessions {
|
||||
if self.window_info(&session.id).await.is_none() {
|
||||
debug!("Session {} has no window registered, attempting to find it...", session.id);
|
||||
|
||||
if let Some(window_info) = self.find_window_for_session(&session.id).await {
|
||||
self.session_window_map.write().await.insert(session.id.clone(), window_info);
|
||||
info!("Found and registered window for session: {}", session.id);
|
||||
} else {
|
||||
debug!("Could not find window for session: {}", session.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advanced window finding using the new components
|
||||
async fn find_window(
|
||||
&self,
|
||||
terminal_app: &str,
|
||||
session_id: &str,
|
||||
tab_reference: &Option<String>,
|
||||
tab_id: &Option<String>,
|
||||
) -> Option<WindowInfo> {
|
||||
// Get all terminal windows using WindowEnumerator
|
||||
let terminal_windows = WindowEnumerator::get_all_terminal_windows();
|
||||
|
||||
// For testing, also try to get session info from API if available
|
||||
let session_info = None; // In a real implementation, this would query the server
|
||||
|
||||
// Use WindowMatcher to find the matching window
|
||||
let mut matcher = self.window_matcher.write().await;
|
||||
|
||||
if let Some(matched_window) = matcher.find_window(
|
||||
terminal_app,
|
||||
session_id,
|
||||
session_info,
|
||||
tab_reference.as_deref(),
|
||||
tab_id.as_deref(),
|
||||
&terminal_windows,
|
||||
) {
|
||||
// Convert from EnumeratedWindowInfo to our WindowInfo
|
||||
Some(WindowInfo {
|
||||
window_id: matched_window.window_id, // No cast needed, already u64
|
||||
owner_pid: matched_window.owner_pid,
|
||||
terminal_app: matched_window.terminal_app.clone(),
|
||||
session_id: session_id.to_string(),
|
||||
created_at: matched_window.created_at.to_rfc3339(),
|
||||
tab_reference: matched_window.tab_reference.clone(),
|
||||
tab_id: matched_window.tab_id.clone(),
|
||||
bounds: matched_window.bounds.as_ref().map(|b| WindowBounds {
|
||||
x: b.x,
|
||||
y: b.y,
|
||||
width: b.width,
|
||||
height: b.height,
|
||||
}),
|
||||
title: matched_window.title.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_window_for_session(&self, session_id: &str) -> Option<WindowInfo> {
|
||||
// Get all terminal windows
|
||||
let terminal_windows = WindowEnumerator::get_all_terminal_windows();
|
||||
|
||||
// Create a minimal session info for matching
|
||||
let session_info = MatcherSessionInfo {
|
||||
id: session_id.to_string(),
|
||||
pid: None, // Would be filled from actual session data
|
||||
working_dir: String::new(),
|
||||
name: None,
|
||||
activity_status: None,
|
||||
};
|
||||
|
||||
// Use WindowMatcher to find the window
|
||||
let mut matcher = self.window_matcher.write().await;
|
||||
|
||||
if let Some(matched_window) = matcher.find_window_for_session(
|
||||
session_id,
|
||||
&session_info,
|
||||
&terminal_windows,
|
||||
) {
|
||||
// Convert from EnumeratedWindowInfo to our WindowInfo
|
||||
Some(WindowInfo {
|
||||
window_id: matched_window.window_id, // No cast needed, already u64
|
||||
owner_pid: matched_window.owner_pid,
|
||||
terminal_app: matched_window.terminal_app.clone(),
|
||||
session_id: session_id.to_string(),
|
||||
created_at: matched_window.created_at.to_rfc3339(),
|
||||
tab_reference: matched_window.tab_reference.clone(),
|
||||
tab_id: matched_window.tab_id.clone(),
|
||||
bounds: matched_window.bounds.as_ref().map(|b| WindowBounds {
|
||||
x: b.x,
|
||||
y: b.y,
|
||||
width: b.width,
|
||||
height: b.height,
|
||||
}),
|
||||
title: matched_window.title.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Platform-specific window focusing implementations
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn focus_window_macos(&self, window_info: &WindowInfo) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
// First activate the application
|
||||
let script = format!(
|
||||
r#"tell application "{}" to activate"#,
|
||||
window_info.terminal_app
|
||||
);
|
||||
|
||||
let output = Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(&script)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run AppleScript: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("AppleScript failed: {}", error));
|
||||
}
|
||||
|
||||
// For Terminal.app, also try to focus specific tab if we have tab reference
|
||||
if window_info.terminal_app == "Terminal" {
|
||||
if let Some(tab_ref) = &window_info.tab_reference {
|
||||
let focus_script = format!(
|
||||
r#"tell application "Terminal"
|
||||
set selected of {} to true
|
||||
activate
|
||||
end tell"#,
|
||||
tab_ref
|
||||
);
|
||||
|
||||
let _ = Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(&focus_script)
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn focus_window_windows(&self, window_info: &WindowInfo) -> Result<(), String> {
|
||||
// Use Windows API to focus window
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{SetForegroundWindow, ShowWindow, SW_RESTORE};
|
||||
|
||||
let hwnd = HWND(window_info.window_id as isize);
|
||||
unsafe {
|
||||
ShowWindow(hwnd, SW_RESTORE);
|
||||
SetForegroundWindow(hwnd);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Err("Window focusing not implemented for Windows".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn focus_window_linux(&self, window_info: &WindowInfo) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
// Try using wmctrl to focus the window
|
||||
let output = Command::new("wmctrl")
|
||||
.arg("-i")
|
||||
.arg("-a")
|
||||
.arg(format!("0x{:x}", window_info.window_id))
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(result) => {
|
||||
if result.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("wmctrl failed to focus window".to_string())
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Try xdotool as fallback
|
||||
let xdotool_output = Command::new("xdotool")
|
||||
.arg("windowactivate")
|
||||
.arg(window_info.window_id.to_string())
|
||||
.output();
|
||||
|
||||
match xdotool_output {
|
||||
Ok(result) if result.status.success() => Ok(()),
|
||||
_ => Err("Failed to focus window on Linux".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2.0.0",
|
||||
"productName": "VibeTunnel",
|
||||
"identifier": "com.vibetunnel.app",
|
||||
"identifier": "sh.vibetunnel.tauri",
|
||||
"build": {
|
||||
"beforeDevCommand": "cd ../web && npm run build",
|
||||
"beforeBuildCommand": "cd ../web && npm run build",
|
||||
|
|
@ -16,6 +16,8 @@
|
|||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"publisher": "VibeTunnel Team",
|
||||
"homepage": "https://vibetunnel.sh",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
|
@ -39,7 +41,10 @@
|
|||
"exceptionDomain": "localhost",
|
||||
"signingIdentity": null,
|
||||
"providerShortName": null,
|
||||
"entitlements": "entitlements.plist"
|
||||
"entitlements": "entitlements.plist",
|
||||
"files": {
|
||||
"Info.plist": "./Info.plist"
|
||||
}
|
||||
},
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ interface SettingsData {
|
|||
debug_mode?: boolean;
|
||||
cleanup_on_startup?: boolean;
|
||||
preferred_terminal?: string;
|
||||
preferred_git_app?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +94,9 @@ export class SettingsApp extends TauriBase {
|
|||
|
||||
@state()
|
||||
private diagnosticReport: any = null;
|
||||
|
||||
@state()
|
||||
private installedGitApps: Array<{value: string, label: string}> = [];
|
||||
static override styles = [
|
||||
formStyles,
|
||||
css`
|
||||
|
|
@ -879,6 +883,9 @@ export class SettingsApp extends TauriBase {
|
|||
if (this.debugMode) {
|
||||
await this.loadDebugData();
|
||||
}
|
||||
|
||||
// Load installed Git apps
|
||||
await this.loadInstalledGitApps();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -906,6 +913,15 @@ export class SettingsApp extends TauriBase {
|
|||
}
|
||||
}
|
||||
|
||||
private async loadInstalledGitApps(): Promise<void> {
|
||||
try {
|
||||
const apps = await this.safeInvoke<Array<{value: string, label: string}>>('get_installed_git_apps');
|
||||
this.installedGitApps = apps || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load installed Git apps:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private applyTheme(theme: 'system' | 'light' | 'dark'): void {
|
||||
const htmlElement = document.documentElement;
|
||||
|
||||
|
|
@ -1291,6 +1307,21 @@ export class SettingsApp extends TauriBase {
|
|||
></settings-select>
|
||||
</div>
|
||||
|
||||
<div class="setting-card">
|
||||
<h3>Git Applications</h3>
|
||||
<settings-select
|
||||
label="Preferred Git App"
|
||||
help="Select which Git GUI application to use when opening repositories"
|
||||
settingKey="advanced.preferred_git_app"
|
||||
.value=${this.settings.advanced?.preferred_git_app || 'auto'}
|
||||
.options=${[
|
||||
{ value: 'auto', label: 'Auto-detect' },
|
||||
...this.installedGitApps
|
||||
]}
|
||||
@change=${this.handleSettingChange}
|
||||
></settings-select>
|
||||
</div>
|
||||
|
||||
<div class="setting-card">
|
||||
<h3>Advanced Options</h3>
|
||||
<settings-checkbox
|
||||
|
|
|
|||
877
tauri/src/menubar.html
Normal file
|
|
@ -0,0 +1,877 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VibeTunnel Menu</title>
|
||||
<style>
|
||||
:root {
|
||||
--menu-width: 360px;
|
||||
--header-gradient-start: #f8f9fa;
|
||||
--header-gradient-end: #e9ecef;
|
||||
--accent-color: #007aff;
|
||||
--accent-hover: #0051d5;
|
||||
--destructive-color: #ff3b30;
|
||||
--success-color: #34c759;
|
||||
--text-primary: #1d1d1f;
|
||||
--text-secondary: #86868b;
|
||||
--border-color: #d2d2d7;
|
||||
--hover-bg: rgba(0, 122, 255, 0.08);
|
||||
--focus-ring: rgba(0, 122, 255, 0.5);
|
||||
--activity-color: #30d158;
|
||||
--git-clean: #34c759;
|
||||
--git-modified: #ff9500;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--header-gradient-start: #2c2c2e;
|
||||
--header-gradient-end: #1c1c1e;
|
||||
--text-primary: #f2f2f7;
|
||||
--text-secondary: #98989f;
|
||||
--border-color: #38383a;
|
||||
--hover-bg: rgba(0, 122, 255, 0.15);
|
||||
--bg-primary: #1c1c1e;
|
||||
--bg-secondary: #2c2c2e;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif;
|
||||
background: var(--bg-primary, white);
|
||||
color: var(--text-primary);
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
width: var(--menu-width);
|
||||
background: var(--bg-primary, white);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
/* Header Section */
|
||||
.header {
|
||||
background: linear-gradient(to bottom, var(--header-gradient-start), var(--header-gradient-end));
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.app-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.status-badge.running {
|
||||
background: rgba(52, 199, 89, 0.1);
|
||||
color: var(--success-color);
|
||||
border: 0.5px solid rgba(52, 199, 89, 0.3);
|
||||
}
|
||||
|
||||
.status-badge.stopped {
|
||||
background: rgba(255, 59, 48, 0.1);
|
||||
color: var(--destructive-color);
|
||||
border: 0.5px solid rgba(255, 59, 48, 0.3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-badge.stopped:hover {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.server-addresses {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.address-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.address-icon {
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.address-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.address-link {
|
||||
font-family: 'SF Mono', Consolas, monospace;
|
||||
color: var(--success-color);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.address-link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Session List */
|
||||
.session-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.session-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.session-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.session-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
margin-bottom: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.session-row:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.session-row:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px var(--focus-ring);
|
||||
}
|
||||
|
||||
.activity-indicator {
|
||||
position: relative;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.activity-glow {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.3;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.activity-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
position: relative;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.session-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.session-command {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.folder-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.folder-button:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.folder-path {
|
||||
font-family: 'SF Mono', Consolas, monospace;
|
||||
}
|
||||
|
||||
.git-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(52, 199, 89, 0.1);
|
||||
color: var(--git-clean);
|
||||
}
|
||||
|
||||
.git-info.modified {
|
||||
background: rgba(255, 149, 0, 0.1);
|
||||
color: var(--git-modified);
|
||||
}
|
||||
|
||||
.duration {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.6;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 59, 48, 0.1);
|
||||
border: 0.5px solid rgba(255, 59, 48, 0.3);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.session-row:hover .close-button {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.session-row:hover .duration {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
}
|
||||
|
||||
.activity-status {
|
||||
font-size: 10px;
|
||||
color: var(--activity-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
opacity: 0.3;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.empty-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Action Bar */
|
||||
.action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.action-button.primary {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-button.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.session-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.session-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.session-list::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.session-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-top-color: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="menu-container" id="menu">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// API communication with Tauri
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
|
||||
// State
|
||||
let serverStatus = null;
|
||||
let sessions = [];
|
||||
let tailscaleStatus = null;
|
||||
let gitRepositories = {};
|
||||
|
||||
// Initialize menu
|
||||
async function init() {
|
||||
try {
|
||||
// Load initial data
|
||||
await Promise.all([
|
||||
loadServerStatus(),
|
||||
loadSessions(),
|
||||
loadTailscaleStatus()
|
||||
]);
|
||||
|
||||
// Render menu
|
||||
render();
|
||||
|
||||
// Set up event listeners
|
||||
setupEventListeners();
|
||||
|
||||
// Start monitoring
|
||||
startMonitoring();
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize menu:', error);
|
||||
showError();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServerStatus() {
|
||||
serverStatus = await invoke('get_server_status');
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
sessions = await invoke('get_monitored_sessions');
|
||||
|
||||
// Load git info for each session
|
||||
for (const session of sessions) {
|
||||
if (session.cwd) {
|
||||
try {
|
||||
const gitInfo = await invoke('get_cached_git_repository', { path: session.cwd });
|
||||
if (gitInfo) {
|
||||
gitRepositories[session.cwd] = gitInfo;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors for sessions without git repos
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTailscaleStatus() {
|
||||
try {
|
||||
tailscaleStatus = await invoke('get_tailscale_status');
|
||||
} catch (e) {
|
||||
tailscaleStatus = null;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const menu = document.getElementById('menu');
|
||||
|
||||
const activeSessions = sessions.filter(s => s.is_active);
|
||||
const idleSessions = sessions.filter(s => !s.is_active);
|
||||
|
||||
menu.innerHTML = `
|
||||
${renderHeader()}
|
||||
${renderSessionList(activeSessions, idleSessions)}
|
||||
${renderActionBar()}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
const addresses = [];
|
||||
|
||||
if (serverStatus?.running) {
|
||||
addresses.push({
|
||||
icon: '🖥',
|
||||
label: 'Local:',
|
||||
address: `127.0.0.1:${serverStatus.port}`
|
||||
});
|
||||
|
||||
if (tailscaleStatus?.is_running && tailscaleStatus?.hostname) {
|
||||
addresses.push({
|
||||
icon: '🛡',
|
||||
label: 'Tailscale:',
|
||||
address: tailscaleStatus.hostname
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="header">
|
||||
<div class="header-content">
|
||||
<div class="header-top">
|
||||
<div class="app-info">
|
||||
<img src="icon.png" class="app-icon" alt="VibeTunnel">
|
||||
<div class="app-title">VibeTunnel</div>
|
||||
</div>
|
||||
<div class="status-badge ${serverStatus?.running ? 'running' : 'stopped'}"
|
||||
onclick="${serverStatus?.running ? '' : 'restartServer()'}">
|
||||
<div class="status-indicator"></div>
|
||||
<span>${serverStatus?.running ? 'Running' : 'Stopped'}</span>
|
||||
</div>
|
||||
</div>
|
||||
${addresses.length > 0 ? `
|
||||
<div class="server-addresses">
|
||||
${addresses.map(addr => `
|
||||
<div class="address-row">
|
||||
<span class="address-icon">${addr.icon}</span>
|
||||
<span class="address-label">${addr.label}</span>
|
||||
<button class="address-link" onclick="openAddress('${addr.address}')">
|
||||
${addr.address}
|
||||
</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSessionList(activeSessions, idleSessions) {
|
||||
if (sessions.length === 0) {
|
||||
return `
|
||||
<div class="session-list">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📂</div>
|
||||
<div class="empty-title">No Active Sessions</div>
|
||||
<div class="empty-subtitle">Create a new session to get started</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="session-list">
|
||||
${activeSessions.length > 0 ? `
|
||||
<div class="session-section">
|
||||
<div class="session-section-title">Active Sessions</div>
|
||||
${activeSessions.map(session => renderSessionRow(session, true)).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
${idleSessions.length > 0 ? `
|
||||
<div class="session-section">
|
||||
<div class="session-section-title">Idle Sessions</div>
|
||||
${idleSessions.map(session => renderSessionRow(session, false)).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSessionRow(session, isActive) {
|
||||
const gitInfo = gitRepositories[session.cwd];
|
||||
const hasChanges = gitInfo && gitInfo.has_changes;
|
||||
|
||||
return `
|
||||
<div class="session-row" tabindex="0" onclick="openSession('${session.id}')"
|
||||
onkeydown="handleSessionKeyDown(event, '${session.id}')">
|
||||
<div class="activity-indicator" style="color: ${isActive ? 'var(--activity-color)' : 'var(--git-clean)'}">
|
||||
<div class="activity-glow"></div>
|
||||
<div class="activity-dot"></div>
|
||||
</div>
|
||||
<div class="session-info">
|
||||
<div class="session-header">
|
||||
<span class="session-command">${getCommandName(session)}</span>
|
||||
${session.name ? `
|
||||
<span class="session-name">– ${session.name}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="session-details">
|
||||
<button class="folder-button" onclick="openFolder('${session.cwd}'); event.stopPropagation();">
|
||||
<span>📁</span>
|
||||
<span class="folder-path">${compactPath(session.cwd)}</span>
|
||||
</button>
|
||||
${gitInfo ? `
|
||||
<div class="git-info ${hasChanges ? 'modified' : ''}">
|
||||
<span>${gitInfo.current_branch || 'main'}</span>
|
||||
${hasChanges ? `<span>(${getGitStatusText(gitInfo)})</span>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
<span class="duration">${formatDuration(session.created_at)}</span>
|
||||
</div>
|
||||
${session.last_activity ? `
|
||||
<div class="activity-status">${session.last_activity}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<button class="close-button" onclick="terminateSession('${session.id}'); event.stopPropagation();">
|
||||
<span style="font-size: 8px;">✕</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderActionBar() {
|
||||
return `
|
||||
<div class="action-bar">
|
||||
<button class="action-button primary" onclick="createNewSession()">
|
||||
<span>+</span>
|
||||
<span>New Session</span>
|
||||
</button>
|
||||
<div class="divider"></div>
|
||||
<button class="action-button" onclick="openSettings()">
|
||||
<span>⚙️</span>
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
<button class="action-button" onclick="quitApp()">
|
||||
<span>Quit</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function getCommandName(session) {
|
||||
// Extract command name from the full command
|
||||
if (!session.shell || session.shell.length === 0) {
|
||||
return 'Terminal';
|
||||
}
|
||||
const parts = session.shell.split('/');
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
function compactPath(path) {
|
||||
// Get the appropriate home directory based on the platform
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
let homePatterns = [];
|
||||
|
||||
if (platform.includes('win')) {
|
||||
// Windows: C:\Users\username or similar
|
||||
homePatterns = [
|
||||
/^[A-Za-z]:\\Users\\[^\\]+/,
|
||||
/^[A-Za-z]:\\Documents and Settings\\[^\\]+/
|
||||
];
|
||||
} else if (platform.includes('mac')) {
|
||||
// macOS: /Users/username
|
||||
homePatterns = [/^\/Users\/[^\/]+/];
|
||||
} else {
|
||||
// Linux/Unix: /home/username
|
||||
homePatterns = [/^\/home\/[^\/]+/];
|
||||
}
|
||||
|
||||
// Try to match and replace home directory with ~
|
||||
for (const pattern of homePatterns) {
|
||||
const match = path.match(pattern);
|
||||
if (match) {
|
||||
return '~' + path.substring(match[0].length);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: shorten long paths
|
||||
const separator = path.includes('\\') ? '\\' : '/';
|
||||
const parts = path.split(separator);
|
||||
if (parts.length > 3) {
|
||||
return '...' + separator + parts.slice(-2).join(separator);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function formatDuration(createdAt) {
|
||||
const start = new Date(createdAt);
|
||||
const now = new Date();
|
||||
const elapsed = (now - start) / 1000; // seconds
|
||||
|
||||
if (elapsed < 60) return 'now';
|
||||
if (elapsed < 3600) return `${Math.floor(elapsed / 60)}m`;
|
||||
if (elapsed < 86400) return `${Math.floor(elapsed / 3600)}h`;
|
||||
return `${Math.floor(elapsed / 86400)}d`;
|
||||
}
|
||||
|
||||
function getGitStatusText(gitInfo) {
|
||||
const parts = [];
|
||||
if (gitInfo.modified_count > 0) parts.push(`${gitInfo.modified_count}M`);
|
||||
if (gitInfo.added_count > 0) parts.push(`${gitInfo.added_count}+`);
|
||||
if (gitInfo.deleted_count > 0) parts.push(`${gitInfo.deleted_count}-`);
|
||||
if (gitInfo.untracked_count > 0) parts.push(`${gitInfo.untracked_count}?`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
async function openSession(sessionId) {
|
||||
try {
|
||||
// First try to focus terminal window
|
||||
await invoke('focus_terminal_window', { sessionId });
|
||||
} catch (e) {
|
||||
// If no window, open in browser
|
||||
await invoke('open_dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
async function terminateSession(sessionId) {
|
||||
try {
|
||||
// TODO: Implement session termination
|
||||
console.log('Terminate session:', sessionId);
|
||||
} catch (error) {
|
||||
console.error('Failed to terminate session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function openFolder(path) {
|
||||
try {
|
||||
await invoke('open_folder', { path });
|
||||
} catch (error) {
|
||||
console.error('Failed to open folder:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function openAddress(address) {
|
||||
if (address.includes('127.0.0.1')) {
|
||||
await invoke('open_dashboard');
|
||||
} else {
|
||||
// Open other addresses directly
|
||||
window.open(`http://${address}`, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
async function restartServer() {
|
||||
try {
|
||||
await invoke('restart_server');
|
||||
await loadServerStatus();
|
||||
render();
|
||||
} catch (error) {
|
||||
console.error('Failed to restart server:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewSession() {
|
||||
// This would open a new session form
|
||||
// For now, just open the dashboard
|
||||
await invoke('open_dashboard');
|
||||
}
|
||||
|
||||
async function openSettings() {
|
||||
await invoke('open_settings_window');
|
||||
}
|
||||
|
||||
async function quitApp() {
|
||||
await invoke('quit_app');
|
||||
}
|
||||
|
||||
function handleSessionKeyDown(event, sessionId) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// Listen for Tauri events
|
||||
window.__TAURI__.event.listen('server-status-changed', async () => {
|
||||
await loadServerStatus();
|
||||
render();
|
||||
});
|
||||
|
||||
window.__TAURI__.event.listen('sessions-changed', async () => {
|
||||
await loadSessions();
|
||||
render();
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
window.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function startMonitoring() {
|
||||
// Refresh data periodically
|
||||
setInterval(async () => {
|
||||
await loadSessions();
|
||||
render();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showError() {
|
||||
const menu = document.getElementById('menu');
|
||||
menu.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">⚠️</div>
|
||||
<div class="empty-title">Connection Error</div>
|
||||
<div class="empty-subtitle">Failed to connect to VibeTunnel service</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -5,30 +5,467 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Server Console - VibeTunnel</title>
|
||||
<style>
|
||||
/* Base styles before component loads */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-primary: #1e1e1e;
|
||||
--bg-secondary: #252526;
|
||||
--bg-tertiary: #2d2d30;
|
||||
--text-primary: #cccccc;
|
||||
--text-secondary: #999999;
|
||||
--text-tertiary: #666666;
|
||||
--border-primary: #3e3e42;
|
||||
--accent: #0e639c;
|
||||
--success: #4ec9b0;
|
||||
--warning: #ce9178;
|
||||
--error: #f48771;
|
||||
--info: #3794ff;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f3f3f3;
|
||||
--bg-tertiary: #f8f8f8;
|
||||
--text-primary: #1e1e1e;
|
||||
--text-secondary: #666666;
|
||||
--text-tertiary: #999999;
|
||||
--border-primary: #e5e5e5;
|
||||
--accent: #0066cc;
|
||||
--success: #007e00;
|
||||
--warning: #cc7a00;
|
||||
--error: #cc0000;
|
||||
--info: #0066cc;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', system-ui, sans-serif;
|
||||
background-color: #f5f5f7;
|
||||
color: #1d1d1f;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000000;
|
||||
color: #f5f5f7;
|
||||
.header {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
user-select: none;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.console-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.controls {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-primary);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.console-output {
|
||||
flex: 1;
|
||||
background: #0e0e0e;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.console-output {
|
||||
background: #f8f8f8;
|
||||
}
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 2px 0;
|
||||
border-radius: 2px;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.log-entry:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
font-weight: 600;
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.log-level.error { color: var(--error); }
|
||||
.log-level.warn { color: var(--warning); }
|
||||
.log-level.info { color: var(--info); }
|
||||
.log-level.debug { color: var(--text-secondary); }
|
||||
|
||||
.log-message {
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border-primary);
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: var(--error);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<server-console-app></server-console-app>
|
||||
<script type="module" src="./components/server-console-app.js"></script>
|
||||
<div class="header">
|
||||
<h1>Server Console</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn" onclick="clearLogs()">Clear</button>
|
||||
<button class="btn" onclick="exportLogs()">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="console-container">
|
||||
<div class="controls">
|
||||
<div class="filter-group">
|
||||
<label>Level:</label>
|
||||
<div class="filter-buttons">
|
||||
<button class="filter-btn active" data-level="all" onclick="setFilter('all')">All</button>
|
||||
<button class="filter-btn" data-level="error" onclick="setFilter('error')">Error</button>
|
||||
<button class="filter-btn" data-level="warn" onclick="setFilter('warn')">Warn</button>
|
||||
<button class="filter-btn" data-level="info" onclick="setFilter('info')">Info</button>
|
||||
<button class="filter-btn" data-level="debug" onclick="setFilter('debug')">Debug</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<input type="text" placeholder="Search logs..." id="searchInput" oninput="filterLogs()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="console-output" id="console-output">
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"/>
|
||||
<line x1="9" y1="9" x2="15" y2="9"/>
|
||||
<line x1="9" y1="13" x2="15" y2="13"/>
|
||||
</svg>
|
||||
<span>Waiting for server logs...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<div class="status-indicator">
|
||||
<div class="status-dot" id="status-dot"></div>
|
||||
<span id="status-text">Connected to server</span>
|
||||
</div>
|
||||
<div id="log-count">0 logs</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { invoke } = window.__TAURI__.core;
|
||||
const { listen } = window.__TAURI__.event;
|
||||
|
||||
let logs = [];
|
||||
let currentFilter = 'all';
|
||||
let isConnected = true;
|
||||
|
||||
// Initialize
|
||||
async function init() {
|
||||
// Load existing logs
|
||||
try {
|
||||
const existingLogs = await invoke('get_server_logs');
|
||||
logs = existingLogs || [];
|
||||
renderLogs();
|
||||
} catch (error) {
|
||||
console.error('Failed to load logs:', error);
|
||||
}
|
||||
|
||||
// Listen for new logs
|
||||
const unlisten = await listen('server-log', (event) => {
|
||||
logs.push(event.payload);
|
||||
renderLogs();
|
||||
// Auto-scroll to bottom
|
||||
const output = document.getElementById('console-output');
|
||||
output.scrollTop = output.scrollHeight;
|
||||
});
|
||||
|
||||
// Listen for server status changes
|
||||
const unlistenStatus = await listen('server-status', (event) => {
|
||||
updateServerStatus(event.payload.running);
|
||||
});
|
||||
}
|
||||
|
||||
function renderLogs() {
|
||||
const output = document.getElementById('console-output');
|
||||
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||
|
||||
let filteredLogs = logs;
|
||||
|
||||
// Filter by level
|
||||
if (currentFilter !== 'all') {
|
||||
filteredLogs = filteredLogs.filter(log =>
|
||||
log.level.toLowerCase() === currentFilter
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by search term
|
||||
if (searchTerm) {
|
||||
filteredLogs = filteredLogs.filter(log =>
|
||||
log.message.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredLogs.length === 0) {
|
||||
output.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"/>
|
||||
<line x1="9" y1="9" x2="15" y2="9"/>
|
||||
<line x1="9" y1="13" x2="15" y2="13"/>
|
||||
</svg>
|
||||
<span>No logs match your filters</span>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
output.innerHTML = filteredLogs.map(log => `
|
||||
<div class="log-entry">
|
||||
<span class="log-timestamp">${log.timestamp}</span>
|
||||
<span class="log-level ${log.level.toLowerCase()}">${log.level}</span>
|
||||
<span class="log-message">${escapeHtml(log.message)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Update count
|
||||
document.getElementById('log-count').textContent = `${filteredLogs.length} logs`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function setFilter(level) {
|
||||
currentFilter = level;
|
||||
|
||||
// Update button states
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.level === level);
|
||||
});
|
||||
|
||||
renderLogs();
|
||||
}
|
||||
|
||||
function filterLogs() {
|
||||
renderLogs();
|
||||
}
|
||||
|
||||
async function clearLogs() {
|
||||
try {
|
||||
await invoke('clear_server_logs');
|
||||
logs = [];
|
||||
renderLogs();
|
||||
} catch (error) {
|
||||
console.error('Failed to clear logs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportLogs() {
|
||||
const logText = logs.map(log =>
|
||||
`[${log.timestamp}] [${log.level}] ${log.message}`
|
||||
).join('\n');
|
||||
|
||||
// Create blob and download
|
||||
const blob = new Blob([logText], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `vibetunnel-logs-${new Date().toISOString()}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function updateServerStatus(running) {
|
||||
isConnected = running;
|
||||
const dot = document.getElementById('status-dot');
|
||||
const text = document.getElementById('status-text');
|
||||
|
||||
if (running) {
|
||||
dot.classList.remove('disconnected');
|
||||
text.textContent = 'Connected to server';
|
||||
} else {
|
||||
dot.classList.add('disconnected');
|
||||
text.textContent = 'Server not running';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -356,40 +356,51 @@ async function main() {
|
|||
console.warn('Warning: Using current time for build - output will not be reproducible');
|
||||
}
|
||||
|
||||
let esbuildCmd = `NODE_NO_WARNINGS=1 npx esbuild src/cli.ts \\
|
||||
--bundle \\
|
||||
--platform=node \\
|
||||
--target=node20 \\
|
||||
--outfile=build/bundle.js \\
|
||||
--format=cjs \\
|
||||
--keep-names \\
|
||||
--external:authenticate-pam \\
|
||||
--external:../build/Release/pty.node \\
|
||||
--external:./build/Release/pty.node \\
|
||||
--define:process.env.BUILD_DATE='"${buildDate}"' \\
|
||||
--define:process.env.BUILD_TIMESTAMP='"${buildTimestamp}"' \\
|
||||
--define:process.env.VIBETUNNEL_SEA='"true"'`;
|
||||
// Build command array for cross-platform compatibility
|
||||
const esbuildArgs = [
|
||||
'src/cli.ts',
|
||||
'--bundle',
|
||||
'--platform=node',
|
||||
'--target=node20',
|
||||
'--outfile=build/bundle.js',
|
||||
'--format=cjs',
|
||||
'--keep-names',
|
||||
'--external:authenticate-pam',
|
||||
'--external:../build/Release/pty.node',
|
||||
'--external:./build/Release/pty.node',
|
||||
`--define:process.env.BUILD_DATE='"${buildDate}"'`,
|
||||
`--define:process.env.BUILD_TIMESTAMP='"${buildTimestamp}"'`,
|
||||
'--define:process.env.VIBETUNNEL_SEA=\'"true"\''
|
||||
];
|
||||
|
||||
// Also inject git commit hash for version tracking
|
||||
try {
|
||||
const gitCommit = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
|
||||
esbuildCmd += ` \\\n --define:process.env.GIT_COMMIT='"${gitCommit}"'`;
|
||||
esbuildArgs.push(`--define:process.env.GIT_COMMIT='"${gitCommit}"'`);
|
||||
} catch (e) {
|
||||
esbuildCmd += ` \\\n --define:process.env.GIT_COMMIT='"unknown"'`;
|
||||
esbuildArgs.push('--define:process.env.GIT_COMMIT=\'"unknown"\'');
|
||||
}
|
||||
|
||||
if (includeSourcemaps) {
|
||||
esbuildCmd += ' \\\n --sourcemap=inline \\\n --source-root=/';
|
||||
esbuildArgs.push('--sourcemap=inline', '--source-root=/');
|
||||
}
|
||||
|
||||
console.log('Running:', esbuildCmd);
|
||||
execSync(esbuildCmd, {
|
||||
console.log('Running: npx esbuild', esbuildArgs.join(' '));
|
||||
|
||||
// Use spawn for better cross-platform compatibility
|
||||
const { spawnSync } = require('child_process');
|
||||
const result = spawnSync('npx', ['esbuild', ...esbuildArgs], {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_NO_WARNINGS: '1'
|
||||
}
|
||||
},
|
||||
shell: process.platform === 'win32'
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`esbuild failed with exit code ${result.status}`);
|
||||
}
|
||||
|
||||
// 2b. Post-process bundle to ensure VIBETUNNEL_SEA is properly set
|
||||
console.log('\nPost-processing bundle for SEA compatibility...');
|
||||
|
|
|
|||
|
|
@ -53,42 +53,42 @@
|
|||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.6.2",
|
||||
"@codemirror/lang-css": "^6.2.1",
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-markdown": "^6.2.5",
|
||||
"@codemirror/lang-python": "^6.1.6",
|
||||
"@codemirror/state": "^6.4.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@codemirror/view": "^6.28.0",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.3.3",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.38.0",
|
||||
"@xterm/headless": "^5.5.0",
|
||||
"authenticate-pam": "^1.0.5",
|
||||
"chalk": "^4.1.2",
|
||||
"express": "^4.19.2",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lit": "^3.3.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"multer": "^2.0.1",
|
||||
"node-pty": "github:microsoft/node-pty#v1.1.0-beta34",
|
||||
"postject": "^1.0.0-alpha.6",
|
||||
"postject": "1.0.0-alpha.6",
|
||||
"signal-exit": "^4.1.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.18.2"
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.0.5",
|
||||
"@biomejs/biome": "^2.0.6",
|
||||
"@open-wc/testing": "^4.0.0",
|
||||
"@playwright/test": "^1.53.1",
|
||||
"@playwright/test": "^1.53.2",
|
||||
"@prettier/plugin-oxc": "^0.0.4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express": "^4.17.23",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^24.0.3",
|
||||
"@types/node": "^24.0.10",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/web-push": "^3.6.4",
|
||||
|
|
@ -98,15 +98,15 @@
|
|||
"autoprefixer": "^10.4.21",
|
||||
"chokidar": "^4.0.3",
|
||||
"chokidar-cli": "^3.0.0",
|
||||
"concurrently": "^9.1.2",
|
||||
"concurrently": "^9.2.0",
|
||||
"esbuild": "^0.25.5",
|
||||
"happy-dom": "^18.0.1",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.6.1",
|
||||
"puppeteer": "^24.10.2",
|
||||
"prettier": "^3.6.2",
|
||||
"puppeteer": "^24.11.2",
|
||||
"supertest": "^7.1.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.20.3",
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export default defineConfig({
|
|||
env: {
|
||||
...process.env, // Include all existing env vars
|
||||
NODE_ENV: 'test',
|
||||
PLAYWRIGHT_TEST: 'true', // Enable test mode for CSP
|
||||
VIBETUNNEL_DISABLE_PUSH_NOTIFICATIONS: 'true',
|
||||
SUPPRESS_CLIENT_ERRORS: 'true',
|
||||
VIBETUNNEL_SEA: '', // Explicitly set to empty to disable SEA loader
|
||||
|
|
|
|||
|
|
@ -16,8 +16,10 @@ let nodePtyPath;
|
|||
try {
|
||||
nodePtyPath = require.resolve('node-pty/package.json');
|
||||
} catch (e) {
|
||||
console.error('Could not find node-pty module');
|
||||
process.exit(1);
|
||||
console.log('Could not find node-pty module');
|
||||
// In CI or during initial install, node-pty might not be installed yet
|
||||
// This is expected behavior, so exit successfully
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const nodePtyDir = path.dirname(nodePtyPath);
|
||||
|
|
|
|||
|
|
@ -597,7 +597,8 @@ describe('Terminal', () => {
|
|||
element.rows = currentRows;
|
||||
|
||||
// Mock character width measurement
|
||||
vi.spyOn(element as any, 'measureCharacterWidth').mockReturnValue(8);
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
vi.spyOn(element, 'measureCharacterWidth').mockReturnValue(8);
|
||||
|
||||
// Calculate container dimensions that would result in the same size
|
||||
const lineHeight = element.fontSize * 1.2;
|
||||
|
|
@ -605,10 +606,12 @@ describe('Terminal', () => {
|
|||
clientWidth: (currentCols + 1) * 8, // Account for -1 in calculation
|
||||
clientHeight: currentRows * lineHeight,
|
||||
};
|
||||
(element as any).container = mockContainer;
|
||||
// @ts-expect-error: accessing private property for testing
|
||||
element.container = mockContainer;
|
||||
|
||||
// Call fitTerminal
|
||||
(element as any).fitTerminal();
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
element.fitTerminal();
|
||||
|
||||
// Terminal resize should NOT be called since dimensions haven't changed
|
||||
expect(mockTerminal?.resize).not.toHaveBeenCalled();
|
||||
|
|
@ -626,22 +629,26 @@ describe('Terminal', () => {
|
|||
clientWidth: 800, // Would result in 100 cols (minus 1 for scrollbar prevention)
|
||||
clientHeight: 600, // Let fitTerminal calculate the actual rows
|
||||
};
|
||||
(element as any).container = mockContainer;
|
||||
// @ts-expect-error: accessing private property for testing
|
||||
element.container = mockContainer;
|
||||
|
||||
// Mock character width measurement
|
||||
vi.spyOn(element as any, 'measureCharacterWidth').mockReturnValue(8);
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
vi.spyOn(element, 'measureCharacterWidth').mockReturnValue(8);
|
||||
|
||||
// Spy on dispatchEvent
|
||||
const dispatchEventSpy = vi.spyOn(element, 'dispatchEvent');
|
||||
|
||||
// Call fitTerminal
|
||||
(element as any).fitTerminal();
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
element.fitTerminal();
|
||||
|
||||
// Terminal resize SHOULD be called - verify it was called
|
||||
expect(mockTerminal?.resize).toHaveBeenCalled();
|
||||
|
||||
// Get the actual values it was called with
|
||||
const [cols, rows] = mockTerminal!.resize.mock.calls[0];
|
||||
if (!mockTerminal) throw new Error('mockTerminal is undefined');
|
||||
const [cols, rows] = mockTerminal.resize.mock.calls[0];
|
||||
|
||||
// Verify cols is different from original (80)
|
||||
expect(cols).toBe(99); // (800/8) - 1 = 99
|
||||
|
|
@ -677,14 +684,19 @@ describe('Terminal', () => {
|
|||
clientWidth: (currentCols + 1) * 8,
|
||||
clientHeight: currentRows * lineHeight,
|
||||
};
|
||||
(element as any).container = mockContainer;
|
||||
// @ts-expect-error: accessing private property for testing
|
||||
element.container = mockContainer;
|
||||
|
||||
vi.spyOn(element as any, 'measureCharacterWidth').mockReturnValue(8);
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
vi.spyOn(element, 'measureCharacterWidth').mockReturnValue(8);
|
||||
|
||||
// Call fitTerminal multiple times
|
||||
(element as any).fitTerminal();
|
||||
(element as any).fitTerminal();
|
||||
(element as any).fitTerminal();
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
element.fitTerminal();
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
element.fitTerminal();
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
element.fitTerminal();
|
||||
|
||||
// Resize should not be called at all (dimensions unchanged)
|
||||
expect(mockTerminal?.resize).not.toHaveBeenCalled();
|
||||
|
|
@ -707,12 +719,15 @@ describe('Terminal', () => {
|
|||
clientHeight: 480,
|
||||
style: { fontSize: '14px' },
|
||||
};
|
||||
(element as any).container = mockContainer;
|
||||
// @ts-expect-error: accessing private property for testing
|
||||
element.container = mockContainer;
|
||||
|
||||
vi.spyOn(element as any, 'measureCharacterWidth').mockReturnValue(8);
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
vi.spyOn(element, 'measureCharacterWidth').mockReturnValue(8);
|
||||
|
||||
// Call fitTerminal
|
||||
(element as any).fitTerminal();
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
element.fitTerminal();
|
||||
|
||||
// In fitHorizontally mode, terminal should maintain its column count
|
||||
expect(element.cols).toBe(80);
|
||||
|
|
@ -740,16 +755,20 @@ describe('Terminal', () => {
|
|||
clientWidth: 1000, // Would result in 125 cols without constraint
|
||||
clientHeight: 480,
|
||||
};
|
||||
(element as any).container = mockContainer;
|
||||
// @ts-expect-error: accessing private property for testing
|
||||
element.container = mockContainer;
|
||||
|
||||
vi.spyOn(element as any, 'measureCharacterWidth').mockReturnValue(8);
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
vi.spyOn(element, 'measureCharacterWidth').mockReturnValue(8);
|
||||
|
||||
// Call fitTerminal
|
||||
(element as any).fitTerminal();
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
element.fitTerminal();
|
||||
|
||||
// Terminal should resize respecting maxCols constraint
|
||||
expect(mockTerminal?.resize).toHaveBeenCalled();
|
||||
const [cols] = mockTerminal!.resize.mock.calls[0];
|
||||
if (!mockTerminal) throw new Error('mockTerminal is undefined');
|
||||
const [cols] = mockTerminal.resize.mock.calls[0];
|
||||
expect(cols).toBe(100); // Should be limited to maxCols
|
||||
});
|
||||
|
||||
|
|
@ -759,7 +778,7 @@ describe('Terminal', () => {
|
|||
element.initialCols = 120;
|
||||
element.initialRows = 30;
|
||||
element.maxCols = 0; // No manual width selection
|
||||
(element as any).userOverrideWidth = false;
|
||||
element.userOverrideWidth = false;
|
||||
|
||||
// Set terminal's current dimensions (different from initial)
|
||||
if (mockTerminal) {
|
||||
|
|
@ -772,16 +791,20 @@ describe('Terminal', () => {
|
|||
clientWidth: 1200, // Would result in 150 cols without constraint
|
||||
clientHeight: 600,
|
||||
};
|
||||
(element as any).container = mockContainer;
|
||||
// @ts-expect-error: accessing private property for testing
|
||||
element.container = mockContainer;
|
||||
|
||||
vi.spyOn(element as any, 'measureCharacterWidth').mockReturnValue(8);
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
vi.spyOn(element, 'measureCharacterWidth').mockReturnValue(8);
|
||||
|
||||
// Call fitTerminal
|
||||
(element as any).fitTerminal();
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
element.fitTerminal();
|
||||
|
||||
// Terminal should be limited to initial cols for tunneled sessions
|
||||
expect(mockTerminal?.resize).toHaveBeenCalled();
|
||||
const [cols] = mockTerminal!.resize.mock.calls[0];
|
||||
if (!mockTerminal) throw new Error('mockTerminal is undefined');
|
||||
const [cols] = mockTerminal.resize.mock.calls[0];
|
||||
expect(cols).toBe(120); // Should be limited to initialCols
|
||||
});
|
||||
|
||||
|
|
@ -791,7 +814,7 @@ describe('Terminal', () => {
|
|||
element.initialCols = 120;
|
||||
element.initialRows = 30;
|
||||
element.maxCols = 0;
|
||||
(element as any).userOverrideWidth = false;
|
||||
element.userOverrideWidth = false;
|
||||
|
||||
// Set terminal's current dimensions
|
||||
if (mockTerminal) {
|
||||
|
|
@ -804,17 +827,21 @@ describe('Terminal', () => {
|
|||
clientWidth: 1200,
|
||||
clientHeight: 600,
|
||||
};
|
||||
(element as any).container = mockContainer;
|
||||
// @ts-expect-error: accessing private property for testing
|
||||
element.container = mockContainer;
|
||||
|
||||
vi.spyOn(element as any, 'measureCharacterWidth').mockReturnValue(8);
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
vi.spyOn(element, 'measureCharacterWidth').mockReturnValue(8);
|
||||
|
||||
// Call fitTerminal
|
||||
(element as any).fitTerminal();
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
element.fitTerminal();
|
||||
|
||||
// Terminal should NOT be limited by initial dimensions for frontend sessions
|
||||
// Should use calculated width: (1200/8) - 1 = 149
|
||||
expect(mockTerminal?.resize).toHaveBeenCalled();
|
||||
const [cols] = mockTerminal!.resize.mock.calls[0];
|
||||
if (!mockTerminal) throw new Error('mockTerminal is undefined');
|
||||
const [cols] = mockTerminal.resize.mock.calls[0];
|
||||
expect(cols).toBe(149); // Should use full calculated width
|
||||
});
|
||||
|
||||
|
|
@ -835,15 +862,18 @@ describe('Terminal', () => {
|
|||
clientWidth: 808, // (100 + 1) * 8 = 808 (accounting for the -1 in calculation)
|
||||
clientHeight: 30 * lineHeight,
|
||||
};
|
||||
(element as any).container = mockContainer;
|
||||
// @ts-expect-error: accessing private property for testing
|
||||
element.container = mockContainer;
|
||||
|
||||
vi.spyOn(element as any, 'measureCharacterWidth').mockReturnValue(8);
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
vi.spyOn(element, 'measureCharacterWidth').mockReturnValue(8);
|
||||
|
||||
// Clear previous calls
|
||||
mockTerminal?.resize.mockClear();
|
||||
|
||||
// Call fitTerminal
|
||||
(element as any).fitTerminal();
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
element.fitTerminal();
|
||||
|
||||
// Resize should NOT be called since calculated dimensions match current
|
||||
expect(mockTerminal?.resize).not.toHaveBeenCalled();
|
||||
|
|
@ -861,16 +891,20 @@ describe('Terminal', () => {
|
|||
clientWidth: 100,
|
||||
clientHeight: 50,
|
||||
};
|
||||
(element as any).container = mockContainer;
|
||||
// @ts-expect-error: accessing private property for testing
|
||||
element.container = mockContainer;
|
||||
|
||||
vi.spyOn(element as any, 'measureCharacterWidth').mockReturnValue(8);
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
vi.spyOn(element, 'measureCharacterWidth').mockReturnValue(8);
|
||||
|
||||
// Call fitTerminal
|
||||
(element as any).fitTerminal();
|
||||
// @ts-expect-error: accessing private method for testing
|
||||
element.fitTerminal();
|
||||
|
||||
// Should resize to minimum allowed dimensions
|
||||
expect(mockTerminal?.resize).toHaveBeenCalled();
|
||||
const [cols, rows] = mockTerminal!.resize.mock.calls[0];
|
||||
if (!mockTerminal) throw new Error('mockTerminal is undefined');
|
||||
const [cols, rows] = mockTerminal.resize.mock.calls[0];
|
||||
|
||||
// The calculation is: Math.max(20, Math.floor(100 / 8) - 1) = Math.max(20, 11) = 20
|
||||
// But if we're getting 19, it might be due to some other factor
|
||||
|
|
|
|||
|
|
@ -367,6 +367,55 @@ export async function createApp(): Promise<AppInstance> {
|
|||
app.use(express.json());
|
||||
logger.debug('Configured express middleware');
|
||||
|
||||
// Add security headers middleware
|
||||
app.use((_req, res, next) => {
|
||||
// Detect if we're in Playwright test environment
|
||||
// Check multiple conditions to ensure test environment is detected
|
||||
const isPlaywrightTest =
|
||||
process.env.PLAYWRIGHT_TEST === 'true' ||
|
||||
process.env.NODE_ENV === 'test' ||
|
||||
config.port === 4022; // Test port from test-config.ts
|
||||
|
||||
// Log once on startup
|
||||
if (!app.locals.cspLogged) {
|
||||
logger.debug(`PLAYWRIGHT_TEST env var: ${process.env.PLAYWRIGHT_TEST}`);
|
||||
logger.debug(`NODE_ENV: ${process.env.NODE_ENV}`);
|
||||
logger.debug(`Server port: ${config.port}`);
|
||||
logger.debug(
|
||||
`CSP mode: ${isPlaywrightTest ? 'test (with unsafe-eval)' : 'production (no unsafe-eval)'}`
|
||||
);
|
||||
app.locals.cspLogged = true;
|
||||
}
|
||||
|
||||
// Content Security Policy to prevent XSS and other injection attacks
|
||||
const scriptSrc = isPlaywrightTest
|
||||
? "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://unpkg.com; " // Add unsafe-eval for Playwright tests
|
||||
: "script-src 'self' 'unsafe-inline' https://unpkg.com; "; // Production CSP without unsafe-eval
|
||||
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; " +
|
||||
scriptSrc +
|
||||
"style-src 'self' 'unsafe-inline'; " + // Allow inline styles
|
||||
"img-src 'self' data: blob:; " + // Allow data and blob URLs for images
|
||||
"font-src 'self' data:; " + // Allow data URLs for fonts
|
||||
"connect-src 'self' ws: wss:; " + // Allow WebSocket connections
|
||||
"frame-ancestors 'none'; " + // Prevent clickjacking
|
||||
"base-uri 'self'; " + // Restrict base tag usage
|
||||
"form-action 'self'" // Restrict form submissions
|
||||
);
|
||||
|
||||
// Additional security headers
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
|
||||
next();
|
||||
});
|
||||
logger.debug('Configured security headers middleware');
|
||||
|
||||
// Control directory for session data
|
||||
const CONTROL_DIR =
|
||||
process.env.VIBETUNNEL_CONTROL_DIR || path.join(os.homedir(), '.vibetunnel/control');
|
||||
|
|
@ -494,6 +543,31 @@ export async function createApp(): Promise<AppInstance> {
|
|||
app.use(
|
||||
express.static(publicPath, {
|
||||
extensions: ['html'], // This allows /logs to resolve to /logs.html
|
||||
setHeaders: (res, path) => {
|
||||
// Apply stricter CSP for HTML files
|
||||
if (path.endsWith('.html')) {
|
||||
const isPlaywrightTest =
|
||||
process.env.PLAYWRIGHT_TEST === 'true' ||
|
||||
process.env.NODE_ENV === 'test' ||
|
||||
config.port === 4022;
|
||||
const scriptSrc = isPlaywrightTest
|
||||
? "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://unpkg.com; "
|
||||
: "script-src 'self' 'unsafe-inline' https://unpkg.com; ";
|
||||
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; " +
|
||||
scriptSrc +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"img-src 'self' data: blob:; " +
|
||||
"font-src 'self' data:; " +
|
||||
"connect-src 'self' ws: wss:; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'"
|
||||
);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
logger.debug(`Serving static files from: ${publicPath}`);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export async function assertSessionInList(
|
|||
sessionName: string,
|
||||
options: { timeout?: number; status?: 'RUNNING' | 'EXITED' | 'KILLED' } = {}
|
||||
): Promise<void> {
|
||||
const { timeout = 5000, status } = options;
|
||||
const { timeout = 15000, status } = options;
|
||||
|
||||
// Ensure we're on the session list page
|
||||
if (page.url().includes('?session=')) {
|
||||
|
|
|
|||
|
|
@ -122,13 +122,11 @@ export class TestSessionManager {
|
|||
|
||||
try {
|
||||
// Wait for page to be ready - either session cards or "no sessions" message
|
||||
await this.page.waitForFunction(
|
||||
() => {
|
||||
const cards = document.querySelectorAll('session-card');
|
||||
const noSessionsMsg = document.querySelector('.text-dark-text-muted');
|
||||
return cards.length > 0 || noSessionsMsg?.textContent?.includes('No terminal sessions');
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
await this.page.waitForSelector(
|
||||
'session-card, .text-dark-text-muted:has-text("No terminal sessions")',
|
||||
{
|
||||
timeout: 5000,
|
||||
}
|
||||
);
|
||||
|
||||
// Check if session exists
|
||||
|
|
|
|||
|
|
@ -55,6 +55,14 @@ test.describe('Session Creation', () => {
|
|||
|
||||
// Navigate back and verify
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for session list to be ready
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForSelector('session-card, .text-dark-text-muted', {
|
||||
state: 'visible',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await assertSessionInList(page, sessionName, { status: 'RUNNING' });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -123,11 +123,14 @@ test.describe('Advanced Session Management', () => {
|
|||
test('should display session metadata correctly', async ({ page }) => {
|
||||
// Create a session with the default command
|
||||
const sessionName = sessionManager.generateSessionName('metadata-test');
|
||||
await sessionManager.createTrackedSession(sessionName, false, 'bash');
|
||||
const { sessionId } = await sessionManager.createTrackedSession(sessionName, false, 'bash');
|
||||
|
||||
// The session is created with default working directory (~)
|
||||
// Since we can't set a custom working directory without shell operators,
|
||||
// we'll just check the default behavior
|
||||
// Navigate to the session to see its metadata
|
||||
await page.goto(`/?session=${sessionId}`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Wait for the session view to be fully loaded
|
||||
await page.waitForSelector('vibe-terminal', { state: 'visible', timeout: 10000 });
|
||||
|
||||
// Check that the path is displayed
|
||||
const pathElement = page.locator('[title="Click to copy path"]');
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ test.describe('Global Session Management', () => {
|
|||
await sessionManager.cleanupAllSessions();
|
||||
});
|
||||
|
||||
test('should kill all sessions at once', async ({ page, sessionListPage }) => {
|
||||
test('should kill all sessions at once', async ({ page }) => {
|
||||
// Increase timeout for this test as it involves multiple sessions
|
||||
test.setTimeout(TIMEOUTS.KILL_ALL_OPERATION * 3); // 90 seconds
|
||||
|
||||
|
|
|
|||