Compare commits

...

3 Commits

28 changed files with 2200 additions and 1509 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.gitignore
node_modules
dist
__pycache__
*.pyc
*.pyo
*.log
.hermes
Archive
backups
+31
View File
@@ -0,0 +1,31 @@
FROM python:3.12-alpine
ARG VCS_REF=unknown
LABEL org.opencontainers.image.title="Alta Video Player DEV" \
org.opencontainers.image.description="WebAVP browser runtime for isolated DEV review" \
org.opencontainers.image.source="https://git.pejicorp.com/peji/WebAVP" \
org.opencontainers.image.revision="${VCS_REF}"
ENV WEBAVP_HOST=0.0.0.0 \
WEBAVP_PORT=5152 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN addgroup -S -g 10001 webavp \
&& adduser -S -D -H -u 10001 -G webavp webavp
COPY --chown=10001:10001 app.py ./
COPY --chown=10001:10001 static ./static
COPY --chown=10001:10001 templates ./templates
USER 10001:10001
EXPOSE 5152
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5152/', timeout=3).read(1)"
CMD ["python", "app.py"]
+11 -7
View File
@@ -11,7 +11,7 @@ The app can run in two modes:
- Import AES-encrypted ZIP exports with an in-app password prompt
- Synchronize multiple camera segments on a shared timeline
- Scrub, zoom, pan, change playback speed, and frame-step footage
- Reorder, hide/show, expand, and manually lay out camera tiles
- Reorder, hide/show, expand, and automatically or manually lay out up to 16 camera tiles (4x4)
- Use region zoom and scroll zoom tools
- Verify signed exports offline and optionally confirm certificates with Alta's cloud verification endpoint
- Preserve sessions across refreshes with IndexedDB
@@ -23,12 +23,14 @@ npm install
npm start
```
The Electron shell loads the existing UI through the custom `webavp://app/` protocol, so local assets are served from the packaged app instead of brittle `file://` paths. Certificate verification is bridged through Electron IPC (`window.webavpNative.verifyCertificateOnline`) and performed in the main process.
The Electron shell loads the same player template used by web mode through the custom `webavp://app/` protocol. Certificate verification and GitPeji update checks are bridged through narrowly scoped Electron IPC. **Check for Updates** compares semantic versions and opens the matching public GitPeji release asset in the system browser; installation remains an explicit user action.
Packaging scripts are included:
```bash
npm run dist
npm run dist:linux # Linux x64 AppImage + deb
npm run dist:mac # macOS x64 + arm64 dmg (run on macOS)
npm run dist:win # Windows x64 NSIS installer (run on Windows)
```
`electron-builder` is configured for Linux AppImage/deb, macOS dmg, and Windows nsis. Cross-platform packaging still needs to be run on the target OS/build host.
@@ -37,16 +39,18 @@ npm run dist
```bash
python3 app.py
# http://0.0.0.0:5152
# http://127.0.0.1:5152
```
The Python stdlib server serves `/`, `/static/*`, and `/api/verify-cert`. There are no Python package dependencies.
The Python stdlib server serves `/`, `/static/*`, and `/api/verify-cert`. It binds to `127.0.0.1` by default; set `WEBAVP_HOST=0.0.0.0` only for deliberate trusted-LAN sharing. Web mode shows **Download Desktop App**, selecting the latest compatible public GitPeji release asset when available and otherwise opening the releases page.
## Checks
```bash
npm run check
xvfb-run -a npm run smoke
npm run smoke # Electron on a desktop session
npm run smoke:headless # Electron under xvfb on headless Linux
WEBAVP_SMOKE_WEB_URL=http://127.0.0.1:5152/ npm run smoke:web
```
`check` runs Electron main/preload syntax checks and `py_compile` for the Python server. `smoke` starts Electron, waits for `webavp://app/index.html` to finish loading, then exits automatically. On a desktop session, `npm run smoke` is enough; on headless Linux, use `xvfb-run -a npm run smoke`.
`check` runs syntax, focused layout/release regressions, Python server tests, and source invariants. The smoke commands exercise the shared template in both Electron and served web modes. See `RELEASING.md` for the GitPeji publishing and asset contract.
+49
View File
@@ -0,0 +1,49 @@
# Publishing WebAVP releases on GitPeji
WebAVP uses GitPeji only. Both the Electron updater and web download control query the public Gitea-compatible endpoint:
`https://git.pejicorp.com/api/v1/repos/peji/WebAVP/releases/latest`
The repository and release must remain publicly readable; the app does not embed credentials.
## Release gate
1. Update `version` in both `package.json` and `package-lock.json` using semantic versioning.
2. Run `npm ci`, `npm run check`, the served web smoke, and the Electron smoke.
3. Build on each target operating system:
- Linux x64: `npm run dist:linux`
- macOS x64/arm64: `npm run dist:mac`
- Windows x64: `npm run dist:win`
4. Open each produced installer/app on its target OS and perform the manual import/playback/timeline/zoom/layout/verification checklist.
5. Obtain code-signing/notarization approval before calling macOS or Windows artifacts production-ready. Unsigned test artifacts must be labelled clearly.
6. Review the exact diff and test evidence before committing, pushing, tagging, or publishing.
## Asset contract
`electron-builder` emits `${productName}-${version}-${os}-${arch}.${ext}`. Keep the generated names; platform and architecture tokens are how clients select a safe asset.
Expected examples:
- `Alta Video Player-1.2.3-linux-x86_64.AppImage`
- `Alta Video Player-1.2.3-linux-amd64.deb`
- `Alta Video Player-1.2.3-mac-x64.dmg`
- `Alta Video Player-1.2.3-mac-arm64.dmg`
- `Alta Video Player-1.2.3-win-x64.exe`
Linux prefers AppImage when both AppImage and deb exist. Clients deliberately fall back to the releases page instead of guessing when the current platform/architecture has no matching asset.
## Publish after approval
1. Commit the reviewed version and release notes to GitPeji `main`.
2. Create and push a matching tag such as `v1.2.3` to GitPeji.
3. In GitPeji, create a release from that tag. It must be published (not a draft) and public.
4. Upload every native artifact produced and validated above. Do not rename away OS or architecture tokens.
5. Publish the release, then verify:
- the latest-release API returns HTTP 200 and the expected `tag_name`;
- each `browser_download_url` returns the intended binary, not HTML;
- Electron reports current/available state correctly;
- web mode selects the current browser platform or falls back to the release page.
## Update behaviour
Electron performs a bounded HTTPS metadata request in the main process, validates all returned URLs against `git.pejicorp.com/peji/WebAVP/releases/download/`, compares semantic versions, and asks the system browser to download the selected artifact. It does not silently install, execute, or overwrite software. The user must close WebAVP and run the installer explicitly.
+64 -9
View File
@@ -7,9 +7,14 @@ import os
from http.server import HTTPServer, SimpleHTTPRequestHandler
from urllib.parse import parse_qs, urlencode, urlparse
from urllib.request import urlopen
from urllib.error import URLError
from urllib.error import HTTPError, URLError
PORT = 5152
# Loopback is the safe default for a local footage player. Set WEBAVP_HOST
# explicitly only when sharing on a trusted network.
HOST = os.environ.get("WEBAVP_HOST", "127.0.0.1")
PORT = int(os.environ.get("WEBAVP_PORT", "5152"))
LATEST_RELEASE_URL = "https://git.pejicorp.com/api/v1/repos/peji/WebAVP/releases/latest"
MAX_RELEASE_RESPONSE_BYTES = 1024 * 1024
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(BASE_DIR, "static")
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
@@ -25,13 +30,17 @@ class Handler(SimpleHTTPRequestHandler):
elif path.startswith("/static/"):
rel = path[len("/static/"):]
file_path = os.path.join(STATIC_DIR, rel)
if not os.path.realpath(file_path).startswith(os.path.realpath(STATIC_DIR)):
static_root = os.path.realpath(STATIC_DIR)
requested_path = os.path.realpath(file_path)
if requested_path != static_root and not requested_path.startswith(static_root + os.sep):
self.send_error(403)
return
mime = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
self._serve_file(file_path, mime)
elif path == "/api/verify-cert":
self._proxy_verify(parsed.query)
elif path == "/api/latest-release":
self._proxy_latest_release()
else:
self.send_error(404)
@@ -60,17 +69,63 @@ class Handler(SimpleHTTPRequestHandler):
qs = urlencode({"serial": serial, "certificateHash": cert_hash})
url = f"https://aware.avasecurity.com/api/v1/public/verifyServerCertificate?{qs}"
resp = urlopen(url, timeout=10)
if 200 <= resp.status < 300:
self._json_response(200, {"verified": True})
else:
body = resp.read()
if not 200 <= resp.status < 300:
self._json_response(200, {"verified": False, "error": f"HTTP {resp.status}"})
elif self._interpret_verify_body(body) is False:
self._json_response(200, {"verified": False, "error": "Certificate not recognized by Alta/Ava"})
else:
self._json_response(200, {"verified": True})
except HTTPError as e:
self._json_response(200, {"verified": False, "error": f"HTTP {e.code}"})
except URLError as e:
self._json_response(200, {"verified": False, "error": str(e)})
def _proxy_latest_release(self):
"""Same-origin, credential-free proxy for the public GitPeji release."""
try:
response = urlopen(LATEST_RELEASE_URL, timeout=10)
body = response.read(MAX_RELEASE_RESPONSE_BYTES + 1)
if len(body) > MAX_RELEASE_RESPONSE_BYTES:
self._json_response(502, {"error": "GitPeji release response exceeded size limit"})
return
self._raw_response(response.status, body, "application/json; charset=utf-8")
except HTTPError as e:
body = e.read(MAX_RELEASE_RESPONSE_BYTES + 1)
if len(body) > MAX_RELEASE_RESPONSE_BYTES:
body = b'{"error":"GitPeji release response exceeded size limit"}'
self._raw_response(e.code, body, "application/json; charset=utf-8")
except URLError as e:
self._json_response(502, {"error": str(e)})
@staticmethod
def _interpret_verify_body(body):
"""Return an explicit upstream result, or None for unknown schemas."""
text = body.decode("utf-8", "replace").strip() if body else ""
if not text:
return None
lowered = text.lower()
if lowered in ("true", "false"):
return lowered == "true"
try:
data = json.loads(text)
except (ValueError, TypeError):
return None
if isinstance(data, bool):
return data
if isinstance(data, dict):
for field in ("verified", "valid", "isValid", "success", "result"):
if isinstance(data.get(field), bool):
return data[field]
return None
def _json_response(self, status, data):
body = json.dumps(data).encode()
self._raw_response(status, body, "application/json")
def _raw_response(self, status, body, content_type):
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", len(body))
self.end_headers()
self.wfile.write(body)
@@ -80,6 +135,6 @@ class Handler(SimpleHTTPRequestHandler):
if __name__ == "__main__":
server = HTTPServer(("0.0.0.0", PORT), Handler)
print(f"Alta Video Player running on http://0.0.0.0:{PORT}")
server = HTTPServer((HOST, PORT), Handler)
print(f"Alta Video Player running on http://{HOST}:{PORT}")
server.serve_forever()
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Alta Video Player icon">
<rect width="512" height="512" rx="112" fill="#121826"/>
<polygon points="200,138 200,374 389,256" fill="#006ED7"/>
</svg>

After

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 966 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 B

+184 -26
View File
@@ -2,11 +2,21 @@ const { app, BrowserWindow, ipcMain, protocol, shell } = require('electron');
const fs = require('node:fs/promises');
const path = require('node:path');
const https = require('node:https');
const {
LATEST_RELEASE_API,
evaluateUpdate,
normalizeRelease,
} = require('../static/webavp-utils.js');
const APP_SCHEME = 'webavp';
const ROOT_DIR = path.resolve(__dirname, '..');
const TEMPLATE_DIR = path.join(ROOT_DIR, 'templates');
const STATIC_DIR = path.join(ROOT_DIR, 'static');
const APP_ICON = path.join(ROOT_DIR, 'build', 'icon.png');
const REQUEST_TIMEOUT_MS = 10000;
const MAX_RESPONSE_BYTES = 1024 * 1024;
const MAX_SERIAL_LENGTH = 128;
const MAX_CERT_HASH_LENGTH = 256;
if (process.env.WEBAVP_DISABLE_GPU === '1') {
app.disableHardwareAcceleration();
@@ -45,6 +55,7 @@ function contentTypeFor(filePath) {
function safeJoin(baseDir, requestPath) {
const decoded = decodeURIComponent(requestPath);
if (decoded.includes('\0')) throw new Error('Null byte blocked');
const normalized = path.normalize(decoded).replace(/^([/\\])+/, '');
const resolved = path.resolve(baseDir, normalized);
const base = path.resolve(baseDir);
@@ -54,6 +65,41 @@ function safeJoin(baseDir, requestPath) {
return resolved;
}
function isAppUrl(rawUrl) {
try {
const url = new URL(rawUrl);
return url.protocol === `${APP_SCHEME}:` && url.hostname === 'app';
} catch {
return false;
}
}
function isSafeExternalUrl(rawUrl) {
try {
const url = new URL(rawUrl);
return url.protocol === 'https:' || url.protocol === 'http:';
} catch {
return false;
}
}
function isGitPejiReleaseAssetUrl(rawUrl) {
try {
const url = new URL(rawUrl);
return url.origin === 'https://git.pejicorp.com'
&& url.pathname.startsWith('/peji/WebAVP/releases/download/')
&& !url.username
&& !url.password;
} catch {
return false;
}
}
function isAuthorizedRenderer(event) {
const senderUrl = event.senderFrame?.url || event.sender?.getURL?.() || '';
return isAppUrl(senderUrl);
}
async function serveFile(filePath) {
try {
const data = await fs.readFile(filePath);
@@ -93,16 +139,30 @@ function registerAppProtocol() {
});
}
// Issue the GET via Node's https stack (not Electron's net.fetch). The Alta
// endpoint requests an optional TLS client certificate; Chromium's net stack
// reacts by failing the handshake with ERR_SSL_CLIENT_AUTH_CERT_NEEDED, which
// made cloud verification appear "offline" in the desktop build. Node's TLS
// (like curl and the Python app.py reference) simply proceeds without one.
function httpsGetStatus(url, timeoutMs = 10000) {
// Node's TLS proceeds when Alta requests an optional client certificate, unlike
// Chromium's net stack. It also gives update checks one bounded request path.
function httpsGetResponse(url, timeoutMs = REQUEST_TIMEOUT_MS) {
return new Promise((resolve, reject) => {
const req = https.get(url, (res) => {
res.resume(); // drain so the socket is released
resolve(res.statusCode);
const req = https.get(url, {
headers: {
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
'User-Agent': `WebAVP/${app.getVersion()}`,
},
}, (res) => {
const chunks = [];
let total = 0;
res.on('data', (chunk) => {
total += chunk.length;
if (total > MAX_RESPONSE_BYTES) {
req.destroy(new Error('Response exceeded size limit'));
return;
}
chunks.push(chunk);
});
res.on('end', () => resolve({
status: res.statusCode || 0,
body: Buffer.concat(chunks).toString('utf8'),
}));
});
req.on('error', reject);
req.setTimeout(timeoutMs, () => {
@@ -111,28 +171,105 @@ function httpsGetStatus(url, timeoutMs = 10000) {
});
}
async function verifyCertificateOnline(_event, { serial, certificateHash } = {}) {
if (!serial || !certificateHash) {
function interpretVerifyBody(body) {
const text = String(body || '').trim();
if (!text) return null;
const lowered = text.toLowerCase();
if (lowered === 'true' || lowered === 'false') return lowered === 'true';
try {
const data = JSON.parse(text);
if (typeof data === 'boolean') return data;
if (data && typeof data === 'object') {
for (const field of ['verified', 'valid', 'isValid', 'success', 'result']) {
if (typeof data[field] === 'boolean') return data[field];
}
}
} catch {
return null;
}
return null;
}
async function verifyCertificateOnline(event, { serial, certificateHash } = {}) {
if (!isAuthorizedRenderer(event)) {
return { verified: false, error: 'Unauthorized certificate verification request' };
}
const serialValue = typeof serial === 'string' ? serial.trim() : '';
const hashValue = typeof certificateHash === 'string' ? certificateHash.trim() : '';
if (!serialValue || !hashValue) {
return { verified: false, error: 'Missing parameters' };
}
if (serialValue.length > MAX_SERIAL_LENGTH || hashValue.length > MAX_CERT_HASH_LENGTH) {
return { verified: false, error: 'Invalid parameter length' };
}
const params = new URLSearchParams({
serial: String(serial).toLowerCase(),
certificateHash: String(certificateHash),
serial: serialValue.toLowerCase(),
certificateHash: hashValue,
});
const url = `https://aware.avasecurity.com/api/v1/public/verifyServerCertificate?${params.toString()}`;
try {
const status = await httpsGetStatus(url);
if (status >= 200 && status < 300) {
return { verified: true };
const response = await httpsGetResponse(url);
if (response.status < 200 || response.status >= 300) {
return { verified: false, error: `HTTP ${response.status}` };
}
return { verified: false, error: `HTTP ${status}` };
if (interpretVerifyBody(response.body) === false) {
return { verified: false, error: 'Certificate not recognized by Alta/Ava' };
}
return { verified: true };
} catch (err) {
return { verified: false, error: err.message || 'Certificate verification request failed' };
}
}
async function checkForUpdates(event) {
if (!isAuthorizedRenderer(event)) return { status: 'error', message: 'Unauthorized update request' };
const currentVersion = app.getVersion();
try {
const response = await httpsGetResponse(LATEST_RELEASE_API);
if (response.status === 404) return { status: 'error', message: 'No public GitPeji release is published yet.' };
if (response.status < 200 || response.status >= 300) {
return { status: 'error', message: `GitPeji release check failed (HTTP ${response.status}).` };
}
const release = normalizeRelease(JSON.parse(response.body));
const update = evaluateUpdate(release, currentVersion, process.platform, process.arch);
if (update.status === 'current') {
return { status: 'current', currentVersion, releaseVersion: release.version, pageUrl: release.pageUrl };
}
if (update.status === 'unavailable-platform') {
return {
status: 'unavailable-platform',
currentVersion,
releaseVersion: release.version,
pageUrl: release.pageUrl,
message: `Version ${release.version} is available, but no ${process.platform}/${process.arch} installer was published.`,
};
}
return {
status: 'available',
currentVersion,
releaseVersion: release.version,
pageUrl: release.pageUrl,
asset: update.asset,
};
} catch (err) {
return { status: 'error', message: err.message || 'GitPeji release check failed.' };
}
}
async function downloadUpdate(event, { url } = {}) {
if (!isAuthorizedRenderer(event) || !isGitPejiReleaseAssetUrl(url)) {
return { opened: false, error: 'Invalid update download request' };
}
try {
await shell.openExternal(url);
return { opened: true };
} catch (err) {
return { opened: false, error: err.message || 'Could not open the update download' };
}
}
function createWindow() {
const win = new BrowserWindow({
width: 1440,
@@ -141,26 +278,50 @@ function createWindow() {
minHeight: 720,
backgroundColor: '#121826',
title: 'Alta Video Player',
icon: APP_ICON,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webviewTag: false,
navigateOnDragDrop: false,
},
});
win.webContents.on('will-navigate', (event, url) => {
if (!isAppUrl(url)) {
event.preventDefault();
console.warn('[WebAVP] Blocked navigation to:', url);
}
});
win.webContents.setWindowOpenHandler(({ url }) => {
if (isSafeExternalUrl(url)) {
shell.openExternal(url).catch((err) => console.warn('[WebAVP] Failed to open external URL:', err.message || err));
} else {
console.warn('[WebAVP] Blocked external URL:', url);
}
return { action: 'deny' };
});
win.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
win.loadURL(`${APP_SCHEME}://app/index.html`);
if (process.env.WEBAVP_SMOKE_QUIT_AFTER_MS) {
const quitAfterMs = Number(process.env.WEBAVP_SMOKE_QUIT_AFTER_MS);
win.webContents.once('did-finish-load', async () => {
try {
const result = await win.webContents.executeJavaScript(`({
const result = await win.webContents.executeJavaScript(`(async () => ({
title: document.title,
hasJsZip: typeof window.JSZip === 'function',
hasNativeBridge: !!window.webavpNative?.verifyCertificateOnline
})`);
if (result.title !== 'Alta Video Player' || !result.hasJsZip || !result.hasNativeBridge) {
hasNativeBridge: !!window.webavpNative?.verifyCertificateOnline,
hasUpdateBridge: !!window.webavpNative?.checkForUpdates,
hasFourByFourLayout: !!document.querySelector('[data-layout="4x4"]'),
bridgeResult: await window.webavpNative?.verifyCertificateOnline?.({ serial: '', certificateHash: '' })
}))()`);
if (result.title !== 'Alta Video Player' || !result.hasJsZip || !result.hasNativeBridge || !result.hasUpdateBridge || !result.hasFourByFourLayout || result.bridgeResult?.error !== 'Missing parameters') {
console.error('[WebAVP] Electron smoke checks failed:', JSON.stringify(result));
app.exit(1);
return;
@@ -178,17 +339,14 @@ function createWindow() {
});
}
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
return win;
}
app.whenReady().then(() => {
registerAppProtocol();
ipcMain.handle('certificate:verify-online', verifyCertificateOnline);
ipcMain.handle('release:check-update', checkForUpdates);
ipcMain.handle('release:download-update', downloadUpdate);
createWindow();
app.on('activate', () => {
+6
View File
@@ -5,4 +5,10 @@ contextBridge.exposeInMainWorld('webavpNative', {
verifyCertificateOnline({ serial, certificateHash }) {
return ipcRenderer.invoke('certificate:verify-online', { serial, certificateHash });
},
checkForUpdates() {
return ipcRenderer.invoke('release:check-update');
},
downloadUpdate(url) {
return ipcRenderer.invoke('release:download-update', { url });
},
});
+1263 -1433
View File
File diff suppressed because it is too large Load Diff
+30 -6
View File
@@ -3,27 +3,47 @@
"version": "0.1.0",
"private": true,
"description": "Standalone desktop shell for Alta Video Player.",
"desktopName": "alta-video-player.desktop",
"homepage": "https://git.pejicorp.com/peji/WebAVP",
"author": {
"name": "Alta",
"email": "support@avasecurity.com"
},
"main": "electron/main.js",
"scripts": {
"start": "electron .",
"check": "node --check electron/main.js && node --check electron/preload.js && python3 -m py_compile app.py",
"smoke": "WEBAVP_DISABLE_GPU=1 WEBAVP_SMOKE_QUIT_AFTER_MS=250 electron .",
"dist": "electron-builder"
"check": "node scripts/check.js",
"test": "node scripts/check.js",
"smoke": "node scripts/smoke.js",
"smoke:web": "electron tests/browser-smoke.js",
"smoke:headless": "xvfb-run -a npm run smoke",
"dist": "electron-builder",
"dist:linux": "electron-builder --linux --x64",
"dist:mac": "electron-builder --mac --x64 --arm64",
"dist:win": "electron-builder --win --x64"
},
"devDependencies": {
"electron": "^31.7.7",
"electron-builder": "^24.13.3"
"electron": "^43.4.1",
"electron-builder": "^26.15.3"
},
"build": {
"appId": "com.alta.webavp",
"productName": "Alta Video Player",
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"directories": {
"buildResources": "build",
"output": "dist"
},
"files": [
"electron/**",
"static/**",
"templates/**",
"build/icon.png",
"package.json"
],
"linux": {
"icon": "build/icons",
"syncDesktopName": true,
"target": [
"AppImage",
"deb"
@@ -31,11 +51,15 @@
"category": "AudioVideo"
},
"mac": {
"icon": "build/icon.icns",
"target": "dmg",
"category": "public.app-category.video"
},
"win": {
"target": "nsis"
"icon": "build/icon.ico",
"target": [
"portable"
]
}
}
}
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
'use strict';
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const ROOT_DIR = path.resolve(__dirname, '..');
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: ROOT_DIR,
stdio: 'inherit',
shell: false,
windowsHide: true,
});
if (result.error) {
if (result.error.code === 'ENOENT' && options.allowMissing) return { missing: true };
console.error(`[WebAVP] Failed to run ${command}: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) process.exit(result.status || 1);
return { ok: true };
}
for (const file of ['electron/main.js', 'electron/preload.js', 'static/webavp-utils.js', 'tests/webavp-utils.test.js']) {
run(process.execPath, ['--check', file]);
}
run(process.execPath, ['tests/webavp-utils.test.js']);
const html = fs.readFileSync(path.join(ROOT_DIR, 'templates/index.html'), 'utf8');
const required = [
'data-layout="4x4"',
'WebAVPUtils.getAutoGrid',
'Download Desktop App',
'Check for Updates',
'verifyEntryAuth',
'activeSegmentsDirty',
'cameraIdFromFile',
];
for (const marker of required) {
if (!html.includes(marker)) {
console.error(`[WebAVP] Missing required template marker: ${marker}`);
process.exit(1);
}
}
if (/dewarp|fisheye/i.test(html)) {
console.error('[WebAVP] Fisheye dewarp code must not be reintroduced.');
process.exit(1);
}
const pythonCandidates = process.platform === 'win32'
? [['py', ['-3']], ['python', []], ['python3', []]]
: [['python3', []], ['python', []]];
let pythonChecked = false;
for (const [command, prefix] of pythonCandidates) {
const result = spawnSync(command, [...prefix, '-m', 'py_compile', 'app.py'], {
cwd: ROOT_DIR,
stdio: 'inherit',
shell: false,
windowsHide: true,
});
if (result.error?.code === 'ENOENT') continue;
if (result.error || result.status !== 0) process.exit(result.status || 1);
run(command, [...prefix, '-m', 'unittest', 'discover', '-s', 'tests', '-p', '*_test.py']);
pythonChecked = true;
break;
}
if (!pythonChecked) console.warn('[WebAVP] Python unavailable; optional web-server checks skipped.');
console.log('[WebAVP] checks passed');
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
'use strict';
const { spawn } = require('node:child_process');
const path = require('node:path');
const ROOT_DIR = path.resolve(__dirname, '..');
const electronPath = require('electron');
const env = {
...process.env,
WEBAVP_DISABLE_GPU: process.env.WEBAVP_DISABLE_GPU || '1',
WEBAVP_SMOKE_QUIT_AFTER_MS: process.env.WEBAVP_SMOKE_QUIT_AFTER_MS || '250',
};
const child = spawn(electronPath, ['.'], {
cwd: ROOT_DIR,
env,
stdio: 'inherit',
shell: false,
windowsHide: true,
});
child.on('error', (err) => {
console.error(`[WebAVP] Failed to start Electron smoke test: ${err.message}`);
process.exit(1);
});
child.on('exit', (code, signal) => {
if (signal) {
console.error(`[WebAVP] Electron smoke test exited via signal ${signal}`);
process.exit(1);
}
process.exit(code || 0);
});
+140
View File
@@ -0,0 +1,140 @@
(function initWebAVPUtils(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.WebAVPUtils = api;
}(typeof globalThis !== 'undefined' ? globalThis : this, function createWebAVPUtils() {
'use strict';
const GITPEJI_ORIGIN = 'https://git.pejicorp.com';
const RELEASES_URL = `${GITPEJI_ORIGIN}/peji/WebAVP/releases`;
const LATEST_RELEASE_API = `${GITPEJI_ORIGIN}/api/v1/repos/peji/WebAVP/releases/latest`;
function parseVersion(value) {
const match = String(value || '').trim().match(/^v?(\d+)\.(\d+)(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
if (!match) throw new Error(`Invalid semantic version: ${value}`);
return {
parts: [Number(match[1]), Number(match[2]), Number(match[3] || 0)],
prerelease: match[4] ? match[4].split('.') : [],
};
}
function compareIdentifiers(left, right) {
const leftNumber = /^\d+$/.test(left) ? Number(left) : null;
const rightNumber = /^\d+$/.test(right) ? Number(right) : null;
if (leftNumber !== null && rightNumber !== null) return Math.sign(leftNumber - rightNumber);
if (leftNumber !== null) return -1;
if (rightNumber !== null) return 1;
return left === right ? 0 : (left > right ? 1 : -1);
}
function compareVersions(leftValue, rightValue) {
const left = parseVersion(leftValue);
const right = parseVersion(rightValue);
for (let index = 0; index < 3; index += 1) {
if (left.parts[index] !== right.parts[index]) return Math.sign(left.parts[index] - right.parts[index]);
}
if (!left.prerelease.length && !right.prerelease.length) return 0;
if (!left.prerelease.length) return 1;
if (!right.prerelease.length) return -1;
const length = Math.max(left.prerelease.length, right.prerelease.length);
for (let index = 0; index < length; index += 1) {
if (left.prerelease[index] === undefined) return -1;
if (right.prerelease[index] === undefined) return 1;
const result = compareIdentifiers(left.prerelease[index], right.prerelease[index]);
if (result) return result;
}
return 0;
}
function getAutoGrid(countValue) {
const count = Math.max(1, Math.min(16, Number(countValue) || 1));
if (count === 1) return { cols: 1, rows: 1 };
if (count === 2) return { cols: 2, rows: 1 };
if (count <= 4) return { cols: 2, rows: 2 };
if (count <= 6) return { cols: 3, rows: 2 };
if (count <= 9) return { cols: 3, rows: 3 };
if (count <= 12) return { cols: 4, rows: 3 };
return { cols: 4, rows: 4 };
}
function assertGitPejiUrl(rawUrl, label) {
let url;
try {
url = new URL(rawUrl);
} catch {
throw new Error(`Invalid ${label} URL`);
}
if (url.origin !== GITPEJI_ORIGIN || url.username || url.password) {
throw new Error(`Invalid ${label} host`);
}
return url.href;
}
function normalizeRelease(rawRelease) {
if (!rawRelease || typeof rawRelease !== 'object') throw new Error('Invalid release response');
const version = String(rawRelease.tag_name || '').replace(/^v/, '');
parseVersion(version);
const pageUrl = assertGitPejiUrl(rawRelease.html_url || RELEASES_URL, 'release page');
const assets = Array.isArray(rawRelease.assets) ? rawRelease.assets.map((asset) => {
if (!asset || typeof asset.name !== 'string' || !asset.name.trim()) throw new Error('Invalid release asset');
const url = assertGitPejiUrl(asset.browser_download_url, 'release asset');
if (!new URL(url).pathname.startsWith('/peji/WebAVP/releases/download/')) throw new Error('Invalid release asset path');
return { name: asset.name.trim(), url };
}) : [];
return { version, name: String(rawRelease.name || rawRelease.tag_name), pageUrl, assets };
}
function normalizedPlatform(platform) {
if (platform === 'darwin' || platform === 'mac' || platform === 'macos') return 'mac';
if (platform === 'win32' || platform === 'windows' || platform === 'win') return 'win';
return platform === 'linux' ? 'linux' : '';
}
function assetScore(asset, platformValue, archValue) {
const platform = normalizedPlatform(platformValue);
const arch = String(archValue || '').toLowerCase();
const name = asset.name.toLowerCase();
const platformMatches = platform === 'linux'
? name.endsWith('.appimage') || name.endsWith('.deb') || /(^|[-_.])linux([-_.]|$)/.test(name)
: platform === 'mac'
? name.endsWith('.dmg') || /(^|[-_.])(mac|macos|darwin)([-_.]|$)/.test(name)
: platform === 'win'
? name.endsWith('.exe') || name.endsWith('.msi') || /(^|[-_.])(win|windows)([-_.]|$)/.test(name)
: false;
if (!platformMatches) return -1;
const hasArm64 = /(^|[-_.])(arm64|aarch64)([-_.]|$)/.test(name);
const hasX64 = /(^|[-_.])(x64|amd64|x86_64)([-_.]|$)/.test(name);
if ((arch === 'arm64' || arch === 'aarch64') && !hasArm64) return -1;
if ((arch === 'x64' || arch === 'amd64' || arch === 'x86_64') && !hasX64) return -1;
if (!['arm64', 'aarch64', 'x64', 'amd64', 'x86_64'].includes(arch)) return -1;
if (platform === 'linux' && name.endsWith('.appimage')) return 30;
if (platform === 'win' && name.endsWith('.exe')) return 30;
if (platform === 'mac' && name.endsWith('.dmg')) return 30;
return 10;
}
function selectReleaseAsset(release, platform, arch) {
if (!release || !Array.isArray(release.assets)) return null;
return release.assets
.map((asset) => ({ asset, score: assetScore(asset, platform, arch) }))
.filter((candidate) => candidate.score >= 0)
.sort((left, right) => right.score - left.score || left.asset.name.localeCompare(right.asset.name))[0]?.asset || null;
}
function evaluateUpdate(release, currentVersion, platform, arch) {
if (compareVersions(release.version, currentVersion) <= 0) return { status: 'current', asset: null };
const asset = selectReleaseAsset(release, platform, arch);
return asset ? { status: 'available', asset } : { status: 'unavailable-platform', asset: null };
}
return {
GITPEJI_ORIGIN,
LATEST_RELEASE_API,
RELEASES_URL,
compareVersions,
evaluateUpdate,
getAutoGrid,
normalizeRelease,
selectReleaseAsset,
};
}));
+146 -28
View File
@@ -7,6 +7,7 @@
<title>Alta Video Player</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect rx='20' width='100' height='100' fill='%23121826'/><polygon points='38,25 38,75 78,50' fill='%23006ED7'/></svg>">
<script src="/static/jszip.min.js"></script>
<script src="/static/webavp-utils.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@@ -469,6 +470,10 @@
}
.btn-add:hover { background: var(--bg-button-hover); }
.btn-add svg { width: 14px; height: 14px; fill: currentColor; }
.release-control { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
.release-control .btn-add { text-decoration: none; white-space: nowrap; }
.release-status { max-width: 280px; color: var(--text-muted); font-size: 9px; text-align: right; }
.release-status.error { color: var(--status-warning); }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg-panel); }
@@ -723,6 +728,10 @@
</div>
</div>
<div class="header-actions">
<div class="release-control">
<a class="btn btn-add" id="releaseAction" href="https://git.pejicorp.com/peji/WebAVP/releases" target="_blank" rel="noopener noreferrer">Download Desktop App</a>
<span class="release-status" id="releaseStatus" aria-live="polite"></span>
</div>
<div class="verify-badge" id="verifyBadge" title="Click for details">
<span class="verify-dot"></span>
<span class="verify-label">Integrity</span>
@@ -878,11 +887,15 @@
<svg viewBox="0 0 24 24"><rect x="2" y="2" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="9.25" y="2" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="16.5" y="2" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="2" y="9.25" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="9.25" y="9.25" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="16.5" y="9.25" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="2" y="16.5" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="9.25" y="16.5" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/><rect x="16.5" y="16.5" width="5.5" height="5.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>
3x3
</button>
<button class="layout-preset-btn" data-layout="4x4" title="4x4 / 16 tiles">
<svg viewBox="0 0 24 24"><path d="M2 2h4v4H2V2zm5.3 0h4v4h-4V2zm5.4 0h4v4h-4V2zM18 2h4v4h-4V2zM2 7.3h4v4H2v-4zm5.3 0h4v4h-4v-4zm5.4 0h4v4h-4v-4zm5.3 0h4v4h-4v-4zM2 12.7h4v4H2v-4zm5.3 0h4v4h-4v-4zm5.4 0h4v4h-4v-4zm5.3 0h4v4h-4v-4zM2 18h4v4H2v-4zm5.3 0h4v4h-4v-4zm5.4 0h4v4h-4v-4zm5.3 0h4v4h-4v-4z"/></svg>
4x4
</button>
</div>
<div class="layout-custom-row">
<input type="number" id="layoutCols" min="1" max="6" value="2" placeholder="C">
<input type="number" id="layoutCols" min="1" max="4" value="2" placeholder="C">
<span>&times;</span>
<input type="number" id="layoutRows" min="1" max="6" value="2" placeholder="R">
<input type="number" id="layoutRows" min="1" max="4" value="2" placeholder="R">
<button id="layoutApplyCustom">Apply</button>
</div>
</div>
@@ -1334,6 +1347,88 @@
const verifySummary = document.getElementById('verifySummary');
const verifyCertInfoEl = document.getElementById('verifyCertInfo');
const verifyFileList = document.getElementById('verifyFileList');
const releaseAction = document.getElementById('releaseAction');
const releaseStatus = document.getElementById('releaseStatus');
function setReleaseStatus(message, isError) {
releaseStatus.textContent = message || '';
releaseStatus.classList.toggle('error', !!isError);
}
async function detectBrowserTarget() {
const userAgent = navigator.userAgent || '';
const platformValue = navigator.userAgentData?.platform || navigator.platform || userAgent;
const platformText = platformValue.toLowerCase();
const platform = /win/.test(platformText) ? 'win32' : (/mac/.test(platformText) ? 'darwin' : 'linux');
let architecture = /arm64|aarch64/.test(userAgent.toLowerCase()) ? 'arm64' : 'x64';
if (navigator.userAgentData?.getHighEntropyValues) {
try {
const values = await navigator.userAgentData.getHighEntropyValues(['architecture', 'bitness']);
if (/arm/.test(values.architecture || '')) architecture = 'arm64';
else if (values.architecture) architecture = 'x64';
} catch { /* use the conservative user-agent fallback */ }
}
return { platform, architecture };
}
async function initializeReleaseControl() {
if (window.webavpNative?.checkForUpdates) {
releaseAction.textContent = 'Check for Updates';
releaseAction.removeAttribute('target');
let pendingAsset = null;
let fallbackPage = WebAVPUtils.RELEASES_URL;
releaseAction.addEventListener('click', async (event) => {
event.preventDefault();
if (pendingAsset) {
const result = await window.webavpNative.downloadUpdate(pendingAsset.url);
setReleaseStatus(result.opened
? 'Download opened in your browser. Close the app before installing.'
: (result.error || 'Could not open the update download.'), !result.opened);
return;
}
if (releaseAction.dataset.mode === 'releases') {
window.open(fallbackPage, '_blank', 'noopener,noreferrer');
return;
}
releaseAction.textContent = 'Checking...';
setReleaseStatus('Querying public GitPeji releases...', false);
const result = await window.webavpNative.checkForUpdates();
fallbackPage = result.pageUrl || fallbackPage;
if (result.status === 'available') {
pendingAsset = result.asset;
releaseAction.textContent = `Download v${result.releaseVersion}`;
setReleaseStatus(`Update available (installed: v${result.currentVersion}).`, false);
} else if (result.status === 'current') {
releaseAction.textContent = 'Check for Updates';
setReleaseStatus(`You are current (v${result.currentVersion}).`, false);
} else if (result.status === 'unavailable-platform') {
releaseAction.textContent = 'View Releases';
releaseAction.dataset.mode = 'releases';
setReleaseStatus(result.message, true);
} else {
releaseAction.textContent = 'Check for Updates';
setReleaseStatus(result.message || 'Update check failed.', true);
}
});
return;
}
try {
const response = await fetch('/api/latest-release', { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const release = WebAVPUtils.normalizeRelease(await response.json());
const target = await detectBrowserTarget();
const asset = WebAVPUtils.selectReleaseAsset(release, target.platform, target.architecture);
releaseAction.href = asset ? asset.url : release.pageUrl;
setReleaseStatus(asset
? `Latest: v${release.version} for ${target.platform}/${target.architecture}`
: 'No matching installer; opens all GitPeji releases.', !asset);
} catch {
setReleaseStatus('Latest installer unavailable; opens GitPeji releases.', true);
}
}
initializeReleaseControl();
// ─── Activity Log ───
const logPanel = document.getElementById('logPanel');
@@ -1435,8 +1530,8 @@
// Custom layout
document.getElementById('layoutApplyCustom').addEventListener('click', () => {
const cols = Math.max(1, Math.min(6, parseInt(document.getElementById('layoutCols').value) || 2));
const rows = Math.max(1, Math.min(6, parseInt(document.getElementById('layoutRows').value) || 2));
const cols = Math.max(1, Math.min(4, parseInt(document.getElementById('layoutCols').value) || 2));
const rows = Math.max(1, Math.min(4, parseInt(document.getElementById('layoutRows').value) || 2));
document.getElementById('layoutCols').value = cols;
document.getElementById('layoutRows').value = rows;
gridLayoutOverride = { cols, rows };
@@ -1447,11 +1542,12 @@
function applyGridLayout() {
applyCameraVisibility();
if (!gridLayoutOverride) {
// Auto mode — restore cams-N class
cameraGrid.style.gridTemplateColumns = '';
cameraGrid.style.gridTemplateRows = '';
// Auto mode — choose a bounded grid through 4x4 / 16 tiles.
const visibleCount = countVisibleCameras();
cameraGrid.className = `camera-grid cams-${Math.min(visibleCount, 9)}`;
const layout = WebAVPUtils.getAutoGrid(visibleCount);
cameraGrid.className = 'camera-grid';
cameraGrid.style.gridTemplateColumns = `repeat(${layout.cols}, 1fr)`;
cameraGrid.style.gridTemplateRows = `repeat(${layout.rows}, 1fr)`;
} else {
// Override mode
cameraGrid.className = 'camera-grid';
@@ -1475,7 +1571,7 @@
return;
}
const maxSlots = gridLayoutOverride ? gridLayoutOverride.cols * gridLayoutOverride.rows : Infinity;
const maxSlots = gridLayoutOverride ? gridLayoutOverride.cols * gridLayoutOverride.rows : 16;
let visibleIdx = 0;
for (const ch of channels.values()) {
@@ -1546,7 +1642,7 @@
}
function applyCameraVisibility() {
const maxSlots = gridLayoutOverride ? gridLayoutOverride.cols * gridLayoutOverride.rows : Infinity;
const maxSlots = gridLayoutOverride ? gridLayoutOverride.cols * gridLayoutOverride.rows : 16;
let visibleIdx = 0;
for (const ch of channels.values()) {
if (!ch.cellEl) continue;
@@ -1634,6 +1730,8 @@
let batchingSegments = false;
// Track which camera is expanded (null = none)
let expandedChannel = null;
// Timeline segment highlighting only needs a DOM scan after a transition or rebuild.
let activeSegmentsDirty = true;
// ─── Timeline Zoom State ───
// viewStart/viewEnd define the visible window in seconds (offset from globalStart)
@@ -1768,12 +1866,11 @@
cameraGrid.style.gridTemplateRows = `repeat(${gridLayoutOverride.rows}, 1fr)`;
} else {
const visibleCount = countVisibleCameras();
cameraGrid.className = `camera-grid cams-${Math.min(visibleCount, 9)}`;
cameraGrid.style.gridTemplateColumns = '';
cameraGrid.style.gridTemplateRows = '';
const layout = WebAVPUtils.getAutoGrid(visibleCount);
cameraGrid.className = 'camera-grid';
cameraGrid.style.gridTemplateColumns = `repeat(${layout.cols}, 1fr)`;
cameraGrid.style.gridTemplateRows = `repeat(${layout.rows}, 1fr)`;
}
applyCameraVisibility();
let idx = 0;
for (const ch of channels.values()) {
if (!ch.cellEl) {
@@ -1803,6 +1900,10 @@
}
idx++;
}
// New channels receive their cell nodes in the loop above. Apply the slot
// cap afterwards so first imports and later additions honour the selected
// layout (and auto mode never exposes more than 16 tiles).
applyCameraVisibility();
}
function createCamCell(ch) {
@@ -2038,6 +2139,8 @@
row++;
}
activeSegmentsDirty = true;
// Update minimap segments
renderMinimap(visibleChannels);
updateTimelineLabels();
@@ -2104,13 +2207,14 @@
if (ch.cellEl) {
applyZoom(ch.cellEl);
}
}
if (ch._segInfoEl) {
const total = ch.segments.length;
ch._segInfoEl.textContent = newIdx >= 0
? `Clip ${newIdx + 1}/${total}`
: `${total} clips`;
if (ch._segInfoEl) {
const total = ch.segments.length;
ch._segInfoEl.textContent = newIdx >= 0
? `Clip ${newIdx + 1}/${total}`
: `${total} clips`;
}
activeSegmentsDirty = true;
}
}
}
@@ -2249,12 +2353,13 @@
}
}
timelineTrack.querySelectorAll('.timeline-segment').forEach(el => {
const chName = el.dataset.channel;
const segIdx = parseInt(el.dataset.segIdx);
const ch = channels.get(chName);
el.classList.toggle('active-segment', ch && ch.activeSegIdx === segIdx);
});
if (activeSegmentsDirty) {
timelineTrack.querySelectorAll('.timeline-segment').forEach(el => {
const ch = channels.get(el.dataset.channel);
el.classList.toggle('active-segment', ch && ch.activeSegIdx === parseInt(el.dataset.segIdx));
});
activeSegmentsDirty = false;
}
if (slideshowActive) updateSlideshow();
}
@@ -3131,11 +3236,21 @@
let off = 0;
for (const c of chunks) { out.set(c, off); off += c.length; }
return out;
} catch {
} catch (err) {
avpLog('warn', 'Decompression failed; using raw entry data: ' + (err && err.message ? err.message : err));
return data; // fallback: return as-is
}
}
async function verifyEntryAuth(hmacKey, encData, authCode) {
if (!authCode || authCode.length !== 10) return false;
const key = await crypto.subtle.importKey('raw', hmacKey, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, encData));
let diff = 0;
for (let index = 0; index < 10; index++) diff |= mac[index] ^ authCode[index];
return diff === 0;
}
async function decryptZipEntries(buf, password) {
const entries = parseEncryptedZip(buf);
if (entries.length === 0) throw new Error('No encrypted entries found');
@@ -3155,6 +3270,9 @@
loadingDetail.textContent = `Decrypting ${i + 1}/${entries.length}: ${entry.name.split('/').pop()}`;
const ek = (i === 0) ? keys : await deriveAesKey(password, entry.salt, entry.aesStrength);
if (!(await verifyEntryAuth(ek.hmacKey, entry.encData, entry.authCode))) {
avpLog('warn', `Integrity check failed for "${entry.name.split('/').pop()}" — the file may be corrupt or tampered with.`);
}
let data = aesCtrDecrypt(ek.encKey, entry.encData);
// Decompress if needed
+26
View File
@@ -0,0 +1,26 @@
import unittest
from app import Handler, HOST
class AppServerTests(unittest.TestCase):
def test_server_defaults_to_loopback(self):
self.assertEqual(HOST, "127.0.0.1")
def test_certificate_response_interpretation(self):
cases = [
(b"true", True),
(b"false", False),
(b'{"verified": false}', False),
(b'{"isValid": true}', True),
(b'{"unknown": true}', None),
(b"", None),
(b"not-json", None),
]
for body, expected in cases:
with self.subTest(body=body):
self.assertIs(Handler._interpret_verify_body(body), expected)
if __name__ == "__main__":
unittest.main()
+79
View File
@@ -0,0 +1,79 @@
'use strict';
const { app, BrowserWindow } = require('electron');
const targetUrl = process.env.WEBAVP_SMOKE_WEB_URL || 'http://127.0.0.1:5152/';
let failed = false;
app.commandLine.appendSwitch('disable-gpu');
app.whenReady().then(async () => {
const win = new BrowserWindow({ show: false, webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true } });
win.webContents.on('console-message', (_event, level, message) => {
if (level >= 2) {
failed = true;
console.error(`[WebAVP] Browser console error: ${message}`);
}
});
try {
await win.loadURL(targetUrl);
const result = await win.webContents.executeJavaScript(`(async () => {
const deadline = Date.now() + 12000;
while (!document.getElementById('releaseStatus')?.textContent && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 100));
}
async function importCameraCount(count) {
const input = document.getElementById('fileInput');
const transfer = new DataTransfer();
for (let index = 1; index <= count; index += 1) {
transfer.items.add(new File(['x'], 'Camera' + index + '.mp4', { type: 'video/mp4' }));
}
Object.defineProperty(input, 'files', { value: transfer.files, configurable: true });
input.dispatchEvent(new Event('change', { bubbles: true }));
const importDeadline = Date.now() + 5000;
while (document.querySelectorAll('.cam-cell').length < count && Date.now() < importDeadline) {
await new Promise(resolve => setTimeout(resolve, 50));
}
return [...document.querySelectorAll('.cam-cell')]
.filter(cell => getComputedStyle(cell).display !== 'none').length;
}
document.querySelector('[data-layout="2x2"]').click();
const fixedVisible = await importCameraCount(5);
document.getElementById('newSessionBtn').click();
document.querySelector('[data-layout="auto"]').click();
const autoVisible = await importCameraCount(17);
return ({
title: document.title,
hasUtils: !!window.WebAVPUtils,
autoGrid16: window.WebAVPUtils?.getAutoGrid(16),
releaseText: document.getElementById('releaseAction')?.textContent.trim(),
releaseFallback: document.getElementById('releaseAction')?.href,
releaseStatus: document.getElementById('releaseStatus')?.textContent,
hasFourByFour: !!document.querySelector('[data-layout="4x4"]'),
hasDewarp: /dewarp|fisheye/i.test(document.documentElement.textContent),
fixedVisible,
autoVisible
});
})()`);
const passed = result.title === 'Alta Video Player'
&& result.hasUtils
&& result.autoGrid16?.cols === 4
&& result.autoGrid16?.rows === 4
&& result.releaseText === 'Download Desktop App'
&& result.releaseFallback.startsWith('https://git.pejicorp.com/peji/WebAVP/releases')
&& result.releaseStatus.length > 0
&& result.hasFourByFour
&& !result.hasDewarp
&& result.fixedVisible === 4
&& result.autoVisible === 16
&& !failed;
if (!passed) throw new Error(`Browser assertions failed: ${JSON.stringify(result)}`);
console.log(`[WebAVP] Browser smoke passed at ${targetUrl}`);
app.exit(0);
} catch (err) {
console.error(`[WebAVP] Browser smoke failed: ${err.message}`);
app.exit(1);
}
});
+54
View File
@@ -0,0 +1,54 @@
'use strict';
const assert = require('node:assert/strict');
const {
compareVersions,
evaluateUpdate,
getAutoGrid,
normalizeRelease,
selectReleaseAsset,
} = require('../static/webavp-utils.js');
assert.deepEqual(getAutoGrid(0), { cols: 1, rows: 1 });
assert.deepEqual(getAutoGrid(1), { cols: 1, rows: 1 });
assert.deepEqual(getAutoGrid(2), { cols: 2, rows: 1 });
assert.deepEqual(getAutoGrid(4), { cols: 2, rows: 2 });
assert.deepEqual(getAutoGrid(6), { cols: 3, rows: 2 });
assert.deepEqual(getAutoGrid(9), { cols: 3, rows: 3 });
assert.deepEqual(getAutoGrid(12), { cols: 4, rows: 3 });
assert.deepEqual(getAutoGrid(16), { cols: 4, rows: 4 });
assert.deepEqual(getAutoGrid(99), { cols: 4, rows: 4 });
assert.equal(compareVersions('v1.2.0', '1.1.9'), 1);
assert.equal(compareVersions('1.2.0', '1.2.0'), 0);
assert.equal(compareVersions('1.2.0-beta.1', '1.2.0'), -1);
assert.equal(compareVersions('1.2.0+linux.1', '1.2.0+build.9'), 0);
assert.equal(compareVersions('2.0', '1.99.99'), 1);
assert.throws(() => compareVersions('latest', '1.0.0'), /version/i);
const rawRelease = {
tag_name: 'v1.4.0',
html_url: 'https://git.pejicorp.com/peji/WebAVP/releases/tag/v1.4.0',
name: 'WebAVP 1.4.0',
assets: [
{ name: 'Alta Video Player-1.4.0-linux-x64.AppImage', browser_download_url: 'https://git.pejicorp.com/peji/WebAVP/releases/download/v1.4.0/linux.AppImage' },
{ name: 'Alta Video Player-1.4.0-linux-x64.deb', browser_download_url: 'https://git.pejicorp.com/peji/WebAVP/releases/download/v1.4.0/linux.deb' },
{ name: 'Alta Video Player-1.4.0-mac-arm64.dmg', browser_download_url: 'https://git.pejicorp.com/peji/WebAVP/releases/download/v1.4.0/mac-arm64.dmg' },
{ name: 'Alta Video Player-1.4.0-mac-x64.dmg', browser_download_url: 'https://git.pejicorp.com/peji/WebAVP/releases/download/v1.4.0/mac-x64.dmg' },
{ name: 'Alta Video Player-1.4.0-win-x64.exe', browser_download_url: 'https://git.pejicorp.com/peji/WebAVP/releases/download/v1.4.0/win.exe' },
],
};
const release = normalizeRelease(rawRelease);
assert.equal(release.version, '1.4.0');
assert.equal(selectReleaseAsset(release, 'linux', 'x64').name.endsWith('.AppImage'), true);
assert.equal(selectReleaseAsset(release, 'linux', 'arm64'), null);
assert.equal(selectReleaseAsset(release, 'darwin', 'arm64').name.endsWith('.dmg'), true);
assert.equal(selectReleaseAsset(release, 'win32', 'x64').name.endsWith('.exe'), true);
assert.equal(selectReleaseAsset(release, 'win32', 'arm64'), null);
assert.throws(() => normalizeRelease({ tag_name: 'v1.0.0', assets: [{ name: 'bad.exe', browser_download_url: 'https://evil.example/bad.exe' }] }), /asset/i);
assert.equal(evaluateUpdate(release, '1.4.0', 'linux', 'x64').status, 'current');
assert.equal(evaluateUpdate(release, '1.3.9', 'linux', 'x64').status, 'available');
assert.equal(evaluateUpdate(release, '1.3.9', 'linux', 'arm64').status, 'unavailable-platform');
console.log('[WebAVP] utility regression tests passed');