Initial commit — Alta Proxy Tool (APT)

Electron desktop app for Avigilon Alta Video camera proxy management.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Zac
2026-02-09 20:28:32 -05:00
commit e813607f63
11 changed files with 9117 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# Dependencies
node_modules/
# Build output
dist/
# External executables (not bundled via npm)
aware-cam-proxy-win.exe
aware-cam-proxy.exe
# Reference documents
*.pdf
# OS files
Thumbs.db
Desktop.ini
.DS_Store
# Editor
.vscode/
*.swp
*.swo
+89
View File
@@ -0,0 +1,89 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Alta Video Camera Proxy — an Electron desktop app that authenticates with Avigilon Alta Video deployments, discovers cameras, and launches external proxy executables (`aware-cam-proxy-win.exe`, `aware-cam-proxy.exe`) to establish camera connections. Windows-only due to the proxy executables.
## Commands
```bash
npm start # Run the app
npm run dev # Run with DevTools open (--dev flag)
npm run build # Build portable Windows .exe (output: dist/)
npm run build-test # Build to directory without packaging
```
No test framework is configured. No linter is configured.
## Architecture
This is a vanilla Electron app (no React/Vue/framework). Four files form the entire application:
```
main.js → Electron main process: IPC handlers, API calls (axios), profile CRUD,
camera proxy process spawning, password encryption (CryptoJS + machine-derived key)
preload.js → contextBridge exposing window.electronAPI with typed IPC invoke wrappers
renderer.js → All UI logic: DOM manipulation, state management, event handlers
index.html → Static HTML shell, no inline scripts (CSP enforced)
styles.css → Dark theme using CSS custom properties
```
### IPC Communication Pattern
All cross-process communication follows one pattern:
1. `main.js` registers handler: `ipcMain.handle('channel-name', async (event, params) => { ... })`
2. `preload.js` exposes it: `channelName: (params) => ipcRenderer.invoke('channel-name', params)`
3. `renderer.js` calls it: `const result = await window.electronAPI.channelName(params)`
All handlers return `{ success: boolean, message?: string, ...data }`.
### IPC Channels
| Channel | Purpose |
|---------|---------|
| `api-login` | POST /api/v1/dologin, returns cookies |
| `api-get-devices` | GET /api/v1/devices with cookie auth |
| `api-get-auth-info` | GET /api/v1/auth to verify session |
| `profiles-load/save/get/delete/update` | CRUD for `~/.alta-api-profiles.json` |
| `camera-proxy-launch` | Spawns aware-cam-proxy-win.exe (user/pass method) |
| `camera-proxy-cookie-launch` | Spawns aware-cam-proxy.exe (cookie method) |
| `camera-proxy-stop` | Kills all proxy processes via taskkill/powershell |
| `camera-proxy-check` | Checks if proxy executable exists |
| `camera-proxy-version` | Runs proxy with -v flag |
### State Management (renderer.js)
All connection state lives in the `sessionData` object (deploymentUrl, cookies, isConnected). There is no separate `isConnected` flag — always use `sessionData.isConnected`.
Active proxy processes are tracked in two Maps: `activeProxyConnections` and `activeCookieProxyConnections`, keyed by device GUID. Max 2 simultaneous connections (`MAX_PROXY_CONNECTIONS`).
### Security Model
- Context isolation enabled, nodeIntegration disabled
- CSP meta tag: `script-src 'self'` — no inline scripts or onclick handlers allowed
- Batch file inputs are sanitized via `sanitizeBatchInput()` to prevent command injection
- Encryption key derived from machine identifiers (hostname, homedir, username) via SHA-256
- Legacy profiles auto-migrate from old static key on first load
- Clipboard is cleared 30 seconds after password copy
- Passwords never written to DOM; kept only in JS variables (`selectedProfile`)
### Profile Storage
Profiles stored at `~/.alta-api-profiles.json`. Passwords encrypted with CryptoJS AES using a machine-derived key. The `profiles-load` handler strips passwords before sending to renderer; `profiles-get` decrypts for a specific profile when needed.
## Key Conventions
- No inline event handlers in HTML — all use `addEventListener` in renderer.js
- All user-provided content rendered to DOM must go through `escapeHtml()` (XSS prevention)
- External processes spawned with `detached: true` + `unref()` so they survive if the app closes
- Device list filters out cloud cameras (`capabilities.localStorage === false` only)
- `clearDeviceList()` must NOT clear proxy connection Maps (proxies may still be running)
## External Executables
- `aware-cam-proxy-win.exe` — username/password auth proxy (required)
- `aware-cam-proxy.exe` — cookie-based auth proxy (optional)
These are not bundled via npm. They must be in the app root directory.
+187
View File
@@ -0,0 +1,187 @@
# Alta Video Camera Proxy
An Electron desktop application for managing Alta Video camera proxy connections with API integration. This application allows you to authenticate with your Alta deployment, browse available cameras, and launch camera proxy connections through an external executable.
## Features
- **Encrypted Profile Management**: Store multiple connection profiles with AES-encrypted passwords
- **API Integration**: Secure authentication and device discovery via Alta Video API
- **Camera Proxy Management**: Launch and manage up to 2 simultaneous camera proxy connections
- **Device Filtering**: Automatically filters to show only local (non-cloud) cameras
- **Device Search**: Quick search functionality to find cameras by name, ID, IP, or model
- **Real-time Status**: Live connection status and device online/offline indicators
- **Modern Dark UI**: Professional dark-mode interface with responsive design
- **Cookie-Based Proxy**: Alternative cookie-based authentication method (requires aware-cam-proxy.exe)
## Prerequisites
- **Node.js** (version 14 or higher)
- **npm** (comes with Node.js)
- **Valid Alta Video API credentials**
- **Windows OS** (required for camera proxy executable)
- **aware-cam-proxy-win.exe** (camera proxy executable) - must be placed in the application directory
## Installation
1. Clone or download this project
2. Open a terminal in the project directory
3. Install dependencies:
```bash
npm install
```
## Usage
### Starting the Application
```bash
npm start
```
Or for development mode with DevTools:
```bash
npm run dev
```
### Creating a Connection Profile
1. Click "Add User" to create a new profile
2. Enter:
- **Profile Name**: A friendly name for this profile
- **Deployment URL**: Your Alta deployment URL (e.g., `https://your-deployment.eu1.aware.avasecurity.com`)
- **Username**: Your Alta username
- **Password**: Your Alta password (stored encrypted)
3. Click "Save Profile"
### Connecting to Alta API
1. Select a profile from the dropdown
2. Click "Connect to API"
3. Devices will automatically load and display in the left sidebar
### Launching Camera Proxy
1. **Connect to API** first
2. **Select a device** from the left sidebar (click on a device name)
3. Click "Start Camera Proxy"
4. A command prompt window will open
5. **Password is copied to clipboard** - press Ctrl+V when prompted
6. The proxy will establish connection to the camera
**Note**: You can run up to 2 simultaneous camera proxy connections. Active connections are indicated with a green "PROXY ACTIVE" badge on the device.
## API Endpoints Used
- **Authentication**: `POST /api/v1/dologin` - User login
- **Device List**: `GET /api/v1/devices` - Retrieve all devices
- **Auth Info**: `GET /api/v1/auth` - Verify authentication status
## Camera Proxy Methods
### Username/Password Method
Uses `aware-cam-proxy-win.exe` with credentials:
```bash
aware-cam-proxy-win.exe -a <domain> -u <username> -d <device-uuid>
```
### Cookie Method (Alternative)
Uses `aware-cam-proxy.exe` with cookie authentication:
```bash
aware-cam-proxy.exe -a <domain> -d <device-uuid> -k <cookie-key>
```
## Security Features
- **Context Isolation**: Renderer process is isolated from Node.js APIs
- **Preload Script**: Secure IPC communication between main and renderer processes
- **Encrypted Storage**: Passwords are encrypted using AES encryption before storage
- **No Hardcoded Credentials**: All credentials are entered and managed by the user
- **Profile-Based Authentication**: Secure profile management with encrypted credential storage
⚠️ **Security Note**: Encryption key is derived from machine identifiers (hostname, homedir, username) via SHA-256. Profiles are not portable between machines.
## File Structure
```
├── main.js # Main Electron process (IPC handlers, API, proxy spawning, encryption)
├── renderer.js # Renderer process (UI logic, state management, event handlers)
├── preload.js # Secure IPC bridge (contextBridge)
├── index.html # Static HTML shell (CSP enforced)
├── styles.css # Dark theme styling (CSS custom properties)
├── package.json # Project dependencies and build config
├── assets/
│ └── icon.png # Application icon
├── CLAUDE.md # Claude Code project instructions
└── README.md # This file
```
**External executables** (not included in repo — must be placed in app directory):
- `aware-cam-proxy-win.exe` — username/password auth proxy (required)
- `aware-cam-proxy.exe` — cookie-based auth proxy (optional)
### Profile Storage
Profiles are stored in: `~/.alta-api-profiles.json` (user home directory)
## Troubleshooting
### Connection Issues
- Verify your deployment URL is correct and accessible
- Check your username and password
- Ensure your network allows HTTPS connections to the deployment
- Check if your account requires 2FA (not currently supported)
### Camera Proxy Issues
- **Executable not found**: Ensure `aware-cam-proxy-win.exe` is in the application directory
- **Proxy won't start**: Check that you're connected to the API and have selected a device
- **Maximum connections**: You can only run 2 simultaneous connections - stop an existing one first
- **Command window closes immediately**: Check credentials and network connectivity
### Device List Issues
- Ensure you're connected to the API first
- Check that your user account has permissions to view devices
- **No devices shown**: You may only have cloud cameras (localStorage=true) which are filtered out
- Use the search box to find specific devices
## API Documentation
This application is built according to the Avigilon Alta Video API documentation. For more advanced features or custom integrations, refer to the official API documentation.
## Limitations
- **Windows Only**: Camera proxy executables are Windows-specific (.exe files)
- **2FA Not Supported**: Two-factor authentication is not currently supported
- **Connection Limit**: Maximum of 2 simultaneous camera proxy connections
- **Local Cameras Only**: Automatically filters out cloud-based cameras (localStorage=true)
- **No Session Refresh**: Sessions may expire and require reconnection
- **Executable Required**: `aware-cam-proxy-win.exe` must be obtained separately
## Building for Distribution
```bash
# Build portable Windows executable
npm run build
# Output will be in: dist/AltaCameraProxy-1.0.0-portable.exe
```
**Important**: Copy `aware-cam-proxy-win.exe` to the same directory as the built executable before distribution.
## Development
To modify or extend this application:
1. **Main Process** ([main.js](main.js)): Electron app lifecycle, API requests, and process management
2. **Renderer Process** ([renderer.js](renderer.js)): UI interactions and state management
3. **Preload Script** ([preload.js](preload.js)): Secure IPC bridge with context isolation
4. **Styling** ([styles.css](styles.css)): Dark mode theme and responsive design
### Adding New API Endpoints
1. Add IPC handler in [main.js](main.js) using `ipcMain.handle()`
2. Expose method in [preload.js](preload.js) via `contextBridge.exposeInMainWorld()`
3. Call from [renderer.js](renderer.js) using `window.electronAPI.yourMethod()`
## License
MIT License - Feel free to modify and distribute as needed.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 MiB

+172
View File
@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;">
<title>Alta Video Camera Proxy with API</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="app-container">
<!-- Main Content Layout -->
<div class="main-layout">
<!-- Left Sidebar - Available Devices -->
<aside class="devices-sidebar">
<div class="sidebar-header">
<h2>Available Devices</h2>
</div>
<div id="deviceStatus" class="status-message"></div>
<!-- Device Search -->
<div class="device-search-container">
<input type="text" id="deviceSearch" placeholder="Search devices..." class="device-search-input">
</div>
<div class="device-list-container">
<div id="deviceList" class="device-list">
<p class="placeholder-text">Connect to API to load devices</p>
</div>
</div>
</aside>
<!-- Main Content Area -->
<main class="main-content">
<div class="content-header">
<h1>Alta Video Camera Proxy</h1>
</div>
<!-- User Profiles Section -->
<section class="content-section profile-section">
<h2>User Profiles</h2>
<div class="profile-controls">
<div class="profile-row">
<label for="profileSelect">Select Profile:</label>
<select id="profileSelect">
<option value="">Select a profile...</option>
</select>
<button type="button" id="addProfileBtn" class="btn-primary">Add User</button>
<button type="button" id="manageProfilesBtn" class="btn-success">Manage Users</button>
</div>
</div>
</section>
<!-- API Connection Section -->
<section class="content-section">
<h2>API Connection</h2>
<div class="connection-status">
<div class="status-row">
<label>Status:</label>
<div class="status-indicator" id="statusIndicator">
<span class="status-dot offline"></span>
<span class="status-text">Disconnected</span>
</div>
</div>
</div>
<div class="connection-controls">
<button type="button" id="connectBtn" class="btn-primary" disabled>Connect to API</button>
<button type="button" id="testConnectionBtn" class="btn-outline" disabled>Test Connection</button>
<button type="button" id="disconnectBtn" class="btn-outline" disabled>Disconnect</button>
</div>
<div id="connectionStatus" class="status-message"></div>
</section>
<!-- Camera Proxy Section -->
<section class="content-section">
<h2>Camera Proxy (Username/Password Method)</h2>
<div class="proxy-controls">
<div class="input-row">
<label for="deviceUUID">Device UUID:</label>
<input type="text" id="deviceUUID" placeholder="(Auto-filled when you select a device from the list)" readonly>
</div>
<div class="proxy-buttons">
<button type="button" id="startProxyBtn" class="btn-primary" disabled>Start Camera Proxy</button>
<button type="button" id="checkVersionBtn" class="btn-outline" disabled>Check Version</button>
<button type="button" id="stopProxyBtn" class="btn-outline" disabled>Stop Proxy</button>
</div>
</div>
</section>
<!-- Cookie-Based Camera Proxy Section (Collapsible) -->
<section class="content-section">
<div class="collapsible-header" id="cookieSectionHeader">
<h2>Camera Proxy (Cookie Method)</h2>
<span class="collapse-icon" id="cookieCollapseIcon"></span>
</div>
<div class="proxy-controls collapsible-content" id="cookieProxyContent" style="display: none;">
<div class="input-row">
<label for="cookieDeviceUUID">Device UUID:</label>
<input type="text" id="cookieDeviceUUID" placeholder="(Auto-filled when you select a device from the list)" readonly>
</div>
<div class="input-row">
<label for="cookieKey">Cookie Key:</label>
<input type="text" id="cookieKey" placeholder="Paste your cookie key here">
</div>
<div class="proxy-buttons">
<button type="button" id="startCookieProxyBtn" class="btn-primary" disabled>Start Cookie Proxy</button>
<button type="button" id="stopCookieProxyBtn" class="btn-outline" disabled>Stop Cookie Proxy</button>
</div>
</div>
</section>
</main>
</div>
</div>
<!-- Hidden elements for device details display -->
<div style="display: none;">
<div id="deviceDetails"></div>
<div id="mainContent"></div>
</div>
<!-- Add Profile Modal -->
<div id="addProfileModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Add Connection Profile</h3>
<span class="close" id="closeAddProfile">&times;</span>
</div>
<form id="addProfileForm">
<div class="form-group">
<label for="profileName">Profile Name:</label>
<input type="text" id="profileName" placeholder="Enter profile name" required>
</div>
<div class="form-group">
<label for="profileUrl">Deployment URL:</label>
<input type="url" id="profileUrl" placeholder="https://your-deployment.eu1.aware.avasecurity.com" required>
</div>
<div class="form-group">
<label for="profileUsername">Username:</label>
<input type="text" id="profileUsername" placeholder="Enter username" required>
</div>
<div class="form-group">
<label for="profilePassword">Password:</label>
<input type="password" id="profilePassword" placeholder="Enter password" required>
</div>
<div class="modal-buttons">
<button type="submit" class="modal-btn primary">Save Profile</button>
<button type="button" class="modal-btn secondary" id="cancelAddProfile">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Manage Profiles Modal -->
<div id="manageProfilesModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Manage Connection Profiles</h3>
<span class="close" id="closeManageProfiles">&times;</span>
</div>
<div class="profiles-list" id="profilesList">
<!-- Profiles will be populated here -->
</div>
<div class="modal-buttons">
<button type="button" class="modal-btn secondary" id="closeManageProfilesBtn">Close</button>
</div>
</div>
</div>
<script src="renderer.js"></script>
</body>
</html>
+834
View File
@@ -0,0 +1,834 @@
const { app, BrowserWindow, ipcMain, shell, clipboard } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
const axios = require('axios');
const CryptoJS = require('crypto-js');
const { spawn } = require('child_process');
const crypto = require('crypto');
let mainWindow;
let activeProxyProcesses = new Map(); // Track active camera proxy processes
// Sanitize strings before embedding in batch files to prevent command injection
function sanitizeBatchInput(input) {
if (typeof input !== 'string') return '';
// Remove characters that have special meaning in batch/cmd: & | < > ^ % " ` !
return input.replace(/[&|<>^%"`!]/g, '');
}
// Profile management
const PROFILES_FILE = path.join(os.homedir(), '.alta-api-profiles.json');
// Derive encryption key from machine-specific identifiers so the profiles file
// is only decryptable on this machine. Falls back to a static key if derivation fails.
function deriveEncryptionKey() {
try {
const machineFactors = [
os.hostname(),
os.homedir(),
os.userInfo().username
].join('|');
return crypto.createHash('sha256').update('alta-proxy-' + machineFactors).digest('hex');
} catch {
return 'alta-api-client-key-2024-fallback';
}
}
const ENCRYPTION_KEY = deriveEncryptionKey();
// Helper functions for profile management
function getProfilesFilePath() {
return PROFILES_FILE;
}
function encryptPassword(password) {
return CryptoJS.AES.encrypt(password, ENCRYPTION_KEY).toString();
}
function decryptPassword(encryptedPassword) {
try {
const bytes = CryptoJS.AES.decrypt(encryptedPassword, ENCRYPTION_KEY);
return bytes.toString(CryptoJS.enc.Utf8);
} catch (error) {
console.error('Failed to decrypt password:', error);
return '';
}
}
// Legacy key for migrating existing profiles
const LEGACY_ENCRYPTION_KEY = 'alta-api-client-key-2024';
function decryptPasswordLegacy(encryptedPassword) {
try {
const bytes = CryptoJS.AES.decrypt(encryptedPassword, LEGACY_ENCRYPTION_KEY);
const result = bytes.toString(CryptoJS.enc.Utf8);
return result || null;
} catch {
return null;
}
}
// Migrate profiles from legacy encryption key to machine-derived key
function migrateProfilesIfNeeded(profiles) {
let migrated = false;
for (const profile of profiles) {
if (!profile.password) continue;
// Try decrypting with current key first
const currentDecrypt = decryptPassword(profile.password);
if (currentDecrypt) continue;
// Try legacy key
const legacyDecrypt = decryptPasswordLegacy(profile.password);
if (legacyDecrypt) {
profile.password = encryptPassword(legacyDecrypt);
migrated = true;
}
}
if (migrated) {
saveProfiles(profiles);
console.log('Migrated profiles to new encryption key');
}
return profiles;
}
function loadProfiles() {
try {
if (fs.existsSync(PROFILES_FILE)) {
const data = fs.readFileSync(PROFILES_FILE, 'utf8');
const profiles = JSON.parse(data);
if (!Array.isArray(profiles)) return [];
return migrateProfilesIfNeeded(profiles);
}
} catch (error) {
console.error('Failed to load profiles:', error);
}
return [];
}
function saveProfiles(profiles) {
try {
fs.writeFileSync(PROFILES_FILE, JSON.stringify(profiles, null, 2));
return true;
} catch (error) {
console.error('Failed to save profiles:', error);
return false;
}
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
},
icon: path.join(__dirname, 'assets', 'icon.png'), // Optional icon
title: 'Alta Video Camera Proxy with API'
});
mainWindow.loadFile('index.html');
// Open DevTools in development
if (process.argv.includes('--dev')) {
mainWindow.webContents.openDevTools();
}
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// IPC handlers for API communication
ipcMain.handle('api-login', async (event, { deploymentUrl, username, password }) => {
try {
const loginUrl = `${deploymentUrl}/api/v1/dologin`;
const response = await axios.post(loginUrl, {
username: username,
password: password
}, {
timeout: 10000,
withCredentials: true
});
// Store cookies for subsequent requests
const cookies = response.headers['set-cookie'];
return {
success: true,
cookies: cookies,
message: 'Login successful'
};
} catch (error) {
console.error('Login error:', error);
return {
success: false,
message: error.response?.data?.message || error.message || 'Login failed'
};
}
});
ipcMain.handle('api-get-devices', async (event, { deploymentUrl, cookies }) => {
try {
const devicesUrl = `${deploymentUrl}/api/v1/devices`;
// Create axios instance with cookies
const axiosInstance = axios.create({
timeout: 10000,
headers: {
'Cookie': cookies ? cookies.join('; ') : ''
}
});
const response = await axiosInstance.get(devicesUrl);
return {
success: true,
devices: response.data,
message: `Found ${response.data.length} devices`
};
} catch (error) {
console.error('Get devices error:', error);
return {
success: false,
message: error.response?.data?.message || error.message || 'Failed to get devices'
};
}
});
ipcMain.handle('api-get-auth-info', async (event, { deploymentUrl, cookies }) => {
try {
const authUrl = `${deploymentUrl}/api/v1/auth`;
const axiosInstance = axios.create({
timeout: 10000,
headers: {
'Cookie': cookies ? cookies.join('; ') : ''
}
});
const response = await axiosInstance.get(authUrl);
return {
success: true,
authInfo: response.data
};
} catch (error) {
console.error('Get auth info error:', error);
return {
success: false,
message: error.response?.data?.message || error.message || 'Failed to get auth info'
};
}
});
// Profile management IPC handlers
ipcMain.handle('profiles-load', async () => {
try {
const profiles = loadProfiles();
// Return profiles without passwords for security
const safeProfiles = profiles.map(profile => ({
id: profile.id,
name: profile.name,
deploymentUrl: profile.deploymentUrl,
username: profile.username
}));
return { success: true, profiles: safeProfiles };
} catch (error) {
console.error('Failed to load profiles:', error);
return { success: false, message: 'Failed to load profiles' };
}
});
ipcMain.handle('profiles-save', async (event, { name, deploymentUrl, username, password }) => {
try {
const profiles = loadProfiles();
const newProfile = {
id: Date.now().toString(),
name: name,
deploymentUrl: deploymentUrl,
username: username,
password: encryptPassword(password),
createdAt: new Date().toISOString()
};
profiles.push(newProfile);
const saved = saveProfiles(profiles);
if (saved) {
return {
success: true,
message: 'Profile saved successfully',
profile: {
id: newProfile.id,
name: newProfile.name,
deploymentUrl: newProfile.deploymentUrl,
username: newProfile.username
}
};
} else {
return { success: false, message: 'Failed to save profile' };
}
} catch (error) {
console.error('Failed to save profile:', error);
return { success: false, message: 'Failed to save profile' };
}
});
ipcMain.handle('profiles-get', async (event, { profileId }) => {
try {
const profiles = loadProfiles();
const profile = profiles.find(p => p.id === profileId);
if (profile) {
return {
success: true,
profile: {
id: profile.id,
name: profile.name,
deploymentUrl: profile.deploymentUrl,
username: profile.username,
password: decryptPassword(profile.password)
}
};
} else {
return { success: false, message: 'Profile not found' };
}
} catch (error) {
console.error('Failed to get profile:', error);
return { success: false, message: 'Failed to get profile' };
}
});
ipcMain.handle('profiles-delete', async (event, { profileId }) => {
try {
const profiles = loadProfiles();
const filteredProfiles = profiles.filter(p => p.id !== profileId);
if (filteredProfiles.length < profiles.length) {
const saved = saveProfiles(filteredProfiles);
if (saved) {
return { success: true, message: 'Profile deleted successfully' };
} else {
return { success: false, message: 'Failed to save changes' };
}
} else {
return { success: false, message: 'Profile not found' };
}
} catch (error) {
console.error('Failed to delete profile:', error);
return { success: false, message: 'Failed to delete profile' };
}
});
ipcMain.handle('profiles-update', async (event, { profileId, name, deploymentUrl, username, password }) => {
try {
const profiles = loadProfiles();
const profileIndex = profiles.findIndex(p => p.id === profileId);
if (profileIndex !== -1) {
profiles[profileIndex] = {
...profiles[profileIndex],
name: name,
deploymentUrl: deploymentUrl,
username: username,
password: password ? encryptPassword(password) : profiles[profileIndex].password,
updatedAt: new Date().toISOString()
};
const saved = saveProfiles(profiles);
if (saved) {
return {
success: true,
message: 'Profile updated successfully',
profile: {
id: profiles[profileIndex].id,
name: profiles[profileIndex].name,
deploymentUrl: profiles[profileIndex].deploymentUrl,
username: profiles[profileIndex].username
}
};
} else {
return { success: false, message: 'Failed to save changes' };
}
} else {
return { success: false, message: 'Profile not found' };
}
} catch (error) {
console.error('Failed to update profile:', error);
return { success: false, message: 'Failed to update profile' };
}
});
// Camera Proxy functionality
ipcMain.handle('camera-proxy-launch', async (event, { deploymentUrl, username, password, deviceUuid }) => {
try {
// Path to the camera proxy executable
const proxyExePath = path.join(__dirname, 'aware-cam-proxy-win.exe');
// Check if the executable exists
if (!fs.existsSync(proxyExePath)) {
return {
success: false,
message: 'Camera proxy executable not found. Please ensure aware-cam-proxy-win.exe is in the application directory.'
};
}
// Extract the domain from the deployment URL
let domain = deploymentUrl;
if (domain.startsWith('https://')) {
domain = domain.substring(8);
} else if (domain.startsWith('http://')) {
domain = domain.substring(7);
}
// Remove trailing path segments
domain = domain.split('/')[0];
// Sanitize all inputs before embedding in batch file
const safeDomain = sanitizeBatchInput(domain);
const safeUsername = sanitizeBatchInput(username);
const safeDeviceUuid = sanitizeBatchInput(deviceUuid);
if (!safeDomain || !safeUsername || !safeDeviceUuid) {
return {
success: false,
message: 'Invalid characters detected in connection parameters. Please check your profile settings.'
};
}
// Copy password to clipboard for easy pasting, then clear after 30 seconds
clipboard.writeText(password);
setTimeout(() => {
try {
if (clipboard.readText() === password) {
clipboard.clear();
}
} catch { /* ignore clipboard errors */ }
}, 30000);
// Create a batch file to launch the camera proxy with proper console
const batchContent = `@echo off
echo Launching Alta Video Camera Proxy...
echo Domain: ${safeDomain}
echo Username: ${safeUsername}
echo Device UUID: ${safeDeviceUuid}
echo.
echo *** PASSWORD HAS BEEN COPIED TO CLIPBOARD ***
echo When prompted for password, simply press Ctrl+V to paste and then Enter.
echo.
"${proxyExePath}" -a "${safeDomain}" -u "${safeUsername}" -d "${safeDeviceUuid}"
echo.
echo Camera proxy has finished. Press any key to close this window.
pause >nul`;
const tempDir = os.tmpdir();
const batchPath = path.join(tempDir, `camera-proxy-${Date.now()}.bat`);
// Write the batch file
fs.writeFileSync(batchPath, batchContent);
console.log('Launching camera proxy via batch file:', batchPath);
console.log('Command will be: aware-cam-proxy-win.exe -a', safeDomain, '-u', safeUsername, '-d', safeDeviceUuid);
// Launch the batch file in a new command prompt window
const cmdProcess = spawn('cmd', ['/c', 'start', 'cmd', '/k', batchPath], {
detached: true,
stdio: 'ignore'
});
// Store the process information for later termination
const processInfo = {
process: cmdProcess,
batchPath: batchPath,
deviceUuid: safeDeviceUuid,
startTime: Date.now(),
username: safeUsername,
domain: safeDomain
};
activeProxyProcesses.set(cmdProcess.pid, processInfo);
// Clean up the batch file after a delay
setTimeout(() => {
try {
if (fs.existsSync(batchPath)) {
fs.unlinkSync(batchPath);
}
} catch (error) {
console.log('Could not clean up batch file:', error.message);
}
}, 60000); // Clean up after 1 minute
cmdProcess.unref(); // Allow the parent process to exit independently
// Clean up process tracking when it exits
cmdProcess.on('exit', () => {
activeProxyProcesses.delete(cmdProcess.pid);
});
return {
success: true,
message: `Camera proxy launched for ${deviceUuid}! Password copied to clipboard - press Ctrl+V to paste when prompted.`,
processId: cmdProcess.pid,
deviceUuid: deviceUuid
};
} catch (error) {
console.error('Failed to launch camera proxy:', error);
return {
success: false,
message: `Failed to launch camera proxy: ${error.message}`
};
}
});
// Cookie-based camera proxy functionality
ipcMain.handle('camera-proxy-cookie-launch', async (event, { deploymentUrl, cookieKey, deviceUuid }) => {
try {
// Path to the cookie-based camera proxy executable
const proxyExePath = path.join(__dirname, 'aware-cam-proxy.exe');
// Check if the executable exists
if (!fs.existsSync(proxyExePath)) {
return {
success: false,
message: 'Cookie-based camera proxy executable not found. Please ensure aware-cam-proxy.exe is in the application directory.'
};
}
// Extract the domain from the deployment URL
let domain = deploymentUrl;
if (domain.startsWith('https://')) {
domain = domain.substring(8);
} else if (domain.startsWith('http://')) {
domain = domain.substring(7);
}
// Remove trailing path segments
domain = domain.split('/')[0];
// Sanitize all inputs before embedding in batch file
const safeDomain = sanitizeBatchInput(domain);
const safeDeviceUuid = sanitizeBatchInput(deviceUuid);
const safeCookieKey = sanitizeBatchInput(cookieKey);
if (!safeDomain || !safeDeviceUuid || !safeCookieKey) {
return {
success: false,
message: 'Invalid characters detected in connection parameters.'
};
}
// Create a batch file to launch the cookie-based camera proxy
const truncatedKey = safeCookieKey.length > 20 ? safeCookieKey.substring(0, 20) + '...' : safeCookieKey;
const batchContent = `@echo off
echo Launching Alta Video Camera Proxy (Cookie Method)...
echo Domain: ${safeDomain}
echo Device UUID: ${safeDeviceUuid}
echo Cookie Key: ${truncatedKey}
echo.
"${proxyExePath}" -a "${safeDomain}" -d "${safeDeviceUuid}" -k "${safeCookieKey}"
echo.
echo Cookie-based camera proxy has finished. Press any key to close this window.
pause >nul`;
const tempDir = os.tmpdir();
const batchPath = path.join(tempDir, `cookie-proxy-${Date.now()}.bat`);
// Write the batch file
fs.writeFileSync(batchPath, batchContent);
console.log('Launching cookie-based camera proxy via batch file:', batchPath);
console.log('Command will be: aware-cam-proxy.exe -a', safeDomain, '-d', safeDeviceUuid, '-k [REDACTED]');
// Launch the batch file in a new command prompt window
const cmdProcess = spawn('cmd', ['/c', 'start', 'cmd', '/k', batchPath], {
detached: true,
stdio: 'ignore'
});
// Store the process information for later termination
const processInfo = {
process: cmdProcess,
batchPath: batchPath,
deviceUuid: safeDeviceUuid,
startTime: Date.now(),
cookieKey: truncatedKey,
domain: safeDomain,
type: 'cookie' // Mark as cookie-based proxy
};
activeProxyProcesses.set(cmdProcess.pid, processInfo);
// Clean up the batch file after a delay
setTimeout(() => {
try {
if (fs.existsSync(batchPath)) {
fs.unlinkSync(batchPath);
}
} catch (error) {
console.log('Could not clean up cookie proxy batch file:', error.message);
}
}, 60000); // Clean up after 1 minute
cmdProcess.unref(); // Allow the parent process to exit independently
// Clean up process tracking when it exits
cmdProcess.on('exit', () => {
activeProxyProcesses.delete(cmdProcess.pid);
});
return {
success: true,
message: `Cookie-based camera proxy launched for ${deviceUuid}!`,
processId: cmdProcess.pid,
deviceUuid: deviceUuid,
type: 'cookie'
};
} catch (error) {
console.error('Failed to launch cookie-based camera proxy:', error);
return {
success: false,
message: `Failed to launch cookie-based camera proxy: ${error.message}`
};
}
});
// Stop camera proxy functionality
ipcMain.handle('camera-proxy-stop', async (event, { processId }) => {
try {
console.log('Attempting to stop camera proxy processes and close terminal windows...');
return new Promise((resolve) => {
let processesKilled = 0;
let totalAttempts = 0;
// Step 1: Kill all aware-cam-proxy-win.exe processes by name
const killProxy = spawn('taskkill', ['/f', '/im', 'aware-cam-proxy-win.exe'], {
stdio: ['ignore', 'pipe', 'pipe']
});
let proxyOutput = '';
let proxyError = '';
killProxy.stdout.on('data', (data) => {
proxyOutput += data.toString();
});
killProxy.stderr.on('data', (data) => {
proxyError += data.toString();
});
killProxy.on('close', (code) => {
totalAttempts++;
if (code === 0 || proxyOutput.includes('SUCCESS')) {
processesKilled++;
console.log('Camera proxy processes terminated successfully');
}
// Step 2: Kill command prompt windows containing our batch file or camera proxy
const killCmdWindows = spawn('powershell', [
'-Command',
`Get-Process | Where-Object {$_.ProcessName -eq "cmd" -and $_.MainWindowTitle -like "*camera-proxy*" -or $_.MainWindowTitle -like "*aware-cam-proxy*" -or $_.MainWindowTitle -like "*Command Prompt*"} | Stop-Process -Force`
], {
stdio: 'ignore'
});
killCmdWindows.on('close', () => {
totalAttempts++;
// Step 3: More aggressive approach - kill all cmd processes that might be related
const killAllCmd = spawn('taskkill', ['/f', '/im', 'cmd.exe'], {
stdio: ['ignore', 'pipe', 'pipe']
});
let cmdOutput = '';
killAllCmd.stdout.on('data', (data) => {
cmdOutput += data.toString();
});
killAllCmd.on('close', (cmdCode) => {
totalAttempts++;
if (cmdCode === 0 || cmdOutput.includes('SUCCESS')) {
processesKilled++;
console.log('Command prompt windows closed successfully');
}
// Step 4: Final cleanup - use wmic as fallback
const wmicKill = spawn('wmic', [
'process', 'where',
'name="cmd.exe" or name="aware-cam-proxy-win.exe"',
'delete'
], {
stdio: 'ignore'
});
wmicKill.on('close', (wmicCode) => {
totalAttempts++;
if (wmicCode === 0) {
processesKilled++;
}
// Clean up our process tracking
activeProxyProcesses.clear();
// Determine final result
if (processesKilled > 0) {
resolve({
success: true,
message: 'Camera proxy processes and terminal windows closed successfully'
});
} else if (proxyError.includes('not found') || proxyError.includes('No tasks')) {
resolve({
success: true,
message: 'No camera proxy processes were running'
});
} else {
resolve({
success: true,
message: 'Attempted to close all camera proxy processes and windows'
});
}
});
wmicKill.on('error', () => {
// Even if wmic fails, we might have succeeded with other methods
activeProxyProcesses.clear();
resolve({
success: processesKilled > 0,
message: processesKilled > 0 ?
'Camera proxy processes terminated, some terminal windows may remain open' :
'Unable to terminate camera proxy processes. Please close terminal windows manually.'
});
});
});
});
});
killProxy.on('error', (error) => {
console.error('Error with taskkill by name:', error);
resolve({
success: false,
message: `Failed to stop camera proxy: ${error.message}`
});
});
});
} catch (error) {
console.error('Failed to stop camera proxy:', error);
return {
success: false,
message: `Failed to stop camera proxy: ${error.message}`
};
}
});
// Check if camera proxy executable exists
ipcMain.handle('camera-proxy-check', async () => {
try {
const proxyExePath = path.join(__dirname, 'aware-cam-proxy-win.exe');
const exists = fs.existsSync(proxyExePath);
return {
success: true,
exists: exists,
path: proxyExePath
};
} catch (error) {
return {
success: false,
exists: false,
message: error.message
};
}
});
// Get version of camera proxy
ipcMain.handle('camera-proxy-version', async () => {
try {
const proxyExePath = path.join(__dirname, 'aware-cam-proxy-win.exe');
if (!fs.existsSync(proxyExePath)) {
return {
success: false,
message: 'Camera proxy executable not found'
};
}
return new Promise((resolve) => {
const versionProcess = spawn(proxyExePath, ['-v'], {
stdio: ['pipe', 'pipe', 'pipe']
});
let output = '';
versionProcess.stdout.on('data', (data) => {
output += data.toString();
});
versionProcess.stderr.on('data', (data) => {
output += data.toString();
});
versionProcess.on('close', (code) => {
resolve({
success: true,
version: output.trim(),
exitCode: code
});
});
versionProcess.on('error', (error) => {
resolve({
success: false,
message: error.message
});
});
// Timeout after 5 seconds
setTimeout(() => {
versionProcess.kill();
resolve({
success: false,
message: 'Version check timed out'
});
}, 5000);
});
} catch (error) {
return {
success: false,
message: error.message
};
}
});
// Get list of active camera proxy processes
ipcMain.handle('camera-proxy-list-active', async () => {
try {
const activeProcesses = Array.from(activeProxyProcesses.entries()).map(([pid, info]) => ({
processId: pid,
deviceUuid: info.deviceUuid,
startTime: info.startTime
}));
return {
success: true,
processes: activeProcesses
};
} catch (error) {
return {
success: false,
message: error.message,
processes: []
};
}
});
+5824
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
{
"name": "alta-api-client",
"version": "1.0.0",
"description": "Electron app for connecting to Alta API",
"main": "main.js",
"scripts": {
"start": "electron .",
"dev": "electron . --dev",
"build": "electron-builder --win --publish=never",
"build-test": "electron-builder --win --dir",
"prebuild": "echo Checking build requirements..."
},
"build": {
"appId": "com.internal.alta-camera-proxy",
"productName": "Alta Camera Proxy",
"directories": {
"output": "dist"
},
"win": {
"target": "portable",
"icon": "assets/icon.png"
},
"portable": {
"artifactName": "AltaCameraProxy-${version}-portable.exe"
},
"afterSign": false,
"afterAllArtifactBuild": false
},
"keywords": [
"electron",
"alta",
"api",
"avigilon"
],
"author": "Your Name",
"license": "MIT",
"devDependencies": {
"electron": "^28.0.0",
"electron-builder": "^26.4.0",
"electron-packager": "^17.1.2"
},
"dependencies": {
"axios": "^1.6.0",
"crypto-js": "^4.2.0"
}
}
+24
View File
@@ -0,0 +1,24 @@
const { contextBridge, ipcRenderer } = require('electron');
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld('electronAPI', {
login: (credentials) => ipcRenderer.invoke('api-login', credentials),
getDevices: (params) => ipcRenderer.invoke('api-get-devices', params),
getAuthInfo: (params) => ipcRenderer.invoke('api-get-auth-info', params),
// Profile management
loadProfiles: () => ipcRenderer.invoke('profiles-load'),
saveProfile: (profile) => ipcRenderer.invoke('profiles-save', profile),
getProfile: (profileId) => ipcRenderer.invoke('profiles-get', { profileId }),
deleteProfile: (profileId) => ipcRenderer.invoke('profiles-delete', { profileId }),
updateProfile: (profileId, profile) => ipcRenderer.invoke('profiles-update', { profileId, ...profile }),
// Camera proxy functionality
launchCameraProxy: (params) => ipcRenderer.invoke('camera-proxy-launch', params),
launchCookieCameraProxy: (params) => ipcRenderer.invoke('camera-proxy-cookie-launch', params),
stopCameraProxy: (processId) => ipcRenderer.invoke('camera-proxy-stop', { processId }),
checkCameraProxy: () => ipcRenderer.invoke('camera-proxy-check'),
getCameraProxyVersion: () => ipcRenderer.invoke('camera-proxy-version'),
listActiveCameraProxies: () => ipcRenderer.invoke('camera-proxy-list-active')
});
+1061
View File
File diff suppressed because it is too large Load Diff
+858
View File
@@ -0,0 +1,858 @@
/* Dark Mode Professional UI - Alta Video Camera Proxy */
/* CSS Variables for Dark Mode Color Palette */
:root {
--bg-primary: #1E1E1E;
--bg-secondary: #2D2D30;
--border: #3C3C3C;
--text-primary: #E0E0E0;
--text-secondary: #999999;
--accent-primary: #0E7AFE;
--accent-primary-hover: #0A5FD9;
--success: #4CAF50;
--error: #F44336;
--warning: #FF9800;
--input-bg: #1E1E1E;
--button-outline: #555555;
--card-bg: #2D2D30;
--hover-bg: #3C3C3C;
--tab-active: #0E7AFE;
}
/* Reset and Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
line-height: 1.4;
overflow: hidden;
}
/* App Container */
.app-container {
height: 100vh;
display: flex;
flex-direction: column;
}
/* Navigation Tabs */
.nav-tabs {
display: flex;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 0;
}
.nav-tab {
background: transparent;
border: none;
color: var(--text-secondary);
padding: 12px 24px;
font-size: 12px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 2px solid transparent;
}
.nav-tab:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
.nav-tab.active {
color: var(--tab-active);
border-bottom-color: var(--tab-active);
background: var(--bg-primary);
}
/* Main Layout */
.main-layout {
flex: 1;
display: flex;
overflow: hidden;
}
/* Left Sidebar - Available Devices */
.devices-sidebar {
width: 220px;
background: var(--card-bg);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
}
.sidebar-header h2 {
font-size: 16px;
font-weight: bold;
color: var(--text-primary);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Device Search */
.device-search-container {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
}
.device-search-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--button-outline);
border-radius: 4px;
font-size: 14px;
background: var(--input-bg);
color: var(--text-primary);
transition: border-color 0.2s ease;
}
.device-search-input::placeholder {
color: var(--text-secondary);
font-style: italic;
}
.device-search-input:focus {
outline: none;
border-color: var(--accent-primary);
}
/* Device List */
.device-list-container {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.device-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.device-item {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 12px;
margin-bottom: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
.device-item:hover {
background: var(--hover-bg);
border-color: var(--accent-primary);
}
.device-item.selected {
background: var(--accent-primary);
color: white;
border-color: var(--accent-primary);
}
.device-item.proxy-active {
border-color: var(--success);
box-shadow: 0 0 8px rgba(76, 175, 80, 0.3);
}
.device-item.proxy-active::before {
content: "PROXY ACTIVE";
position: absolute;
top: -8px;
right: 8px;
background: var(--success);
color: white;
font-size: 10px;
font-weight: bold;
padding: 2px 6px;
border-radius: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.device-item.selected.proxy-active {
border-color: var(--success);
box-shadow: 0 0 8px rgba(76, 175, 80, 0.5);
}
.device-name {
font-size: 14px;
font-weight: bold;
color: inherit;
flex: 1;
}
/* Device Status Dot */
.device-status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
margin-left: 8px;
flex-shrink: 0;
}
.device-status-dot.online {
background: var(--success);
box-shadow: 0 0 6px rgba(76, 175, 80, 0.6);
}
.device-status-dot.offline {
background: var(--error);
box-shadow: 0 0 6px rgba(244, 67, 54, 0.6);
}
.placeholder-text {
color: var(--text-secondary);
font-style: italic;
text-align: center;
padding: 20px;
font-size: 12px;
}
/* Main Content Area */
.main-content {
flex: 1;
padding: 24px;
overflow-y: auto;
background: var(--bg-primary);
}
.content-header {
margin-bottom: 24px;
text-align: center;
}
.content-header h1 {
font-size: 24px;
font-weight: 600;
color: var(--tab-active);
margin: 0;
}
/* Content Sections */
.content-section {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
}
.content-section h2 {
font-size: 16px;
font-weight: bold;
color: var(--tab-active);
margin: 0 0 16px 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Profile Controls - Smaller and more compact */
.profile-section {
padding: 12px 20px;
}
.profile-controls {
margin-bottom: 0;
}
.profile-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 0;
}
.profile-row label {
font-size: 14px;
font-weight: bold;
color: var(--text-primary);
min-width: 100px;
}
.profile-row select {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--button-outline);
border-radius: 4px;
font-size: 14px;
background: var(--input-bg);
color: var(--text-primary);
max-width: 250px;
}
.profile-row select:focus {
outline: none;
border-color: var(--accent-primary);
}
/* Buttons */
button {
font-family: inherit;
font-size: 14px;
font-weight: bold;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
text-transform: none;
}
.btn-primary {
background: var(--accent-primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-primary-hover);
}
.btn-success {
background: var(--success);
color: white;
}
.btn-success:hover:not(:disabled) {
background: #45a049;
}
.btn-outline {
background: transparent;
color: var(--text-primary);
border: 1px solid var(--button-outline);
}
.btn-outline:hover:not(:disabled) {
background: var(--hover-bg);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Selected Profile Info */
.selected-profile-info {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 12px;
margin-top: 8px;
}
.selected-profile-info p {
margin: 0;
color: var(--text-secondary);
font-style: italic;
font-size: 8px;
}
.selected-profile-info.has-profile p {
color: var(--text-primary);
font-style: normal;
}
.profile-detail {
display: flex;
justify-content: space-between;
margin: 4px 0;
font-size: 8px;
}
.profile-detail strong {
color: var(--text-primary);
font-weight: bold;
}
/* Connection Status */
.connection-status {
margin-bottom: 16px;
}
.status-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.status-row label {
font-size: 14px;
font-weight: bold;
color: var(--text-primary);
min-width: 50px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--error);
}
.status-dot.online {
background: var(--success);
}
.status-dot.offline {
background: var(--error);
}
.status-text {
font-size: 14px;
font-weight: bold;
color: var(--text-primary);
}
/* Connection Controls */
.connection-controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
/* Proxy Controls */
.proxy-controls {
display: flex;
flex-direction: column;
gap: 12px;
}
.input-row {
display: flex;
align-items: center;
gap: 12px;
}
.input-row label {
font-size: 14px;
font-weight: bold;
color: var(--text-primary);
min-width: 80px;
}
.input-row input {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--button-outline);
border-radius: 4px;
font-size: 14px;
background: var(--input-bg);
color: var(--text-secondary);
font-style: italic;
}
.input-row input:focus {
outline: none;
border-color: var(--accent-primary);
}
.proxy-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* Status Messages */
.status-message {
padding: 8px 12px;
border-radius: 4px;
margin: 8px 0;
font-weight: bold;
font-size: 14px;
display: none;
border: 1px solid transparent;
}
.status-message.success {
background-color: rgba(76, 175, 80, 0.1);
color: var(--success);
border-color: var(--success);
}
.status-message.error {
background-color: rgba(244, 67, 54, 0.1);
color: var(--error);
border-color: var(--error);
}
.status-message.warning {
background-color: rgba(255, 152, 0, 0.1);
color: var(--warning);
border-color: var(--warning);
}
.status-message.info {
background-color: rgba(14, 122, 254, 0.1);
color: var(--accent-primary);
border-color: var(--accent-primary);
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
}
.modal-content {
background-color: var(--card-bg);
margin: 2vh auto;
padding: 0;
border-radius: 8px;
width: clamp(400px, 80vw, 800px);
max-height: 90vh;
border: 1px solid var(--border);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: modalSlideIn 0.3s ease;
overflow: hidden;
display: flex;
flex-direction: column;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
border-radius: 8px 8px 0 0;
}
.modal-header h3 {
margin: 0;
font-size: clamp(16px, 2.5vw, 20px);
font-weight: bold;
color: var(--text-primary);
}
.close {
color: var(--text-secondary);
font-size: clamp(20px, 3vw, 28px);
font-weight: bold;
cursor: pointer;
line-height: 1;
transition: color 0.2s ease;
}
.close:hover {
color: var(--text-primary);
}
.modal form {
padding: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: bold;
font-size: clamp(14px, 2vw, 18px);
color: var(--text-primary);
}
.form-group input {
width: 100%;
padding: clamp(8px, 1.5vw, 12px) clamp(12px, 2vw, 16px);
border: 1px solid var(--button-outline);
border-radius: 4px;
font-size: clamp(14px, 2vw, 18px);
background: var(--input-bg);
color: var(--text-primary);
transition: border-color 0.2s ease;
}
.form-group input:focus {
outline: none;
border-color: var(--accent-primary);
}
.modal-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--border);
background: var(--bg-secondary);
border-radius: 0 0 8px 8px;
}
.modal-btn {
padding: clamp(8px, 1.5vw, 12px) clamp(16px, 2.5vw, 24px);
font-size: clamp(14px, 2vw, 18px);
font-weight: bold;
}
.modal-btn.primary {
background: var(--accent-primary);
color: white;
}
.modal-btn.secondary {
background: var(--button-outline);
color: var(--text-primary);
}
/* Profiles List */
.profiles-list {
max-height: 60vh;
overflow-y: auto;
padding: clamp(16px, 2.5vw, 24px) clamp(20px, 3vw, 32px);
flex: 1;
}
.profile-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: clamp(12px, 2vw, 18px);
margin-bottom: clamp(8px, 1.5vw, 12px);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
transition: background-color 0.2s ease;
}
.profile-item:hover {
background: var(--hover-bg);
}
.profile-info h4 {
margin: 0 0 clamp(4px, 1vw, 8px) 0;
color: var(--text-primary);
font-size: clamp(14px, 2vw, 18px);
font-weight: bold;
}
.profile-info p {
margin: 0;
color: var(--text-secondary);
font-size: clamp(12px, 1.8vw, 16px);
}
.profile-actions {
display: flex;
gap: 6px;
}
.profile-action-btn {
padding: clamp(6px, 1vw, 10px) clamp(8px, 1.5vw, 12px);
font-size: clamp(12px, 1.8vw, 16px);
font-weight: bold;
border: none;
border-radius: 3px;
cursor: pointer;
transition: all 0.2s ease;
min-width: clamp(50px, 8vw, 80px);
}
.profile-action-btn.edit {
background: var(--warning);
color: white;
}
.profile-action-btn.delete {
background: var(--error);
color: white;
}
.profile-action-btn:hover {
opacity: 0.8;
}
.no-profiles {
text-align: center;
color: var(--text-secondary);
font-style: italic;
padding: clamp(30px, 5vw, 50px);
font-size: clamp(14px, 2vw, 18px);
}
/* Dark Scrollbars */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: var(--button-outline);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--hover-bg);
}
/* Loading Animation */
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.6; }
100% { opacity: 1; }
}
button:disabled {
animation: pulse 2s infinite;
}
/* Responsive Design */
@media (max-width: 768px) {
.devices-sidebar {
width: 150px;
}
.main-content {
padding: 16px;
}
.profile-row,
.connection-controls,
.proxy-buttons {
flex-direction: column;
gap: 8px;
}
.profile-row select {
max-width: none;
}
.modal-content {
width: 95vw;
margin: 1vh auto;
}
.profile-item {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.profile-actions {
align-self: stretch;
justify-content: flex-end;
}
}
/* Large screen optimizations */
@media (min-width: 1200px) {
.modal-content {
max-width: 900px;
}
}
/* Focus States for Accessibility */
button:focus,
input:focus,
select:focus {
outline: 2px solid var(--accent-primary);
outline-offset: 1px;
}
/* High Contrast Support */
@media (prefers-contrast: high) {
:root {
--border: #666666;
--text-secondary: #CCCCCC;
--button-outline: #777777;
}
}
/* Collapsible Section Styles */
.collapsible-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 0;
margin: 0 0 16px 0;
transition: all 0.2s ease;
}
.collapsible-header:hover {
opacity: 0.8;
}
.collapsible-header h2 {
margin: 0;
flex: 1;
}
.collapse-icon {
font-size: 16px;
color: var(--accent-primary);
font-weight: bold;
transition: transform 0.3s ease;
margin-left: 8px;
}
.collapse-icon.expanded {
transform: rotate(180deg);
}
.collapsible-content {
transition: all 0.3s ease;
overflow: hidden;
}
.collapsible-content.collapsed {
display: none !important;
}