9 Commits

Author SHA1 Message Date
peji 7309d78344 Add self-update feature via GitHub Releases
Checks for updates on startup (silent, badge on button if available)
and on manual click. Downloads new portable .exe to temp dir, creates
a batch script to swap the running executable after quit, then relaunches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 22:23:48 -05:00
peji cccb20bd31 Update app icon to Avigilon Alta logo, remove Test Connection button, rename proxy buttons
- Replace app icon (assets/icon.png) with Avigilon Alta "A" logo
- Add Windows .ico version of the icon
- Remove Test Connection button from API Connection section
- Rename Start/Stop Cookie Proxy buttons to Start/Stop Proxy
- Add .claude/ and nul to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 22:03:51 -05:00
peji 632d5c749a Update Chrome extension icons with Avigilon Alta logo
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 21:57:17 -05:00
peji c9b466b0d1 Fix proxy stop not killing aware-cam-proxy.exe processes
The stop handler was targeting the wrong executable name
(aware-cam-proxy-win.exe instead of aware-cam-proxy.exe), so taskkill
never found the running process. Also removed dangerous fallbacks that
killed all cmd.exe processes system-wide and deprecated wmic calls.
Simplified to a single targeted taskkill. Launch now uses a unique
window title and cmd /c so terminals close naturally after the proxy
exits.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 21:50:38 -05:00
peji f57183448f Update CLAUDE.md and README.md for cookie-only architecture
Rewrite both docs to reflect the current state: Chrome extension
cookie auth, no profiles/passwords, simplified IPC channels and
file structure, updated troubleshooting and security sections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 21:30:11 -05:00
peji 0f12915b47 Remove dead code from profile and password proxy systems
Strip ~966 lines of unused code after transitioning to cookie-only
auth: profile CRUD handlers, encryption helpers, username/password
proxy launcher, proxy check/version handlers, dead preload methods,
orphaned CSS (modals, profiles, nav tabs, collapsible sections),
unused DOM elements and renderer functions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 21:27:11 -05:00
peji ec5989cf02 Remove profiles and password proxy UI, simplify to cookie-only auth
Strip out User Profiles section, username/password proxy method, and
related modals. Cookie proxy section is now always visible and renamed
to just "Camera Proxy". Cookie key input hidden (auto-populated by
Chrome extension).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 21:15:28 -05:00
peji 67437a0c46 Add Chrome extension cookie bridge for session import
Users logged into Alta in Chrome can now send their session cookie
to the running Electron app via a local HTTP server on port 18247,
eliminating the need for re-authentication.

- main.js: HTTP cookie server with CORS, token, domain validation
- preload.js: onExtensionCookie push-pattern IPC bridge
- renderer.js: handleExtensionCookie sets session, fetches devices
- chrome-extension/: Manifest V3 extension with popup UI
- CLAUDE.md: updated architecture docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 20:58:54 -05:00
peji e813607f63 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>
2026-02-09 20:28:32 -05:00
19 changed files with 9041 additions and 327 deletions
+28
View File
@@ -0,0 +1,28 @@
# 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
# Claude Code
.claude/
# Windows null device artifact
nul
+112
View File
@@ -0,0 +1,112 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Alta Proxy Tool (APT) — an Electron desktop app that authenticates with Avigilon Alta Video deployments via a companion Chrome extension, discovers cameras, and launches `aware-cam-proxy.exe` to establish camera connections. Authentication uses cookie import from Chrome — no username/password login flow. Windows-only due to the proxy executable.
## Repository
- **GitHub**: https://github.com/PageZ948/Alta-Proxy-Tool (private)
- **Branch**: master
- **Git identity**: Zac <zpage948@gmail.com> (repo-local config)
## 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). Core files:
```
main.js → Electron main process: IPC handlers, API calls (axios),
cookie proxy process spawning, local HTTP cookie server
preload.js → contextBridge exposing window.electronAPI with IPC 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
```
A companion Chrome extension lives in `chrome-extension/`:
```
chrome-extension/
manifest.json → Manifest V3, cookies + activeTab permissions
popup.html → Extension popup UI
popup.css → Dark theme matching the Electron app
popup.js → Tab detection, cookie retrieval, POST to localhost
icon*.png → Placeholder icons
```
### Authentication Flow
There is no login form or profile system. Authentication works exclusively through the Chrome extension cookie bridge:
1. User logs into Alta deployment in Chrome
2. Clicks the Chrome extension popup → "Send Cookie to APT"
3. Extension POSTs `{deploymentUrl, cookieValue}` to `http://127.0.0.1:18247/cookie` with `X-APT-Token` header
4. `main.js` HTTP server validates and forwards via IPC push to renderer
5. `renderer.js` `handleExtensionCookie()` sets session state, auto-populates cookie key, fetches devices
The extension is loaded unpacked via `chrome://extensions/` → Developer mode → Load unpacked → select `chrome-extension/`.
### IPC Communication Pattern
Most cross-process communication follows the request/response 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 }`.
There is one **push-pattern** channel for the Chrome extension cookie bridge:
- `main.js` sends: `mainWindow.webContents.send('extension-cookie-received', data)`
- `preload.js` bridges: `ipcRenderer.on('extension-cookie-received', callback)`
- `renderer.js` listens via `window.electronAPI.onExtensionCookie(callback)`
### IPC Channels
| Channel | Purpose |
|---------|---------|
| `api-get-devices` | GET /api/v1/devices with cookie auth |
| `api-get-auth-info` | GET /api/v1/auth to verify session |
| `camera-proxy-cookie-launch` | Spawns aware-cam-proxy.exe (cookie method) |
| `camera-proxy-stop` | Kills all proxy processes via taskkill/powershell |
| `extension-cookie-received` | Push channel: cookie data from Chrome extension → renderer |
### 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 cookie proxy processes are tracked in `activeCookieProxyConnections` Map, keyed by device GUID.
### 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
- Local HTTP cookie server (port 18247) bound to `127.0.0.1` only
- Cookie server validates: shared token header, CORS restricted to `chrome-extension://` origins, deployment URL must be `*.avasecurity.com` or `*.avigilon.com` over HTTPS, type/length limits on all inputs, 64KB body size limit
## 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 Executable
- `aware-cam-proxy.exe` — cookie-based auth proxy (required)
Not bundled via npm. Must be in the app root directory. Gitignored along with `*.pdf`, `node_modules/`, and `dist/`.
+186
View File
@@ -0,0 +1,186 @@
# Alta Video Camera Proxy
An Electron desktop application for managing Alta Video camera proxy connections. Authenticates via a companion Chrome extension that imports your existing Alta session cookie, discovers cameras, and launches proxy connections.
## Features
- **Chrome Extension Authentication**: Import your Alta session cookie from Chrome with one click — no manual login
- **API Integration**: Device discovery via Alta Video API using cookie auth
- **Camera Proxy Management**: Launch and manage 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
- **Auto-Update**: Checks for updates on startup via GitHub Releases, with one-click in-place update
- **Modern Dark UI**: Professional dark-mode interface with responsive design
## Prerequisites
- **Node.js** (version 14 or higher)
- **npm** (comes with Node.js)
- **Google Chrome** (for the authentication extension)
- **Windows OS** (required for camera proxy executable)
- **aware-cam-proxy.exe** (camera proxy executable) — must be placed in the application directory
- **An active Alta Video session** in Chrome (logged in to your deployment)
## Installation
1. Clone or download this project
2. Install dependencies:
```bash
npm install
```
3. Place `aware-cam-proxy.exe` in the project root directory
### Chrome Extension Setup
1. Open Chrome and navigate to `chrome://extensions/`
2. Enable **Developer mode** (toggle in top-right)
3. Click **Load unpacked**
4. Select the `chrome-extension/` folder from this project
5. The extension icon will appear in the Chrome toolbar
## Usage
### Starting the Application
```bash
npm start
```
Or for development mode with DevTools:
```bash
npm run dev
```
### Connecting to Alta
1. **Log into your Alta deployment** in Chrome (e.g., `https://your-site.eu1.aware.avasecurity.com`)
2. **Click the extension icon** in Chrome — it will detect the Alta tab
3. **Click "Send Cookie to APT"** — the app will connect and load devices automatically
### Launching Camera Proxy
1. **Connect via Chrome extension** (above)
2. **Select a device** from the left sidebar
3. **Click "Start Camera Proxy"**
4. A command prompt window will open with the proxy connection
### Updating
The app checks for updates automatically 2 seconds after launch. If a newer version is available on GitHub Releases, the "Check for Updates" button in the header will show a green badge.
- **Manual check**: Click "Check for Updates" in the top-right corner
- **Install**: The update modal shows release notes — click "Install Update" to download and replace the current executable
- The app will quit, swap the `.exe`, and relaunch automatically
## API Endpoints Used
- **Device List**: `GET /api/v1/devices` — Retrieve all devices
- **Auth Info**: `GET /api/v1/auth` — Verify authentication status
## How It Works
```
Chrome Extension (popup click)
→ POST http://127.0.0.1:18247/cookie
→ Electron app HTTP server receives cookie
→ Sets session state, fetches devices
→ User selects device → launches aware-cam-proxy.exe
```
The Electron app runs a local HTTP server on port 18247 that only accepts requests from Chrome extensions with a shared token. The Chrome extension reads the `va` session cookie from the active Alta tab and sends it to the app.
## Security
- **Context Isolation**: Renderer process is isolated from Node.js APIs
- **Preload Script**: Secure IPC communication between main and renderer processes
- **CSP Enforced**: `script-src 'self'` — no inline scripts allowed
- **Localhost Only**: Cookie server binds to `127.0.0.1`, not accessible from network
- **CORS Restricted**: Only `chrome-extension://` origins accepted
- **Domain Validation**: Only `*.avasecurity.com` and `*.avigilon.com` URLs accepted
- **Input Sanitization**: Batch file inputs sanitized to prevent command injection
- **Size Limits**: 64KB body limit on cookie server, type/length validation on all inputs
## File Structure
```
├── main.js # Main process (IPC, API calls, proxy spawning, cookie server)
├── renderer.js # Renderer process (UI logic, state management)
├── preload.js # Secure IPC bridge (contextBridge)
├── index.html # Static HTML shell (CSP enforced)
├── styles.css # Dark theme styling
├── package.json # Dependencies and build config
├── chrome-extension/ # Chrome extension for cookie import
│ ├── manifest.json # Manifest V3
│ ├── popup.html # Extension popup UI
│ ├── popup.css # Dark theme styling
│ ├── popup.js # Tab detection, cookie retrieval
│ └── icon*.png # Extension icons
├── assets/
│ └── icon.png # Application icon
├── CLAUDE.md # Claude Code project instructions
└── README.md # This file
```
**External executable** (not included in repo):
- `aware-cam-proxy.exe` — cookie-based auth proxy (required, place in app root)
## Troubleshooting
### Connection Issues
- Ensure you are **logged into Alta in Chrome** before clicking the extension
- Verify the extension shows "Detected: [hostname]" in green
- If extension shows "Alta Proxy Tool is not running" — start the Electron app first
- If "Session cookie has expired" — log into Alta again in Chrome
- Check that the app console shows "Cookie server listening on http://127.0.0.1:18247"
### Camera Proxy Issues
- **Executable not found**: Ensure `aware-cam-proxy.exe` is in the application directory
- **Proxy won't start**: Check that you're connected and have selected a device
- **Command window closes immediately**: Check network connectivity to the deployment
### Device List Issues
- Ensure you're connected via the Chrome extension first
- Check that your user account has permissions to view devices
- **No devices shown**: You may only have cloud cameras which are filtered out
- Use the search box to find specific devices
## Building for Distribution
```bash
# Build portable Windows executable
npm run build
# Output: dist/AltaCameraProxy-1.0.0-portable.exe
```
**Important**: Copy `aware-cam-proxy.exe` to the same directory as the built executable before distribution.
## Limitations
- **Windows Only**: Camera proxy executable is Windows-specific
- **Chrome Required**: Authentication requires the Chrome extension
- **Local Cameras Only**: Automatically filters out cloud-based cameras
- **No Session Refresh**: Sessions may expire and require re-import from Chrome
- **Executable Required**: `aware-cam-proxy.exe` must be obtained separately
- **Update Requires Write Access**: Self-update needs write permission to the directory containing the `.exe`
## Development
To modify or extend this application:
1. **Main Process** ([main.js](main.js)): App lifecycle, API requests, proxy spawning, cookie server
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. **Chrome Extension** ([chrome-extension/](chrome-extension/)): Cookie import from browser
5. **Styling** ([styles.css](styles.css)): Dark mode theme and responsive design
### Adding New IPC Endpoints
1. Add 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: 279 KiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+25
View File
@@ -0,0 +1,25 @@
{
"manifest_version": 3,
"name": "Alta Proxy Tool Bridge",
"version": "1.0.0",
"description": "Send Alta session cookies to the Alta Proxy Tool desktop app.",
"permissions": ["cookies", "activeTab"],
"host_permissions": [
"https://*.avasecurity.com/*",
"https://*.avigilon.com/*",
"http://127.0.0.1:18247/*"
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icon16.png",
"48": "icon48.png",
"128": "icon128.png"
}
},
"icons": {
"16": "icon16.png",
"48": "icon48.png",
"128": "icon128.png"
}
}
+100
View File
@@ -0,0 +1,100 @@
/* Dark theme matching the Electron app */
body {
margin: 0;
padding: 0;
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
background: #1E1E1E;
color: #E0E0E0;
font-size: 14px;
min-width: 300px;
}
.popup-container {
padding: 16px;
}
h1 {
font-size: 16px;
font-weight: 600;
color: #0E7AFE;
margin: 0 0 12px 0;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.tab-info {
background: #2D2D30;
border: 1px solid #3C3C3C;
border-radius: 4px;
padding: 10px 12px;
margin-bottom: 12px;
font-size: 13px;
color: #999999;
word-break: break-all;
}
.tab-info.detected {
color: #4CAF50;
border-color: #4CAF50;
}
.tab-info.not-detected {
color: #F44336;
border-color: #F44336;
}
.send-btn {
display: block;
width: 100%;
padding: 10px 16px;
font-family: inherit;
font-size: 14px;
font-weight: bold;
color: white;
background: #0E7AFE;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s ease;
}
.send-btn:hover:not(:disabled) {
background: #0A5FD9;
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.status-msg {
margin-top: 10px;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: bold;
display: none;
border: 1px solid transparent;
}
.status-msg.success {
display: block;
background: rgba(76, 175, 80, 0.1);
color: #4CAF50;
border-color: #4CAF50;
}
.status-msg.error {
display: block;
background: rgba(244, 67, 54, 0.1);
color: #F44336;
border-color: #F44336;
}
.status-msg.info {
display: block;
background: rgba(14, 122, 254, 0.1);
color: #0E7AFE;
border-color: #0E7AFE;
}
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src http://127.0.0.1:18247;">
<title>Alta Proxy Tool Bridge</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="popup-container">
<h1>Alta Proxy Tool</h1>
<div id="tabInfo" class="tab-info">Checking tab...</div>
<button id="sendBtn" class="send-btn" disabled>Send Cookie to APT</button>
<div id="statusMsg" class="status-msg"></div>
</div>
<script src="popup.js"></script>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
const APT_URL = 'http://127.0.0.1:18247/cookie';
const APT_TOKEN = 'apt-local-bridge-token';
const tabInfo = document.getElementById('tabInfo');
const sendBtn = document.getElementById('sendBtn');
const statusMsg = document.getElementById('statusMsg');
let detectedOrigin = null;
function showStatus(message, type) {
statusMsg.textContent = message;
statusMsg.className = 'status-msg ' + type;
}
// Check the active tab on popup open
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (!tabs || tabs.length === 0) {
tabInfo.textContent = 'No active tab found.';
tabInfo.className = 'tab-info not-detected';
return;
}
const tab = tabs[0];
let url;
try {
url = new URL(tab.url);
} catch {
tabInfo.textContent = 'Cannot read this tab URL.';
tabInfo.className = 'tab-info not-detected';
return;
}
const hostname = url.hostname;
const isAlta = hostname.endsWith('.avasecurity.com') || hostname.endsWith('.avigilon.com');
if (!isAlta) {
tabInfo.textContent = 'This tab is not an Alta deployment.';
tabInfo.className = 'tab-info not-detected';
return;
}
detectedOrigin = url.origin;
tabInfo.textContent = 'Detected: ' + hostname;
tabInfo.className = 'tab-info detected';
sendBtn.disabled = false;
});
// Send cookie on button click
sendBtn.addEventListener('click', async () => {
if (!detectedOrigin) return;
sendBtn.disabled = true;
showStatus('Retrieving cookie...', 'info');
try {
const cookie = await chrome.cookies.get({ url: detectedOrigin, name: 'va' });
if (!cookie || !cookie.value) {
showStatus('No "va" session cookie found. Are you logged in?', 'error');
sendBtn.disabled = false;
return;
}
if (cookie.expirationDate && cookie.expirationDate < Date.now() / 1000) {
showStatus('Session cookie has expired. Please log in again.', 'error');
sendBtn.disabled = false;
return;
}
showStatus('Sending to Alta Proxy Tool...', 'info');
const response = await fetch(APT_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-APT-Token': APT_TOKEN
},
body: JSON.stringify({
deploymentUrl: detectedOrigin,
cookieValue: cookie.value
})
});
const data = await response.json();
if (data.success) {
showStatus('Cookie sent successfully!', 'success');
} else {
showStatus('Error: ' + (data.message || 'Unknown error'), 'error');
sendBtn.disabled = false;
}
} catch (err) {
if (err.message && err.message.includes('Failed to fetch')) {
showStatus('Alta Proxy Tool is not running.', 'error');
} else {
showStatus('Error: ' + err.message, 'error');
}
sendBtn.disabled = false;
}
});
+96 -327
View File
@@ -1,341 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- GitHub Pages rebuild marker: 2026-05-22 -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Alta Proxy Tool</title>
<style>
:root {
--bg-primary: #1E1E1E;
--bg-secondary: #2D2D30;
--border: #3C3C3C;
--text-primary: #E0E0E0;
--text-secondary: #999999;
--accent-primary: #0E7AFE;
--accent-primary-hover: #0A5FD9;
--card-bg: #2D2D30;
}
* {
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: 16px;
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
}
a {
color: var(--accent-primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Header */
.header {
border-bottom: 1px solid var(--border);
padding: 16px 0;
background: var(--bg-secondary);
}
.container {
max-width: 720px;
margin: 0 auto;
padding: 0 24px;
}
.header .container {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-brand {
display: flex;
align-items: center;
gap: 12px;
}
.header-brand img {
width: 32px;
height: 32px;
}
.header-brand span {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.header-link {
color: var(--text-secondary);
font-size: 14px;
}
.header-link:hover {
color: var(--text-primary);
}
/* Hero */
main {
flex: 1;
}
.hero {
text-align: center;
padding: 100px 0 48px;
}
.hero-icon {
width: 96px;
height: 96px;
margin-bottom: 24px;
}
.hero h1 {
font-size: 40px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 12px;
}
.hero .tagline {
font-size: 18px;
color: var(--text-secondary);
margin-bottom: 40px;
max-width: 500px;
margin-left: auto;
margin-right: auto;
}
.btn-download {
display: inline-block;
background: var(--accent-primary);
color: #fff;
font-size: 18px;
font-weight: 600;
padding: 16px 40px;
border-radius: 6px;
transition: background 0.2s;
}
.btn-download:hover {
background: var(--accent-primary-hover);
text-decoration: none;
}
.hero-note {
margin-top: 14px;
font-size: 13px;
color: var(--text-secondary);
}
/* Kit contents */
.kit-info {
padding: 48px 0;
border-top: 1px solid var(--border);
}
.kit-info h2 {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16px;
text-align: center;
}
.kit-contents {
display: flex;
gap: 16px;
justify-content: center;
}
.kit-item {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px 28px;
text-align: center;
}
.kit-item strong {
display: block;
font-size: 14px;
margin-bottom: 4px;
}
.kit-item span {
font-size: 13px;
color: var(--text-secondary);
}
/* Setup */
.setup {
padding: 48px 0;
border-top: 1px solid var(--border);
}
.setup h2 {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 20px;
text-align: center;
}
.setup-steps {
list-style: none;
max-width: 480px;
margin: 0 auto;
counter-reset: step;
}
.setup-steps li {
counter-increment: step;
padding: 12px 0 12px 44px;
position: relative;
font-size: 15px;
color: var(--text-secondary);
}
.setup-steps li + li {
border-top: 1px solid var(--border);
}
.setup-steps li::before {
content: counter(step);
position: absolute;
left: 0;
top: 12px;
width: 28px;
height: 28px;
background: var(--accent-primary);
color: #fff;
font-weight: 700;
font-size: 13px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.setup-steps code {
background: var(--bg-primary);
border: 1px solid var(--border);
padding: 1px 5px;
border-radius: 3px;
font-size: 13px;
color: var(--accent-primary);
}
.setup-note {
text-align: center;
margin-top: 20px;
font-size: 13px;
color: var(--text-secondary);
}
/* Footer */
.footer {
border-top: 1px solid var(--border);
padding: 24px 0;
text-align: center;
font-size: 13px;
color: var(--text-secondary);
}
.footer a {
color: var(--text-secondary);
}
.footer a:hover {
color: var(--text-primary);
}
/* Responsive */
@media (max-width: 600px) {
.hero {
padding: 60px 0 36px;
}
.hero h1 {
font-size: 28px;
}
.hero .tagline {
font-size: 16px;
}
.btn-download {
font-size: 16px;
padding: 12px 28px;
}
.kit-contents {
flex-direction: column;
align-items: center;
}
.kit-item {
width: 100%;
max-width: 280px;
}
}
</style>
<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>
<header class="header">
<div class="container">
<div class="header-brand">
<img src="icon.png" alt="Alta Proxy Tool">
<span>Alta Proxy Tool</span>
<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>
<button type="button" id="checkUpdateBtn" class="btn-update" title="Check for Updates">
<span class="update-icon">&#x21bb;</span>
<span class="update-text">Check for Updates</span>
</button>
</div>
<!-- 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="disconnectBtn" class="btn-outline" disabled>Disconnect</button>
</div>
<div id="connectionStatus" class="status-message"></div>
</section>
<!-- Cookie-Based Camera Proxy Section -->
<section class="content-section">
<h2>Camera Proxy</h2>
<div class="proxy-controls">
<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" style="display: none;">
<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 Proxy</button>
<button type="button" id="stopCookieProxyBtn" class="btn-outline" disabled>Stop Proxy</button>
</div>
</div>
</section>
</main>
</div>
</div>
<!-- Update Modal -->
<div id="updateModalOverlay" class="update-modal-overlay" style="display: none;">
<div class="update-modal-card">
<div class="update-modal-header">
<h3>Update Available</h3>
<button type="button" id="updateModalCloseBtn" class="update-modal-close">&times;</button>
</div>
<a href="https://github.com/PageZ948/Alta-Proxy-Tool" class="header-link">GitHub</a>
</div>
</header>
<main>
<div class="container">
<div class="hero">
<img src="icon.png" alt="" class="hero-icon">
<h1>Alta Proxy Tool</h1>
<a href="https://github.com/PageZ948/Alta-Proxy-Tool/releases/latest/download/AltaProxyToolKit.zip" class="btn-download">Download Kit for Windows</a>
<div class="update-modal-body">
<p id="updateModalMessage" class="update-modal-message"></p>
<div id="updateModalNotes" class="update-modal-notes"></div>
<div id="updateProgressContainer" class="update-progress-container" style="display: none;">
<div class="update-progress-track">
<div id="updateProgressFill" class="update-progress-fill" style="width: 0%"></div>
</div>
<span id="updateProgressText" class="update-progress-text">0%</span>
</div>
</div>
<div class="update-modal-footer">
<button type="button" id="updateInstallBtn" class="btn-primary">Install Update</button>
<button type="button" id="updateLaterBtn" class="btn-outline">Later</button>
</div>
<section class="setup">
<h2>Setup</h2>
<ol class="setup-steps">
<li>Extract the zip to any folder</li>
<li>Load <code>chrome-extension/</code> in Chrome via <code>chrome://extensions</code> (Developer mode)</li>
<li>Log into your Alta deployment in Chrome</li>
<li>Click the extension icon and send cookies to the app</li>
<li>Run <strong>AltaCameraProxy.exe</strong> and start proxying</li>
</ol>
<p class="setup-note">Requires Windows 10+, Google Chrome, and an Avigilon Alta account.</p>
</section>
</div>
</main>
<footer class="footer">
<div class="container">
<a href="https://github.com/PageZ948/Alta-Proxy-Tool">Alta Proxy Tool on GitHub</a>
</div>
</footer>
</div>
<script src="renderer.js"></script>
</body>
</html>
+594
View File
@@ -0,0 +1,594 @@
const { app, BrowserWindow, ipcMain, shell } = require('electron');
const path = require('path');
const fs = require('fs');
const os = require('os');
const axios = require('axios');
const https = require('https');
const { spawn } = require('child_process');
const http = require('http');
let mainWindow;
let activeProxyProcesses = new Map(); // Track active camera proxy processes
let cookieServer = null;
const COOKIE_SERVER_PORT = 18247;
const COOKIE_SERVER_TOKEN = 'apt-local-bridge-token';
// 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, '');
}
function startCookieServer() {
cookieServer = http.createServer((req, res) => {
// CORS headers — only allow Chrome extension origins
const origin = req.headers.origin || '';
if (origin.startsWith('chrome-extension://')) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-APT-Token');
// Handle preflight
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
// Only accept POST /cookie
if (req.method !== 'POST' || req.url !== '/cookie') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Not found' }));
return;
}
// Verify shared token
if (req.headers['x-apt-token'] !== COOKIE_SERVER_TOKEN) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Forbidden' }));
return;
}
// Read body with 64KB size limit
let body = '';
let bodySize = 0;
const MAX_BODY_SIZE = 65536;
req.on('data', (chunk) => {
bodySize += chunk.length;
if (bodySize > MAX_BODY_SIZE) {
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Payload too large' }));
req.destroy();
return;
}
body += chunk;
});
req.on('end', () => {
try {
const data = JSON.parse(body);
const { deploymentUrl, cookieValue } = data;
if (!deploymentUrl || !cookieValue) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Missing deploymentUrl or cookieValue' }));
return;
}
// Validate types and lengths
if (typeof deploymentUrl !== 'string' || typeof cookieValue !== 'string' ||
deploymentUrl.length > 512 || cookieValue.length > 4096) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Invalid parameter types or lengths' }));
return;
}
// Validate deployment URL is an Alta domain
try {
const parsed = new URL(deploymentUrl);
const isAltaDomain = parsed.hostname.endsWith('.avasecurity.com') ||
parsed.hostname.endsWith('.avigilon.com');
if (!isAltaDomain || parsed.protocol !== 'https:') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Invalid deployment URL domain' }));
return;
}
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Invalid deployment URL' }));
return;
}
if (mainWindow && !mainWindow.isDestroyed()) {
const cookies = ['va=' + cookieValue];
mainWindow.webContents.send('extension-cookie-received', {
deploymentUrl: deploymentUrl.replace(/\/$/, ''),
cookies,
cookieValue
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: 'Cookie received' }));
} else {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Application window not available' }));
}
} catch (e) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'Invalid JSON' }));
}
});
});
cookieServer.listen(COOKIE_SERVER_PORT, '127.0.0.1', () => {
console.log(`Cookie server listening on http://127.0.0.1:${COOKIE_SERVER_PORT}`);
});
cookieServer.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`Cookie server error: Port ${COOKIE_SERVER_PORT} is already in use`);
} else {
console.error('Cookie server error:', err.message);
}
});
}
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();
startCookieServer();
});
app.on('before-quit', () => {
if (cookieServer) {
cookieServer.close();
}
});
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-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'
};
}
});
// 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
title APT-Proxy-${safeDeviceUuid}
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 with unique title
const windowTitle = `APT-Proxy-${safeDeviceUuid}`;
const cmdProcess = spawn('cmd', ['/c', 'start', `"${windowTitle}"`, 'cmd', '/c', 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,
windowTitle: windowTitle, // Store window title for targeted cleanup
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...');
return new Promise((resolve) => {
// Kill all aware-cam-proxy.exe processes by name
const killProxy = spawn('taskkill', ['/f', '/im', 'aware-cam-proxy.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) => {
// Clean up our process tracking
activeProxyProcesses.clear();
if (code === 0 || proxyOutput.includes('SUCCESS')) {
console.log('Camera proxy processes terminated successfully');
resolve({
success: true,
message: 'Camera proxy processes stopped successfully'
});
} else if (proxyError.includes('not found') || proxyError.includes('No tasks')) {
console.log('No camera proxy processes were running');
resolve({
success: true,
message: 'No camera proxy processes were running'
});
} else {
resolve({
success: true,
message: 'Attempted to stop all camera proxy processes'
});
}
});
killProxy.on('error', (error) => {
console.error('Error with taskkill by name:', error);
activeProxyProcesses.clear();
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}`
};
}
});
// --- Self-Update Functionality ---
// Compare semver versions: returns -1 if a < b, 0 if equal, 1 if a > b
function compareVersions(a, b) {
// Strip pre-release tags (e.g. "1.2.3-beta.1" → "1.2.3")
const cleanA = a.replace(/-.*$/, '');
const cleanB = b.replace(/-.*$/, '');
const partsA = cleanA.split('.').map(Number);
const partsB = cleanB.split('.').map(Number);
for (let i = 0; i < 3; i++) {
const numA = partsA[i] || 0;
const numB = partsB[i] || 0;
if (numA < numB) return -1;
if (numA > numB) return 1;
}
return 0;
}
// Follow HTTPS redirects and return the final response (for GitHub asset downloads)
function httpsGetFollowRedirects(url, callback, redirectCount = 0) {
if (redirectCount >= 5) {
return callback(null, new Error('Too many redirects'));
}
const parsed = new URL(url);
if (parsed.protocol !== 'https:') {
return callback(null, new Error('Only HTTPS URLs are allowed'));
}
https.get(url, { headers: { 'User-Agent': 'Alta-Proxy-Tool' } }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
const redirectUrl = new URL(res.headers.location, url).href;
httpsGetFollowRedirects(redirectUrl, callback, redirectCount + 1);
} else {
callback(res);
}
}).on('error', (err) => {
callback(null, err);
});
}
ipcMain.handle('get-current-version', async () => {
return { success: true, version: app.getVersion() };
});
ipcMain.handle('check-for-updates', async () => {
try {
const response = await axios.get(
'https://api.github.com/repos/PageZ948/Alta-Proxy-Tool/releases/latest',
{
timeout: 10000,
headers: { 'User-Agent': 'Alta-Proxy-Tool', 'Accept': 'application/vnd.github.v3+json' }
}
);
const release = response.data;
const latestVersion = release.tag_name.replace(/^v/, '');
const currentVersion = app.getVersion();
const updateAvailable = compareVersions(currentVersion, latestVersion) < 0;
// Find the portable .exe asset
const exeAsset = release.assets.find(a => /AltaCameraProxy-.*-portable\.exe$/i.test(a.name));
const downloadUrl = exeAsset ? exeAsset.browser_download_url : null;
return {
success: true,
updateAvailable,
currentVersion,
latestVersion,
downloadUrl,
releaseNotes: release.body || '',
releaseName: release.name || `v${latestVersion}`
};
} catch (error) {
if (error.response && error.response.status === 404) {
return { success: true, updateAvailable: false, currentVersion: app.getVersion(), message: 'No releases available yet' };
}
if (error.response && error.response.status === 403) {
return { success: false, message: 'GitHub API rate limit exceeded. Try again later.' };
}
console.error('Check for updates error:', error.message);
return { success: false, message: error.message || 'Failed to check for updates' };
}
});
ipcMain.handle('download-and-install-update', async (event, { downloadUrl }) => {
try {
// Determine the path to the currently running executable
const currentExePath = process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe');
const currentDir = path.dirname(currentExePath);
const currentExeName = path.basename(currentExePath);
// Check write permission on the app directory
try {
fs.accessSync(currentDir, fs.constants.W_OK);
} catch {
return { success: false, message: 'No write permission to the application directory. Try running as administrator.' };
}
const tempDir = os.tmpdir();
const tempExePath = path.join(tempDir, `AltaCameraProxy-update-${Date.now()}.exe`);
// Download the file with progress reporting
await new Promise((resolve, reject) => {
httpsGetFollowRedirects(downloadUrl, (res, err) => {
if (err) return reject(err);
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`Download failed with status ${res.statusCode}`));
}
const totalSize = parseInt(res.headers['content-length'], 10) || 0;
let downloadedSize = 0;
const fileStream = fs.createWriteStream(tempExePath);
res.on('data', (chunk) => {
downloadedSize += chunk.length;
if (totalSize > 0 && mainWindow && !mainWindow.isDestroyed()) {
const percent = Math.round((downloadedSize / totalSize) * 100);
mainWindow.webContents.send('update-download-progress', { percent, downloadedSize, totalSize });
}
});
res.pipe(fileStream);
fileStream.on('finish', () => {
fileStream.close();
resolve();
});
fileStream.on('error', (err) => {
fs.unlink(tempExePath, () => {});
reject(err);
});
});
});
// Verify downloaded file size (sanity check: > 10MB for an Electron portable exe)
const stats = fs.statSync(tempExePath);
if (stats.size < 10 * 1024 * 1024) {
fs.unlinkSync(tempExePath);
return { success: false, message: 'Downloaded file is too small — update may be corrupt.' };
}
// Create batch script to replace the exe after this process exits
const batchPath = path.join(tempDir, `apt-update-${Date.now()}.bat`);
const pid = process.pid;
const batchContent = `@echo off\r\ntitle APT-Updater\r\necho Waiting for Alta Proxy Tool to close...\r\n:waitloop\r\ntasklist /fi "PID eq ${pid}" 2>nul | find "${pid}" >nul\r\nif not errorlevel 1 (\r\n timeout /t 1 /nobreak >nul\r\n goto waitloop\r\n)\r\necho Applying update...\r\ncopy /y "${tempExePath}" "${path.join(currentDir, currentExeName)}"\r\nif errorlevel 1 (\r\n echo Update failed! Could not copy new version.\r\n pause\r\n del "${tempExePath}" >nul 2>&1\r\n del "%~f0" >nul 2>&1\r\n exit /b 1\r\n)\r\necho Update complete. Launching new version...\r\nstart "" "${path.join(currentDir, currentExeName)}"\r\ndel "${tempExePath}" >nul 2>&1\r\ndel "%~f0" >nul 2>&1\r\n`;
fs.writeFileSync(batchPath, batchContent);
// Spawn the updater batch script detached
let updater;
try {
updater = spawn('cmd', ['/c', batchPath], {
detached: true,
stdio: 'ignore',
windowsHide: true
});
updater.unref();
} catch (spawnError) {
console.error('Failed to spawn updater:', spawnError);
try { fs.unlinkSync(tempExePath); } catch {}
try { fs.unlinkSync(batchPath); } catch {}
return { success: false, message: 'Failed to start updater process.' };
}
// Quit the app after a delay to let the IPC response return to renderer
setTimeout(() => {
app.quit();
}, 1500);
return { success: true, message: 'Update is being installed. The app will restart shortly.' };
} catch (error) {
console.error('Download and install update error:', error);
return { success: false, message: error.message || 'Failed to download and install update' };
}
});
+6438
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"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",
"png-to-ico": "^3.0.1",
"sharp": "^0.34.5"
},
"dependencies": {
"axios": "^1.6.0",
"crypto-js": "^4.2.0"
}
}
+37
View File
@@ -0,0 +1,37 @@
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', {
getDevices: (params) => ipcRenderer.invoke('api-get-devices', params),
getAuthInfo: (params) => ipcRenderer.invoke('api-get-auth-info', params),
// Camera proxy functionality
launchCookieCameraProxy: (params) => ipcRenderer.invoke('camera-proxy-cookie-launch', params),
stopCameraProxy: (processId) => ipcRenderer.invoke('camera-proxy-stop', { processId }),
// Extension cookie bridge (push from main process)
onExtensionCookie: (callback) => {
ipcRenderer.on('extension-cookie-received', (event, data) => {
try {
callback(data);
} catch (error) {
console.error('Extension cookie handler error:', error);
}
});
},
// Self-update functionality
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
downloadAndInstallUpdate: (params) => ipcRenderer.invoke('download-and-install-update', params),
getCurrentVersion: () => ipcRenderer.invoke('get-current-version'),
onUpdateDownloadProgress: (callback) => {
ipcRenderer.on('update-download-progress', (event, data) => {
try {
callback(data);
} catch (error) {
console.error('Update download progress handler error:', error);
}
});
}
});
+616
View File
@@ -0,0 +1,616 @@
// Global variables to store session data
let sessionData = {
deploymentUrl: '',
cookies: null,
isConnected: false
};
// DOM elements
const connectionStatus = document.getElementById('connectionStatus');
const deviceStatus = document.getElementById('deviceStatus');
const deviceList = document.getElementById('deviceList');
const statusIndicator = document.getElementById('statusIndicator');
const deviceSearch = document.getElementById('deviceSearch');
// Connection buttons
const disconnectBtn = document.getElementById('disconnectBtn');
// Cookie proxy elements
const cookieDeviceUUID = document.getElementById('cookieDeviceUUID');
const cookieKey = document.getElementById('cookieKey');
const startCookieProxyBtn = document.getElementById('startCookieProxyBtn');
const stopCookieProxyBtn = document.getElementById('stopCookieProxyBtn');
// Update elements
const checkUpdateBtn = document.getElementById('checkUpdateBtn');
const updateModalOverlay = document.getElementById('updateModalOverlay');
const updateModalCloseBtn = document.getElementById('updateModalCloseBtn');
const updateModalMessage = document.getElementById('updateModalMessage');
const updateModalNotes = document.getElementById('updateModalNotes');
const updateProgressContainer = document.getElementById('updateProgressContainer');
const updateProgressFill = document.getElementById('updateProgressFill');
const updateProgressText = document.getElementById('updateProgressText');
const updateInstallBtn = document.getElementById('updateInstallBtn');
const updateLaterBtn = document.getElementById('updateLaterBtn');
// Track selected device
let selectedDevice = null;
let activeCookieProxyConnections = new Map(); // Track cookie-based proxy connections
let allDevices = []; // Store all devices for search functionality
let pendingUpdateInfo = null; // Store update info for install action
// Event listeners
disconnectBtn.addEventListener('click', handleDisconnect);
// Cookie proxy event listeners
startCookieProxyBtn.addEventListener('click', handleStartCookieProxy);
stopCookieProxyBtn.addEventListener('click', handleStopCookieProxy);
// Cookie key input listener to update button states
cookieKey.addEventListener('input', updateCookieProxyButtonStates);
// Device search event listener
deviceSearch.addEventListener('input', handleDeviceSearch);
// Update event listeners
checkUpdateBtn.addEventListener('click', handleCheckForUpdates);
updateInstallBtn.addEventListener('click', handleInstallUpdate);
updateLaterBtn.addEventListener('click', closeUpdateModal);
updateModalCloseBtn.addEventListener('click', closeUpdateModal);
// Handle disconnect
function handleDisconnect() {
sessionData.isConnected = false;
sessionData.cookies = null;
sessionData.deploymentUrl = '';
selectedDevice = null;
activeCookieProxyConnections.clear();
allDevices = []; // Clear stored devices
updateConnectionStatus(false);
updateButtonStates();
clearDeviceList();
cookieDeviceUUID.value = '';
cookieKey.value = '';
deviceSearch.value = ''; // Clear search input
// Clear device status message
deviceStatus.style.display = 'none';
deviceStatus.textContent = '';
showStatus(connectionStatus, 'Disconnected from API', 'info');
}
// Handle start cookie proxy
async function handleStartCookieProxy() {
if (!selectedDevice) {
showStatus(connectionStatus, 'Please select a device first', 'error');
return;
}
const cookieKeyValue = cookieKey.value.trim();
if (!cookieKeyValue) {
showStatus(connectionStatus, 'Please enter a cookie key', 'error');
return;
}
if (!sessionData.deploymentUrl) {
showStatus(connectionStatus, 'Please connect to API first to get deployment URL', 'error');
return;
}
// Check if this device already has an active cookie connection
const deviceId = selectedDevice.guid || selectedDevice.id;
if (activeCookieProxyConnections.has(deviceId)) {
showStatus(connectionStatus, `Cookie proxy already running for device ${selectedDevice.name}`, 'warning');
return;
}
// Disable button during launch
startCookieProxyBtn.disabled = true;
showStatus(connectionStatus, `Starting cookie proxy for device ${selectedDevice.name}...`, 'info');
try {
const result = await window.electronAPI.launchCookieCameraProxy({
deploymentUrl: sessionData.deploymentUrl,
cookieKey: cookieKeyValue,
deviceUuid: deviceId
});
if (result.success) {
// Track this connection
activeCookieProxyConnections.set(deviceId, {
processId: result.processId,
deviceName: selectedDevice.name,
deviceId: deviceId,
startTime: Date.now(),
type: 'cookie'
});
updateCookieProxyButtonStates();
showStatus(connectionStatus, `${result.message} (Cookie proxy active for ${selectedDevice.name})`, 'success');
} else {
showStatus(connectionStatus, `Failed to start cookie proxy: ${result.message}`, 'error');
}
} catch (error) {
console.error('Cookie proxy launch error:', error);
showStatus(connectionStatus, `Error launching cookie proxy: ${error.message}`, 'error');
} finally {
startCookieProxyBtn.disabled = false;
}
}
// Handle stop cookie proxy
async function handleStopCookieProxy() {
if (activeCookieProxyConnections.size === 0) {
showStatus(connectionStatus, 'No active cookie proxy connections found', 'warning');
return;
}
showStatus(connectionStatus, 'Stopping cookie proxy connections...', 'info');
try {
const result = await window.electronAPI.stopCameraProxy(null); // Stop all processes
if (result.success) {
activeCookieProxyConnections.clear();
showStatus(connectionStatus, 'Cookie proxy connections stopped successfully', 'success');
// Update visual indicators for all devices
const deviceItems = deviceList.querySelectorAll('.device-item');
deviceItems.forEach(item => {
item.classList.remove('cookie-proxy-active');
});
} else {
showStatus(connectionStatus, `Failed to stop cookie proxy: ${result.message}`, 'warning');
}
} catch (error) {
console.error('Stop cookie proxy error:', error);
showStatus(connectionStatus, 'Error stopping cookie proxy', 'error');
}
updateCookieProxyButtonStates();
}
// Update cookie proxy button states
function updateCookieProxyButtonStates() {
const hasSelectedDevice = selectedDevice !== null;
const hasCookieKey = cookieKey.value.trim().length > 0;
const hasActiveConnection = activeCookieProxyConnections.size > 0;
if (sessionData.isConnected && hasSelectedDevice && hasCookieKey) {
const deviceId = selectedDevice.guid || selectedDevice.id;
const isThisDeviceActive = activeCookieProxyConnections.has(deviceId);
startCookieProxyBtn.disabled = isThisDeviceActive;
stopCookieProxyBtn.disabled = !hasActiveConnection;
} else {
startCookieProxyBtn.disabled = true;
stopCookieProxyBtn.disabled = !hasActiveConnection;
}
}
// Update connection status indicator
function updateConnectionStatus(connected) {
const statusDot = statusIndicator.querySelector('.status-dot');
const statusText = statusIndicator.querySelector('.status-text');
if (connected) {
statusDot.className = 'status-dot online';
statusText.textContent = 'Connected';
} else {
statusDot.className = 'status-dot offline';
statusText.textContent = 'Disconnected';
}
}
// Update button states based on connection status
function updateButtonStates() {
if (sessionData.isConnected) {
disconnectBtn.disabled = false;
} else {
disconnectBtn.disabled = true;
startCookieProxyBtn.disabled = true;
stopCookieProxyBtn.disabled = true;
}
// Always update cookie proxy button states
updateCookieProxyButtonStates();
}
// Handle get devices (now called automatically)
async function handleGetDevices() {
if (!sessionData.isConnected) {
showStatus(deviceStatus, 'Please connect to the API first', 'error');
return;
}
showStatus(deviceStatus, 'Fetching devices...', 'info');
clearDeviceList();
try {
const result = await window.electronAPI.getDevices({
deploymentUrl: sessionData.deploymentUrl,
cookies: sessionData.cookies
});
if (result.success) {
// Filter devices to only show non-cloud cameras (localStorage = false)
const filteredDevices = result.devices.filter(device => {
// Check if device has capabilities and localStorage property
if (device.capabilities && device.capabilities.localStorage !== undefined) {
// Only show devices where localStorage is false (non-cloud cameras)
return device.capabilities.localStorage === false;
}
// If no capabilities or localStorage property, include the device (fallback)
return true;
});
const totalDevices = result.devices.length;
const filteredCount = filteredDevices.length;
const cloudDevicesHidden = totalDevices - filteredCount;
let statusMessage = `Found ${filteredCount} local camera${filteredCount !== 1 ? 's' : ''}`;
if (cloudDevicesHidden > 0) {
statusMessage += ` (${cloudDevicesHidden} cloud camera${cloudDevicesHidden !== 1 ? 's' : ''} hidden)`;
}
showStatus(deviceStatus, statusMessage, 'success');
// Store all devices for search functionality
allDevices = filteredDevices;
displayDevices(filteredDevices);
} else {
showStatus(deviceStatus, `Failed to get devices: ${result.message}`, 'error');
}
} catch (error) {
console.error('Get devices error:', error);
showStatus(deviceStatus, `Error getting devices: ${error.message}`, 'error');
}
}
// Helper function to determine device status from API data
function getDeviceStatus(device) {
// Check for live.display_status first (Alta API standard)
if (device.live && device.live.display_status) {
const status = device.live.display_status.toLowerCase();
// Handle color-based status responses from Alta API
if (status === 'green') {
return {
isOnline: true,
statusText: 'Online'
};
} else if (status === 'red') {
return {
isOnline: false,
statusText: 'Offline'
};
} else if (status === 'yellow' || status === 'orange') {
return {
isOnline: false,
statusText: 'Warning'
};
}
// Handle text-based status responses
return {
isOnline: status === 'online' || status === 'live' || status === 'connected',
statusText: status === 'online' || status === 'live' || status === 'connected' ? 'Online' : 'Offline'
};
}
// Fallback to other possible status fields
if (device.online !== undefined) {
return {
isOnline: device.online,
statusText: device.online ? 'Online' : 'Offline'
};
}
if (device.status) {
const status = device.status.toLowerCase();
// Handle color-based status in other fields
if (status === 'green') {
return {
isOnline: true,
statusText: 'Online'
};
} else if (status === 'red') {
return {
isOnline: false,
statusText: 'Offline'
};
}
return {
isOnline: status === 'online' || status === 'live' || status === 'connected',
statusText: status === 'online' || status === 'live' || status === 'connected' ? 'Online' : 'Offline'
};
}
// Default to offline if no status information available
return {
isOnline: false,
statusText: 'Offline'
};
}
// Handle device search
function handleDeviceSearch() {
const searchTerm = deviceSearch.value.toLowerCase().trim();
if (!searchTerm) {
// Show all devices if search is empty
displayDevices(allDevices);
return;
}
// Filter devices based on search term
const filteredDevices = allDevices.filter(device => {
const deviceName = (device.name || '').toLowerCase();
const deviceId = (device.guid || device.id || '').toLowerCase();
const deviceType = (device.type || '').toLowerCase();
const deviceModel = (device.model || '').toLowerCase();
const deviceIp = (device.ipAddress || '').toLowerCase();
return deviceName.includes(searchTerm) ||
deviceId.includes(searchTerm) ||
deviceType.includes(searchTerm) ||
deviceModel.includes(searchTerm) ||
deviceIp.includes(searchTerm);
});
displayDevices(filteredDevices);
}
// Display devices in the UI
function displayDevices(devices) {
clearDeviceList();
if (!devices || devices.length === 0) {
const searchTerm = deviceSearch.value.toLowerCase().trim();
const message = searchTerm ? 'No devices match your search' : 'No devices found';
deviceList.innerHTML = `<p class="no-devices">${message}</p>`;
return;
}
devices.forEach((device, index) => {
const deviceItem = document.createElement('div');
deviceItem.className = 'device-item';
deviceItem.dataset.deviceIndex = index;
deviceItem.dataset.deviceId = device.guid || device.id;
const deviceStatus = getDeviceStatus(device);
const deviceId = device.guid || device.id;
const isCookieProxyActive = activeCookieProxyConnections.has(deviceId);
// Add cookie-proxy-active class if this device has an active cookie connection
if (isCookieProxyActive) {
deviceItem.classList.add('cookie-proxy-active');
}
deviceItem.innerHTML = `
<div class="device-name">${escapeHtml(device.name || 'Unnamed Device')}</div>
<div class="device-status-dot ${deviceStatus.isOnline ? 'online' : 'offline'}"></div>
`;
// Add click handler for device selection
deviceItem.addEventListener('click', () => selectDevice(device, deviceItem));
deviceList.appendChild(deviceItem);
});
// Update cookie proxy button states after displaying devices
updateCookieProxyButtonStates();
}
// Handle device selection
function selectDevice(device, deviceElement) {
// Remove previous selection
const previousSelected = deviceList.querySelector('.device-item.selected');
if (previousSelected) {
previousSelected.classList.remove('selected');
}
// Select current device
deviceElement.classList.add('selected');
selectedDevice = device;
// Auto-populate cookie device UUID field
const uuid = device.guid || device.id || '';
cookieDeviceUUID.value = uuid;
// Show device selection feedback with connection status
if (uuid) {
const isCookieActive = activeCookieProxyConnections.has(uuid);
const cookieStatusText = isCookieActive ? ' (COOKIE PROXY ACTIVE)' : '';
showStatus(connectionStatus, `Selected device: ${device.name || 'Unnamed Device'} (UUID: ${uuid})${cookieStatusText}`, 'info');
}
updateCookieProxyButtonStates();
}
// Utility functions
function showStatus(element, message, type) {
element.textContent = message;
element.className = `status-message ${type}`;
element.style.display = 'block';
}
function clearDeviceList() {
deviceList.innerHTML = '<p class="placeholder-text">Connect to API to load devices</p>';
selectedDevice = null;
}
function escapeHtml(text) {
if (typeof text !== 'string') return text;
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// --- Self-Update Functions ---
async function handleCheckForUpdates() {
checkUpdateBtn.disabled = true;
try {
const result = await window.electronAPI.checkForUpdates();
if (!result.success) {
showStatus(connectionStatus, result.message || 'Failed to check for updates', 'error');
return;
}
if (result.message === 'No releases available yet') {
showStatus(connectionStatus, 'No releases available yet', 'info');
return;
}
if (result.updateAvailable) {
showUpdateModal(result);
} else {
showStatus(connectionStatus, `You're on the latest version (v${result.currentVersion})`, 'success');
}
} catch (error) {
console.error('Check for updates error:', error);
showStatus(connectionStatus, 'Could not check for updates. Check your internet connection.', 'error');
} finally {
checkUpdateBtn.disabled = false;
}
}
function showUpdateModal(updateInfo) {
pendingUpdateInfo = updateInfo;
updateModalMessage.textContent = `A new version is available: v${updateInfo.latestVersion} (current: v${updateInfo.currentVersion})`;
updateModalNotes.textContent = updateInfo.releaseNotes || '';
// Reset progress state
updateProgressContainer.style.display = 'none';
updateProgressFill.style.width = '0%';
updateProgressText.textContent = '0%';
// Reset button states
updateInstallBtn.disabled = !updateInfo.downloadUrl;
updateLaterBtn.disabled = false;
updateInstallBtn.textContent = 'Install Update';
if (!updateInfo.downloadUrl) {
updateModalMessage.textContent += '\n(No downloadable asset found for this release)';
}
updateModalOverlay.style.display = 'flex';
}
async function handleInstallUpdate() {
if (!pendingUpdateInfo || !pendingUpdateInfo.downloadUrl) return;
// Disable controls during download
updateInstallBtn.disabled = true;
updateInstallBtn.textContent = 'Downloading...';
updateLaterBtn.disabled = true;
updateModalCloseBtn.style.display = 'none';
updateProgressContainer.style.display = 'flex';
try {
const result = await window.electronAPI.downloadAndInstallUpdate({
downloadUrl: pendingUpdateInfo.downloadUrl
});
if (result.success) {
updateInstallBtn.textContent = 'Restarting...';
updateProgressFill.style.width = '100%';
updateProgressText.textContent = '100%';
} else {
showStatus(connectionStatus, `Update failed: ${result.message}`, 'error');
closeUpdateModal();
}
} catch (error) {
console.error('Install update error:', error);
showStatus(connectionStatus, 'Update failed. Please try again.', 'error');
closeUpdateModal();
}
}
function closeUpdateModal() {
updateModalOverlay.style.display = 'none';
pendingUpdateInfo = null;
updateProgressContainer.style.display = 'none';
updateProgressFill.style.width = '0%';
updateProgressText.textContent = '0%';
updateInstallBtn.textContent = 'Install Update';
updateInstallBtn.disabled = false;
updateLaterBtn.disabled = false;
updateModalCloseBtn.style.display = '';
}
async function checkForUpdatesOnStartup() {
try {
const result = await window.electronAPI.checkForUpdates();
if (result.success && result.updateAvailable) {
checkUpdateBtn.classList.add('update-available');
checkUpdateBtn.title = `Update available: v${result.latestVersion}`;
showStatus(connectionStatus, `Update available: v${result.latestVersion}`, 'info');
// Store for quick modal open
pendingUpdateInfo = result;
}
} catch (error) {
// Silent fail on startup check
console.log('Startup update check failed:', error.message);
}
}
// Handle cookie received from Chrome extension via local HTTP bridge
async function handleExtensionCookie(data) {
const { deploymentUrl, cookies, cookieValue } = data;
// If already connected, disconnect first
if (sessionData.isConnected) {
handleDisconnect();
}
// Set session state from extension cookie
sessionData.deploymentUrl = deploymentUrl;
sessionData.cookies = cookies;
sessionData.isConnected = true;
showStatus(connectionStatus, `Connected via Chrome extension to ${deploymentUrl}`, 'success');
updateConnectionStatus(true);
updateButtonStates();
// Auto-populate cookie key
cookieKey.value = cookieValue;
updateCookieProxyButtonStates();
// Fetch devices
try {
await handleGetDevices();
} catch (err) {
console.error('Failed to fetch devices after extension cookie:', err);
showStatus(deviceStatus, 'Connected, but failed to load devices.', 'warning');
}
}
// Initialize the app
document.addEventListener('DOMContentLoaded', async () => {
console.log('Alta Video Camera Proxy loaded');
// Initialize connection status
updateConnectionStatus(false);
updateButtonStates();
// Listen for cookies pushed from Chrome extension
window.electronAPI.onExtensionCookie(handleExtensionCookie);
// Listen for update download progress
window.electronAPI.onUpdateDownloadProgress((data) => {
updateProgressFill.style.width = `${data.percent}%`;
updateProgressText.textContent = `${data.percent}%`;
});
// Auto-check for updates after 2 seconds
setTimeout(checkForUpdatesOnStartup, 2000);
});
+643
View File
@@ -0,0 +1,643 @@
/* 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;
}
/* 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-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;
display: flex;
align-items: center;
justify-content: space-between;
}
.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;
}
/* 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;
}
/* 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);
}
/* 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;
}
/* Update Button */
.btn-update {
display: flex;
align-items: center;
gap: 6px;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--button-outline);
padding: 6px 12px;
font-size: 12px;
font-weight: bold;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
position: relative;
}
.btn-update:hover:not(:disabled) {
color: var(--text-primary);
border-color: var(--accent-primary);
}
.btn-update:hover:not(:disabled) .update-icon {
animation: spin 0.6s ease;
}
.btn-update:disabled .update-icon {
animation: spin 1s linear infinite;
}
.btn-update.update-available {
color: var(--success);
border-color: var(--success);
box-shadow: 0 0 8px rgba(76, 175, 80, 0.3);
}
.btn-update.update-available::after {
content: '';
position: absolute;
top: -3px;
right: -3px;
width: 8px;
height: 8px;
background: var(--success);
border-radius: 50%;
box-shadow: 0 0 6px rgba(76, 175, 80, 0.8);
}
.update-icon {
font-size: 14px;
display: inline-block;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Update Modal */
.update-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.update-modal-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
width: 480px;
max-width: 90%;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.update-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.update-modal-header h3 {
font-size: 16px;
color: var(--text-primary);
margin: 0;
}
.update-modal-close {
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 20px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.update-modal-close:hover {
color: var(--text-primary);
}
.update-modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.update-modal-message {
font-size: 14px;
color: var(--text-primary);
margin: 0 0 12px 0;
}
.update-modal-notes {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
padding: 12px;
background: var(--bg-primary);
border-radius: 4px;
border: 1px solid var(--border);
}
.update-modal-notes:empty {
display: none;
}
.update-progress-container {
margin-top: 16px;
display: flex;
align-items: center;
gap: 12px;
}
.update-progress-track {
flex: 1;
height: 8px;
background: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--border);
}
.update-progress-fill {
height: 100%;
background: var(--accent-primary);
border-radius: 4px;
transition: width 0.3s ease;
}
.update-progress-text {
font-size: 12px;
font-weight: bold;
color: var(--text-secondary);
min-width: 36px;
text-align: right;
}
.update-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--border);
}
/* Responsive Design */
@media (max-width: 768px) {
.devices-sidebar {
width: 150px;
}
.main-content {
padding: 16px;
}
.connection-controls,
.proxy-buttons {
flex-direction: column;
gap: 8px;
}
}
/* 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;
}
}