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:
+22
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.8 MiB |
+172
@@ -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">×</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">×</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>
|
||||||
@@ -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: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
Generated
+5824
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+858
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user