5 Commits

31 changed files with 2421 additions and 1510 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

+46
View File
@@ -0,0 +1,46 @@
# Production pageview privacy contract
`static/privacy-pageview.js` loads the current Umami script only on HTTPS,
exact hostname `player.nenasets.com`, default port, and known application document routes.
It never loads on loopback, desktop protocol, DEV, previous, rollback, API,
admin, download, or unknown routes. Website: `dc1b14a6-d1ba-4802-b895-553b3c81c677`.
Automatic Umami tracking is disabled, including history and click hooks. One
manual pageview is sent per loaded document. The before-send hook reconstructs
a strict projection: website, hostname, static title `Alta Export Player`, static path
`/`, empty referrer, validated ordinary browser screen/language. It drops
identity, unknown/custom events, tags, arbitrary properties, user title, URL,
query/fragment, filenames, upload contents, forms and application state.
Only fixed download controls emit fixed event names/properties: `#releaseAction` → `download_desktop_app` (`tool: alta-export-player`, `format: desktop`). Native desktop/update modes are excluded.
These are click intents, not successful-download reports. A capture listener does
not read the link URL or filename, cancel navigation, or await analytics. Unknown events and
caller-provided event data are discarded; whitelisted event data is reconstructed.
The hook is installed before creating/loading the external script, auto tracking
is disabled, and an unavailable bootstrap produces no tracker GET/POST. The script
uses anonymous CORS and no-referrer; collection fetch credentials are omit.
No application data is read. Both script requests and collection requests must
have no Cookie, Authorization or Referer headers. Tracker credentials are omit.
The only CSP additions are the exact script and endpoint paths:
`https://umami.nenasets.com/script.js` in script-src and
`https://umami.nenasets.com/api/send` in connect-src. Existing restrictions stay.
No gateway/auth/deployment/registry configuration is changed by this commit.
## Repeatable gates
- `node --test tests/analytics.test.js`: hostname/path/protocol/port allowlist,
single pageview, automatic tracking off, reconstruction and identity/event denial.
- `node scripts/check.js`: native utility/server checks plus analytics suite.
- Root is the only native application document route. No synthetic media fixture
is provided; local acceptance exercised shell/layout controls, not footage playback.
Built-image Chromium desktop/mobile acceptance and request evidence live outside
this source checkout in the rollout project worker reports. The audited real
tracker was served locally and its POSTs intercepted with a204 sink, not sent to
production Umami. This does not prove organic ingestion. ONVIF acceptance uses
unchanged documented real bundled catalogue records, independent local signing
secrets and a test-only employee verifier; it does not prove a Google login.
No push or deployment is part of this commit. Inspect CI and deployment bindings
before publishing; the coordinator owns exact-image promotion and rollback.
+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 });
},
});
+1256 -1426
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"
]
}
}
}
+71
View File
@@ -0,0 +1,71 @@
#!/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']);
run(process.execPath, ['--test', 'tests/analytics.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);
});
+56
View File
@@ -0,0 +1,56 @@
/* Static production pageviews/download intent. No application data to Umami. */
(() => {
'use strict';
const hostname = "player.nenasets.com";
const website = "dc1b14a6-d1ba-4802-b895-553b3c81c677";
const url = "/";
const title = "Alta Export Player";
const paths = ["/"];
const eligible = () => window.location.protocol === 'https:'
&& window.location.hostname === hostname && window.location.port === ''
&& paths.includes(window.location.pathname);
if (!eligible() || window.__altaPageviewLoaded) return;
window.__altaPageviewLoaded = true;
const controls = {releaseAction: 'download_desktop_app'};
const events = {download_desktop_app: {tool: 'alta-export-player', format: 'desktop'}};
const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
// Rebuild, don't spread the input: no identity, arbitrary properties, referrer,
// customer title, query, fragment, form, filename or upload data can survive.
window.altaSafePageview = (type, payload) => {
if (!eligible() || type !== 'event' || !payload) return null;
if (payload.name && (typeof payload.name !== 'string' || !own(events, payload.name))) return null;
const safe = { website, hostname, url, title, referrer: '',
screen: typeof payload.screen === 'string' && /^\d{1,5}x\d{1,5}$/.test(payload.screen) ? payload.screen : undefined,
language: typeof payload.language === 'string' && /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(payload.language) ? payload.language : undefined };
if (payload.name) { safe.name = payload.name; safe.data = {...events[payload.name]}; }
return safe;
};
const tracker = document.createElement('script');
tracker.src = 'https://umami.nenasets.com/script.js';
tracker.defer = true;
tracker.referrerPolicy = 'no-referrer';
tracker.crossOrigin = 'anonymous';
for (const [key, value] of Object.entries({
'website-id': website, domains: hostname, 'auto-track': 'false',
'exclude-search': 'true', 'exclude-hash': 'true',
'fetch-credentials': 'omit', 'before-send': 'altaSafePageview'
})) tracker.setAttribute('data-' + key, value);
let sent = false;
tracker.onload = () => {
if (!sent && eligible() && window.umami) {
sent = true;
window.umami.track();
}
};
// Capture intent once, before application handlers; never cancel/await navigation.
// The web download anchor and native desktop/update behavior stay unchanged.
document.addEventListener('click', event => {
if (!eligible() || window.webavpNative || event.button !== 0) return;
const control = event.target.closest?.('a[id]');
if (!control || control.disabled || control.getAttribute('aria-disabled') === 'true'
|| !own(controls, control.id) || !window.umami) return;
const name = controls[control.id];
try { window.umami.track(name, {...events[name]}); } catch (_) { /* Analytics is optional. */ }
}, true);
document.head.appendChild(tracker);
})();
+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,
};
}));
+142 -22
View File
@@ -3,10 +3,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; media-src blob:; img-src 'self' data:;">
<meta name="referrer" content="no-referrer">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://umami.nenasets.com/script.js; style-src 'self' 'unsafe-inline'; connect-src 'self' https://umami.nenasets.com/api/send; media-src blob:; img-src 'self' data:;">
<script src="/static/privacy-pageview.js?v=2" defer></script>
<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 +472,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 +730,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 +889,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 +1349,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 +1532,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 +1544,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 +1573,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 +1644,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 +1732,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 +1868,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 +1902,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 +2141,8 @@
row++;
}
activeSegmentsDirty = true;
// Update minimap segments
renderMinimap(visibleChannels);
updateTimelineLabels();
@@ -2104,7 +2209,6 @@
if (ch.cellEl) {
applyZoom(ch.cellEl);
}
}
if (ch._segInfoEl) {
const total = ch.segments.length;
@@ -2112,6 +2216,8 @@
? `Clip ${newIdx + 1}/${total}`
: `${total} clips`;
}
activeSegmentsDirty = true;
}
}
}
@@ -2249,12 +2355,13 @@
}
}
if (activeSegmentsDirty) {
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);
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 +3238,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 +3272,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
+115
View File
@@ -0,0 +1,115 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.resolve(__dirname, '..');
const player = fs.existsSync(path.join(root, 'package.json'));
const site = player ? 'dc1b14a6-d1ba-4802-b895-553b3c81c677' : 'e00bb594-4c6e-4471-911d-be3f7fdd9fae';
const host = player ? 'player.nenasets.com' : 'tools.nenasets.com';
const route = player ? '/' : '/private/onvif/';
function boot(url) {
const scripts = [], tracked = [], listeners = [];
const window = { location: new URL(url), umami: {track: (...args) => tracked.push(args)} };
const document = { addEventListener: (type, fn, capture) => listeners.push({type, fn, capture}),
createElement: () => ({setAttribute(k,v) { this[k] = v; }}),
head: {appendChild: s => { assert.equal(typeof window.altaSafePageview, 'function', 'hook installed before load'); scripts.push(s); }} };
const context = vm.createContext({window, document});
const source = fs.readFileSync(path.join(root, 'static/privacy-pageview.js'), 'utf8');
vm.runInContext(source, context);
return {window, scripts, tracked, listeners, context, source};
}
test('production pageviews use one manually loaded no-referrer tracker', () => {
const b = boot('https://' + host + route + '?customer=PRIVATE#PRIVATE');
assert.equal(b.scripts.length, 1);
const s = b.scripts[0];
assert.equal(s.src, 'https://umami.nenasets.com/script.js');
assert.equal(s.referrerPolicy, 'no-referrer');
assert.equal(s.crossOrigin, 'anonymous');
assert.equal(s['data-auto-track'], 'false');
assert.equal(s['data-exclude-search'], 'true');
assert.equal(s['data-exclude-hash'], 'true');
assert.equal(s['data-fetch-credentials'], 'omit');
assert.equal(s['data-domains'], host);
assert.equal(s['data-website-id'], site);
s.onload(); s.onload();
vm.runInContext(b.source, b.context);
assert.equal(b.scripts.length, 1);
assert.equal(b.tracked.length, 1);
});
test('before-send rebuilds a minimal static pageview; no identity, events or arbitrary data', () => {
const b = boot('https://' + host + route);
const guard = b.window[b.scripts[0]['data-before-send']];
const dirty = {website:'PRIVATE',url:'PRIVATE',title:'PRIVATE',hostname:'PRIVATE',referrer:'PRIVATE',name:'PRIVATE',data:{filename:'PRIVATE'},id:'PRIVATE',tag:'PRIVATE',screen:'1920x1080',language:'en-US'};
assert.equal(guard('identify', dirty), null);
assert.equal(guard('event', dirty), null);
delete dirty.name;
const safe = JSON.parse(JSON.stringify(guard('event', dirty)));
assert.deepEqual(safe, {website:site,hostname:host,url:route,title:player?'Alta Export Player':'ONVIF Tool',referrer:'',screen:'1920x1080',language:'en-US'});
assert.ok(!JSON.stringify(safe).includes('PRIVATE'));
b.window.location = new URL('https://' + host + '/private/previous/');
assert.equal(guard('event', dirty), null);
});
test('fail closed on DEV, previous, unknown, API, port, alternate host and HTTP routes', () => {
const excluded = ['http://' + host + route, 'https://' + host + ':8443' + route,
'https://dev.' + host + route, 'https://127.0.0.1' + route,
'https://' + host + '/private/onvif-dev/', 'https://' + host + '/private/onvif-previous/',
'https://' + host + '/private/onvif/previous/', 'https://' + host + '/private/onvif/api/lookup',
'https://' + host + '/private/onvif/download', 'https://' + host + '/private/onvif/admin',
'https://' + host + '/customer-name', 'https://' + host + '/api/verify-cert'];
for (const url of excluded) assert.equal(boot(url).scripts.length, 0, url);
});
const downloads = player ? [['releaseAction', 'download_desktop_app', {tool:'alta-export-player',format:'desktop'}]]
: [['download-template', 'download_csv_template', {tool:'onvif',format:'csv',kind:'template'}],
['download-report', 'download_csv_report', {tool:'onvif',format:'csv',kind:'report'}]];
test('static download clicks count once without changing native navigation or reading user data', () => {
const b = boot('https://' + host + route);
b.scripts[0].onload();
vm.runInContext(b.source, b.context);
assert.equal(b.listeners.length, 1);
assert.equal(b.listeners[0].type, 'click');
assert.equal(b.listeners[0].capture, true);
for (const [id, name, data] of downloads) {
const control = {id,tagName:'A',disabled:false,getAttribute:()=>null,
form:{checkValidity:()=>false},closest:()=>control};
const event = {target:{closest:()=>control},button:0,preventDefault:()=>assert.fail('navigation intercepted')};
const n=b.tracked.length;
b.listeners[0].fn(event);
assert.equal(b.tracked.length,n+1);
assert.equal(b.tracked.at(-1)[0],name);
assert.deepEqual(JSON.parse(JSON.stringify(b.tracked.at(-1)[1])),data);
const safe=b.window.altaSafePageview('event',{name,data:{url:'PRIVATE',filename:'PRIVATE'},id:'PRIVATE',tag:'PRIVATE'});
assert.equal(safe.name,name);
assert.deepEqual(JSON.parse(JSON.stringify(safe.data)),data);
assert.ok(!JSON.stringify(safe).includes('PRIVATE'));
control.disabled=true;b.listeners[0].fn(event);assert.equal(b.tracked.length,n+1);
}
b.window.location=new URL('https://'+host+'/private/previous/');
const n=b.tracked.length;
b.listeners[0].fn({target:{closest:()=>({id:downloads[0][0],tagName:'A'})},button:0});
assert.equal(b.tracked.length,n);
});
test('analytics missing or throwing does not prevent download clicks', () => {
const b=boot('https://'+host+route);
const e={target:{closest:()=>({id:downloads[0][0],tagName:'A',getAttribute:()=>null})},button:0};
delete b.window.umami;assert.doesNotThrow(()=>b.listeners[0].fn(e));
b.window.umami={track:()=>{throw Error('offline');}};assert.doesNotThrow(()=>b.listeners[0].fn(e));
});
test('known events use an own-property whitelist, native desktop modes are excluded', () => {
const b=boot('https://'+host+route);
for(const name of ['__proto__','constructor','toString','PRIVATE', {toString:()=>downloads[0][1]}])
assert.equal(b.window.altaSafePageview('event',{name}),null);
if(player){
b.window.webavpNative={checkForUpdates:()=>{}};
const n=b.tracked.length;
b.listeners[0].fn({target:{closest:()=>({id:'releaseAction',tagName:'A',getAttribute:()=>null})},button:0});
assert.equal(b.tracked.length,n);
}
});
test('only native application document routes are eligible', () => {
for(const suffix of (player ? [''] : ['', 'upload', 'results', 'process', 'clear']))
assert.equal(boot('https://' + host + route + suffix).scripts.length, 1, suffix);
});
+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');