Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64f162440f | |||
| e30e0395d3 | |||
| d1013c87a1 | |||
| c3c3fc83e3 | |||
| 14ce5c728d | |||
| 60f56a2dea | |||
| 099cc252fe | |||
| 3a6be50e31 | |||
| 7309d78344 | |||
| cccb20bd31 | |||
| 632d5c749a | |||
| c9b466b0d1 | |||
| f57183448f | |||
| 0f12915b47 | |||
| ec5989cf02 | |||
| 67437a0c46 | |||
| e813607f63 |
@@ -0,0 +1,37 @@
|
|||||||
|
name: Deploy GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- 'docs/**'
|
||||||
|
- '.github/workflows/deploy-pages.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Configure Pages
|
||||||
|
uses: actions/configure-pages@v5
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: docs
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
@@ -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
|
||||||
@@ -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/`.
|
||||||
@@ -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.
|
||||||
|
After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,72 @@
|
|||||||
|
# build-kit.ps1 — Build the Alta Proxy Tool Kit zip for distribution
|
||||||
|
# Usage: powershell -ExecutionPolicy Bypass -File build-kit.ps1
|
||||||
|
#
|
||||||
|
# Produces: dist/AltaProxyToolKit.zip containing:
|
||||||
|
# - AltaCameraProxy-<version>-portable.exe
|
||||||
|
# - aware-cam-proxy.exe
|
||||||
|
# - chrome-extension/ (folder)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Read version from package.json
|
||||||
|
$pkg = Get-Content "package.json" -Raw | ConvertFrom-Json
|
||||||
|
$version = $pkg.version
|
||||||
|
Write-Host "Building Alta Proxy Tool Kit v$version" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Step 1: Build the Electron portable exe
|
||||||
|
Write-Host "`n[1/4] Building Electron app..." -ForegroundColor Yellow
|
||||||
|
npm run build
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "Build failed!" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$portableExe = "dist\AltaCameraProxy-$version-portable.exe"
|
||||||
|
if (-not (Test-Path $portableExe)) {
|
||||||
|
Write-Host "Expected output not found: $portableExe" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host " Built: $portableExe" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Step 2: Verify aware-cam-proxy.exe exists
|
||||||
|
Write-Host "`n[2/4] Checking for aware-cam-proxy.exe..." -ForegroundColor Yellow
|
||||||
|
if (-not (Test-Path "aware-cam-proxy.exe")) {
|
||||||
|
Write-Host "aware-cam-proxy.exe not found in project root!" -ForegroundColor Red
|
||||||
|
Write-Host "Place aware-cam-proxy.exe in the project root and try again." -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host " Found: aware-cam-proxy.exe" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Step 3: Verify chrome-extension folder exists
|
||||||
|
Write-Host "`n[3/4] Checking for chrome-extension/..." -ForegroundColor Yellow
|
||||||
|
if (-not (Test-Path "chrome-extension\manifest.json")) {
|
||||||
|
Write-Host "chrome-extension/ folder not found or missing manifest.json!" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host " Found: chrome-extension/" -ForegroundColor Green
|
||||||
|
|
||||||
|
# Step 4: Create the kit zip
|
||||||
|
Write-Host "`n[4/4] Creating AltaProxyToolKit.zip..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$kitDir = "dist\AltaProxyToolKit"
|
||||||
|
$zipPath = "dist\AltaProxyToolKit.zip"
|
||||||
|
|
||||||
|
# Clean previous kit
|
||||||
|
if (Test-Path $kitDir) { Remove-Item $kitDir -Recurse -Force }
|
||||||
|
if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
|
||||||
|
|
||||||
|
# Assemble kit contents
|
||||||
|
New-Item -ItemType Directory -Path $kitDir | Out-Null
|
||||||
|
Copy-Item $portableExe "$kitDir\AltaCameraProxy.exe"
|
||||||
|
Copy-Item "aware-cam-proxy.exe" "$kitDir\aware-cam-proxy.exe"
|
||||||
|
Copy-Item "chrome-extension" "$kitDir\chrome-extension" -Recurse
|
||||||
|
|
||||||
|
# Create zip
|
||||||
|
Compress-Archive -Path "$kitDir\*" -DestinationPath $zipPath -Force
|
||||||
|
|
||||||
|
# Clean up temp directory
|
||||||
|
Remove-Item $kitDir -Recurse -Force
|
||||||
|
|
||||||
|
$zipSize = [math]::Round((Get-Item $zipPath).Length / 1MB, 1)
|
||||||
|
Write-Host "`nKit ready: $zipPath ($zipSize MB)" -ForegroundColor Green
|
||||||
|
Write-Host "Upload this file as a release asset named 'AltaProxyToolKit.zip'" -ForegroundColor Cyan
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 675 B |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 31 KiB |
@@ -0,0 +1,341 @@
|
|||||||
|
<!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>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="header-brand">
|
||||||
|
<img src="icon.png" alt="Alta Proxy Tool">
|
||||||
|
<span>Alta Proxy Tool</span>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,341 +1,110 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<!-- GitHub Pages rebuild marker: 2026-05-22 -->
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Alta Proxy Tool</title>
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;">
|
||||||
<style>
|
<title>Alta Video Camera Proxy with API</title>
|
||||||
:root {
|
<link rel="stylesheet" href="styles.css">
|
||||||
--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>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
<header class="header">
|
<!-- Main Content Layout -->
|
||||||
<div class="container">
|
<div class="main-layout">
|
||||||
<div class="header-brand">
|
<!-- Left Sidebar - Available Devices -->
|
||||||
<img src="icon.png" alt="Alta Proxy Tool">
|
<aside class="devices-sidebar">
|
||||||
<span>Alta Proxy Tool</span>
|
<div class="sidebar-header">
|
||||||
</div>
|
<h2>Available Devices</h2>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<section class="setup">
|
<div id="deviceStatus" class="status-message"></div>
|
||||||
<h2>Setup</h2>
|
|
||||||
<ol class="setup-steps">
|
<!-- Device Search -->
|
||||||
<li>Extract the zip to any folder</li>
|
<div class="device-search-container">
|
||||||
<li>Load <code>chrome-extension/</code> in Chrome via <code>chrome://extensions</code> (Developer mode)</li>
|
<input type="text" id="deviceSearch" placeholder="Search devices..." class="device-search-input">
|
||||||
<li>Log into your Alta deployment in Chrome</li>
|
</div>
|
||||||
<li>Click the extension icon and send cookies to the app</li>
|
|
||||||
<li>Run <strong>AltaCameraProxy.exe</strong> and start proxying</li>
|
<div class="device-list-container">
|
||||||
</ol>
|
<div id="deviceList" class="device-list">
|
||||||
<p class="setup-note">Requires Windows 10+, Google Chrome, and an Avigilon Alta account.</p>
|
<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">↻</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>
|
</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>
|
||||||
|
<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>
|
</main>
|
||||||
|
|
||||||
<footer class="footer">
|
|
||||||
<div class="container">
|
|
||||||
<a href="https://github.com/PageZ948/Alta-Proxy-Tool">Alta Proxy Tool on GitHub</a>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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">×</button>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="renderer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,631 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
// Get the directory where the user-facing executable resides.
|
||||||
|
// In portable builds, __dirname points to a temp extraction directory,
|
||||||
|
// so we use the actual .exe location instead.
|
||||||
|
function getAppDirectory() {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
return path.dirname(process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe'));
|
||||||
|
}
|
||||||
|
return __dirname;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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-device-sites', async (event, { deploymentUrl, cookies }) => {
|
||||||
|
try {
|
||||||
|
const sitesUrl = `${deploymentUrl}/api/v1/deviceSites`;
|
||||||
|
|
||||||
|
const axiosInstance = axios.create({
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Cookie': cookies ? cookies.join('; ') : ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await axiosInstance.get(sitesUrl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
sites: response.data
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get device sites error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
sites: [],
|
||||||
|
message: error.response?.data?.message || error.message || 'Failed to get device sites'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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(getAppDirectory(), '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 = getAppDirectory();
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"build-kit": "powershell -ExecutionPolicy Bypass -File build-kit.ps1",
|
||||||
|
"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",
|
||||||
|
"signAndEditExecutable": false
|
||||||
|
},
|
||||||
|
"portable": {
|
||||||
|
"artifactName": "AltaCameraProxy-${version}-portable.exe"
|
||||||
|
},
|
||||||
|
"forceCodeSigning": 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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),
|
||||||
|
getDeviceSites: (params) => ipcRenderer.invoke('api-get-device-sites', 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,764 @@
|
|||||||
|
// 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 allSites = {}; // Store site id → site name mapping
|
||||||
|
let collapsedSites = new Set(); // Track which site groups are collapsed
|
||||||
|
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
|
||||||
|
allSites = {}; // Clear site data
|
||||||
|
collapsedSites.clear();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch device sites and build id → name map
|
||||||
|
async function fetchDeviceSites() {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.getDeviceSites({
|
||||||
|
deploymentUrl: sessionData.deploymentUrl,
|
||||||
|
cookies: sessionData.cookies
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && Array.isArray(result.sites)) {
|
||||||
|
allSites = {};
|
||||||
|
result.sites.forEach(site => {
|
||||||
|
if (site.id) {
|
||||||
|
allSites[site.id] = site.name || 'Unnamed Site';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Could not fetch device sites:', error.message);
|
||||||
|
allSites = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// Fetch devices and sites in parallel
|
||||||
|
const [devicesResult] = await Promise.all([
|
||||||
|
window.electronAPI.getDevices({
|
||||||
|
deploymentUrl: sessionData.deploymentUrl,
|
||||||
|
cookies: sessionData.cookies
|
||||||
|
}),
|
||||||
|
fetchDeviceSites()
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (devicesResult.success) {
|
||||||
|
// Filter devices to only show non-cloud cameras (localStorage = false)
|
||||||
|
const filteredDevices = devicesResult.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 = devicesResult.devices.length;
|
||||||
|
const filteredCount = filteredDevices.length;
|
||||||
|
const cloudDevicesHidden = totalDevices - filteredCount;
|
||||||
|
|
||||||
|
const siteCount = Object.keys(allSites).length;
|
||||||
|
let statusMessage = `Found ${filteredCount} local camera${filteredCount !== 1 ? 's' : ''}`;
|
||||||
|
if (siteCount > 0) {
|
||||||
|
statusMessage += ` across ${siteCount} site${siteCount !== 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: ${devicesResult.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 (includes site name)
|
||||||
|
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();
|
||||||
|
const siteName = (device.server_group_id && allSites[device.server_group_id] || '').toLowerCase();
|
||||||
|
|
||||||
|
return deviceName.includes(searchTerm) ||
|
||||||
|
deviceId.includes(searchTerm) ||
|
||||||
|
deviceType.includes(searchTerm) ||
|
||||||
|
deviceModel.includes(searchTerm) ||
|
||||||
|
deviceIp.includes(searchTerm) ||
|
||||||
|
siteName.includes(searchTerm);
|
||||||
|
});
|
||||||
|
|
||||||
|
displayDevices(filteredDevices);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group devices by their site using server_group_id → allSites mapping
|
||||||
|
function groupDevicesBySite(devices) {
|
||||||
|
const groups = {};
|
||||||
|
const ungrouped = [];
|
||||||
|
|
||||||
|
devices.forEach(device => {
|
||||||
|
const siteId = device.server_group_id;
|
||||||
|
const siteName = siteId && allSites[siteId] ? allSites[siteId] : null;
|
||||||
|
|
||||||
|
if (siteName) {
|
||||||
|
if (!groups[siteId]) {
|
||||||
|
groups[siteId] = { name: siteName, devices: [] };
|
||||||
|
}
|
||||||
|
groups[siteId].devices.push(device);
|
||||||
|
} else {
|
||||||
|
ungrouped.push(device);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { groups, ungrouped };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a device item DOM element
|
||||||
|
function createDeviceItem(device, index) {
|
||||||
|
const deviceItem = document.createElement('div');
|
||||||
|
deviceItem.className = 'device-item';
|
||||||
|
deviceItem.dataset.deviceIndex = index;
|
||||||
|
deviceItem.dataset.deviceId = device.guid || device.id;
|
||||||
|
|
||||||
|
const status = getDeviceStatus(device);
|
||||||
|
const deviceId = device.guid || device.id;
|
||||||
|
const isCookieProxyActive = activeCookieProxyConnections.has(deviceId);
|
||||||
|
|
||||||
|
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 ${status.isOnline ? 'online' : 'offline'}"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
deviceItem.addEventListener('click', () => selectDevice(device, deviceItem));
|
||||||
|
return deviceItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display devices in the UI, grouped by site
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSites = Object.keys(allSites).length > 0;
|
||||||
|
|
||||||
|
// If no sites were fetched, fall back to flat list
|
||||||
|
if (!hasSites) {
|
||||||
|
devices.forEach((device, index) => {
|
||||||
|
deviceList.appendChild(createDeviceItem(device, index));
|
||||||
|
});
|
||||||
|
updateCookieProxyButtonStates();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { groups, ungrouped } = groupDevicesBySite(devices);
|
||||||
|
|
||||||
|
// Sort site groups alphabetically by name
|
||||||
|
const sortedSiteIds = Object.keys(groups).sort((a, b) =>
|
||||||
|
groups[a].name.localeCompare(groups[b].name)
|
||||||
|
);
|
||||||
|
|
||||||
|
let globalIndex = 0;
|
||||||
|
|
||||||
|
sortedSiteIds.forEach(siteId => {
|
||||||
|
const group = groups[siteId];
|
||||||
|
const isCollapsed = collapsedSites.has(siteId);
|
||||||
|
|
||||||
|
// Site header
|
||||||
|
const siteHeader = document.createElement('div');
|
||||||
|
siteHeader.className = 'site-group-header' + (isCollapsed ? ' collapsed' : '');
|
||||||
|
siteHeader.innerHTML = `
|
||||||
|
<span class="site-group-arrow">${isCollapsed ? '\u25B6' : '\u25BC'}</span>
|
||||||
|
<span class="site-group-name">${escapeHtml(group.name)}</span>
|
||||||
|
<span class="site-group-count">${group.devices.length}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
siteHeader.addEventListener('click', () => {
|
||||||
|
if (collapsedSites.has(siteId)) {
|
||||||
|
collapsedSites.delete(siteId);
|
||||||
|
} else {
|
||||||
|
collapsedSites.add(siteId);
|
||||||
|
}
|
||||||
|
// Re-render with current search filter
|
||||||
|
const searchTerm = deviceSearch.value.toLowerCase().trim();
|
||||||
|
if (searchTerm) {
|
||||||
|
handleDeviceSearch();
|
||||||
|
} else {
|
||||||
|
displayDevices(allDevices);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
deviceList.appendChild(siteHeader);
|
||||||
|
|
||||||
|
// Device items (hidden if collapsed)
|
||||||
|
if (!isCollapsed) {
|
||||||
|
group.devices.forEach(device => {
|
||||||
|
deviceList.appendChild(createDeviceItem(device, globalIndex++));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ungrouped devices at the bottom
|
||||||
|
if (ungrouped.length > 0) {
|
||||||
|
if (sortedSiteIds.length > 0) {
|
||||||
|
const ungroupedHeader = document.createElement('div');
|
||||||
|
ungroupedHeader.className = 'site-group-header' + (collapsedSites.has('__ungrouped') ? ' collapsed' : '');
|
||||||
|
ungroupedHeader.innerHTML = `
|
||||||
|
<span class="site-group-arrow">${collapsedSites.has('__ungrouped') ? '\u25B6' : '\u25BC'}</span>
|
||||||
|
<span class="site-group-name">Ungrouped</span>
|
||||||
|
<span class="site-group-count">${ungrouped.length}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
ungroupedHeader.addEventListener('click', () => {
|
||||||
|
if (collapsedSites.has('__ungrouped')) {
|
||||||
|
collapsedSites.delete('__ungrouped');
|
||||||
|
} else {
|
||||||
|
collapsedSites.add('__ungrouped');
|
||||||
|
}
|
||||||
|
const searchTerm = deviceSearch.value.toLowerCase().trim();
|
||||||
|
if (searchTerm) {
|
||||||
|
handleDeviceSearch();
|
||||||
|
} else {
|
||||||
|
displayDevices(allDevices);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
deviceList.appendChild(ungroupedHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!collapsedSites.has('__ungrouped') || sortedSiteIds.length === 0) {
|
||||||
|
ungrouped.forEach(device => {
|
||||||
|
deviceList.appendChild(createDeviceItem(device, globalIndex++));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,690 @@
|
|||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Site Group Headers */
|
||||||
|
.site-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-group-header:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-group-arrow {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-group-name {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-group-count {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||