feat: publish Alta Local Camera Discovery
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
SECURITY-REVIEW.md
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Peji
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Alta Local Camera Discovery
|
||||||
|
|
||||||
|
Standalone Electron utility for discovering Alta cloud-native cameras on the technician's current LAN. It listens for `_vaioncam._tcp.local` mDNS announcements and reports each camera's Alta Key, IP address, and hostname Web UI URL without Alta credentials or camera configuration changes.
|
||||||
|
|
||||||
|
The original Python web prototype remains available in `app.py`; the Electron app performs discovery directly in its main process through a narrow, isolated preload bridge. `src/scan-session.js` owns the single-flight scan lifecycle, defensive result snapshot, and latest-scan camera URL authorization so Electron bootstrap code remains small and testable.
|
||||||
|
|
||||||
|
## Run the Electron app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use
|
||||||
|
|
||||||
|
1. Connect the computer to the same LAN/VLAN as the cameras.
|
||||||
|
2. Select **Start discovery**.
|
||||||
|
3. Copy an individual Alta Key or select **Export CSV**.
|
||||||
|
4. Select a camera hostname to open its local HTTPS Web UI.
|
||||||
|
|
||||||
|
The CSV filename is `alta-camera-keys.csv` and its columns are:
|
||||||
|
|
||||||
|
```csv
|
||||||
|
Alta Key,IP Address,Hostname URL
|
||||||
|
```
|
||||||
|
|
||||||
|
## Download
|
||||||
|
|
||||||
|
The current Windows x64 portable release is available from GitPeji:
|
||||||
|
|
||||||
|
- [Download Alta Local Camera Discovery for Windows](https://git.pejicorp.com/peji/AltaNetowrkScanner/releases/download/v1.0.0/AltaLocalCameraDiscovery-1.0.0-portable.exe)
|
||||||
|
- [View source and release notes](https://git.pejicorp.com/peji/AltaNetowrkScanner)
|
||||||
|
|
||||||
|
No installer or Alta credentials are required. The current Windows release is unsigned and may trigger Microsoft Defender SmartScreen; verify its SHA-256 against the checksum published with the release.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:win # Windows x64 portable EXE
|
||||||
|
npm run build:linux # Linux x64 AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
Artifacts are written under `dist/`. The Electron app icon is `assets/icon.png` / `assets/icon.ico`: the approved radar artwork in the Alta colourway with the Alta A layered above it. Release artifacts are built from clean, tagged source revisions and are not committed to the repository.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
npm run scan
|
||||||
|
npm run build-test
|
||||||
|
npm run build-test:win
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run scan` exercises the same Node mDNS engine used by Electron. The legacy Python scanner can still be checked with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m unittest -v test_app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security and limits
|
||||||
|
|
||||||
|
- Renderer Node integration is disabled; context isolation and sandboxing are enabled.
|
||||||
|
- The UI is served from an allowlisted `app://` protocol; arbitrary local files and renderer permissions are denied.
|
||||||
|
- IPC accepts only the app's main frame at the exact packaged application URL.
|
||||||
|
- Discovery requires matching Alta Key/hostname, HTTPS port 443, and a valid private or link-local address; scans are concurrency- and result-bounded.
|
||||||
|
- External navigation is denied except a root HTTPS camera URL from the latest successful scan.
|
||||||
|
- CSV content is generated in the main process and written only through a native save dialog.
|
||||||
|
- Release builds are configured to disable Electron's Node/inspection/file-privilege fuses and enable ASAR integrity enforcement.
|
||||||
|
- mDNS normally does not cross VLANs or routed boundaries.
|
||||||
|
- mDNS advertisements are not cryptographically authenticated; results are correlated and constrained local announcements, not proof of device identity.
|
||||||
|
- Only powered, reachable cameras advertising `_vaioncam._tcp.local` are shown.
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
Alta Local Camera Discovery does not request or store Alta credentials. It listens for local `_vaioncam._tcp.local` mDNS announcements, validates expected Alta camera record structure, and restricts privileged Electron actions to a narrow main-process policy.
|
||||||
|
|
||||||
|
## Trust model
|
||||||
|
|
||||||
|
- mDNS is unauthenticated LAN input. The app correlates service identity, camera key, hostname, port, and local address, but cannot cryptographically prove that an advertiser is a genuine Alta camera.
|
||||||
|
- Camera Web UI links are HTTPS root URLs authorised only by the latest successful scan.
|
||||||
|
- The Electron renderer is sandboxed, context-isolated, denied general permissions, and served through an allowlisted `app://` protocol.
|
||||||
|
- CSV generation and filesystem writes remain in the main process.
|
||||||
|
|
||||||
|
## Release status
|
||||||
|
|
||||||
|
Release assets are produced from tagged GitPeji commits with a locked dependency graph and published SHA-256 checksums. Windows artifacts are currently unsigned internal releases and may trigger Microsoft Defender SmartScreen.
|
||||||
|
|
||||||
|
Report suspected issues through the repository issue tracker or directly to the internal tool owner. Do not include credentials, camera exports, or customer data in reports.
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from zeroconf import ServiceBrowser, ServiceInfo, ServiceListener, Zeroconf
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
SERVICE_TYPE = "_vaioncam._tcp.local."
|
||||||
|
KEY_PATTERN = re.compile(r"^[A-Z0-9]{4}(?:-[A-Z0-9]{4}){2}$")
|
||||||
|
|
||||||
|
|
||||||
|
def camera_from_service(name: str, hostname: str, addresses: list[str], port: int):
|
||||||
|
suffix = "." + SERVICE_TYPE
|
||||||
|
if not name.lower().endswith(suffix.lower()):
|
||||||
|
return None
|
||||||
|
alta_key = name[: -len(suffix)].upper()
|
||||||
|
if not KEY_PATTERN.fullmatch(alta_key):
|
||||||
|
return None
|
||||||
|
ipv4 = next((address for address in addresses if ":" not in address), None)
|
||||||
|
hostname = (hostname or "").rstrip(".")
|
||||||
|
return {
|
||||||
|
"alta_key": alta_key,
|
||||||
|
"hostname": hostname,
|
||||||
|
"web_ui_url": f"https://{hostname}" if hostname else "",
|
||||||
|
"ip_address": ipv4 or (addresses[0] if addresses else ""),
|
||||||
|
"port": port,
|
||||||
|
"service": SERVICE_TYPE.rstrip("."),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_results(rows):
|
||||||
|
unique = {}
|
||||||
|
for row in rows:
|
||||||
|
unique[(row["alta_key"], row.get("ip_address", ""))] = row
|
||||||
|
return sorted(unique.values(), key=lambda row: (row["alta_key"], row.get("ip_address", "")))
|
||||||
|
|
||||||
|
|
||||||
|
class CameraListener(ServiceListener):
|
||||||
|
def __init__(self):
|
||||||
|
self.rows = []
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
|
def _capture(self, zeroconf: Zeroconf, service_type: str, name: str):
|
||||||
|
info: ServiceInfo | None = zeroconf.get_service_info(service_type, name, timeout=2500)
|
||||||
|
if not info:
|
||||||
|
return
|
||||||
|
row = camera_from_service(name, info.server or "", info.parsed_addresses(), info.port)
|
||||||
|
if row:
|
||||||
|
with self.lock:
|
||||||
|
self.rows.append(row)
|
||||||
|
|
||||||
|
def add_service(self, zeroconf, service_type, name):
|
||||||
|
self._capture(zeroconf, service_type, name)
|
||||||
|
|
||||||
|
def update_service(self, zeroconf, service_type, name):
|
||||||
|
self._capture(zeroconf, service_type, name)
|
||||||
|
|
||||||
|
def remove_service(self, zeroconf, service_type, name):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def scan_cameras(timeout: float = 5.0):
|
||||||
|
listener = CameraListener()
|
||||||
|
zeroconf = Zeroconf()
|
||||||
|
browser = ServiceBrowser(zeroconf, SERVICE_TYPE, listener)
|
||||||
|
try:
|
||||||
|
time.sleep(timeout)
|
||||||
|
with listener.lock:
|
||||||
|
cameras = normalize_results(listener.rows)
|
||||||
|
finally:
|
||||||
|
browser.cancel()
|
||||||
|
zeroconf.close()
|
||||||
|
return cameras
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, directory=str(ROOT / "static"), **kwargs)
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
print(f"{self.client_address[0]} - {fmt % args}")
|
||||||
|
|
||||||
|
def _json(self, payload, status=HTTPStatus.OK):
|
||||||
|
body = json.dumps(payload).encode()
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.send_header("X-Frame-Options", "DENY")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path == "/api/health":
|
||||||
|
return self._json({"ok": True, "service": "Alta Camera Finder"})
|
||||||
|
if self.path == "/api/scan":
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
cameras = scan_cameras()
|
||||||
|
return self._json({
|
||||||
|
"ok": True,
|
||||||
|
"count": len(cameras),
|
||||||
|
"duration_seconds": round(time.monotonic() - started, 1),
|
||||||
|
"cameras": cameras,
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
return self._json({"ok": False, "error": str(exc)}, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
if self.path == "/":
|
||||||
|
self.path = "/index.html"
|
||||||
|
return super().do_GET()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Discover Alta cloud-native cameras using mDNS")
|
||||||
|
parser.add_argument("--host", default="0.0.0.0")
|
||||||
|
parser.add_argument("--port", type=int, default=8787)
|
||||||
|
parser.add_argument("--scan", action="store_true", help="scan once and print JSON")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.scan:
|
||||||
|
print(json.dumps(scan_cameras(), indent=2))
|
||||||
|
return
|
||||||
|
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||||
|
print(f"Alta Camera Finder listening on http://{args.host}:{args.port}", flush=True)
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
@@ -0,0 +1,115 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { app, BrowserWindow, clipboard, dialog, ipcMain, net, protocol, shell } = require('electron');
|
||||||
|
const fs = require('node:fs/promises');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { pathToFileURL } = require('node:url');
|
||||||
|
const { buildCsv } = require('./static/export-utils');
|
||||||
|
const { APP_RENDERER_URL, resolveAppAssetPath } = require('./src/app-protocol');
|
||||||
|
const { KEY_PATTERN } = require('./src/discovery');
|
||||||
|
const { createScanSession } = require('./src/scan-session');
|
||||||
|
|
||||||
|
protocol.registerSchemesAsPrivileged([{
|
||||||
|
scheme: 'app',
|
||||||
|
privileges: { standard: true, secure: true, supportFetchAPI: true },
|
||||||
|
}]);
|
||||||
|
|
||||||
|
let mainWindow = null;
|
||||||
|
const scanSession = createScanSession();
|
||||||
|
|
||||||
|
function isTrustedSender(event) {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed() || !event || !event.senderFrame) return false;
|
||||||
|
return event.sender === mainWindow.webContents &&
|
||||||
|
event.senderFrame === mainWindow.webContents.mainFrame &&
|
||||||
|
event.senderFrame.url === APP_RENDERER_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerIpc(channel, handler) {
|
||||||
|
ipcMain.handle(channel, async (event, ...args) => {
|
||||||
|
if (!isTrustedSender(event)) throw new Error('Forbidden IPC sender');
|
||||||
|
return handler(...args);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCameraUrl(value) {
|
||||||
|
const url = scanSession.allowedCameraUrl(value);
|
||||||
|
if (!url) return;
|
||||||
|
await shell.openExternal(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerIpcHandlers() {
|
||||||
|
registerIpc('scan-cameras', scanSession.scanOnce);
|
||||||
|
registerIpc('copy-text', (value) => {
|
||||||
|
const text = String(value || '');
|
||||||
|
if (!KEY_PATTERN.test(text)) throw new Error('Invalid Alta Key.');
|
||||||
|
clipboard.writeText(text);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
registerIpc('export-csv', async () => {
|
||||||
|
const csv = buildCsv(scanSession.getLatestCameras());
|
||||||
|
if (Buffer.byteLength(csv, 'utf8') > 1024 * 1024) throw new Error('CSV export is too large.');
|
||||||
|
const result = await dialog.showSaveDialog(mainWindow, {
|
||||||
|
title: 'Export discovered Alta cameras',
|
||||||
|
defaultPath: 'alta-camera-keys.csv',
|
||||||
|
filters: [{ name: 'CSV file', extensions: ['csv'] }],
|
||||||
|
});
|
||||||
|
if (result.canceled || !result.filePath) return { ok: false, canceled: true };
|
||||||
|
await fs.writeFile(result.filePath, csv, 'utf8');
|
||||||
|
return { ok: true, canceled: false };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerAppProtocol() {
|
||||||
|
const staticDir = path.join(__dirname, 'static');
|
||||||
|
protocol.handle('app', (request) => {
|
||||||
|
const assetPath = resolveAppAssetPath(request.url, staticDir);
|
||||||
|
if (!assetPath) return new Response('Not found', { status: 404 });
|
||||||
|
return net.fetch(pathToFileURL(assetPath).href);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function denyRendererPermissions(targetSession) {
|
||||||
|
targetSession.setPermissionCheckHandler(() => false);
|
||||||
|
targetSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1180,
|
||||||
|
height: 760,
|
||||||
|
minWidth: 820,
|
||||||
|
minHeight: 620,
|
||||||
|
backgroundColor: '#f5f6f8',
|
||||||
|
icon: path.join(__dirname, 'assets', 'icon.png'),
|
||||||
|
title: 'Alta Local Camera Discovery',
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
denyRendererPermissions(mainWindow.webContents.session);
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
openCameraUrl(url).catch(() => {});
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
mainWindow.webContents.on('will-navigate', (event) => event.preventDefault());
|
||||||
|
mainWindow.loadURL(APP_RENDERER_URL);
|
||||||
|
if (!app.isPackaged && process.argv.includes('--dev')) mainWindow.webContents.openDevTools();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
registerAppProtocol();
|
||||||
|
registerIpcHandlers();
|
||||||
|
createWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit();
|
||||||
|
});
|
||||||
Generated
+3647
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"name": "alta-local-camera-discovery",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Standalone local discovery utility for Alta cloud-native cameras",
|
||||||
|
"desktopName": "Alta Local Camera Discovery",
|
||||||
|
"main": "main.js",
|
||||||
|
"author": "Peji",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.pejicorp.com/peji/AltaNetowrkScanner.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://git.pejicorp.com/peji/AltaNetowrkScanner",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://git.pejicorp.com/peji/AltaNetowrkScanner/issues"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"dev": "electron . --dev",
|
||||||
|
"scan": "node scripts/scan.js",
|
||||||
|
"test": "node --test test/*.test.js && node test_export_utils.js",
|
||||||
|
"check": "npm test && node --check main.js && node --check preload.js && node --check static/app.js && node --check static/export-utils.js",
|
||||||
|
"build-test": "electron-builder --linux dir --publish=never",
|
||||||
|
"build:linux": "electron-builder --linux AppImage --publish=never",
|
||||||
|
"build-test:win": "electron-builder --win dir --publish=never",
|
||||||
|
"build:win": "electron-builder --win portable --publish=never"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.internal.alta-local-camera-discovery",
|
||||||
|
"productName": "Alta Local Camera Discovery",
|
||||||
|
"directories": {
|
||||||
|
"output": "dist"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"main.js",
|
||||||
|
"preload.js",
|
||||||
|
"src/**/*",
|
||||||
|
"static/**/*",
|
||||||
|
"assets/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"electronFuses": {
|
||||||
|
"runAsNode": false,
|
||||||
|
"enableCookieEncryption": true,
|
||||||
|
"enableNodeOptionsEnvironmentVariable": false,
|
||||||
|
"enableNodeCliInspectArguments": false,
|
||||||
|
"enableEmbeddedAsarIntegrityValidation": true,
|
||||||
|
"onlyLoadAppFromAsar": true,
|
||||||
|
"loadBrowserProcessSpecificV8Snapshot": false,
|
||||||
|
"grantFileProtocolExtraPrivileges": false
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": "portable",
|
||||||
|
"icon": "assets/icon.ico"
|
||||||
|
},
|
||||||
|
"portable": {
|
||||||
|
"artifactName": "AltaLocalCameraDiscovery-${version}-portable.exe"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": "AppImage",
|
||||||
|
"icon": "assets/icon.png",
|
||||||
|
"category": "Utility",
|
||||||
|
"syncDesktopName": true,
|
||||||
|
"artifactName": "AltaLocalCameraDiscovery-${version}.${ext}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bonjour-service": "1.4.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "43.4.1",
|
||||||
|
"electron-builder": "26.15.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('altaDiscovery', Object.freeze({
|
||||||
|
scan: () => ipcRenderer.invoke('scan-cameras'),
|
||||||
|
copyText: (text) => ipcRenderer.invoke('copy-text', text),
|
||||||
|
exportCsv: () => ipcRenderer.invoke('export-csv'),
|
||||||
|
}));
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
zeroconf==0.147.0
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { scanCameras } = require('../src/discovery');
|
||||||
|
|
||||||
|
scanCameras().then((cameras) => {
|
||||||
|
process.stdout.write(`${JSON.stringify(cameras, null, 2)}\n`);
|
||||||
|
}).catch((error) => {
|
||||||
|
process.stderr.write(`${error.message}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const APP_ORIGIN = 'app://bundle';
|
||||||
|
const APP_RENDERER_URL = `${APP_ORIGIN}/index.html`;
|
||||||
|
const ALLOWED_RENDERER_ASSETS = new Set([
|
||||||
|
'index.html',
|
||||||
|
'styles.css',
|
||||||
|
'app.js',
|
||||||
|
'export-utils.js',
|
||||||
|
'alta-mark.png',
|
||||||
|
'alta-video-logo.png',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function resolveAppAssetPath(value, staticDir) {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.protocol !== 'app:' ||
|
||||||
|
url.hostname !== 'bundle' ||
|
||||||
|
url.username !== '' ||
|
||||||
|
url.password !== '' ||
|
||||||
|
url.port !== '' ||
|
||||||
|
url.hash !== '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const asset = decodeURIComponent(url.pathname).replace(/^\//, '');
|
||||||
|
if (!asset || asset.includes('/') || asset.includes('\\') || !ALLOWED_RENDERER_ASSETS.has(asset)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return path.join(staticDir, asset);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ALLOWED_RENDERER_ASSETS,
|
||||||
|
APP_ORIGIN,
|
||||||
|
APP_RENDERER_URL,
|
||||||
|
resolveAppAssetPath,
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const net = require('node:net');
|
||||||
|
const { Bonjour } = require('bonjour-service');
|
||||||
|
|
||||||
|
const SERVICE_TYPE = 'vaioncam';
|
||||||
|
const SERVICE_PROTOCOL = 'tcp';
|
||||||
|
const SERVICE_PORT = 443;
|
||||||
|
const DEFAULT_MAX_RESULTS = 256;
|
||||||
|
const KEY_PATTERN = /^[A-Z0-9]{4}(?:-[A-Z0-9]{4}){2}$/;
|
||||||
|
|
||||||
|
function altaKeyFromName(name) {
|
||||||
|
const raw = String(name || '').trim();
|
||||||
|
const suffix = '._vaioncam._tcp.local';
|
||||||
|
const candidate = raw.toLowerCase().endsWith(suffix)
|
||||||
|
? raw.slice(0, -suffix.length)
|
||||||
|
: raw;
|
||||||
|
const altaKey = candidate.toUpperCase();
|
||||||
|
return KEY_PATTERN.test(altaKey) ? altaKey : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIpAddress(value) {
|
||||||
|
const address = String(value || '').trim().split('%', 1)[0];
|
||||||
|
return net.isIP(address) ? address.toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalAddress(value) {
|
||||||
|
const address = normalizeIpAddress(value);
|
||||||
|
const family = net.isIP(address);
|
||||||
|
if (family === 4) {
|
||||||
|
const [first, second] = address.split('.').map(Number);
|
||||||
|
return first === 10 ||
|
||||||
|
(first === 172 && second >= 16 && second <= 31) ||
|
||||||
|
(first === 192 && second === 168) ||
|
||||||
|
(first === 169 && second === 254);
|
||||||
|
}
|
||||||
|
if (family === 6) {
|
||||||
|
const firstGroup = Number.parseInt(address.split(':', 1)[0], 16);
|
||||||
|
return (firstGroup & 0xfe00) === 0xfc00 || (firstGroup & 0xffc0) === 0xfe80;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cameraFromService(service) {
|
||||||
|
if (!service || String(service.type || '').toLowerCase() !== SERVICE_TYPE) return null;
|
||||||
|
if (String(service.protocol || '').toLowerCase() !== SERVICE_PROTOCOL) return null;
|
||||||
|
if (service.port !== SERVICE_PORT) return null;
|
||||||
|
|
||||||
|
const altaKey = altaKeyFromName(service.name || service.fqdn);
|
||||||
|
if (!altaKey) return null;
|
||||||
|
|
||||||
|
const hostname = String(service.host || '').trim().replace(/\.$/, '').toLowerCase();
|
||||||
|
const expectedHostname = `avacam-${altaKey.toLowerCase()}.local`;
|
||||||
|
if (hostname !== expectedHostname || hostname.length > 253) return null;
|
||||||
|
|
||||||
|
const addresses = Array.isArray(service.addresses)
|
||||||
|
? service.addresses.slice(0, 16).map(normalizeIpAddress).filter(isLocalAddress)
|
||||||
|
: [];
|
||||||
|
const ipAddress = addresses.find((address) => net.isIP(address) === 4) || addresses[0] || '';
|
||||||
|
if (!ipAddress) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
alta_key: altaKey,
|
||||||
|
hostname,
|
||||||
|
web_ui_url: `https://${hostname}`,
|
||||||
|
ip_address: ipAddress,
|
||||||
|
port: SERVICE_PORT,
|
||||||
|
service: '_vaioncam._tcp.local',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResults(rows) {
|
||||||
|
const unique = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row) continue;
|
||||||
|
unique.set(`${row.alta_key}\0${row.ip_address || ''}`, row);
|
||||||
|
}
|
||||||
|
return [...unique.values()].sort((a, b) =>
|
||||||
|
a.alta_key.localeCompare(b.alta_key) || String(a.ip_address).localeCompare(String(b.ip_address))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanCameras({ durationMs = 5000, maxResults = DEFAULT_MAX_RESULTS, BonjourClass = Bonjour } = {}) {
|
||||||
|
if (!Number.isFinite(durationMs) || durationMs < 1 || durationMs > 30000) {
|
||||||
|
return Promise.reject(new Error('Scan duration must be between 1 and 30000 milliseconds.'));
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > 512) {
|
||||||
|
return Promise.reject(new Error('Maximum results must be between 1 and 512.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const rows = [];
|
||||||
|
const seen = new Set();
|
||||||
|
let bonjour;
|
||||||
|
let browser;
|
||||||
|
let timer;
|
||||||
|
let finished = false;
|
||||||
|
|
||||||
|
const finish = (error) => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
try { if (browser) browser.stop(); } catch {}
|
||||||
|
try { if (bonjour) bonjour.destroy(); } catch {}
|
||||||
|
if (error) reject(error); else resolve(normalizeResults(rows));
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
bonjour = new BonjourClass();
|
||||||
|
browser = bonjour.find({ type: SERVICE_TYPE, protocol: SERVICE_PROTOCOL });
|
||||||
|
browser.on('up', (service) => {
|
||||||
|
if (finished) return;
|
||||||
|
const camera = cameraFromService(service);
|
||||||
|
if (!camera) return;
|
||||||
|
const identity = `${camera.alta_key}\0${camera.ip_address}`;
|
||||||
|
if (seen.has(identity)) return;
|
||||||
|
seen.add(identity);
|
||||||
|
rows.push(camera);
|
||||||
|
if (rows.length >= maxResults) finish();
|
||||||
|
});
|
||||||
|
browser.on('error', finish);
|
||||||
|
timer = setTimeout(() => finish(), durationMs);
|
||||||
|
} catch (error) {
|
||||||
|
finish(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_MAX_RESULTS,
|
||||||
|
KEY_PATTERN,
|
||||||
|
SERVICE_PORT,
|
||||||
|
SERVICE_PROTOCOL,
|
||||||
|
SERVICE_TYPE,
|
||||||
|
altaKeyFromName,
|
||||||
|
cameraFromService,
|
||||||
|
isLocalAddress,
|
||||||
|
normalizeResults,
|
||||||
|
scanCameras,
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { performance } = require('node:perf_hooks');
|
||||||
|
const { scanCameras } = require('./discovery');
|
||||||
|
const { normalizeCameraUrl } = require('./url-policy');
|
||||||
|
|
||||||
|
function copyCameras(cameras) {
|
||||||
|
return cameras.map((camera) => ({ ...camera }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createScanSession({ scan = scanCameras, now = () => performance.now() } = {}) {
|
||||||
|
let latestCameras = [];
|
||||||
|
let allowedCameraUrls = new Set();
|
||||||
|
let scanInFlight = null;
|
||||||
|
|
||||||
|
function getLatestCameras() {
|
||||||
|
return copyCameras(latestCameras);
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowedCameraUrl(value) {
|
||||||
|
const normalized = normalizeCameraUrl(value);
|
||||||
|
return normalized && allowedCameraUrls.has(normalized) ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanOnce() {
|
||||||
|
if (scanInFlight) return scanInFlight;
|
||||||
|
|
||||||
|
latestCameras = [];
|
||||||
|
allowedCameraUrls = new Set();
|
||||||
|
const started = now();
|
||||||
|
let scanResult;
|
||||||
|
try {
|
||||||
|
scanResult = Promise.resolve(scan());
|
||||||
|
} catch (error) {
|
||||||
|
scanResult = Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sharedPromise;
|
||||||
|
sharedPromise = scanResult
|
||||||
|
.then((cameras) => {
|
||||||
|
latestCameras = copyCameras(cameras);
|
||||||
|
allowedCameraUrls = new Set(latestCameras.map((camera) => camera.web_ui_url));
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
count: latestCameras.length,
|
||||||
|
duration_seconds: Number(((now() - started) / 1000).toFixed(1)),
|
||||||
|
cameras: getLatestCameras(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (scanInFlight === sharedPromise) scanInFlight = null;
|
||||||
|
});
|
||||||
|
scanInFlight = sharedPromise;
|
||||||
|
return sharedPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({ allowedCameraUrl, getLatestCameras, scanOnce });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createScanSession };
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const CAMERA_HOST_PATTERN = /^avacam-[a-z0-9]{4}(?:-[a-z0-9]{4}){2}\.local$/;
|
||||||
|
|
||||||
|
function normalizeCameraUrl(value) {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.protocol !== 'https:' ||
|
||||||
|
!CAMERA_HOST_PATTERN.test(url.hostname) ||
|
||||||
|
url.username !== '' ||
|
||||||
|
url.password !== '' ||
|
||||||
|
url.port !== '' ||
|
||||||
|
url.pathname !== '/' ||
|
||||||
|
url.search !== '' ||
|
||||||
|
url.hash !== '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `https://${url.hostname}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCameraUrl(value) {
|
||||||
|
return normalizeCameraUrl(value) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { CAMERA_HOST_PATTERN, isCameraUrl, normalizeCameraUrl };
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
+153
@@ -0,0 +1,153 @@
|
|||||||
|
const scanButton = document.querySelector('#scanButton');
|
||||||
|
const buttonText = document.querySelector('#buttonText');
|
||||||
|
const results = document.querySelector('#results');
|
||||||
|
const empty = document.querySelector('#empty');
|
||||||
|
const errorBox = document.querySelector('#error');
|
||||||
|
const list = document.querySelector('#cameraList');
|
||||||
|
const title = document.querySelector('#resultTitle');
|
||||||
|
const meta = document.querySelector('#scanMeta');
|
||||||
|
const errorText = document.querySelector('#errorText');
|
||||||
|
const exportButton = document.querySelector('#exportCsvButton');
|
||||||
|
const actionStatus = document.querySelector('#actionStatus');
|
||||||
|
let currentCameras = [];
|
||||||
|
|
||||||
|
function hidePanels() {
|
||||||
|
results.hidden = true;
|
||||||
|
empty.hidden = true;
|
||||||
|
errorBox.hidden = true;
|
||||||
|
actionStatus.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cameraCard(camera) {
|
||||||
|
const article = document.createElement('article');
|
||||||
|
article.className = 'camera';
|
||||||
|
|
||||||
|
const details = document.createElement('div');
|
||||||
|
const key = document.createElement('div');
|
||||||
|
key.className = 'camera-key';
|
||||||
|
key.textContent = camera.alta_key;
|
||||||
|
const host = document.createElement('a');
|
||||||
|
host.className = 'camera-host';
|
||||||
|
host.href = camera.web_ui_url;
|
||||||
|
host.target = '_blank';
|
||||||
|
host.rel = 'noopener noreferrer';
|
||||||
|
host.textContent = camera.hostname;
|
||||||
|
host.title = 'Open camera Web UI';
|
||||||
|
details.append(key, host);
|
||||||
|
|
||||||
|
const network = document.createElement('div');
|
||||||
|
network.className = 'network-data';
|
||||||
|
const ip = document.createElement('span');
|
||||||
|
ip.className = 'camera-ip';
|
||||||
|
ip.textContent = camera.ip_address;
|
||||||
|
const networkLabel = document.createElement('span');
|
||||||
|
networkLabel.className = 'network-label';
|
||||||
|
networkLabel.textContent = 'HTTPS · mDNS';
|
||||||
|
network.append(ip, networkLabel);
|
||||||
|
|
||||||
|
const copy = document.createElement('button');
|
||||||
|
copy.className = 'copy-button';
|
||||||
|
copy.type = 'button';
|
||||||
|
copy.dataset.altaKey = camera.alta_key;
|
||||||
|
copy.textContent = 'Copy key';
|
||||||
|
copy.setAttribute('aria-label', `Copy Alta Key ${camera.alta_key}`);
|
||||||
|
|
||||||
|
article.append(details, network, copy);
|
||||||
|
return article;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyText(text) {
|
||||||
|
if (window.altaDiscovery) {
|
||||||
|
await window.altaDiscovery.copyText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.setAttribute('readonly', '');
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
document.body.append(textarea);
|
||||||
|
textarea.select();
|
||||||
|
const copied = document.execCommand('copy');
|
||||||
|
textarea.remove();
|
||||||
|
if (!copied) throw new Error('Clipboard access was blocked by the browser.');
|
||||||
|
}
|
||||||
|
|
||||||
|
list.addEventListener('click', async (event) => {
|
||||||
|
const copyButton = event.target.closest('.copy-button');
|
||||||
|
if (!copyButton) return;
|
||||||
|
try {
|
||||||
|
await copyText(copyButton.dataset.altaKey);
|
||||||
|
copyButton.textContent = 'Copied';
|
||||||
|
copyButton.classList.add('copied');
|
||||||
|
actionStatus.textContent = `${copyButton.dataset.altaKey} copied to clipboard.`;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
copyButton.textContent = 'Copy key';
|
||||||
|
copyButton.classList.remove('copied');
|
||||||
|
}, 1800);
|
||||||
|
} catch (error) {
|
||||||
|
actionStatus.textContent = error.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
exportButton.addEventListener('click', async () => {
|
||||||
|
if (window.altaDiscovery) {
|
||||||
|
try {
|
||||||
|
const result = await window.altaDiscovery.exportCsv();
|
||||||
|
actionStatus.textContent = result.canceled ? 'CSV export canceled.' : 'CSV exported successfully.';
|
||||||
|
} catch (error) {
|
||||||
|
actionStatus.textContent = error.message;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const csv = AltaExportUtils.buildCsv(currentCameras);
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = 'alta-camera-keys.csv';
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
actionStatus.textContent = 'CSV exported with Alta Keys and camera Web UI URLs.';
|
||||||
|
});
|
||||||
|
|
||||||
|
scanButton.addEventListener('click', async () => {
|
||||||
|
hidePanels();
|
||||||
|
currentCameras = [];
|
||||||
|
scanButton.disabled = true;
|
||||||
|
scanButton.classList.add('scanning');
|
||||||
|
buttonText.textContent = 'Scanning…';
|
||||||
|
try {
|
||||||
|
let data;
|
||||||
|
if (window.altaDiscovery) {
|
||||||
|
data = await window.altaDiscovery.scan();
|
||||||
|
} else {
|
||||||
|
const response = await fetch('/api/scan', { cache: 'no-store' });
|
||||||
|
data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.error || 'The scanner returned an error.');
|
||||||
|
}
|
||||||
|
if (!data.ok) throw new Error(data.error || 'The scanner returned an error.');
|
||||||
|
currentCameras = data.cameras;
|
||||||
|
if (!currentCameras.length) {
|
||||||
|
empty.hidden = false;
|
||||||
|
} else {
|
||||||
|
list.replaceChildren(...currentCameras.map(cameraCard));
|
||||||
|
title.textContent = `${data.count} camera${data.count === 1 ? '' : 's'} found`;
|
||||||
|
meta.textContent = `Scan completed in ${data.duration_seconds}s`;
|
||||||
|
results.hidden = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorText.textContent = error.message;
|
||||||
|
errorBox.hidden = false;
|
||||||
|
} finally {
|
||||||
|
scanButton.disabled = false;
|
||||||
|
scanButton.classList.remove('scanning');
|
||||||
|
buttonText.textContent = 'Discover again';
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
(function(root,factory){const api=factory();if(typeof module==='object'&&module.exports)module.exports=api;else root.AltaExportUtils=api;})(typeof globalThis!=='undefined'?globalThis:this,function(){
|
||||||
|
function csvCell(value){const text=String(value??'');return /[",\r\n]/.test(text)?`"${text.replaceAll('"','""')}"`:text}
|
||||||
|
function buildCsv(cameras){const rows=[['Alta Key','IP Address','Hostname URL'],...cameras.map(camera=>[camera.alta_key,camera.ip_address,camera.web_ui_url])];return rows.map(row=>row.map(csvCell).join(',')).join('\r\n')+'\r\n'}
|
||||||
|
return{buildCsv};
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'">
|
||||||
|
<title>Local Alta Camera Discovery</title>
|
||||||
|
<link rel="stylesheet" href="styles.css?v=13">
|
||||||
|
<script src="export-utils.js?v=13" defer></script>
|
||||||
|
<script src="app.js?v=13" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="product-brand">
|
||||||
|
<img class="alta-logo" src="alta-mark.png" alt="Alta">
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-shell">
|
||||||
|
<section class="page-heading">
|
||||||
|
<h1>Local Alta Camera Discovery</h1>
|
||||||
|
<p>Locate cloud-native Alta cameras available on this network and recover the information required for deployment.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="discovery-panel" aria-labelledby="scan-title">
|
||||||
|
<div class="discovery-copy">
|
||||||
|
<span class="network-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 64 64" focusable="false">
|
||||||
|
<circle class="radar-field" cx="32" cy="32" r="28"></circle>
|
||||||
|
<path class="radar-sector" d="M32 32V4A28 28 0 0 1 54.7 48.4Z"></path>
|
||||||
|
<circle class="radar-ring" cx="32" cy="32" r="20.5"></circle>
|
||||||
|
<circle class="radar-ring" cx="32" cy="32" r="13"></circle>
|
||||||
|
<circle class="radar-ring" cx="32" cy="32" r="6.2"></circle>
|
||||||
|
<path class="radar-sweep" d="M32 32V4M32 32l22.7 16.4"></path>
|
||||||
|
<circle class="radar-target" cx="23.3" cy="13.4" r="3.8"></circle>
|
||||||
|
<circle class="radar-target" cx="40" cy="21.8" r="3.8"></circle>
|
||||||
|
<circle class="radar-target" cx="52.3" cy="34.8" r="3.8"></circle>
|
||||||
|
<circle class="radar-target" cx="16.3" cy="45.2" r="3.8"></circle>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 id="scan-title">Discover cameras on this network</h2>
|
||||||
|
<p>The scan uses local mDNS announcements and does not access or modify camera configuration.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="scan-action">
|
||||||
|
<button id="scanButton" class="primary-button" type="button"><span class="radar"></span><span id="buttonText">Start discovery</span></button>
|
||||||
|
<span>Usually completes in 5 seconds</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="results" class="results-panel" hidden>
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="section-label">DISCOVERY RESULTS</p>
|
||||||
|
<h2 id="resultTitle">Cameras found</h2>
|
||||||
|
<span id="scanMeta"></span>
|
||||||
|
</div>
|
||||||
|
<button id="exportCsvButton" class="secondary-button" type="button">Export CSV</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-head" aria-hidden="true">
|
||||||
|
<span>Alta camera</span><span>Network address</span><span>Action</span>
|
||||||
|
</div>
|
||||||
|
<div id="cameraList" class="camera-list"></div>
|
||||||
|
<div class="panel-footer"><span id="actionStatus" role="status" aria-live="polite"></span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="empty" class="state-panel" hidden>
|
||||||
|
<h2>No Alta cameras found</h2>
|
||||||
|
<p>Confirm the cameras are powered and connected to the same local network or VLAN as this device.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="error" class="state-panel error" hidden>
|
||||||
|
<h2>Discovery could not be completed</h2><p id="errorText"></p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer"><span>Alta Video Local Camera Discovery</span></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { resolveAppAssetPath } = require('../src/app-protocol');
|
||||||
|
|
||||||
|
const staticDir = path.join(__dirname, '..', 'static');
|
||||||
|
|
||||||
|
test('app protocol resolves only explicitly packaged renderer assets', () => {
|
||||||
|
assert.equal(resolveAppAssetPath('app://bundle/index.html', staticDir), path.join(staticDir, 'index.html'));
|
||||||
|
assert.equal(resolveAppAssetPath('app://bundle/styles.css?v=12', staticDir), path.join(staticDir, 'styles.css'));
|
||||||
|
assert.equal(resolveAppAssetPath('app://bundle/alta-mark.png', staticDir), path.join(staticDir, 'alta-mark.png'));
|
||||||
|
assert.equal(resolveAppAssetPath('app://other/index.html', staticDir), null);
|
||||||
|
assert.equal(resolveAppAssetPath('app://bundle/../main.js', staticDir), null);
|
||||||
|
assert.equal(resolveAppAssetPath('app://bundle/%2e%2e/main.js', staticDir), null);
|
||||||
|
assert.equal(resolveAppAssetPath('file:///etc/passwd', staticDir), null);
|
||||||
|
assert.equal(resolveAppAssetPath('app://bundle/not-packaged.js', staticDir), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { EventEmitter } = require('node:events');
|
||||||
|
const {
|
||||||
|
altaKeyFromName,
|
||||||
|
cameraFromService,
|
||||||
|
isLocalAddress,
|
||||||
|
normalizeResults,
|
||||||
|
scanCameras,
|
||||||
|
} = require('../src/discovery');
|
||||||
|
|
||||||
|
const validService = (overrides = {}) => ({
|
||||||
|
name: 'ABH7-C62U-GC3E',
|
||||||
|
type: 'vaioncam',
|
||||||
|
protocol: 'tcp',
|
||||||
|
host: 'avacam-abh7-c62u-gc3e.local.',
|
||||||
|
addresses: ['fe80::1', '10.0.0.223'],
|
||||||
|
port: 443,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts an Alta Key from service names', () => {
|
||||||
|
assert.equal(altaKeyFromName('abh7-c62u-gc3e'), 'ABH7-C62U-GC3E');
|
||||||
|
assert.equal(altaKeyFromName('ABH7-C62U-GC3E._vaioncam._tcp.local'), 'ABH7-C62U-GC3E');
|
||||||
|
assert.equal(altaKeyFromName('not-a-key'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('projects a correlated local Bonjour service into a safe camera row', () => {
|
||||||
|
assert.deepEqual(cameraFromService(validService()), {
|
||||||
|
alta_key: 'ABH7-C62U-GC3E',
|
||||||
|
hostname: 'avacam-abh7-c62u-gc3e.local',
|
||||||
|
web_ui_url: 'https://avacam-abh7-c62u-gc3e.local',
|
||||||
|
ip_address: '10.0.0.223',
|
||||||
|
port: 443,
|
||||||
|
service: '_vaioncam._tcp.local',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects uncorrelated or non-local service announcements', () => {
|
||||||
|
assert.equal(cameraFromService(validService({ type: undefined })), null);
|
||||||
|
assert.equal(cameraFromService(validService({ protocol: undefined })), null);
|
||||||
|
assert.equal(cameraFromService(validService({ host: 'avacam-attacker.local.' })), null);
|
||||||
|
assert.equal(cameraFromService(validService({ port: 8443 })), null);
|
||||||
|
assert.equal(cameraFromService(validService({ addresses: ['203.0.113.10'] })), null);
|
||||||
|
assert.equal(cameraFromService(validService({ addresses: [] })), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recognizes supported private and link-local IP scopes', () => {
|
||||||
|
for (const address of ['10.0.0.1', '172.16.0.1', '172.31.255.254', '192.168.1.1', '169.254.1.1', 'fe80::1', 'fc00::1', 'fd12::1']) {
|
||||||
|
assert.equal(isLocalAddress(address), true, address);
|
||||||
|
}
|
||||||
|
for (const address of ['8.8.8.8', '172.32.0.1', '2001:4860:4860::8888', 'not-an-ip']) {
|
||||||
|
assert.equal(isLocalAddress(address), false, address);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deduplicates and sorts cameras', () => {
|
||||||
|
const a = { alta_key: 'AHG2-Z4ND-FVRN', ip_address: '10.0.0.192' };
|
||||||
|
const b = { alta_key: 'ABH7-C62U-GC3E', ip_address: '10.0.0.223' };
|
||||||
|
assert.deepEqual(normalizeResults([a, b, a]), [b, a]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scans through the Bonjour adapter and closes resources', async () => {
|
||||||
|
let browserStopped = false;
|
||||||
|
let bonjourDestroyed = false;
|
||||||
|
class FakeBonjour {
|
||||||
|
find(query) {
|
||||||
|
assert.deepEqual(query, { type: 'vaioncam', protocol: 'tcp' });
|
||||||
|
const browser = new EventEmitter();
|
||||||
|
browser.stop = () => { browserStopped = true; };
|
||||||
|
process.nextTick(() => browser.emit('up', validService()));
|
||||||
|
return browser;
|
||||||
|
}
|
||||||
|
destroy() { bonjourDestroyed = true; }
|
||||||
|
}
|
||||||
|
const rows = await scanCameras({ durationMs: 5, BonjourClass: FakeBonjour });
|
||||||
|
assert.equal(rows.length, 1);
|
||||||
|
assert.equal(rows[0].alta_key, 'ABH7-C62U-GC3E');
|
||||||
|
assert.equal(browserStopped, true);
|
||||||
|
assert.equal(bonjourDestroyed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('caps scan results and closes discovery immediately at the cap', async () => {
|
||||||
|
let browserStopped = false;
|
||||||
|
class FakeBonjour {
|
||||||
|
find() {
|
||||||
|
const browser = new EventEmitter();
|
||||||
|
browser.stop = () => { browserStopped = true; };
|
||||||
|
process.nextTick(() => {
|
||||||
|
for (let index = 0; index < 10; index += 1) {
|
||||||
|
const key = `AAAA-BBBB-${String(index).padStart(4, '0')}`;
|
||||||
|
browser.emit('up', validService({
|
||||||
|
name: key,
|
||||||
|
host: `avacam-${key.toLowerCase()}.local.`,
|
||||||
|
addresses: [`10.0.0.${index + 1}`],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return browser;
|
||||||
|
}
|
||||||
|
destroy() {}
|
||||||
|
}
|
||||||
|
const rows = await scanCameras({ durationMs: 1000, maxResults: 3, BonjourClass: FakeBonjour });
|
||||||
|
assert.equal(rows.length, 3);
|
||||||
|
assert.equal(browserStopped, true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const root = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
test('Electron boundary stays isolated and packaged files are declared', () => {
|
||||||
|
const main = fs.readFileSync(path.join(root, 'main.js'), 'utf8');
|
||||||
|
const preload = fs.readFileSync(path.join(root, 'preload.js'), 'utf8');
|
||||||
|
const appProtocol = fs.readFileSync(path.join(root, 'src', 'app-protocol.js'), 'utf8');
|
||||||
|
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||||
|
assert.match(main, /nodeIntegration:\s*false/);
|
||||||
|
assert.match(main, /contextIsolation:\s*true/);
|
||||||
|
assert.match(main, /sandbox:\s*true/);
|
||||||
|
assert.match(main, /isTrustedSender/);
|
||||||
|
assert.match(main, /setPermissionCheckHandler/);
|
||||||
|
assert.match(main, /setPermissionRequestHandler/);
|
||||||
|
assert.match(main, /registerSchemesAsPrivileged/);
|
||||||
|
assert.match(appProtocol, /app:\/\/bundle/);
|
||||||
|
assert.doesNotMatch(main, /\.loadFile\s*\(/);
|
||||||
|
assert.doesNotMatch(preload, /require\(['"](?:node:)?(?:fs|child_process|dgram)/);
|
||||||
|
assert.match(preload, /exportCsv:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('export-csv'\)/);
|
||||||
|
assert.ok(pkg.build.files.includes('src/**/*'));
|
||||||
|
assert.ok(pkg.build.files.includes('static/**/*'));
|
||||||
|
assert.equal(pkg.build.win.icon, 'assets/icon.ico');
|
||||||
|
assert.equal(pkg.build.electronFuses.runAsNode, false);
|
||||||
|
assert.equal(pkg.build.electronFuses.enableNodeOptionsEnvironmentVariable, false);
|
||||||
|
assert.equal(pkg.build.electronFuses.enableNodeCliInspectArguments, false);
|
||||||
|
assert.equal(pkg.build.electronFuses.enableEmbeddedAsarIntegrityValidation, true);
|
||||||
|
assert.equal(pkg.build.electronFuses.onlyLoadAppFromAsar, true);
|
||||||
|
assert.equal(pkg.build.electronFuses.grantFileProtocolExtraPrivileges, false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { createScanSession } = require('../src/scan-session');
|
||||||
|
|
||||||
|
const camera = {
|
||||||
|
alta_key: 'ABH7-C62U-GC3E',
|
||||||
|
hostname: 'avacam-abh7-c62u-gc3e.local',
|
||||||
|
web_ui_url: 'https://avacam-abh7-c62u-gc3e.local',
|
||||||
|
ip_address: '10.0.0.223',
|
||||||
|
port: 443,
|
||||||
|
service: '_vaioncam._tcp.local',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('shares one scan, publishes defensive state, and binds camera URLs', async () => {
|
||||||
|
let scanCalls = 0;
|
||||||
|
let releaseScan;
|
||||||
|
const scan = () => {
|
||||||
|
scanCalls += 1;
|
||||||
|
return new Promise((resolve) => { releaseScan = resolve; });
|
||||||
|
};
|
||||||
|
const times = [1000, 2345];
|
||||||
|
const session = createScanSession({ scan, now: () => times.shift() });
|
||||||
|
|
||||||
|
const first = session.scanOnce();
|
||||||
|
const second = session.scanOnce();
|
||||||
|
assert.strictEqual(first, second);
|
||||||
|
assert.equal(scanCalls, 1);
|
||||||
|
assert.equal(session.allowedCameraUrl(camera.web_ui_url), null);
|
||||||
|
assert.deepEqual(session.getLatestCameras(), []);
|
||||||
|
|
||||||
|
releaseScan([camera]);
|
||||||
|
assert.deepEqual(await first, {
|
||||||
|
ok: true,
|
||||||
|
count: 1,
|
||||||
|
duration_seconds: 1.3,
|
||||||
|
cameras: [camera],
|
||||||
|
});
|
||||||
|
assert.equal(session.allowedCameraUrl(camera.web_ui_url), camera.web_ui_url);
|
||||||
|
assert.equal(session.allowedCameraUrl(`${camera.web_ui_url}/?secret=value`), null);
|
||||||
|
assert.equal(session.allowedCameraUrl('https://avacam-ahg2-z4nd-fvrn.local'), null);
|
||||||
|
|
||||||
|
const exposed = session.getLatestCameras();
|
||||||
|
exposed[0].alta_key = 'MUTATED';
|
||||||
|
assert.equal(session.getLatestCameras()[0].alta_key, camera.alta_key);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revokes prior state at rescan and permits retry after failure', async () => {
|
||||||
|
let call = 0;
|
||||||
|
let releaseSecondScan;
|
||||||
|
const scan = () => {
|
||||||
|
call += 1;
|
||||||
|
if (call === 1) return Promise.resolve([camera]);
|
||||||
|
if (call === 2) return new Promise((_resolve, reject) => { releaseSecondScan = reject; });
|
||||||
|
return Promise.resolve([camera]);
|
||||||
|
};
|
||||||
|
const session = createScanSession({ scan, now: () => 0 });
|
||||||
|
|
||||||
|
await session.scanOnce();
|
||||||
|
assert.equal(session.allowedCameraUrl(camera.web_ui_url), camera.web_ui_url);
|
||||||
|
|
||||||
|
const failed = session.scanOnce();
|
||||||
|
assert.equal(session.allowedCameraUrl(camera.web_ui_url), null);
|
||||||
|
assert.deepEqual(session.getLatestCameras(), []);
|
||||||
|
releaseSecondScan(new Error('network failed'));
|
||||||
|
await assert.rejects(failed, /network failed/);
|
||||||
|
|
||||||
|
const retried = await session.scanOnce();
|
||||||
|
assert.equal(call, 3);
|
||||||
|
assert.equal(retried.count, 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { isCameraUrl, normalizeCameraUrl } = require('../src/url-policy');
|
||||||
|
|
||||||
|
const cameraUrl = 'https://avacam-abh7-c62u-gc3e.local';
|
||||||
|
|
||||||
|
test('permits only root local Alta camera HTTPS URLs', () => {
|
||||||
|
assert.equal(isCameraUrl(cameraUrl), true);
|
||||||
|
assert.equal(isCameraUrl('http://avacam-abh7-c62u-gc3e.local'), false);
|
||||||
|
assert.equal(isCameraUrl('https://example.com'), false);
|
||||||
|
assert.equal(isCameraUrl('https://user:pass@avacam-abh7-c62u-gc3e.local'), false);
|
||||||
|
assert.equal(isCameraUrl('https://avacam-abh7-c62u-gc3e.local:8443'), false);
|
||||||
|
assert.equal(isCameraUrl(`${cameraUrl}/anything`), false);
|
||||||
|
assert.equal(isCameraUrl(`${cameraUrl}/?secret=value`), false);
|
||||||
|
assert.equal(isCameraUrl(`${cameraUrl}/#fragment`), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes camera URLs to their canonical form', () => {
|
||||||
|
assert.equal(normalizeCameraUrl(`${cameraUrl}/`), cameraUrl);
|
||||||
|
assert.equal(normalizeCameraUrl(`${cameraUrl.toUpperCase()}/`), cameraUrl);
|
||||||
|
});
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from app import camera_from_service, normalize_results
|
||||||
|
|
||||||
|
|
||||||
|
class CameraDiscoveryTests(unittest.TestCase):
|
||||||
|
def test_extracts_alta_key_and_hostname(self):
|
||||||
|
camera = camera_from_service(
|
||||||
|
"ABH7-C62U-GC3E._vaioncam._tcp.local.",
|
||||||
|
"avacam-abh7-c62u-gc3e.local.",
|
||||||
|
["10.0.0.223"],
|
||||||
|
443,
|
||||||
|
)
|
||||||
|
self.assertEqual(camera["alta_key"], "ABH7-C62U-GC3E")
|
||||||
|
self.assertEqual(camera["hostname"], "avacam-abh7-c62u-gc3e.local")
|
||||||
|
self.assertEqual(camera["web_ui_url"], "https://avacam-abh7-c62u-gc3e.local")
|
||||||
|
self.assertEqual(camera["ip_address"], "10.0.0.223")
|
||||||
|
|
||||||
|
def test_rejects_non_camera_service(self):
|
||||||
|
self.assertIsNone(camera_from_service(
|
||||||
|
"printer._ipp._tcp.local.", "printer.local.", ["10.0.0.50"], 631
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_deduplicates_and_sorts_results(self):
|
||||||
|
rows = [
|
||||||
|
{"alta_key": "BBBB-BBBB-BBBB", "ip_address": "10.0.0.2"},
|
||||||
|
{"alta_key": "AAAA-AAAA-AAAA", "ip_address": "10.0.0.3"},
|
||||||
|
{"alta_key": "BBBB-BBBB-BBBB", "ip_address": "10.0.0.2"},
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
[row["alta_key"] for row in normalize_results(rows)],
|
||||||
|
["AAAA-AAAA-AAAA", "BBBB-BBBB-BBBB"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { buildCsv } = require('./static/export-utils.js');
|
||||||
|
|
||||||
|
const cameras = [
|
||||||
|
{
|
||||||
|
alta_key: 'ABH7-C62U-GC3E',
|
||||||
|
hostname: 'avacam-abh7-c62u-gc3e.local',
|
||||||
|
web_ui_url: 'https://avacam-abh7-c62u-gc3e.local',
|
||||||
|
ip_address: '10.0.0.223',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alta_key: 'AHG2-Z4ND-FVRN',
|
||||||
|
hostname: 'avacam-ahg2-z4nd-fvrn.local',
|
||||||
|
web_ui_url: 'https://avacam-ahg2-z4nd-fvrn.local',
|
||||||
|
ip_address: '10.0.0.192',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(require('./static/export-utils.js').buildKeyPaste, undefined);
|
||||||
|
assert.equal(
|
||||||
|
buildCsv(cameras),
|
||||||
|
'Alta Key,IP Address,Hostname URL\r\n' +
|
||||||
|
'ABH7-C62U-GC3E,10.0.0.223,https://avacam-abh7-c62u-gc3e.local\r\n' +
|
||||||
|
'AHG2-Z4ND-FVRN,10.0.0.192,https://avacam-ahg2-z4nd-fvrn.local\r\n'
|
||||||
|
);
|
||||||
|
console.log('export-utils tests passed');
|
||||||
Reference in New Issue
Block a user