Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ace4568f81 | |||
| c08d2ad381 | |||
| 43892865c3 | |||
| 7d484a3998 | |||
| 3b5e2acb75 | |||
| 8d256cd4ab | |||
| c65a813e5f | |||
| 6a8a4cf95a |
@@ -27,7 +27,7 @@ npm run build
|
||||
- IPC methods are narrow and sender-validated against the main local file frame.
|
||||
- Bridge is exactly `127.0.0.1:18247`, exact committed extension origin, and `X-APT-Pairing` authenticated.
|
||||
- Persist only the pairing hash envelope under Electron `userData`; plaintext is shown once after first run/rotation.
|
||||
- Proxy helper launches directly with `shell: false`; stop only app-owned child processes.
|
||||
- Proxy helper launches directly with `shell: false`; passwordless launch uses the main-owned paired session only through the helper's required `-k` argument. Never expose that cookie to renderer IPC, UI, logs, files, shell commands, or tracked process metadata; stop only app-owned child processes.
|
||||
- Update behavior is check-only against the exact GitPeji API. The only follow-up action opens the fixed GitPeji releases page externally.
|
||||
- Never add download/install/replace logic, arbitrary URLs, redirect following, shell scripts, broad process killing, hardcoded bridge secrets, or credential-bearing renderer state.
|
||||
- Synthetic tests only: never use a real tenant or Alta session.
|
||||
|
||||
@@ -8,7 +8,7 @@ APT is a Windows Electron desktop app that imports an existing Alta Video sessio
|
||||
- The renderer receives only connection origin/state, device/site/auth responses, and owned proxy metadata. It never receives or supplies the Alta session value.
|
||||
- The bridge listens only on `127.0.0.1:18247`, accepts only the committed extension origin, requires the `X-APT-Pairing` secret, limits concurrent/body/deadline work, and validates an exact canonical Alta HTTPS origin.
|
||||
- The pairing envelope is a scrypt hash stored atomically under Electron `userData` with restrictive permissions. The plaintext secret is shown once on first run or rotation. Revoke invalidates it.
|
||||
- Proxy launch is a direct, detached `spawn` of the fixed helper with `shell: false`. Its exact arguments are deployment host, non-secret Alta username/email, and selected device UUID; no session bearer, password, or 2FA value reaches the command line. Stop actions can target only children owned by this app.
|
||||
- Proxy launch is a direct, detached `spawn` of the fixed helper with `shell: false`. APT passes the main-process-only paired Alta session through the helper's supported `-k` cookie mode so users are not prompted for Alta credentials again. The cookie never crosses renderer IPC, enters a shell, or reaches logs, files, tracked process metadata, or UI state; it is necessarily present in the helper process arguments because the external helper exposes no pipe/stdin credential interface. Stop actions can target only children owned by this app.
|
||||
- Updates are **check-only**. APT checks the exact GitPeji release API and can open only `https://git.pejicorp.com/peji/Alta-Proxy-Tool/releases` in the system browser. It never downloads, replaces, or executes an update.
|
||||
|
||||
## Requirements
|
||||
@@ -30,11 +30,14 @@ npm start
|
||||
2. Start APT. Under **Bridge Pairing**, copy the one-time secret.
|
||||
3. Open the extension pairing settings, paste the secret, and save it.
|
||||
4. Visit your Alta deployment in Chrome and use **Send to APT**.
|
||||
5. Select a local camera, enter your Alta username/email, and choose **Start Proxy**.
|
||||
6. Complete the password and 2FA prompts in the helper's visible Windows console. APT does not collect or pass those secrets.
|
||||
5. Select a local camera and choose **Start Proxy**. APT reuses the paired Chrome session; no Alta username, password, or 2FA prompt is required.
|
||||
|
||||
Use **Generate / Rotate** if a pairing may have been exposed, then update the extension. Use **Revoke** to immediately disable bridge authentication.
|
||||
|
||||
## Camera hierarchy and large deployments
|
||||
|
||||
The sidebar presents collapsed Alta sites, then device groups, then cameras, including explicit fallback nodes for ungrouped, orphaned, unknown, or conflicting metadata. Search opens only matching ancestors and supports full tree keyboard navigation. A fixed-height virtual window keeps the mounted DOM bounded even when thousands of cameras are visible; selection and active-proxy state survive expansion, search, and scrolling. If site or group metadata cannot be loaded, every valid camera remains selectable in the fallback hierarchy and APT shows a warning.
|
||||
|
||||
## Development and verification
|
||||
|
||||
```bash
|
||||
@@ -54,7 +57,8 @@ Core files:
|
||||
- `src/proxy-launch.js` — fixed shell-free helper process management
|
||||
- `src/update-policy.js` — exact GitPeji check-only release policy
|
||||
- `preload.js` — narrow context bridge
|
||||
- `renderer.js`, `index.html`, `styles.css` — non-secret UI
|
||||
- `device-tree.js`, `sidebar-controller.js`, `sidebar-view.js` — pure hierarchy, interaction state, and bounded DOM adapter
|
||||
- `renderer.js`, `index.html`, `styles.css` — non-secret UI integration and Alta styling
|
||||
- `chrome-extension/` — stable-ID paired cookie sender
|
||||
- `test/` — pure and end-to-end contract tests
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Alta Proxy Tool Bridge",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.5",
|
||||
"description": "Send Alta session cookies to a paired Alta Proxy Tool desktop app.",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt4EZdkSgOsyiy5DRe0JkX+BpK94FpMjBU59NVIqDPO8QBDwvqNDWT/UjqHK/0aqSxzed5KibX6MdAvc495+u1sCybFdjDdXyBewEvg+PDqGiJketlZKC9dcR1RXHuPgAoM3NaNbMb3TqYcS9J4iGq0UwadxubkQrEcPiuyR6oriOkop8q9/5DWGb15wOGmiCuVmlXfUjNJIvNBm9P/ZHtgFBYDI2PuSSI5GI4j04VFpEyfNlFCrpi8GQ7bYZzezigZWXRjhhNwkx39bNHlkAWYa8XGZseCpKKvi0EaeCoBPjoYSAt161SM1dqX+/UC61/sLOU/SpDB1SYGTm5DC+7wIDAQAB",
|
||||
"permissions": ["cookies", "activeTab", "clipboardWrite", "storage"],
|
||||
|
||||
@@ -75,9 +75,3 @@ button:disabled { cursor: not-allowed; opacity: 0.45; }
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #3c3c3c;
|
||||
}
|
||||
|
||||
.privacy-note {
|
||||
margin-top: 24px;
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<body>
|
||||
<main class="options-card">
|
||||
<h1>Pair with Alta Proxy Tool</h1>
|
||||
<p>In the desktop app, create a one-time pairing secret. Paste it below on this computer. Treat it like a password.</p>
|
||||
<p>Create a one-time pairing secret in APT, then paste it below.</p>
|
||||
<form id="pairingForm">
|
||||
<label for="pairingSecret">One-time pairing secret</label>
|
||||
<input id="pairingSecret" name="pairingSecret" type="password" inputmode="text" autocomplete="off" spellcheck="false" required>
|
||||
@@ -22,7 +22,7 @@
|
||||
<p id="currentState">Checking...</p>
|
||||
<button id="forgetBtn" type="button" class="danger-btn" disabled>Forget pairing</button>
|
||||
</section>
|
||||
<p class="privacy-note">The secret is stored only in Chrome extension local storage. It is never displayed again or copied automatically.</p>
|
||||
|
||||
</main>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -88,14 +88,6 @@ h1 {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.copy-warning {
|
||||
margin: 10px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-left: 3px solid #f0a33a;
|
||||
background: rgba(240, 163, 58, 0.08);
|
||||
color: #d7c29f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-msg {
|
||||
display: none;
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
<button id="sendBtn" class="primary-btn" type="button" disabled>Send to APT</button>
|
||||
<button id="copyBtn" class="secondary-btn" type="button" disabled>Copy VA Token</button>
|
||||
</div>
|
||||
<p id="copyWarning" class="copy-warning">Copying exposes the full bearer token. Clipboard history or sync may retain it. A confirmation is required.</p>
|
||||
<div id="statusMsg" class="status-msg" role="status" aria-live="polite"></div>
|
||||
</main>
|
||||
<script src="popup.js"></script>
|
||||
|
||||
@@ -45,7 +45,6 @@ function createPopupController({
|
||||
const pairingInfo = documentApi.getElementById('pairingInfo');
|
||||
const sendBtn = documentApi.getElementById('sendBtn');
|
||||
const copyBtn = documentApi.getElementById('copyBtn');
|
||||
const copyWarning = documentApi.getElementById('copyWarning');
|
||||
const statusMsg = documentApi.getElementById('statusMsg');
|
||||
const openOptionsBtn = documentApi.getElementById('openOptionsBtn');
|
||||
|
||||
@@ -159,9 +158,7 @@ function createPopupController({
|
||||
|
||||
async function copyToken() {
|
||||
if (!detectedOrigin || !pairingSecret || busy) return;
|
||||
const confirmed = confirmCopy(
|
||||
'Copy the full VA bearer token? Clipboard history or sync may retain it. Continue only if you will paste it into a trusted destination.'
|
||||
);
|
||||
const confirmed = confirmCopy('Copy VA token?');
|
||||
if (!confirmed) {
|
||||
showStatus('Copy cancelled. The VA token was not read.', 'info');
|
||||
return;
|
||||
@@ -172,7 +169,7 @@ function createPopupController({
|
||||
try {
|
||||
cookieValue = await getVaCookieValue();
|
||||
await navigatorApi.clipboard.writeText(cookieValue);
|
||||
showStatus('VA token copied. Clear your clipboard after use.', 'success');
|
||||
showStatus('VA token copied.', 'success');
|
||||
} catch (error) {
|
||||
showCookieError(error, true);
|
||||
} finally {
|
||||
@@ -182,7 +179,6 @@ function createPopupController({
|
||||
}
|
||||
|
||||
async function init() {
|
||||
copyWarning.textContent = 'Copying exposes the full bearer token. Clipboard history or sync may retain it. A confirmation is required.';
|
||||
sendBtn.addEventListener('click', sendToApt);
|
||||
copyBtn.addEventListener('click', copyToken);
|
||||
openOptionsBtn.addEventListener('click', () => chromeApi.runtime.openOptionsPage());
|
||||
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
'use strict';
|
||||
|
||||
(function exposeDeviceTree(globalScope) {
|
||||
const { toWellFormedString } = typeof module !== 'undefined' && module.exports
|
||||
? require('./well-formed-string')
|
||||
: globalScope.AptWellFormedString;
|
||||
const SPECIAL = Object.freeze({
|
||||
ORPHAN_SITE: '__orphaned__',
|
||||
UNGROUPED: '__ungrouped__',
|
||||
UNKNOWN_GROUP_PREFIX: '__unknown_group__:',
|
||||
UNKNOWN_SITE_PREFIX: '__unknown_site__:',
|
||||
});
|
||||
|
||||
function text(value) {
|
||||
return value === null || value === undefined ? '' : toWellFormedString(value).trim();
|
||||
}
|
||||
|
||||
function normalized(value) {
|
||||
return text(value).normalize('NFKD').toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function keyPart(value) {
|
||||
const encoded = encodeURIComponent(toWellFormedString(value));
|
||||
return `${encoded.length}:${encoded}`;
|
||||
}
|
||||
|
||||
function compareNodes(left, right) {
|
||||
const nameOrder = left.normalizedName === right.normalizedName
|
||||
? 0
|
||||
: (left.normalizedName < right.normalizedName ? -1 : 1);
|
||||
const idOrder = left.canonicalId === right.canonicalId
|
||||
? 0
|
||||
: (left.canonicalId < right.canonicalId ? -1 : 1);
|
||||
return nameOrder || idOrder || left.ordinal - right.ordinal;
|
||||
}
|
||||
|
||||
function createIdentity(kind, item, ordinal, occurrences, diagnostics) {
|
||||
const rawId = text(item && item.id);
|
||||
const canonicalId = rawId || `__invalid_${kind}_${ordinal}`;
|
||||
const count = (occurrences.get(canonicalId) || 0) + 1;
|
||||
occurrences.set(canonicalId, count);
|
||||
if (!rawId) diagnostics.push({ code: 'malformed-id', kind, ordinal, id: rawId });
|
||||
if (count > 1) diagnostics.push({ code: 'duplicate-id', kind, ordinal, id: canonicalId });
|
||||
return {
|
||||
rawId,
|
||||
canonicalId,
|
||||
uniqueId: count === 1 ? canonicalId : `${canonicalId}~${count}`,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSite(id, name, ordinal, extra) {
|
||||
const syntheticType = extra && extra.syntheticType;
|
||||
return Object.assign({
|
||||
kind: 'site', id, canonicalId: id, name, normalizedName: normalized(name), ordinal,
|
||||
key: syntheticType
|
||||
? `site:s:${keyPart(syntheticType)}:${keyPart(id)}`
|
||||
: `site:t:${keyPart(id)}`,
|
||||
pendingDeletion: false, groups: [], synthetic: false,
|
||||
}, extra);
|
||||
}
|
||||
|
||||
function makeGroup(site, id, name, ordinal, extra) {
|
||||
const syntheticType = extra && extra.syntheticType;
|
||||
return Object.assign({
|
||||
kind: 'group', id, canonicalId: id, name, normalizedName: normalized(name), ordinal,
|
||||
key: syntheticType
|
||||
? `group:s:${keyPart(syntheticType)}:${keyPart(site.key)}:${keyPart(id)}`
|
||||
: `group:t:${keyPart(site.key)}:${keyPart(id)}`,
|
||||
parentKey: site.key, site, cameras: [],
|
||||
pendingDeletion: false, synthetic: false,
|
||||
}, extra);
|
||||
}
|
||||
|
||||
function buildDeviceTree(payload = {}) {
|
||||
const diagnostics = [];
|
||||
const sourceSites = Array.isArray(payload.sites) ? payload.sites : [];
|
||||
const sourceGroups = Array.isArray(payload.groups) ? payload.groups : [];
|
||||
const sourceDevices = Array.isArray(payload.devices) ? payload.devices : [];
|
||||
const rowByKey = new Map();
|
||||
const cameraByKey = new Map();
|
||||
const sites = [];
|
||||
const siteByRawId = new Map();
|
||||
const groupByRawId = new Map();
|
||||
const groupOccurrences = new Map();
|
||||
const siteOccurrences = new Map();
|
||||
const cameraOccurrences = new Map();
|
||||
const syntheticSites = new Map();
|
||||
let syntheticOrdinal = sourceSites.length + sourceGroups.length + sourceDevices.length;
|
||||
|
||||
function registerRow(node) {
|
||||
if (!rowByKey.has(node.key)) {
|
||||
rowByKey.set(node.key, node);
|
||||
return true;
|
||||
}
|
||||
const collidedKey = node.key;
|
||||
let collisionOrdinal = 2;
|
||||
do {
|
||||
node.key = `${collidedKey}:collision:${collisionOrdinal}`;
|
||||
collisionOrdinal += 1;
|
||||
} while (rowByKey.has(node.key));
|
||||
diagnostics.push({
|
||||
code: 'row-key-collision', key: collidedKey, resolvedKey: node.key,
|
||||
kind: node.kind, id: node.canonicalId,
|
||||
});
|
||||
rowByKey.set(node.key, node);
|
||||
return false;
|
||||
}
|
||||
|
||||
sourceSites.forEach((item, ordinal) => {
|
||||
const safe = item && typeof item === 'object' ? item : {};
|
||||
const identity = createIdentity('site', safe, ordinal, siteOccurrences, diagnostics);
|
||||
const site = makeSite(identity.uniqueId, text(safe.name) || identity.canonicalId, ordinal, {
|
||||
canonicalId: identity.canonicalId,
|
||||
pendingDeletion: safe.pendingDeletion === true || Boolean(text(safe.pendingDeletionStart)),
|
||||
source: {
|
||||
id: identity.canonicalId,
|
||||
name: text(safe.name),
|
||||
pendingDeletionStart: text(safe.pendingDeletionStart) || null,
|
||||
},
|
||||
});
|
||||
sites.push(site);
|
||||
registerRow(site);
|
||||
if (identity.rawId && !siteByRawId.has(identity.rawId)) siteByRawId.set(identity.rawId, site);
|
||||
});
|
||||
|
||||
function ensureUnknownSite(rawSiteId) {
|
||||
const labelId = text(rawSiteId);
|
||||
const id = labelId ? `${SPECIAL.UNKNOWN_SITE_PREFIX}${labelId}` : SPECIAL.ORPHAN_SITE;
|
||||
const syntheticType = labelId ? 'unknown-site' : 'orphan';
|
||||
const identity = `${syntheticType}:${keyPart(labelId)}`;
|
||||
let site = syntheticSites.get(identity);
|
||||
if (!site) {
|
||||
const name = labelId ? `Unknown site: ${labelId}` : 'Orphaned cameras';
|
||||
site = makeSite(id, name, syntheticOrdinal++, {
|
||||
synthetic: true, syntheticType, hierarchyStatus: syntheticType,
|
||||
});
|
||||
sites.push(site);
|
||||
registerRow(site);
|
||||
syntheticSites.set(identity, site);
|
||||
}
|
||||
return site;
|
||||
}
|
||||
|
||||
sourceGroups.forEach((item, ordinal) => {
|
||||
const safe = item && typeof item === 'object' ? item : {};
|
||||
const identity = createIdentity('group', safe, ordinal, groupOccurrences, diagnostics);
|
||||
const parentId = text(safe.parentId);
|
||||
const site = siteByRawId.get(parentId) || ensureUnknownSite(parentId);
|
||||
if (!siteByRawId.has(parentId)) diagnostics.push({ code: parentId ? 'unknown-parent-site' : 'malformed-parent-site', key: `group:${site.id}:${identity.uniqueId}`, id: parentId });
|
||||
const group = makeGroup(site, identity.uniqueId, text(safe.name) || identity.canonicalId, ordinal, {
|
||||
canonicalId: identity.canonicalId,
|
||||
rawParentId: parentId,
|
||||
pendingDeletion: safe.pendingDeletion === true || Boolean(text(safe.pendingDeletionStart)),
|
||||
source: {
|
||||
id: identity.canonicalId,
|
||||
name: text(safe.name),
|
||||
parentId,
|
||||
pendingDeletionStart: text(safe.pendingDeletionStart) || null,
|
||||
},
|
||||
});
|
||||
site.groups.push(group);
|
||||
registerRow(group);
|
||||
if (identity.rawId && !groupByRawId.has(identity.rawId)) groupByRawId.set(identity.rawId, group);
|
||||
});
|
||||
|
||||
function ensureGroup(site, id, name, status, template) {
|
||||
const syntheticType = status === 'conflict' ? 'conflict' : status;
|
||||
const key = `group:s:${keyPart(syntheticType)}:${keyPart(site.key)}:${keyPart(id)}`;
|
||||
let group = rowByKey.get(key);
|
||||
if (!group) {
|
||||
group = makeGroup(site, id, name, syntheticOrdinal++, {
|
||||
synthetic: true, syntheticType,
|
||||
hierarchyStatus: status,
|
||||
canonicalId: template ? template.canonicalId : id,
|
||||
pendingDeletion: template ? template.pendingDeletion : false,
|
||||
source: template ? template.source : undefined,
|
||||
});
|
||||
site.groups.push(group);
|
||||
registerRow(group);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
sourceDevices.forEach((item, ordinal) => {
|
||||
const safe = item && typeof item === 'object' ? item : {};
|
||||
const identity = createIdentity('camera', safe, ordinal, cameraOccurrences, diagnostics);
|
||||
const cameraKey = `camera:t:${keyPart(identity.uniqueId)}`;
|
||||
const directSiteId = text(safe.siteId);
|
||||
const requestedGroupId = text(safe.deviceGroupId);
|
||||
const knownGroup = requestedGroupId ? groupByRawId.get(requestedGroupId) : undefined;
|
||||
let site;
|
||||
let hierarchyStatus = text(safe.hierarchyStatus) || 'assigned';
|
||||
|
||||
if (directSiteId) {
|
||||
site = siteByRawId.get(directSiteId) || ensureUnknownSite(directSiteId);
|
||||
if (!siteByRawId.has(directSiteId)) hierarchyStatus = 'unknown-site';
|
||||
} else if (knownGroup) {
|
||||
site = knownGroup.site;
|
||||
hierarchyStatus = 'inferred-site';
|
||||
} else {
|
||||
site = ensureUnknownSite('');
|
||||
hierarchyStatus = requestedGroupId ? 'unknown-group' : 'orphan';
|
||||
}
|
||||
|
||||
let group;
|
||||
if (!requestedGroupId) {
|
||||
group = ensureGroup(site, SPECIAL.UNGROUPED, 'Ungrouped', 'ungrouped');
|
||||
} else if (!knownGroup) {
|
||||
group = ensureGroup(site, `${SPECIAL.UNKNOWN_GROUP_PREFIX}${requestedGroupId}`, `Unknown group: ${requestedGroupId}`, 'unknown-group');
|
||||
if (hierarchyStatus !== 'unknown-site') hierarchyStatus = 'unknown-group';
|
||||
diagnostics.push({ code: 'unknown-group', key: cameraKey, id: requestedGroupId });
|
||||
} else if (knownGroup.site !== site) {
|
||||
group = ensureGroup(site, knownGroup.id, knownGroup.name, 'conflict', knownGroup);
|
||||
hierarchyStatus = 'conflict';
|
||||
diagnostics.push({ code: 'site-group-conflict', key: cameraKey, siteId: directSiteId, groupId: requestedGroupId, groupSiteId: knownGroup.site.canonicalId });
|
||||
} else {
|
||||
group = knownGroup;
|
||||
}
|
||||
|
||||
const name = text(safe.name) || identity.canonicalId;
|
||||
const source = {
|
||||
id: identity.canonicalId,
|
||||
name,
|
||||
type: text(safe.type) || null,
|
||||
model: text(safe.model) || null,
|
||||
address: text(safe.address) || null,
|
||||
siteId: directSiteId || null,
|
||||
deviceGroupId: requestedGroupId || null,
|
||||
displayStatus: text(safe.displayStatus) || null,
|
||||
localStorage: typeof safe.localStorage === 'boolean' ? safe.localStorage : null,
|
||||
};
|
||||
const camera = {
|
||||
kind: 'camera', id: identity.uniqueId, canonicalId: identity.canonicalId,
|
||||
key: cameraKey, name, normalizedName: normalized(name), ordinal,
|
||||
parentKey: group.key, site, group, hierarchyStatus, source,
|
||||
};
|
||||
camera.searchCorpus = normalized([
|
||||
name, identity.canonicalId, safe.model, safe.type, safe.address, site.name, group.name,
|
||||
].map(text).join(' '));
|
||||
group.cameras.push(camera);
|
||||
registerRow(camera);
|
||||
cameraByKey.set(camera.key, camera);
|
||||
});
|
||||
|
||||
sites.forEach((site) => {
|
||||
site.groups.sort(compareNodes);
|
||||
site.groups.forEach((group) => group.cameras.sort(compareNodes));
|
||||
});
|
||||
sites.sort(compareNodes);
|
||||
|
||||
return Object.freeze({ sites, rowByKey, cameraByKey, diagnostics, cameraCount: cameraByKey.size });
|
||||
}
|
||||
|
||||
function rowDescriptor(node, level, expanded, selectedKey, position, size) {
|
||||
const aria = {
|
||||
level,
|
||||
expanded: node.kind === 'camera' ? undefined : expanded,
|
||||
selected: node.key === selectedKey,
|
||||
posinset: position,
|
||||
setsize: size,
|
||||
};
|
||||
return {
|
||||
key: node.key, parentKey: node.parentKey || null, kind: node.kind, id: node.id,
|
||||
name: node.name, pendingDeletion: node.pendingDeletion === true,
|
||||
hierarchyStatus: node.hierarchyStatus, aria,
|
||||
ariaLevel: aria.level, ariaExpanded: aria.expanded, ariaSelected: aria.selected,
|
||||
ariaPosInSet: aria.posinset, ariaSetSize: aria.setsize,
|
||||
};
|
||||
}
|
||||
|
||||
function flattenVisibleRows(model, options = {}) {
|
||||
const expandedKeys = options.expandedKeys instanceof Set ? options.expandedKeys : new Set();
|
||||
const selectedKey = typeof options.selectedKey === 'string' ? options.selectedKey : null;
|
||||
const matches = options.matchCameraKeys instanceof Set ? options.matchCameraKeys : null;
|
||||
const rows = [];
|
||||
const includedGroups = (site) => site.groups.filter((group) => !matches || group.cameras.some((camera) => matches.has(camera.key)));
|
||||
const includedSites = model.sites.filter((site) => !matches || includedGroups(site).length > 0);
|
||||
|
||||
includedSites.forEach((site, siteIndex) => {
|
||||
const siteExpanded = expandedKeys.has(site.key);
|
||||
rows.push(rowDescriptor(site, 1, siteExpanded, selectedKey, siteIndex + 1, includedSites.length));
|
||||
if (!siteExpanded) return;
|
||||
const groups = includedGroups(site);
|
||||
groups.forEach((group, groupIndex) => {
|
||||
const groupExpanded = expandedKeys.has(group.key);
|
||||
rows.push(rowDescriptor(group, 2, groupExpanded, selectedKey, groupIndex + 1, groups.length));
|
||||
if (!groupExpanded) return;
|
||||
const cameras = matches ? group.cameras.filter((camera) => matches.has(camera.key)) : group.cameras;
|
||||
cameras.forEach((camera, cameraIndex) => {
|
||||
rows.push(rowDescriptor(camera, 3, undefined, selectedKey, cameraIndex + 1, cameras.length));
|
||||
});
|
||||
});
|
||||
});
|
||||
Object.defineProperty(rows, 'hiddenSelected', {
|
||||
value: Boolean(selectedKey && model.rowByKey.has(selectedKey) && !rows.some((row) => row.key === selectedKey)),
|
||||
enumerable: true,
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function calculateVirtualWindow(rows, options = {}) {
|
||||
const rowHeight = Number.isFinite(options.rowHeight) && options.rowHeight > 0 ? options.rowHeight : 28;
|
||||
const viewportHeight = Number.isFinite(options.viewportHeight) && options.viewportHeight >= 0 ? options.viewportHeight : 0;
|
||||
const requestedScrollTop = Math.max(0, Number.isFinite(options.scrollTop) ? options.scrollTop : 0);
|
||||
const overscan = Math.max(0, Number.isInteger(options.overscan) ? options.overscan : 16);
|
||||
const scrollTop = Math.min(requestedScrollTop, Math.max(0, (rows.length * rowHeight) - viewportHeight));
|
||||
const visibleStart = Math.floor(scrollTop / rowHeight);
|
||||
const visibleEnd = Math.ceil((scrollTop + viewportHeight) / rowHeight);
|
||||
const startIndex = Math.max(0, Math.min(rows.length, visibleStart - overscan));
|
||||
const endIndex = Math.max(startIndex, Math.min(rows.length, visibleEnd + overscan));
|
||||
return {
|
||||
rows: rows.slice(startIndex, endIndex), startIndex, endIndex,
|
||||
offsetTop: startIndex * rowHeight, totalHeight: rows.length * rowHeight, scrollTop,
|
||||
};
|
||||
}
|
||||
|
||||
function createSearchRunner(options = {}) {
|
||||
const scheduler = typeof options.scheduler === 'function'
|
||||
? options.scheduler
|
||||
: (work) => setTimeout(work, 0);
|
||||
const chunkSize = Number.isInteger(options.chunkSize) && options.chunkSize > 0 ? options.chunkSize : 250;
|
||||
let generation = 0;
|
||||
|
||||
function search(model, query, searchOptions = {}) {
|
||||
const myGeneration = ++generation;
|
||||
const terms = normalized(query).split(/\s+/).filter(Boolean);
|
||||
const cameras = [...model.cameraByKey.values()];
|
||||
const cameraKeys = new Set();
|
||||
const expandedKeys = new Set(searchOptions.expandedKeys instanceof Set ? searchOptions.expandedKeys : []);
|
||||
let index = 0;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
function finish(cancelled) {
|
||||
resolve(Object.freeze({ generation: myGeneration, cancelled, count: cameraKeys.size, cameraKeys, expandedKeys }));
|
||||
}
|
||||
function work() {
|
||||
if (myGeneration !== generation) return finish(true);
|
||||
const end = Math.min(cameras.length, index + chunkSize);
|
||||
for (; index < end; index += 1) {
|
||||
const camera = cameras[index];
|
||||
if (terms.every((term) => camera.searchCorpus.includes(term))) {
|
||||
cameraKeys.add(camera.key);
|
||||
expandedKeys.add(camera.site.key);
|
||||
expandedKeys.add(camera.group.key);
|
||||
}
|
||||
}
|
||||
if (index < cameras.length) scheduler(work);
|
||||
else finish(false);
|
||||
}
|
||||
scheduler(work);
|
||||
});
|
||||
}
|
||||
|
||||
return Object.freeze({ search, cancel() { generation += 1; }, get generation() { return generation; } });
|
||||
}
|
||||
|
||||
const api = Object.freeze({
|
||||
SPECIAL,
|
||||
buildDeviceTree,
|
||||
buildDeviceTreeModel: buildDeviceTree,
|
||||
flattenVisibleRows,
|
||||
flattenRows: flattenVisibleRows,
|
||||
createSearchRunner,
|
||||
calculateVirtualWindow,
|
||||
});
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
if (globalScope) globalScope.AptDeviceTree = api;
|
||||
}(typeof window !== 'undefined' ? window : undefined));
|
||||
@@ -40,7 +40,7 @@ Feature discovery begins only after Phase A passes independent security review a
|
||||
- Use a fresh worktree from GitPeji `master` at `a80074ac57b7a4517837b5d95754f5e6433df3ac` or newer.
|
||||
- Preserve current Tool Hub downloads and production visibility until Zac approves replacement.
|
||||
- Do not use real Alta cookies in automated tests; use conspicuous synthetic sentinels.
|
||||
- Do not write cookies to logs, disk, command files, shell strings, crash reports, analytics, or test artifacts.
|
||||
- Do not write cookies to logs, disk, command files, shell strings, crash reports, analytics, or test artifacts. The sole accepted exception is the fixed helper's required `-k` argv value for Zac's explicitly required paired-extension passwordless proxy flow; keep it main-process-only, direct-spawned with `shell: false`, redacted from errors, and absent from renderer IPC and tracked metadata.
|
||||
- No production release until the exact candidate passes controller verification plus independent security and release reviews.
|
||||
- Mac distribution remains approval-gated under Kanban task `t_42bad4d3`.
|
||||
|
||||
|
||||
@@ -16,3 +16,7 @@
|
||||
- Synthetic sentinel credentials only in tests.
|
||||
- Work remains on the isolated hardening branch until review gates pass.
|
||||
- No feature additions until Critical/Important security closure.
|
||||
|
||||
## Accepted passwordless proxy exception
|
||||
|
||||
Zac explicitly requires the paired-extension **Connect → select camera → Start Proxy** flow without re-entering Alta credentials. The external `aware-cam-proxy.exe` helper supports cookie authentication only through `-k`; it exposes no stdin, named-pipe, environment-variable, or credential-file interface. APT therefore passes the validated main-owned session directly to the fixed helper as one argv value. This exception remains bounded by direct `shell: false` spawn, trusted-device allowlisting, no renderer/IPC credential parameter, no logs/files/shells, redacted launch errors, safe tracked metadata, and synthetic regression tests.
|
||||
|
||||
+18
-10
@@ -12,13 +12,19 @@
|
||||
<div class="main-layout">
|
||||
<aside class="devices-sidebar">
|
||||
<div class="sidebar-header"><h2>Available Devices</h2></div>
|
||||
<div id="deviceStatus" class="status-message"></div>
|
||||
<div id="deviceStatus" class="status-message" role="status" aria-live="polite"></div>
|
||||
<div class="device-search-container">
|
||||
<input type="text" id="deviceSearch" placeholder="Search devices..." class="device-search-input" autocomplete="off">
|
||||
<label class="visually-hidden" for="deviceSearch">Search cameras</label>
|
||||
<input type="search" id="deviceSearch" placeholder="Search cameras..." class="device-search-input" autocomplete="off" aria-controls="deviceList">
|
||||
<p id="deviceResults" class="tree-results" role="status" aria-live="polite">Connect to load cameras</p>
|
||||
</div>
|
||||
<div class="device-list-container">
|
||||
<div id="deviceList" class="device-list">
|
||||
<p class="placeholder-text">Connect through the paired Chrome extension to load devices</p>
|
||||
<div id="deviceList" class="device-list virtual-tree" role="tree" tabindex="0" aria-label="Alta camera hierarchy" aria-activedescendant="" aria-describedby="deviceResults">
|
||||
<div id="treeSpacer" class="virtual-tree-spacer">
|
||||
<div id="treeWindow" class="virtual-tree-window">
|
||||
<p class="placeholder-text">Connect through the paired Chrome extension to load devices</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -45,11 +51,12 @@
|
||||
</div>
|
||||
<div class="connection-controls">
|
||||
<button type="button" id="disconnectBtn" class="btn-outline" disabled>Disconnect</button>
|
||||
<button type="button" id="managePairingBtn" class="btn-outline" hidden>Manage Pairing</button>
|
||||
</div>
|
||||
<div id="connectionStatus" class="status-message"></div>
|
||||
</section>
|
||||
|
||||
<section class="content-section pairing-section">
|
||||
<section id="pairingSection" class="content-section pairing-section" hidden>
|
||||
<h2>Bridge Pairing</h2>
|
||||
<p class="section-help">Only the committed APT Chrome extension can send a session. Pair it with a one-time secret.</p>
|
||||
<div class="status-row">
|
||||
@@ -64,6 +71,7 @@
|
||||
<div class="proxy-buttons">
|
||||
<button type="button" id="rotatePairingBtn" class="btn-primary">Generate / Rotate</button>
|
||||
<button type="button" id="revokePairingBtn" class="btn-outline">Revoke</button>
|
||||
<button type="button" id="hidePairingBtn" class="btn-outline" hidden>Done</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -74,11 +82,7 @@
|
||||
<label for="selectedDeviceId">Device UUID:</label>
|
||||
<input type="text" id="selectedDeviceId" placeholder="Select a device from the list" readonly>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<label for="altaUsername">Alta username / email:</label>
|
||||
<input type="text" id="altaUsername" maxlength="254" placeholder="name@example.com" autocomplete="username" spellcheck="false">
|
||||
</div>
|
||||
<p class="section-help">The proxy helper opens a Windows console and prompts for your password and 2FA. APT never sends those secrets on its command line.</p>
|
||||
<p class="section-help">APT uses the paired Chrome extension session. Select a camera, then start the proxy—no Alta credentials need to be entered again.</p>
|
||||
<div class="proxy-buttons">
|
||||
<button type="button" id="startProxyBtn" class="btn-primary" disabled>Start Proxy</button>
|
||||
<button type="button" id="stopProxyBtn" class="btn-outline" disabled>Stop Proxy</button>
|
||||
@@ -102,7 +106,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="well-formed-string.js"></script>
|
||||
<script src="renderer-controller.js"></script>
|
||||
<script src="device-tree.js"></script>
|
||||
<script src="sidebar-controller.js"></script>
|
||||
<script src="sidebar-view.js"></script>
|
||||
<script src="renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -78,8 +78,10 @@ function registerIpc(channel, handler) {
|
||||
function registerIpcHandlers() {
|
||||
registerIpc('get-devices', () => runtime.getDevices());
|
||||
registerIpc('get-device-sites', () => runtime.getDeviceSites());
|
||||
registerIpc('get-device-groups', () => runtime.getDeviceGroups());
|
||||
registerIpc('get-device-hierarchy', () => runtime.getDeviceHierarchy());
|
||||
registerIpc('get-auth-info', () => runtime.getAuthInfo());
|
||||
registerIpc('launch-proxy', (deviceId, username) => runtime.launchProxy(deviceId, username));
|
||||
registerIpc('launch-proxy', (deviceId) => runtime.launchProxy(deviceId));
|
||||
registerIpc('stop-proxy', async (key) => {
|
||||
const result = await runtime.stopProxy(key);
|
||||
sendConnectionState();
|
||||
@@ -116,7 +118,10 @@ function startBridgeServer() {
|
||||
const handler = createBridgeHandler({
|
||||
bridgeAuth: pairingController.bridgeAuth,
|
||||
sessionStore: runtime.sessionStore,
|
||||
onConnectionStateChanged: sendConnectionState,
|
||||
onConnectionStateChanged: () => {
|
||||
runtime.onSessionChanged();
|
||||
sendConnectionState();
|
||||
},
|
||||
});
|
||||
bridgeServer = http.createServer((request, response) => {
|
||||
handler(request, response).catch(() => {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "alta-api-client",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "alta-api-client",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "1.19.0"
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "alta-api-client",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.5",
|
||||
"description": "Secure Windows Electron client for the Alta Camera Proxy",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
@@ -25,6 +25,10 @@
|
||||
"preload.js",
|
||||
"renderer.js",
|
||||
"renderer-controller.js",
|
||||
"well-formed-string.js",
|
||||
"device-tree.js",
|
||||
"sidebar-controller.js",
|
||||
"sidebar-view.js",
|
||||
"index.html",
|
||||
"styles.css",
|
||||
"src/**/*",
|
||||
|
||||
+3
-1
@@ -12,8 +12,10 @@ function onConnectionStateChanged(callback) {
|
||||
contextBridge.exposeInMainWorld('electronAPI', Object.freeze({
|
||||
getDevices: () => ipcRenderer.invoke('get-devices'),
|
||||
getDeviceSites: () => ipcRenderer.invoke('get-device-sites'),
|
||||
getDeviceGroups: () => ipcRenderer.invoke('get-device-groups'),
|
||||
getDeviceHierarchy: () => ipcRenderer.invoke('get-device-hierarchy'),
|
||||
getAuthInfo: () => ipcRenderer.invoke('get-auth-info'),
|
||||
launchProxy: (deviceId, username) => ipcRenderer.invoke('launch-proxy', deviceId, username),
|
||||
launchProxy: (deviceId) => ipcRenderer.invoke('launch-proxy', deviceId),
|
||||
stopProxy: (key) => ipcRenderer.invoke('stop-proxy', key),
|
||||
disconnect: () => ipcRenderer.invoke('disconnect'),
|
||||
getConnectionState: () => ipcRenderer.invoke('get-connection-state'),
|
||||
|
||||
+10
-1
@@ -1,6 +1,15 @@
|
||||
'use strict';
|
||||
|
||||
(function exposeRendererController(globalScope) {
|
||||
function shouldShowPairingOnboarding({
|
||||
paired = false,
|
||||
secretVisible = false,
|
||||
connected = false,
|
||||
manuallyOpen = false,
|
||||
} = {}) {
|
||||
return manuallyOpen || !paired || (secretVisible && !connected);
|
||||
}
|
||||
|
||||
function createRendererController({
|
||||
disconnect,
|
||||
renderConnectionState,
|
||||
@@ -35,7 +44,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
const api = Object.freeze({ createRendererController });
|
||||
const api = Object.freeze({ createRendererController, shouldShowPairingOnboarding });
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
if (globalScope) globalScope.AptRendererController = api;
|
||||
}(typeof window !== 'undefined' ? window : undefined));
|
||||
|
||||
+216
-154
@@ -3,11 +3,13 @@
|
||||
const connectionStatus = document.getElementById('connectionStatus');
|
||||
const deviceStatus = document.getElementById('deviceStatus');
|
||||
const deviceList = document.getElementById('deviceList');
|
||||
const treeSpacer = document.getElementById('treeSpacer');
|
||||
const treeWindow = document.getElementById('treeWindow');
|
||||
const deviceResults = document.getElementById('deviceResults');
|
||||
const statusIndicator = document.getElementById('statusIndicator');
|
||||
const deviceSearch = document.getElementById('deviceSearch');
|
||||
const disconnectBtn = document.getElementById('disconnectBtn');
|
||||
const selectedDeviceId = document.getElementById('selectedDeviceId');
|
||||
const altaUsername = document.getElementById('altaUsername');
|
||||
const startProxyBtn = document.getElementById('startProxyBtn');
|
||||
const stopProxyBtn = document.getElementById('stopProxyBtn');
|
||||
const checkUpdateBtn = document.getElementById('checkUpdateBtn');
|
||||
@@ -15,18 +17,34 @@ const updateNotice = document.getElementById('updateNotice');
|
||||
const updateMessage = document.getElementById('updateMessage');
|
||||
const openReleasesBtn = document.getElementById('openReleasesBtn');
|
||||
const dismissUpdateBtn = document.getElementById('dismissUpdateBtn');
|
||||
const pairingSection = document.getElementById('pairingSection');
|
||||
const managePairingBtn = document.getElementById('managePairingBtn');
|
||||
const pairingState = document.getElementById('pairingState');
|
||||
const pairingSecret = document.getElementById('pairingSecret');
|
||||
const pairingSecretRow = document.getElementById('pairingSecretRow');
|
||||
const rotatePairingBtn = document.getElementById('rotatePairingBtn');
|
||||
const revokePairingBtn = document.getElementById('revokePairingBtn');
|
||||
const hidePairingBtn = document.getElementById('hidePairingBtn');
|
||||
|
||||
const ROW_HEIGHT = window.AptSidebarView.ROW_HEIGHT;
|
||||
let connection = { connected: false, origin: null, activeProxies: [] };
|
||||
let selectedDevice = null;
|
||||
let allDevices = [];
|
||||
let allSites = {};
|
||||
let collapsedSites = new Set();
|
||||
let loadingDevices = false;
|
||||
let hierarchyModel = null;
|
||||
let sidebarController = null;
|
||||
let loadGeneration = 0;
|
||||
let searchTimer = null;
|
||||
let scrollFrame = null;
|
||||
let pairingView = { paired: false, secretVisible: false, manuallyOpen: false };
|
||||
|
||||
function renderPairingVisibility() {
|
||||
const visible = window.AptRendererController.shouldShowPairingOnboarding({
|
||||
...pairingView,
|
||||
connected: connection.connected,
|
||||
});
|
||||
pairingSection.hidden = !visible;
|
||||
managePairingBtn.hidden = visible || !pairingView.paired;
|
||||
hidePairingBtn.hidden = !pairingView.paired;
|
||||
}
|
||||
|
||||
function showStatus(element, message, type) {
|
||||
element.textContent = message;
|
||||
@@ -35,7 +53,19 @@ function showStatus(element, message, type) {
|
||||
}
|
||||
|
||||
function activeDeviceIds() {
|
||||
return new Set((connection.activeProxies || []).map((proxy) => proxy.deviceId));
|
||||
return new Set((connection.activeProxies || []).map((proxy) => String(proxy.deviceId || '').toLowerCase()));
|
||||
}
|
||||
|
||||
function deviceStatusFor(device) {
|
||||
const value = String(device && (device.displayStatus ?? device.status ?? device.online) || '').toLowerCase();
|
||||
return ['green', 'online', 'live', 'connected', 'true'].includes(value);
|
||||
}
|
||||
|
||||
function updateProxyButtons() {
|
||||
const id = selectedDevice && selectedDevice.id;
|
||||
const active = id ? activeDeviceIds().has(id.toLowerCase()) : false;
|
||||
startProxyBtn.disabled = !connection.connected || !id || active;
|
||||
stopProxyBtn.disabled = !id || !active;
|
||||
}
|
||||
|
||||
function renderConnectionState(state) {
|
||||
@@ -49,172 +79,147 @@ function renderConnectionState(state) {
|
||||
dot.className = `status-dot ${connection.connected ? 'online' : 'offline'}`;
|
||||
text.textContent = connection.connected ? 'Connected' : 'Disconnected';
|
||||
disconnectBtn.disabled = !connection.connected;
|
||||
if (connection.connected) {
|
||||
pairingView.secretVisible = false;
|
||||
pairingView.manuallyOpen = false;
|
||||
pairingSecret.value = '';
|
||||
pairingSecretRow.style.display = 'none';
|
||||
}
|
||||
renderPairingVisibility();
|
||||
updateProxyButtons();
|
||||
if (sidebarController) sidebarController.refresh();
|
||||
}
|
||||
|
||||
function updateProxyButtons() {
|
||||
const id = selectedDevice && (selectedDevice.guid || selectedDevice.id);
|
||||
const active = id ? activeDeviceIds().has(id.toLowerCase()) : false;
|
||||
const hasUsername = altaUsername.value.trim().length > 0;
|
||||
startProxyBtn.disabled = !connection.connected || !id || !hasUsername || active;
|
||||
stopProxyBtn.disabled = !id || !active;
|
||||
}
|
||||
|
||||
function clearDeviceList() {
|
||||
deviceList.textContent = '';
|
||||
function showTreePlaceholder(message) {
|
||||
treeSpacer.style.height = '0px';
|
||||
treeWindow.style.transform = '';
|
||||
const placeholder = document.createElement('p');
|
||||
placeholder.className = 'placeholder-text';
|
||||
placeholder.textContent = connection.connected ? 'Loading devices...' : 'Connect through the paired Chrome extension to load devices';
|
||||
deviceList.appendChild(placeholder);
|
||||
placeholder.textContent = message;
|
||||
treeWindow.replaceChildren(placeholder);
|
||||
deviceList.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
|
||||
function clearHierarchy() {
|
||||
loadGeneration += 1;
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = null;
|
||||
if (sidebarController) sidebarController.destroy();
|
||||
sidebarController = null;
|
||||
hierarchyModel = null;
|
||||
selectedDevice = null;
|
||||
selectedDeviceId.value = '';
|
||||
deviceSearch.value = '';
|
||||
deviceResults.textContent = connection.connected ? 'Loading cameras' : 'Connect to load cameras';
|
||||
showTreePlaceholder(connection.connected
|
||||
? 'Loading devices...'
|
||||
: 'Connect through the paired Chrome extension to load devices');
|
||||
updateProxyButtons();
|
||||
}
|
||||
|
||||
function deviceStatusFor(device) {
|
||||
const raw = device && device.live && device.live.display_status !== undefined
|
||||
? device.live.display_status
|
||||
: device && device.status !== undefined
|
||||
? device.status
|
||||
: device && device.online;
|
||||
const value = String(raw ?? '').toLowerCase();
|
||||
return ['green', 'online', 'live', 'connected', 'true'].includes(value);
|
||||
}
|
||||
|
||||
function selectDevice(device, item) {
|
||||
const previous = deviceList.querySelector('.device-item.selected');
|
||||
if (previous) previous.classList.remove('selected');
|
||||
item.classList.add('selected');
|
||||
selectedDevice = device;
|
||||
const id = device.guid || device.id || '';
|
||||
selectedDeviceId.value = id;
|
||||
showStatus(connectionStatus, `Selected device: ${device.name || 'Unnamed Device'}`, 'info');
|
||||
function syncSelection(snapshot) {
|
||||
const selectedKey = snapshot && snapshot.state.selectedKey;
|
||||
const camera = selectedKey && hierarchyModel ? hierarchyModel.cameraByKey.get(selectedKey) : null;
|
||||
if (!camera) return;
|
||||
if (selectedDevice && selectedDevice.id === camera.source.id) return;
|
||||
selectedDevice = camera.source;
|
||||
selectedDeviceId.value = selectedDevice.id;
|
||||
showStatus(connectionStatus, `Selected device: ${selectedDevice.name || 'Unnamed Device'}`, 'info');
|
||||
updateProxyButtons();
|
||||
}
|
||||
|
||||
function createDeviceItem(device) {
|
||||
const item = document.createElement('div');
|
||||
const id = device.guid || device.id || '';
|
||||
item.className = 'device-item';
|
||||
item.dataset.deviceId = id;
|
||||
if (activeDeviceIds().has(String(id).toLowerCase())) item.classList.add('proxy-active');
|
||||
const name = document.createElement('div');
|
||||
name.className = 'device-name';
|
||||
name.textContent = device.name || 'Unnamed Device';
|
||||
const dot = document.createElement('div');
|
||||
dot.className = `device-status-dot ${deviceStatusFor(device) ? 'online' : 'offline'}`;
|
||||
item.append(name, dot);
|
||||
item.addEventListener('click', () => selectDevice(device, item));
|
||||
return item;
|
||||
function syncTreeScroll(snapshot) {
|
||||
if (!snapshot) return;
|
||||
const target = snapshot.state.scrollTop;
|
||||
if (Math.abs(deviceList.scrollTop - target) >= ROW_HEIGHT) deviceList.scrollTop = target;
|
||||
}
|
||||
|
||||
function groupDevicesBySite(devices) {
|
||||
const groups = new Map();
|
||||
for (const device of devices) {
|
||||
const siteId = device.server_group_id;
|
||||
const siteName = siteId && allSites[siteId];
|
||||
const key = siteName ? siteId : '__ungrouped';
|
||||
if (!groups.has(key)) groups.set(key, { name: siteName || 'Ungrouped', devices: [] });
|
||||
groups.get(key).devices.push(device);
|
||||
}
|
||||
return [...groups.entries()].sort((left, right) => left[1].name.localeCompare(right[1].name));
|
||||
function describeHierarchyWarning(hierarchy, model) {
|
||||
const diagnostics = hierarchy && hierarchy.diagnostics && typeof hierarchy.diagnostics === 'object'
|
||||
? hierarchy.diagnostics
|
||||
: {};
|
||||
const metadataErrors = hierarchy && hierarchy.metadataErrors && typeof hierarchy.metadataErrors === 'object'
|
||||
? hierarchy.metadataErrors
|
||||
: {};
|
||||
const unavailable = ['sites', 'groups'].filter((kind) => metadataErrors[kind] || (diagnostics[kind] && diagnostics[kind].error));
|
||||
const issueCount = model.diagnostics.length;
|
||||
if (!unavailable.length && issueCount === 0) return '';
|
||||
const parts = [];
|
||||
if (unavailable.length) parts.push(`${unavailable.join(' and ')} metadata unavailable`);
|
||||
if (issueCount) parts.push(`${issueCount} hierarchy warning${issueCount === 1 ? '' : 's'}`);
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
function displayDevices(devices) {
|
||||
deviceList.textContent = '';
|
||||
if (!devices.length) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'no-devices';
|
||||
empty.textContent = deviceSearch.value.trim() ? 'No devices match your search' : 'No local cameras found';
|
||||
deviceList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const grouped = Object.keys(allSites).length > 0;
|
||||
if (!grouped) {
|
||||
for (const device of devices) deviceList.appendChild(createDeviceItem(device));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [siteId, group] of groupDevicesBySite(devices)) {
|
||||
const header = document.createElement('div');
|
||||
const collapsed = collapsedSites.has(siteId);
|
||||
header.className = `site-group-header${collapsed ? ' collapsed' : ''}`;
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'site-group-arrow';
|
||||
arrow.textContent = collapsed ? '\u25B6' : '\u25BC';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'site-group-name';
|
||||
name.textContent = group.name;
|
||||
const count = document.createElement('span');
|
||||
count.className = 'site-group-count';
|
||||
count.textContent = String(group.devices.length);
|
||||
header.append(arrow, name, count);
|
||||
header.addEventListener('click', () => {
|
||||
if (collapsedSites.has(siteId)) collapsedSites.delete(siteId);
|
||||
else collapsedSites.add(siteId);
|
||||
filterDevices();
|
||||
});
|
||||
deviceList.appendChild(header);
|
||||
if (!collapsed) for (const device of group.devices) deviceList.appendChild(createDeviceItem(device));
|
||||
}
|
||||
}
|
||||
|
||||
function filterDevices() {
|
||||
const query = deviceSearch.value.toLowerCase().trim();
|
||||
if (!query) {
|
||||
displayDevices(allDevices);
|
||||
return;
|
||||
}
|
||||
displayDevices(allDevices.filter((device) => {
|
||||
const values = [
|
||||
device.name, device.guid, device.id, device.type, device.model, device.ipAddress,
|
||||
device.server_group_id && allSites[device.server_group_id],
|
||||
];
|
||||
return values.some((value) => String(value || '').toLowerCase().includes(query));
|
||||
}));
|
||||
function installHierarchy(hierarchy) {
|
||||
hierarchyModel = window.AptDeviceTree.buildDeviceTreeModel(hierarchy);
|
||||
const view = window.AptSidebarView.createSidebarView({
|
||||
document,
|
||||
tree: deviceList,
|
||||
spacer: treeSpacer,
|
||||
windowElement: treeWindow,
|
||||
model: hierarchyModel,
|
||||
getActiveDeviceIds: activeDeviceIds,
|
||||
isOnline: deviceStatusFor,
|
||||
onCommit: (snapshot) => {
|
||||
const count = snapshot.state.searchQuery && snapshot.state.searchCameraKeys
|
||||
? snapshot.state.searchCameraKeys.size
|
||||
: hierarchyModel.cameraCount;
|
||||
deviceResults.textContent = snapshot.state.searchQuery
|
||||
? `${count} exact search result${count === 1 ? '' : 's'}.`
|
||||
: `${count} camera${count === 1 ? '' : 's'}.`;
|
||||
},
|
||||
});
|
||||
sidebarController = window.AptSidebarController.createSidebarController({
|
||||
model: hierarchyModel,
|
||||
view,
|
||||
rowHeight: ROW_HEIGHT,
|
||||
overscan: 16,
|
||||
initialState: { viewportHeight: deviceList.clientHeight },
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDevices() {
|
||||
if (!connection.connected || loadingDevices) return;
|
||||
loadingDevices = true;
|
||||
showStatus(deviceStatus, 'Fetching devices...', 'info');
|
||||
if (!connection.connected) return;
|
||||
const generation = ++loadGeneration;
|
||||
const origin = connection.origin;
|
||||
showStatus(deviceStatus, 'Fetching camera hierarchy...', 'info');
|
||||
try {
|
||||
const [devicesResult, sitesResult] = await Promise.all([
|
||||
window.electronAPI.getDevices(),
|
||||
window.electronAPI.getDeviceSites(),
|
||||
]);
|
||||
allSites = {};
|
||||
if (sitesResult.success && Array.isArray(sitesResult.sites)) {
|
||||
for (const site of sitesResult.sites) if (site.id) allSites[site.id] = site.name || 'Unnamed Site';
|
||||
}
|
||||
if (!devicesResult.success) {
|
||||
allDevices = [];
|
||||
showStatus(deviceStatus, devicesResult.message || 'Failed to load devices', 'error');
|
||||
clearDeviceList();
|
||||
const result = await window.electronAPI.getDeviceHierarchy();
|
||||
if (generation !== loadGeneration || !connection.connected || connection.origin !== origin) return;
|
||||
if (!result.success) {
|
||||
showStatus(deviceStatus, result.message || 'Failed to load cameras', 'error');
|
||||
showTreePlaceholder('Could not load cameras');
|
||||
return;
|
||||
}
|
||||
allDevices = devicesResult.devices.filter((device) =>
|
||||
!device.capabilities || device.capabilities.localStorage === undefined || device.capabilities.localStorage === false
|
||||
const hierarchy = result.hierarchy || { devices: [], sites: [], groups: [] };
|
||||
installHierarchy(hierarchy);
|
||||
const warning = describeHierarchyWarning(hierarchy, hierarchyModel);
|
||||
const count = hierarchyModel.cameraCount;
|
||||
showStatus(
|
||||
deviceStatus,
|
||||
warning ? `Found ${count} camera${count === 1 ? '' : 's'}; ${warning}. Cameras remain available in fallback hierarchy.` : `Found ${count} camera${count === 1 ? '' : 's'}`,
|
||||
warning ? 'warning' : 'success',
|
||||
);
|
||||
showStatus(deviceStatus, `Found ${allDevices.length} local camera${allDevices.length === 1 ? '' : 's'}`, 'success');
|
||||
filterDevices();
|
||||
} catch {
|
||||
showStatus(deviceStatus, 'Could not load devices.', 'error');
|
||||
} finally {
|
||||
loadingDevices = false;
|
||||
if (generation !== loadGeneration) return;
|
||||
showStatus(deviceStatus, 'Could not load cameras.', 'error');
|
||||
showTreePlaceholder('Could not load cameras');
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchTree(action) {
|
||||
if (!sidebarController) return null;
|
||||
const snapshot = sidebarController.dispatch(action);
|
||||
syncSelection(snapshot);
|
||||
syncTreeScroll(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const rendererController = window.AptRendererController.createRendererController({
|
||||
disconnect: () => window.electronAPI.disconnect(),
|
||||
renderConnectionState,
|
||||
clearDisconnectedState: () => {
|
||||
allDevices = [];
|
||||
allSites = {};
|
||||
collapsedSites.clear();
|
||||
altaUsername.value = '';
|
||||
clearDeviceList();
|
||||
clearHierarchy();
|
||||
},
|
||||
showConnectionStatus: (message, type) => showStatus(connectionStatus, message, type),
|
||||
});
|
||||
@@ -224,15 +229,13 @@ async function handleDisconnect() {
|
||||
}
|
||||
|
||||
async function handleStartProxy() {
|
||||
const id = selectedDevice && (selectedDevice.guid || selectedDevice.id);
|
||||
const id = selectedDevice && selectedDevice.id;
|
||||
if (!id) return;
|
||||
startProxyBtn.disabled = true;
|
||||
showStatus(connectionStatus, `Starting proxy for ${selectedDevice.name || 'selected device'}...`, 'info');
|
||||
const result = await window.electronAPI.launchProxy(id, altaUsername.value);
|
||||
const result = await window.electronAPI.launchProxy(id);
|
||||
if (result.success) {
|
||||
connection = await window.electronAPI.getConnectionState();
|
||||
renderConnectionState(connection);
|
||||
filterDevices();
|
||||
renderConnectionState(await window.electronAPI.getConnectionState());
|
||||
showStatus(connectionStatus, 'Camera proxy started.', 'success');
|
||||
} else {
|
||||
showStatus(connectionStatus, result.message || 'Failed to start camera proxy.', 'error');
|
||||
@@ -241,13 +244,11 @@ async function handleStartProxy() {
|
||||
}
|
||||
|
||||
async function handleStopProxy() {
|
||||
const id = selectedDevice && (selectedDevice.guid || selectedDevice.id);
|
||||
const id = selectedDevice && selectedDevice.id;
|
||||
if (!id) return;
|
||||
stopProxyBtn.disabled = true;
|
||||
const result = await window.electronAPI.stopProxy(id);
|
||||
connection = await window.electronAPI.getConnectionState();
|
||||
renderConnectionState(connection);
|
||||
filterDevices();
|
||||
renderConnectionState(await window.electronAPI.getConnectionState());
|
||||
showStatus(connectionStatus, result.success ? 'Camera proxy stopped.' : result.message || 'Failed to stop camera proxy.', result.success ? 'success' : 'error');
|
||||
}
|
||||
|
||||
@@ -275,6 +276,11 @@ async function checkForUpdates(showCurrent = true) {
|
||||
}
|
||||
|
||||
function renderPairingStatus(result) {
|
||||
pairingView = {
|
||||
paired: result.paired === true,
|
||||
secretVisible: Boolean(result.secret),
|
||||
manuallyOpen: Boolean(result.secret) || result.paired !== true,
|
||||
};
|
||||
pairingState.textContent = result.paired ? 'Paired' : 'Revoked / not paired';
|
||||
pairingState.className = result.paired ? 'paired' : 'unpaired';
|
||||
revokePairingBtn.disabled = !result.paired;
|
||||
@@ -286,6 +292,18 @@ function renderPairingStatus(result) {
|
||||
pairingSecret.value = '';
|
||||
pairingSecretRow.style.display = 'none';
|
||||
}
|
||||
renderPairingVisibility();
|
||||
}
|
||||
|
||||
async function runSearch(query) {
|
||||
if (!sidebarController) return;
|
||||
const result = await sidebarController.search(query);
|
||||
if (result && !result.cancelled) {
|
||||
const count = result.count;
|
||||
deviceResults.textContent = query.trim()
|
||||
? `${count} exact search result${count === 1 ? '' : 's'}.`
|
||||
: `${hierarchyModel.cameraCount} camera${hierarchyModel.cameraCount === 1 ? '' : 's'}.`;
|
||||
}
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
@@ -296,10 +314,12 @@ async function initialize() {
|
||||
const wasConnected = connection.connected;
|
||||
const previousOrigin = connection.origin;
|
||||
renderConnectionState(state);
|
||||
if (!state.connected) {
|
||||
clearHierarchy();
|
||||
return;
|
||||
}
|
||||
if (state.connected && (!wasConnected || state.origin !== previousOrigin)) {
|
||||
allDevices = [];
|
||||
allSites = {};
|
||||
clearDeviceList();
|
||||
clearHierarchy();
|
||||
showStatus(connectionStatus, `Connected to ${state.origin}`, 'success');
|
||||
await loadDevices();
|
||||
}
|
||||
@@ -307,14 +327,56 @@ async function initialize() {
|
||||
setTimeout(() => checkForUpdates(false), 2000);
|
||||
}
|
||||
|
||||
deviceList.addEventListener('click', (event) => {
|
||||
const row = event.target.closest('.tree-row[data-row-key]');
|
||||
if (!row || !deviceList.contains(row)) return;
|
||||
dispatchTree({ type: 'CLICK', rowKey: row.dataset.rowKey });
|
||||
deviceList.focus();
|
||||
});
|
||||
deviceList.addEventListener('keydown', (event) => {
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter', ' ', 'Spacebar', 'Escape'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
dispatchTree({ type: 'KEY', key: event.key });
|
||||
if (event.key === 'Escape') {
|
||||
deviceSearch.value = '';
|
||||
runSearch('');
|
||||
}
|
||||
});
|
||||
deviceList.addEventListener('scroll', () => {
|
||||
if (scrollFrame || !sidebarController) return;
|
||||
scrollFrame = requestAnimationFrame(() => {
|
||||
scrollFrame = null;
|
||||
sidebarController.dispatch({ type: 'SET_VIEWPORT', scrollTop: deviceList.scrollTop, viewportHeight: deviceList.clientHeight });
|
||||
});
|
||||
});
|
||||
deviceSearch.addEventListener('input', () => {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => runSearch(deviceSearch.value), 125);
|
||||
});
|
||||
deviceSearch.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
deviceSearch.value = '';
|
||||
runSearch('');
|
||||
});
|
||||
disconnectBtn.addEventListener('click', handleDisconnect);
|
||||
startProxyBtn.addEventListener('click', handleStartProxy);
|
||||
stopProxyBtn.addEventListener('click', handleStopProxy);
|
||||
deviceSearch.addEventListener('input', filterDevices);
|
||||
altaUsername.addEventListener('input', updateProxyButtons);
|
||||
checkUpdateBtn.addEventListener('click', () => checkForUpdates(true));
|
||||
openReleasesBtn.addEventListener('click', () => window.electronAPI.openFixedReleasesPage());
|
||||
dismissUpdateBtn.addEventListener('click', () => { updateNotice.style.display = 'none'; });
|
||||
rotatePairingBtn.addEventListener('click', async () => renderPairingStatus(await window.electronAPI.rotatePairing()));
|
||||
revokePairingBtn.addEventListener('click', async () => renderPairingStatus(await window.electronAPI.revokePairing()));
|
||||
managePairingBtn.addEventListener('click', () => {
|
||||
pairingView.manuallyOpen = true;
|
||||
renderPairingVisibility();
|
||||
});
|
||||
hidePairingBtn.addEventListener('click', () => {
|
||||
pairingView.manuallyOpen = false;
|
||||
pairingView.secretVisible = false;
|
||||
pairingSecret.value = '';
|
||||
pairingSecretRow.style.display = 'none';
|
||||
renderPairingVisibility();
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', () => { initialize().catch(() => showStatus(connectionStatus, 'Application initialization failed.', 'error')); });
|
||||
|
||||
@@ -171,7 +171,10 @@ function verifySource() {
|
||||
for (const [label, version] of [['lockfile', lock.version], ['lockfile root package', lock.packages?.['']?.version]]) {
|
||||
if (version !== pkg.version) fail(`${label} version ${version} does not match application version ${pkg.version}`);
|
||||
}
|
||||
const productionRoots = ['main.js', 'preload.js', 'renderer.js', 'index.html', 'src/', 'chrome-extension/'];
|
||||
const productionRoots = [
|
||||
'main.js', 'preload.js', 'renderer.js', 'renderer-controller.js', 'device-tree.js',
|
||||
'sidebar-controller.js', 'sidebar-view.js', 'index.html', 'src/', 'chrome-extension/',
|
||||
];
|
||||
const productionFiles = new Map([...files].filter(([name]) =>
|
||||
productionRoots.some((root) => name === root || name.startsWith(root))
|
||||
));
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
'use strict';
|
||||
|
||||
(function defineSidebarController(root, factory) {
|
||||
const treeApi = typeof module !== 'undefined' && module.exports
|
||||
? require('./device-tree')
|
||||
: root && root.AptDeviceTree;
|
||||
const api = factory(treeApi);
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
if (root) root.AptSidebarController = api;
|
||||
}(typeof window !== 'undefined' ? window : undefined, function sidebarFactory(treeApi) {
|
||||
function validKey(key) {
|
||||
return typeof key === 'string' && /^(site|group|camera):[^\s]+$/.test(key);
|
||||
}
|
||||
|
||||
function copySet(value) {
|
||||
return value instanceof Set ? new Set(value) : new Set();
|
||||
}
|
||||
|
||||
function createInitialState(initial = {}) {
|
||||
return {
|
||||
expandedKeys: copySet(initial.expandedKeys),
|
||||
activeKey: validKey(initial.activeKey) ? initial.activeKey : null,
|
||||
selectedKey: validKey(initial.selectedKey) ? initial.selectedKey : null,
|
||||
searchQuery: typeof initial.searchQuery === 'string' ? initial.searchQuery : '',
|
||||
searchCameraKeys: initial.searchCameraKeys instanceof Set ? new Set(initial.searchCameraKeys) : null,
|
||||
searchExpandedKeys: initial.searchExpandedKeys instanceof Set ? new Set(initial.searchExpandedKeys) : null,
|
||||
searchGeneration: Number.isInteger(initial.searchGeneration) ? initial.searchGeneration : 0,
|
||||
scrollTop: Number.isFinite(initial.scrollTop) ? Math.max(0, initial.scrollTop) : 0,
|
||||
viewportHeight: Number.isFinite(initial.viewportHeight) ? Math.max(0, initial.viewportHeight) : 320,
|
||||
};
|
||||
}
|
||||
|
||||
function findRow(rows, key) {
|
||||
if (!validKey(key) || !Array.isArray(rows)) return null;
|
||||
return rows.find((row) => row && row.key === key) || null;
|
||||
}
|
||||
|
||||
function expansionState(state) {
|
||||
return state.searchQuery && state.searchExpandedKeys instanceof Set
|
||||
? { field: 'searchExpandedKeys', keys: state.searchExpandedKeys }
|
||||
: { field: 'expandedKeys', keys: state.expandedKeys };
|
||||
}
|
||||
|
||||
function changeExpansion(state, row, force) {
|
||||
if (!row || row.kind === 'camera') return state;
|
||||
const current = expansionState(state);
|
||||
const expandedKeys = copySet(current.keys);
|
||||
const shouldExpand = force === undefined ? !expandedKeys.has(row.key) : force;
|
||||
if (shouldExpand) expandedKeys.add(row.key);
|
||||
else expandedKeys.delete(row.key);
|
||||
return Object.assign({}, state, { [current.field]: expandedKeys, activeKey: row.key });
|
||||
}
|
||||
|
||||
function activate(state, row) {
|
||||
if (!row) return state;
|
||||
if (row.kind === 'camera') return Object.assign({}, state, { activeKey: row.key, selectedKey: row.key });
|
||||
return changeExpansion(state, row);
|
||||
}
|
||||
|
||||
function moveActive(state, rows, targetIndex) {
|
||||
if (!rows.length) return state;
|
||||
const index = Math.max(0, Math.min(rows.length - 1, targetIndex));
|
||||
return Object.assign({}, state, { activeKey: rows[index].key });
|
||||
}
|
||||
|
||||
function reduceSidebar(state, action, rows = []) {
|
||||
if (!state || !action || typeof action.type !== 'string') return state;
|
||||
if (action.type === 'ACTIVATE' || action.type === 'CLICK') {
|
||||
return activate(state, findRow(rows, action.rowKey));
|
||||
}
|
||||
if (action.type === 'SET_SELECTION') {
|
||||
if (!validKey(action.rowKey) || !action.rowKey.startsWith('camera:') ||
|
||||
(!action.allowHidden && !findRow(rows, action.rowKey))) return state;
|
||||
return Object.assign({}, state, { selectedKey: action.rowKey, activeKey: action.rowKey });
|
||||
}
|
||||
if (action.type === 'SET_ACTIVE') {
|
||||
const row = findRow(rows, action.rowKey);
|
||||
return row ? Object.assign({}, state, { activeKey: row.key }) : state;
|
||||
}
|
||||
if (action.type === 'SET_VIEWPORT') {
|
||||
const scrollTop = Number.isFinite(action.scrollTop) ? Math.max(0, action.scrollTop) : state.scrollTop;
|
||||
const viewportHeight = Number.isFinite(action.viewportHeight) ? Math.max(0, action.viewportHeight) : state.viewportHeight;
|
||||
if (scrollTop === state.scrollTop && viewportHeight === state.viewportHeight) return state;
|
||||
return Object.assign({}, state, { scrollTop, viewportHeight });
|
||||
}
|
||||
if (action.type === 'SET_SEARCH_QUERY') {
|
||||
const searchQuery = typeof action.query === 'string' ? action.query : '';
|
||||
return Object.assign({}, state, {
|
||||
searchQuery,
|
||||
searchCameraKeys: searchQuery ? state.searchCameraKeys : null,
|
||||
searchExpandedKeys: searchQuery ? state.searchExpandedKeys : null,
|
||||
});
|
||||
}
|
||||
if (action.type === 'SET_SEARCH_RESULTS') {
|
||||
if (!Number.isInteger(action.generation) || action.generation < state.searchGeneration) return state;
|
||||
return Object.assign({}, state, {
|
||||
searchQuery: typeof action.query === 'string' ? action.query : state.searchQuery,
|
||||
searchCameraKeys: action.cameraKeys instanceof Set ? new Set(action.cameraKeys) : null,
|
||||
searchExpandedKeys: action.expandedKeys instanceof Set ? new Set(action.expandedKeys) : null,
|
||||
searchGeneration: action.generation,
|
||||
});
|
||||
}
|
||||
if (action.type !== 'KEY' || typeof action.key !== 'string') return state;
|
||||
|
||||
if (action.key === 'Escape') {
|
||||
if (!state.searchQuery && !state.searchCameraKeys) return state;
|
||||
return Object.assign({}, state, { searchQuery: '', searchCameraKeys: null, searchExpandedKeys: null });
|
||||
}
|
||||
if (!rows.length) return state;
|
||||
let index = rows.findIndex((row) => row.key === state.activeKey);
|
||||
if (index < 0) {
|
||||
if (action.key === 'ArrowDown' || action.key === 'Home') return moveActive(state, rows, 0);
|
||||
if (action.key === 'ArrowUp' || action.key === 'End') return moveActive(state, rows, rows.length - 1);
|
||||
index = 0;
|
||||
}
|
||||
const row = rows[index];
|
||||
const expandedKeys = expansionState(state).keys;
|
||||
switch (action.key) {
|
||||
case 'ArrowDown': return moveActive(state, rows, index + 1);
|
||||
case 'ArrowUp': return moveActive(state, rows, index - 1);
|
||||
case 'Home': return moveActive(state, rows, 0);
|
||||
case 'End': return moveActive(state, rows, rows.length - 1);
|
||||
case 'ArrowRight': {
|
||||
if (row.kind === 'camera') return state;
|
||||
if (!expandedKeys.has(row.key)) return changeExpansion(state, row, true);
|
||||
const child = rows[index + 1];
|
||||
return child && child.parentKey === row.key ? Object.assign({}, state, { activeKey: child.key }) : state;
|
||||
}
|
||||
case 'ArrowLeft':
|
||||
if (row.kind !== 'camera' && expandedKeys.has(row.key)) return changeExpansion(state, row, false);
|
||||
return row.parentKey && validKey(row.parentKey) ? Object.assign({}, state, { activeKey: row.parentKey }) : state;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
case 'Spacebar': return activate(state, row);
|
||||
default: return state;
|
||||
}
|
||||
}
|
||||
|
||||
function createSidebarController(options = {}) {
|
||||
if (!treeApi || typeof treeApi.flattenVisibleRows !== 'function' || typeof treeApi.calculateVirtualWindow !== 'function') {
|
||||
throw new Error('AptDeviceTree must be loaded before AptSidebarController.');
|
||||
}
|
||||
if (!options.model || !options.view || typeof options.view.commit !== 'function') {
|
||||
throw new TypeError('Sidebar controller requires a model and view.commit adapter.');
|
||||
}
|
||||
const model = options.model;
|
||||
const view = options.view;
|
||||
const rowHeight = Number.isFinite(options.rowHeight) ? options.rowHeight : 30;
|
||||
const overscan = Number.isInteger(options.overscan) ? options.overscan : 16;
|
||||
const searchRunner = options.searchRunner || treeApi.createSearchRunner({ scheduler: options.scheduler, chunkSize: options.chunkSize });
|
||||
let state = createInitialState(options.initialState);
|
||||
let lastSnapshot;
|
||||
|
||||
function effectiveExpandedKeys() {
|
||||
return state.searchQuery && state.searchExpandedKeys ? state.searchExpandedKeys : state.expandedKeys;
|
||||
}
|
||||
|
||||
function commit(options = {}) {
|
||||
const rows = treeApi.flattenVisibleRows(model, {
|
||||
expandedKeys: effectiveExpandedKeys(),
|
||||
selectedKey: state.selectedKey,
|
||||
matchCameraKeys: state.searchQuery ? state.searchCameraKeys : null,
|
||||
});
|
||||
const activeIndex = rows.findIndex((row) => row.key === state.activeKey);
|
||||
const maximumScrollTop = Math.max(0, (rows.length * rowHeight) - state.viewportHeight);
|
||||
const clampedScrollTop = Math.max(0, Math.min(state.scrollTop, maximumScrollTop));
|
||||
if (clampedScrollTop !== state.scrollTop) {
|
||||
state = Object.assign({}, state, { scrollTop: clampedScrollTop });
|
||||
}
|
||||
if (options.ensureActive === true && activeIndex >= 0 && state.viewportHeight > 0) {
|
||||
const activeTop = activeIndex * rowHeight;
|
||||
const activeBottom = activeTop + rowHeight;
|
||||
let scrollTop = state.scrollTop;
|
||||
if (activeTop < scrollTop) scrollTop = activeTop;
|
||||
else if (activeBottom > scrollTop + state.viewportHeight) scrollTop = activeBottom - state.viewportHeight;
|
||||
if (scrollTop !== state.scrollTop) state = Object.assign({}, state, { scrollTop });
|
||||
}
|
||||
const window = treeApi.calculateVirtualWindow(rows, {
|
||||
scrollTop: state.scrollTop, viewportHeight: state.viewportHeight, rowHeight, overscan,
|
||||
});
|
||||
lastSnapshot = Object.freeze({ state, rows, window });
|
||||
view.commit(lastSnapshot);
|
||||
return lastSnapshot;
|
||||
}
|
||||
|
||||
function dispatch(action) {
|
||||
const visibleRows = lastSnapshot ? lastSnapshot.rows : [];
|
||||
let safeAction = action;
|
||||
if (action && action.type === 'SET_SELECTION') {
|
||||
if (!model.cameraByKey.has(action.rowKey)) return lastSnapshot;
|
||||
safeAction = Object.assign({}, action, { allowHidden: true });
|
||||
}
|
||||
const next = reduceSidebar(state, safeAction, visibleRows);
|
||||
if (next === state) return lastSnapshot;
|
||||
state = next;
|
||||
const ensureActive = safeAction.type === 'KEY' || safeAction.type === 'SET_ACTIVE';
|
||||
return commit({ ensureActive });
|
||||
}
|
||||
|
||||
async function search(query) {
|
||||
const normalizedQuery = typeof query === 'string' ? query : '';
|
||||
if (!normalizedQuery.trim()) {
|
||||
searchRunner.cancel();
|
||||
return dispatch({ type: 'SET_SEARCH_QUERY', query: '' });
|
||||
}
|
||||
state = reduceSidebar(state, { type: 'SET_SEARCH_QUERY', query: normalizedQuery }, lastSnapshot ? lastSnapshot.rows : []);
|
||||
const result = await searchRunner.search(model, normalizedQuery, { expandedKeys: state.expandedKeys });
|
||||
if (result.cancelled) return result;
|
||||
dispatch({
|
||||
type: 'SET_SEARCH_RESULTS', query: normalizedQuery, generation: result.generation,
|
||||
cameraKeys: result.cameraKeys, expandedKeys: result.expandedKeys,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
commit();
|
||||
return Object.freeze({
|
||||
dispatch, search, refresh: commit, getState: () => state, getSnapshot: () => lastSnapshot,
|
||||
destroy() { searchRunner.cancel(); },
|
||||
});
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
createInitialState,
|
||||
reduceSidebar,
|
||||
sidebarReducer: reduceSidebar,
|
||||
createSidebarController,
|
||||
});
|
||||
}));
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
'use strict';
|
||||
|
||||
(function exposeSidebarView(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
if (root) root.AptSidebarView = api;
|
||||
}(typeof window !== 'undefined' ? window : undefined, function sidebarViewFactory() {
|
||||
const ROW_HEIGHT = 30;
|
||||
|
||||
function stableDomId(key) {
|
||||
return `apt-tree-row-${Array.from(String(key || '')).map((character) => character.codePointAt(0).toString(16)).join('-')}`;
|
||||
}
|
||||
|
||||
function describeVirtualRows(snapshot, options = {}) {
|
||||
const activeIds = options.activeDeviceIds instanceof Set ? options.activeDeviceIds : new Set();
|
||||
const model = options.model;
|
||||
const isOnline = typeof options.isOnline === 'function' ? options.isOnline : () => false;
|
||||
const windowRows = snapshot && snapshot.window && Array.isArray(snapshot.window.rows)
|
||||
? snapshot.window.rows
|
||||
: [];
|
||||
return windowRows.map((row, windowIndex) => {
|
||||
const node = model && model.rowByKey ? model.rowByKey.get(row.key) : null;
|
||||
const device = node && node.kind === 'camera' ? node.source : null;
|
||||
const deviceId = device ? device.id : row.id;
|
||||
const classes = ['tree-row', `tree-${row.kind}`];
|
||||
if (row.ariaSelected) classes.push('selected');
|
||||
if (snapshot.state && snapshot.state.activeKey === row.key) classes.push('active');
|
||||
if (row.pendingDeletion) classes.push('pending-deletion');
|
||||
if (row.hierarchyStatus && !['assigned', 'inferred-site'].includes(row.hierarchyStatus)) classes.push('hierarchy-warning');
|
||||
if (row.kind === 'camera') {
|
||||
classes.push(isOnline(device || row) ? 'online' : 'offline');
|
||||
if (activeIds.has(String(deviceId || '').toLowerCase())) classes.push('proxy-active');
|
||||
}
|
||||
return Object.freeze({
|
||||
key: row.key,
|
||||
kind: row.kind,
|
||||
name: row.name,
|
||||
device,
|
||||
deviceId,
|
||||
role: 'treeitem',
|
||||
domId: stableDomId(row.key),
|
||||
top: windowIndex * ROW_HEIGHT,
|
||||
classes: classes.join(' '),
|
||||
pendingDeletion: row.pendingDeletion === true,
|
||||
hierarchyStatus: row.hierarchyStatus,
|
||||
address: device && device.address ? device.address : '',
|
||||
aria: {
|
||||
level: row.ariaLevel,
|
||||
expanded: row.ariaExpanded,
|
||||
selected: row.ariaSelected,
|
||||
posinset: row.ariaPosInSet,
|
||||
setsize: row.ariaSetSize,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setAria(element, name, value) {
|
||||
if (value === undefined || value === null) element.removeAttribute(`aria-${name}`);
|
||||
else element.setAttribute(`aria-${name}`, String(value));
|
||||
}
|
||||
|
||||
function createSidebarView(options = {}) {
|
||||
const documentRef = options.document;
|
||||
const tree = options.tree;
|
||||
const spacer = options.spacer;
|
||||
const windowElement = options.windowElement;
|
||||
if (!documentRef || !tree || !spacer || !windowElement) {
|
||||
throw new TypeError('Sidebar view requires document, tree, spacer, and windowElement.');
|
||||
}
|
||||
|
||||
function createRow(descriptor) {
|
||||
const element = documentRef.createElement('div');
|
||||
element.id = descriptor.domId;
|
||||
element.className = descriptor.classes;
|
||||
element.dataset.rowKey = descriptor.key;
|
||||
element.setAttribute('role', descriptor.role);
|
||||
element.style.top = `${descriptor.top}px`;
|
||||
setAria(element, 'level', descriptor.aria.level);
|
||||
setAria(element, 'expanded', descriptor.aria.expanded);
|
||||
setAria(element, 'selected', descriptor.aria.selected);
|
||||
setAria(element, 'posinset', descriptor.aria.posinset);
|
||||
setAria(element, 'setsize', descriptor.aria.setsize);
|
||||
|
||||
const disclosure = documentRef.createElement('span');
|
||||
disclosure.className = 'tree-disclosure';
|
||||
disclosure.setAttribute('aria-hidden', 'true');
|
||||
disclosure.textContent = descriptor.kind === 'camera' ? '' : (descriptor.aria.expanded ? '\u25bc' : '\u25b6');
|
||||
const label = documentRef.createElement('span');
|
||||
label.className = 'tree-label';
|
||||
label.textContent = descriptor.name;
|
||||
element.append(disclosure, label);
|
||||
|
||||
if (descriptor.address) {
|
||||
const address = documentRef.createElement('span');
|
||||
address.className = 'tree-address';
|
||||
address.textContent = descriptor.address;
|
||||
element.appendChild(address);
|
||||
}
|
||||
if (descriptor.pendingDeletion) {
|
||||
const pending = documentRef.createElement('span');
|
||||
pending.className = 'tree-badge pending';
|
||||
pending.textContent = 'Pending deletion';
|
||||
element.appendChild(pending);
|
||||
}
|
||||
if (descriptor.hierarchyStatus && !['assigned', 'inferred-site'].includes(descriptor.hierarchyStatus)) {
|
||||
const warning = documentRef.createElement('span');
|
||||
warning.className = 'tree-warning';
|
||||
warning.title = `Hierarchy: ${descriptor.hierarchyStatus}`;
|
||||
warning.setAttribute('aria-label', `Hierarchy warning: ${descriptor.hierarchyStatus}`);
|
||||
warning.textContent = '!';
|
||||
element.appendChild(warning);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
function commit(snapshot) {
|
||||
const descriptors = describeVirtualRows(snapshot, {
|
||||
model: options.model,
|
||||
activeDeviceIds: typeof options.getActiveDeviceIds === 'function' ? options.getActiveDeviceIds() : new Set(),
|
||||
isOnline: options.isOnline,
|
||||
});
|
||||
spacer.style.height = `${snapshot.window.totalHeight}px`;
|
||||
windowElement.style.transform = `translateY(${snapshot.window.offsetTop}px)`;
|
||||
windowElement.style.height = `${descriptors.length * ROW_HEIGHT}px`;
|
||||
const fragment = documentRef.createDocumentFragment();
|
||||
for (const descriptor of descriptors) fragment.appendChild(createRow(descriptor));
|
||||
windowElement.replaceChildren(fragment);
|
||||
|
||||
const active = descriptors.find((row) => snapshot.state.activeKey === row.key);
|
||||
if (active) tree.setAttribute('aria-activedescendant', active.domId);
|
||||
else tree.removeAttribute('aria-activedescendant');
|
||||
tree.classList.toggle('has-hidden-selection', snapshot.rows.hiddenSelected === true);
|
||||
if (typeof options.onCommit === 'function') options.onCommit(snapshot, descriptors);
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
return Object.freeze({ commit });
|
||||
}
|
||||
|
||||
return Object.freeze({ ROW_HEIGHT, stableDomId, describeVirtualRows, createSidebarView });
|
||||
}));
|
||||
+39
-12
@@ -3,13 +3,24 @@
|
||||
const { canonicalizeAltaOrigin, assertSameAltaOrigin } = require('./url-policy');
|
||||
const { formatAltaError } = require('./error-redaction');
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEVICE_MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
|
||||
const HIERARCHY_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||
const AUTH_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||
const DEFAULT_MAX_RESPONSE_BYTES = DEVICE_MAX_RESPONSE_BYTES;
|
||||
const MAX_ARRAY_OBJECTS = 10_000;
|
||||
const DEFAULT_MAX_REDIRECTS = 3;
|
||||
const ENDPOINTS = Object.freeze({
|
||||
getDevices: Object.freeze({ path: '/api/v1/devices', shape: 'array' }),
|
||||
getDeviceSites: Object.freeze({ path: '/api/v1/deviceSites', shape: 'array' }),
|
||||
getAuthInfo: Object.freeze({ path: '/api/v1/auth', shape: 'object' }),
|
||||
getDevices: Object.freeze({
|
||||
path: '/api/v1/devices', shape: 'array', maxResponseBytes: DEVICE_MAX_RESPONSE_BYTES, maxObjects: MAX_ARRAY_OBJECTS,
|
||||
}),
|
||||
getDeviceSites: Object.freeze({
|
||||
path: '/api/v1/deviceSites', shape: 'array', maxResponseBytes: HIERARCHY_MAX_RESPONSE_BYTES, maxObjects: MAX_ARRAY_OBJECTS,
|
||||
}),
|
||||
getDeviceGroups: Object.freeze({
|
||||
path: '/api/v1/deviceGroups', shape: 'array', maxResponseBytes: HIERARCHY_MAX_RESPONSE_BYTES, maxObjects: MAX_ARRAY_OBJECTS,
|
||||
}),
|
||||
getAuthInfo: Object.freeze({ path: '/api/v1/auth', shape: 'object', maxResponseBytes: AUTH_MAX_RESPONSE_BYTES }),
|
||||
});
|
||||
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
||||
|
||||
@@ -118,7 +129,7 @@ class AltaClient {
|
||||
}
|
||||
if (typeof transport !== 'function') throw new TypeError('AltaClient transport must be a function');
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > DEFAULT_TIMEOUT_MS) {
|
||||
throw new RangeError('AltaClient timeout must be between 1 and 10000ms');
|
||||
throw new RangeError('AltaClient timeout must be between 1 and 30000ms');
|
||||
}
|
||||
if (!Number.isInteger(maxResponseBytes) || maxResponseBytes < 1 || maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES) {
|
||||
throw new RangeError('Invalid Alta response size limit');
|
||||
@@ -141,6 +152,10 @@ class AltaClient {
|
||||
return this.#invoke('getDeviceSites', args);
|
||||
}
|
||||
|
||||
getDeviceGroups(...args) {
|
||||
return this.#invoke('getDeviceGroups', args);
|
||||
}
|
||||
|
||||
getAuthInfo(...args) {
|
||||
return this.#invoke('getAuthInfo', args);
|
||||
}
|
||||
@@ -150,6 +165,7 @@ class AltaClient {
|
||||
throw altaError('INVALID_ALTA_ARGUMENTS', 'Alta API methods do not accept renderer parameters');
|
||||
}
|
||||
const endpoint = ENDPOINTS[operation];
|
||||
const maxResponseBytes = Math.min(this.maxResponseBytes, endpoint.maxResponseBytes);
|
||||
const session = this.sessionStore.requireSession();
|
||||
const origin = canonicalizeAltaOrigin(session.origin);
|
||||
const cookie = session.cookie;
|
||||
@@ -157,10 +173,15 @@ class AltaClient {
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
const data = await this.#request(operation, `${origin}${endpoint.path}`, origin, cookie, deadline, controller, 0);
|
||||
const data = await this.#request(
|
||||
operation, `${origin}${endpoint.path}`, origin, cookie, deadline, controller, 0, maxResponseBytes,
|
||||
);
|
||||
if (endpoint.shape === 'array' && !Array.isArray(data)) {
|
||||
throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response did not contain an array');
|
||||
}
|
||||
if (endpoint.shape === 'array' && data.length > endpoint.maxObjects) {
|
||||
throw altaError('ALTA_RESPONSE_TOO_MANY_OBJECTS', 'Alta response exceeded the object limit');
|
||||
}
|
||||
if (endpoint.shape === 'object' && (!data || typeof data !== 'object' || Array.isArray(data))) {
|
||||
throw altaError('INVALID_ALTA_RESPONSE_DATA', 'Alta response did not contain an object');
|
||||
}
|
||||
@@ -172,7 +193,7 @@ class AltaClient {
|
||||
}
|
||||
}
|
||||
|
||||
async #request(operation, url, origin, cookie, deadline, controller, redirectCount) {
|
||||
async #request(operation, url, origin, cookie, deadline, controller, redirectCount, maxResponseBytes) {
|
||||
assertSameAltaOrigin(url, origin);
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) throw altaError('ALTA_TIMEOUT', 'Alta request timed out', { timeout: true });
|
||||
@@ -196,7 +217,7 @@ class AltaClient {
|
||||
signal: controller.signal,
|
||||
proxy: false,
|
||||
maxRedirects: 0,
|
||||
maxResponseBytes: this.maxResponseBytes,
|
||||
maxResponseBytes,
|
||||
}))),
|
||||
timeoutPromise,
|
||||
]);
|
||||
@@ -209,7 +230,7 @@ class AltaClient {
|
||||
}
|
||||
const headers = normalizeHeaders(response.headers);
|
||||
// Bound every response, including redirects and errors, before acting on it.
|
||||
enforceResponseSize(response.data, headers, this.maxResponseBytes);
|
||||
enforceResponseSize(response.data, headers, maxResponseBytes);
|
||||
|
||||
if (REDIRECT_STATUSES.has(response.status)) {
|
||||
if (typeof headers.location !== 'string' || headers.location.length === 0) {
|
||||
@@ -225,14 +246,16 @@ class AltaClient {
|
||||
} catch {
|
||||
throw altaError('UNSAFE_ALTA_REDIRECT', 'Alta redirect changed the validated origin');
|
||||
}
|
||||
return this.#request(operation, target.href, origin, cookie, deadline, controller, redirectCount + 1);
|
||||
return this.#request(
|
||||
operation, target.href, origin, cookie, deadline, controller, redirectCount + 1, maxResponseBytes,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status < 200 || response.status > 299) {
|
||||
throw altaError('ALTA_HTTP_ERROR', 'Alta request returned an error status', { status: response.status });
|
||||
}
|
||||
|
||||
return parseData(response.data, this.maxResponseBytes);
|
||||
return parseData(response.data, maxResponseBytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,10 +265,14 @@ function createAltaClient(options) {
|
||||
|
||||
module.exports = {
|
||||
AltaClient,
|
||||
AUTH_MAX_RESPONSE_BYTES,
|
||||
DEFAULT_MAX_REDIRECTS,
|
||||
DEFAULT_MAX_RESPONSE_BYTES,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
DEVICE_MAX_RESPONSE_BYTES,
|
||||
ENDPOINTS,
|
||||
HIERARCHY_MAX_RESPONSE_BYTES,
|
||||
MAX_ARRAY_OBJECTS,
|
||||
axiosTransport,
|
||||
createAltaClient,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
'use strict';
|
||||
|
||||
const { validateDeviceId } = require('./proxy-launch');
|
||||
const { toWellFormedString } = require('../well-formed-string');
|
||||
|
||||
const MAX_PROJECTED_PAYLOAD_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
function projectionError(code, message) {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function nullableString(value) {
|
||||
return typeof value === 'string' ? toWellFormedString(value) : null;
|
||||
}
|
||||
|
||||
function nullableId(value) {
|
||||
return typeof value === 'string' && value.length > 0 ? toWellFormedString(value) : null;
|
||||
}
|
||||
|
||||
function nullableBoolean(value) {
|
||||
return typeof value === 'boolean' ? value : null;
|
||||
}
|
||||
|
||||
function projectDevice(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
let id;
|
||||
try {
|
||||
id = validateDeviceId(raw.guid);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
name: nullableString(raw.name),
|
||||
type: nullableString(raw.type),
|
||||
model: nullableString(raw.model),
|
||||
address: nullableString(raw.address),
|
||||
// server_group_id identifies the Alta server/cloud connector, not a device site.
|
||||
// The tree infers the site through device_group_id -> deviceGroups.parent_id.
|
||||
siteId: null,
|
||||
deviceGroupId: nullableId(raw.device_group_id),
|
||||
displayStatus: nullableString(raw.live && raw.live.display_status),
|
||||
localStorage: nullableBoolean(raw.capabilities && raw.capabilities.localStorage),
|
||||
};
|
||||
}
|
||||
|
||||
function projectSite(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const id = nullableId(raw.id);
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
name: nullableString(raw.name),
|
||||
pendingDeletionStart: nullableString(raw.pending_deletion_start),
|
||||
};
|
||||
}
|
||||
|
||||
function projectGroup(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const id = nullableId(raw.id);
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
name: nullableString(raw.name),
|
||||
parentId: nullableId(raw.parent_id),
|
||||
pendingDeletionStart: nullableString(raw.pending_deletion_start),
|
||||
};
|
||||
}
|
||||
|
||||
function projectArray(values, projector) {
|
||||
const projected = [];
|
||||
let invalid = 0;
|
||||
for (const value of Array.isArray(values) ? values : []) {
|
||||
const item = projector(value);
|
||||
if (item) projected.push(item);
|
||||
else invalid += 1;
|
||||
}
|
||||
return { projected, invalid };
|
||||
}
|
||||
|
||||
function safeMetadataError(value) {
|
||||
return typeof value === 'string' && value.length > 0
|
||||
? Array.from(toWellFormedString(value)).slice(0, 512).join('')
|
||||
: null;
|
||||
}
|
||||
|
||||
function projectDeviceHierarchy({ devices = [], sites = [], groups = [], metadataErrors = {} } = {}) {
|
||||
if (!Array.isArray(devices) || !Array.isArray(sites) || !Array.isArray(groups)) {
|
||||
throw new TypeError('Hierarchy projection requires arrays');
|
||||
}
|
||||
const deviceProjection = projectArray(devices, projectDevice);
|
||||
const eligibleDevices = deviceProjection.projected.filter((device) => device.localStorage !== true);
|
||||
const cloudNativeExcluded = deviceProjection.projected.length - eligibleDevices.length;
|
||||
const siteProjection = projectArray(sites, projectSite);
|
||||
const groupProjection = projectArray(groups, projectGroup);
|
||||
const payload = {
|
||||
devices: eligibleDevices,
|
||||
sites: siteProjection.projected,
|
||||
groups: groupProjection.projected,
|
||||
diagnostics: {
|
||||
devices: {
|
||||
received: devices.length,
|
||||
eligible: eligibleDevices.length,
|
||||
invalid: deviceProjection.invalid,
|
||||
cloudNativeExcluded,
|
||||
},
|
||||
sites: {
|
||||
received: sites.length,
|
||||
projected: siteProjection.projected.length,
|
||||
invalid: siteProjection.invalid,
|
||||
error: safeMetadataError(metadataErrors.sites),
|
||||
},
|
||||
groups: {
|
||||
received: groups.length,
|
||||
projected: groupProjection.projected.length,
|
||||
invalid: groupProjection.invalid,
|
||||
error: safeMetadataError(metadataErrors.groups),
|
||||
},
|
||||
},
|
||||
};
|
||||
if (Buffer.byteLength(JSON.stringify(payload)) > MAX_PROJECTED_PAYLOAD_BYTES) {
|
||||
throw projectionError('PROJECTED_PAYLOAD_TOO_LARGE', 'Projected Alta hierarchy exceeded the IPC size limit');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_PROJECTED_PAYLOAD_BYTES,
|
||||
projectDevice,
|
||||
projectDeviceHierarchy,
|
||||
projectGroup,
|
||||
projectSite,
|
||||
};
|
||||
+125
-14
@@ -10,7 +10,10 @@ const {
|
||||
readJsonBody,
|
||||
} = require('./bridge-auth');
|
||||
const { RELEASES_PAGE_URL } = require('./update-policy');
|
||||
const { validateDeviceId, validateUsername } = require('./proxy-launch');
|
||||
const { projectDeviceHierarchy, projectGroup, projectSite } = require('./device-projection');
|
||||
const { validateCookie, validateDeviceId } = require('./proxy-launch');
|
||||
|
||||
const DEFAULT_DISCOVERY_TIMEOUT_MS = 60_000;
|
||||
|
||||
const PAIRING_ENVELOPE_FILENAME = 'bridge-pairing.json';
|
||||
|
||||
@@ -18,8 +21,9 @@ function safeErrorMessage(error, fallback) {
|
||||
const allowed = new Set([
|
||||
'NO_ALTA_SESSION', 'INVALID_ALTA_ARGUMENTS', 'ALTA_TIMEOUT', 'ALTA_HTTP_ERROR',
|
||||
'INVALID_ALTA_RESPONSE', 'INVALID_ALTA_RESPONSE_DATA', 'ALTA_RESPONSE_TOO_LARGE',
|
||||
'ALTA_RESPONSE_TOO_MANY_OBJECTS', 'PROJECTED_PAYLOAD_TOO_LARGE',
|
||||
'UNSAFE_ALTA_REDIRECT', 'TOO_MANY_ALTA_REDIRECTS', 'HELPER_NOT_FOUND',
|
||||
'UNSUPPORTED_PLATFORM', 'INVALID_DEVICE_ID', 'INVALID_USERNAME', 'SPAWN_FAILED',
|
||||
'UNSUPPORTED_PLATFORM', 'INVALID_DEVICE_ID', 'INVALID_SESSION_COOKIE', 'SPAWN_FAILED',
|
||||
]);
|
||||
return error && allowed.has(error.code) && typeof error.message === 'string'
|
||||
? error.message
|
||||
@@ -196,14 +200,22 @@ class AppRuntime {
|
||||
checkForUpdate,
|
||||
currentVersion,
|
||||
openExternal,
|
||||
discoveryTimeoutMs = DEFAULT_DISCOVERY_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
if (!Number.isInteger(discoveryTimeoutMs) || discoveryTimeoutMs < 1 ||
|
||||
discoveryTimeoutMs > DEFAULT_DISCOVERY_TIMEOUT_MS) {
|
||||
throw new RangeError('AppRuntime discovery timeout must be between 1 and 60000ms');
|
||||
}
|
||||
this.sessionStore = sessionStore;
|
||||
this.altaClient = altaClient;
|
||||
this.proxyManager = proxyManager;
|
||||
this.checkForUpdatePolicy = checkForUpdate;
|
||||
this.currentVersion = currentVersion;
|
||||
this.openExternal = openExternal;
|
||||
this.discoveryTimeoutMs = discoveryTimeoutMs;
|
||||
this.allowedDeviceIds = new Set();
|
||||
this.allowedDeviceDiscovery = null;
|
||||
this.discoveryGeneration = 0;
|
||||
this.proxyByDevice = new Map();
|
||||
}
|
||||
|
||||
@@ -223,15 +235,56 @@ class AppRuntime {
|
||||
return tracked.filter((proxy) => this.proxyByDevice.get(proxy.deviceId) === proxy.processId);
|
||||
}
|
||||
|
||||
_setAllowedDevices(devices, discovery) {
|
||||
this.allowedDeviceIds = new Set(devices.map((device) => device.id));
|
||||
this.allowedDeviceDiscovery = discovery;
|
||||
}
|
||||
|
||||
_beginDiscovery() {
|
||||
const state = this.sessionStore.describe();
|
||||
this.allowedDeviceIds.clear();
|
||||
this.allowedDeviceDiscovery = null;
|
||||
return Object.freeze({
|
||||
generation: ++this.discoveryGeneration,
|
||||
connected: state.connected,
|
||||
origin: state.origin,
|
||||
});
|
||||
}
|
||||
|
||||
_isCurrentDiscovery(discovery) {
|
||||
const state = this.sessionStore.describe();
|
||||
return discovery.generation === this.discoveryGeneration &&
|
||||
discovery.connected === state.connected && discovery.origin === state.origin;
|
||||
}
|
||||
|
||||
_staleDiscoveryResult() {
|
||||
return {
|
||||
success: false,
|
||||
stale: true,
|
||||
hierarchy: { devices: [], sites: [], groups: [] },
|
||||
message: 'Alta device discovery result is stale',
|
||||
};
|
||||
}
|
||||
|
||||
invalidateDiscovery() {
|
||||
this.discoveryGeneration += 1;
|
||||
this.allowedDeviceIds.clear();
|
||||
this.allowedDeviceDiscovery = null;
|
||||
}
|
||||
|
||||
onSessionChanged() {
|
||||
this.invalidateDiscovery();
|
||||
}
|
||||
|
||||
async getDevices() {
|
||||
const discovery = this._beginDiscovery();
|
||||
try {
|
||||
const devices = await this.altaClient.getDevices();
|
||||
this.allowedDeviceIds = new Set();
|
||||
for (const device of devices) {
|
||||
const candidate = device && (device.guid || device.id);
|
||||
try { this.allowedDeviceIds.add(validateDeviceId(candidate)); } catch {}
|
||||
}
|
||||
return { success: true, devices };
|
||||
const hierarchy = projectDeviceHierarchy({
|
||||
devices: await this.altaClient.getDevices(), sites: [], groups: [],
|
||||
});
|
||||
if (!this._isCurrentDiscovery(discovery)) return this._staleDiscoveryResult();
|
||||
this._setAllowedDevices(hierarchy.devices, discovery);
|
||||
return { success: true, devices: hierarchy.devices };
|
||||
} catch (error) {
|
||||
return { success: false, devices: [], message: safeErrorMessage(error, 'Failed to get devices') };
|
||||
}
|
||||
@@ -239,12 +292,69 @@ class AppRuntime {
|
||||
|
||||
async getDeviceSites() {
|
||||
try {
|
||||
return { success: true, sites: await this.altaClient.getDeviceSites() };
|
||||
const sites = (await this.altaClient.getDeviceSites()).map(projectSite).filter(Boolean);
|
||||
return { success: true, sites };
|
||||
} catch (error) {
|
||||
return { success: false, sites: [], message: safeErrorMessage(error, 'Failed to get device sites') };
|
||||
}
|
||||
}
|
||||
|
||||
async getDeviceGroups() {
|
||||
try {
|
||||
const groups = (await this.altaClient.getDeviceGroups()).map(projectGroup).filter(Boolean);
|
||||
return { success: true, groups };
|
||||
} catch (error) {
|
||||
return { success: false, groups: [], message: safeErrorMessage(error, 'Failed to get device groups') };
|
||||
}
|
||||
}
|
||||
|
||||
async getDeviceHierarchy() {
|
||||
const discoveryState = this._beginDiscovery();
|
||||
let timer;
|
||||
try {
|
||||
const discovery = Promise.allSettled([
|
||||
this.altaClient.getDevices(),
|
||||
this.altaClient.getDeviceSites(),
|
||||
this.altaClient.getDeviceGroups(),
|
||||
]);
|
||||
const deadline = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
const error = new Error('Alta device discovery timed out');
|
||||
error.code = 'DISCOVERY_TIMEOUT';
|
||||
reject(error);
|
||||
}, this.discoveryTimeoutMs);
|
||||
});
|
||||
const [deviceResult, siteResult, groupResult] = await Promise.race([discovery, deadline]);
|
||||
if (deviceResult.status === 'rejected') throw deviceResult.reason;
|
||||
|
||||
const metadataErrors = {};
|
||||
if (siteResult.status === 'rejected') {
|
||||
metadataErrors.sites = 'Device sites unavailable';
|
||||
}
|
||||
if (groupResult.status === 'rejected') {
|
||||
metadataErrors.groups = 'Device groups unavailable';
|
||||
}
|
||||
const hierarchy = projectDeviceHierarchy({
|
||||
devices: deviceResult.value,
|
||||
sites: siteResult.status === 'fulfilled' ? siteResult.value : [],
|
||||
groups: groupResult.status === 'fulfilled' ? groupResult.value : [],
|
||||
metadataErrors,
|
||||
});
|
||||
if (!this._isCurrentDiscovery(discoveryState)) return this._staleDiscoveryResult();
|
||||
this._setAllowedDevices(hierarchy.devices, discoveryState);
|
||||
return { success: true, hierarchy };
|
||||
} catch (error) {
|
||||
if (!this._isCurrentDiscovery(discoveryState)) return this._staleDiscoveryResult();
|
||||
this.allowedDeviceIds.clear();
|
||||
const message = error && error.code === 'DISCOVERY_TIMEOUT'
|
||||
? 'Alta device discovery timed out'
|
||||
: safeErrorMessage(error, 'Failed to discover Alta devices');
|
||||
return { success: false, hierarchy: { devices: [], sites: [], groups: [] }, message };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthInfo() {
|
||||
try {
|
||||
return { success: true, authInfo: await this.altaClient.getAuthInfo() };
|
||||
@@ -253,12 +363,12 @@ class AppRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
async launchProxy(deviceId, username) {
|
||||
async launchProxy(deviceId) {
|
||||
try {
|
||||
const validatedId = validateDeviceId(deviceId);
|
||||
const validatedUsername = validateUsername(username);
|
||||
this._reconcileProxies();
|
||||
if (!this.allowedDeviceIds.has(validatedId)) {
|
||||
if (!this.allowedDeviceDiscovery || !this._isCurrentDiscovery(this.allowedDeviceDiscovery) ||
|
||||
!this.allowedDeviceIds.has(validatedId)) {
|
||||
return { success: false, message: 'Select a device from the current Alta device list.' };
|
||||
}
|
||||
if (this.proxyByDevice.has(validatedId)) {
|
||||
@@ -267,7 +377,7 @@ class AppRuntime {
|
||||
const session = this.sessionStore.requireSession();
|
||||
const result = this.proxyManager.launchProxy({
|
||||
deploymentHost: new URL(session.origin).hostname,
|
||||
username: validatedUsername,
|
||||
cookie: validateCookie(session.cookie),
|
||||
deviceId: validatedId,
|
||||
});
|
||||
this.proxyByDevice.set(validatedId, result.processId);
|
||||
@@ -298,6 +408,7 @@ class AppRuntime {
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
this.invalidateDiscovery();
|
||||
const tracked = this._reconcileProxies();
|
||||
await Promise.all(tracked.map(async (proxy) => {
|
||||
try {
|
||||
|
||||
+16
-15
@@ -5,7 +5,7 @@ const nodePath = require('node:path');
|
||||
const { spawn: nodeSpawn } = require('node:child_process');
|
||||
|
||||
const HELPER_FILENAME = 'aware-cam-proxy.exe';
|
||||
const MAX_USERNAME_LENGTH = 254;
|
||||
const MAX_COOKIE_LENGTH = 4096;
|
||||
const MAX_HOST_LENGTH = 253;
|
||||
const DEVICE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
@@ -42,19 +42,20 @@ function validateDeviceId(value) {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
function validateUsername(value) {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_USERNAME_LENGTH || value.trim().length === 0) {
|
||||
throw new ProxyLaunchError('Alta username is invalid.', 'INVALID_USERNAME');
|
||||
function validateCookie(value) {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_COOKIE_LENGTH) {
|
||||
throw new ProxyLaunchError('Alta session cookie is invalid.', 'INVALID_SESSION_COOKIE');
|
||||
}
|
||||
if (/[\u0000-\u001f\u007f-\u009f]/.test(value)) {
|
||||
throw new ProxyLaunchError('Alta username must not contain control characters.', 'INVALID_USERNAME');
|
||||
if (/[;\u0000-\u001f\u007f-\u009f]/.test(value)) {
|
||||
throw new ProxyLaunchError('Alta session cookie must not contain separators or control characters.', 'INVALID_SESSION_COOKIE');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeProcessError(error) {
|
||||
function safeProcessError(error, secret) {
|
||||
const source = error && typeof error.message === 'string' ? error.message : 'Unknown process error';
|
||||
return source.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').slice(0, 512);
|
||||
const redacted = secret ? source.split(secret).join('[REDACTED]') : source;
|
||||
return redacted.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').slice(0, 512);
|
||||
}
|
||||
|
||||
function safeMetadata(entry, status = entry.status) {
|
||||
@@ -129,7 +130,7 @@ function createProxyManager({
|
||||
|
||||
const deploymentHost = validateDeploymentHost(request.deploymentHost);
|
||||
const deviceId = validateDeviceId(request.deviceId);
|
||||
const username = validateUsername(request.username);
|
||||
const cookie = validateCookie(request.cookie);
|
||||
|
||||
if (!fs.existsSync(helperPath)) {
|
||||
throw new ProxyLaunchError('Proxy helper was not found in the approved application directory.', 'HELPER_NOT_FOUND');
|
||||
@@ -139,17 +140,17 @@ function createProxyManager({
|
||||
try {
|
||||
child = spawn(
|
||||
helperPath,
|
||||
['-a', deploymentHost, '-u', username, '-d', deviceId],
|
||||
['-a', deploymentHost, '-d', deviceId, '-k', cookie],
|
||||
{
|
||||
shell: false,
|
||||
detached: true,
|
||||
stdio: 'inherit',
|
||||
stdio: 'ignore',
|
||||
windowsHide: false
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
throw new ProxyLaunchError(
|
||||
`Failed to launch proxy helper: ${safeProcessError(error)}`,
|
||||
`Failed to launch proxy helper: ${safeProcessError(error, cookie)}`,
|
||||
'SPAWN_FAILED'
|
||||
);
|
||||
}
|
||||
@@ -268,10 +269,10 @@ function createProxyManager({
|
||||
module.exports = {
|
||||
DEFAULT_STOP_TIMEOUT_MS,
|
||||
HELPER_FILENAME,
|
||||
MAX_USERNAME_LENGTH,
|
||||
MAX_COOKIE_LENGTH,
|
||||
ProxyLaunchError,
|
||||
createProxyManager,
|
||||
validateCookie,
|
||||
validateDeploymentHost,
|
||||
validateDeviceId,
|
||||
validateUsername
|
||||
validateDeviceId
|
||||
};
|
||||
|
||||
+170
-2
@@ -51,7 +51,7 @@ body {
|
||||
|
||||
/* Left Sidebar - Available Devices */
|
||||
.devices-sidebar {
|
||||
width: 220px;
|
||||
width: 300px;
|
||||
background: var(--card-bg);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
@@ -113,7 +113,175 @@ body {
|
||||
.device-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Accessible, bounded virtual hierarchy */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.tree-results {
|
||||
min-height: 16px;
|
||||
margin-top: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.virtual-tree {
|
||||
position: relative;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.virtual-tree:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px var(--accent-primary);
|
||||
}
|
||||
|
||||
.virtual-tree.has-hidden-selection::after {
|
||||
content: 'Selected camera hidden';
|
||||
position: sticky;
|
||||
bottom: 4px;
|
||||
left: 8px;
|
||||
z-index: 3;
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--warning);
|
||||
font-size: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.virtual-tree-spacer {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.virtual-tree-window {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.tree-row {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding-right: 8px;
|
||||
border-left: 2px solid transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tree-row:hover,
|
||||
.tree-row.active {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.tree-row.active {
|
||||
border-left-color: var(--accent-primary);
|
||||
box-shadow: inset 0 0 0 1px rgba(14, 122, 254, 0.35);
|
||||
}
|
||||
|
||||
.tree-row.selected {
|
||||
background: rgba(14, 122, 254, 0.28);
|
||||
}
|
||||
|
||||
.tree-site {
|
||||
padding-left: 8px;
|
||||
color: var(--accent-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tree-group {
|
||||
padding-left: 24px;
|
||||
color: #b8cbea;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tree-camera {
|
||||
padding-left: 42px;
|
||||
}
|
||||
|
||||
.tree-camera::before {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: 0 0 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.tree-camera.online::before {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.tree-camera.proxy-active {
|
||||
border-right: 3px solid var(--success);
|
||||
}
|
||||
|
||||
.tree-disclosure {
|
||||
width: 10px;
|
||||
flex: 0 0 10px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 9px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tree-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-address {
|
||||
margin-left: auto;
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.tree-badge.pending,
|
||||
.tree-row.pending-deletion {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.tree-warning {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 14px;
|
||||
border: 1px solid var(--warning);
|
||||
border-radius: 50%;
|
||||
color: var(--warning);
|
||||
font-size: 9px;
|
||||
line-height: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.device-item {
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { SessionStore } = require('../src/session-store');
|
||||
const { AltaClient } = require('../src/alta-client');
|
||||
const {
|
||||
AltaClient,
|
||||
DEVICE_MAX_RESPONSE_BYTES,
|
||||
HIERARCHY_MAX_RESPONSE_BYTES,
|
||||
MAX_ARRAY_OBJECTS,
|
||||
} = require('../src/alta-client');
|
||||
|
||||
const ORIGIN = 'https://tenant.avasecurity.com';
|
||||
const SENTINEL = 'HERMES_SENTINEL_SECRET';
|
||||
@@ -24,17 +29,20 @@ test('uses only stored authority, fixed endpoint paths and hardened transport op
|
||||
calls.push(options);
|
||||
if (options.url.endsWith('/devices')) return response([]);
|
||||
if (options.url.endsWith('/deviceSites')) return response([{ id: 'site-1' }]);
|
||||
if (options.url.endsWith('/deviceGroups')) return response([{ id: 'group-1' }]);
|
||||
return response({ user: 'engineer' });
|
||||
};
|
||||
const client = new AltaClient({ sessionStore: readyStore(), transport });
|
||||
|
||||
assert.deepEqual(await client.getDevices(), []);
|
||||
assert.deepEqual(await client.getDeviceSites(), [{ id: 'site-1' }]);
|
||||
assert.deepEqual(await client.getDeviceGroups(), [{ id: 'group-1' }]);
|
||||
assert.deepEqual(await client.getAuthInfo(), { user: 'engineer' });
|
||||
|
||||
assert.deepEqual(calls.map((call) => call.url), [
|
||||
`${ORIGIN}/api/v1/devices`,
|
||||
`${ORIGIN}/api/v1/deviceSites`,
|
||||
`${ORIGIN}/api/v1/deviceGroups`,
|
||||
`${ORIGIN}/api/v1/auth`,
|
||||
]);
|
||||
for (const call of calls) {
|
||||
@@ -42,11 +50,56 @@ test('uses only stored authority, fixed endpoint paths and hardened transport op
|
||||
assert.equal(call.proxy, false);
|
||||
assert.equal(call.maxRedirects, 0);
|
||||
assert.match(call.headers.Cookie, /^va=HERMES_SENTINEL_SECRET$/);
|
||||
assert.ok(call.timeout > 0 && call.timeout <= 10_000);
|
||||
assert.ok(call.timeout > 0 && call.timeout <= 30_000);
|
||||
assert.ok(call.maxResponseBytes > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('uses endpoint-specific 32 MiB/4 MiB limits and a 30 second request deadline', async () => {
|
||||
const calls = [];
|
||||
const client = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
transport: async (options) => { calls.push(options); return response([]); },
|
||||
});
|
||||
|
||||
await client.getDevices();
|
||||
await client.getDeviceSites();
|
||||
await client.getDeviceGroups();
|
||||
|
||||
assert.deepEqual(calls.map(({ maxResponseBytes }) => maxResponseBytes), [
|
||||
DEVICE_MAX_RESPONSE_BYTES,
|
||||
HIERARCHY_MAX_RESPONSE_BYTES,
|
||||
HIERARCHY_MAX_RESPONSE_BYTES,
|
||||
]);
|
||||
assert.ok(calls.every(({ timeout }) => timeout > 0 && timeout <= 30_000));
|
||||
});
|
||||
|
||||
test('accepts device bodies above 2 MiB and rejects bodies above 32 MiB before parsing', async () => {
|
||||
const acceptedBody = JSON.stringify([{
|
||||
guid: '550e8400-e29b-41d4-a716-446655440000',
|
||||
padding: 'x'.repeat((2 * 1024 * 1024) + 1),
|
||||
}]);
|
||||
const accepted = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
transport: async () => response(Buffer.from(acceptedBody)),
|
||||
});
|
||||
assert.equal((await accepted.getDevices()).length, 1);
|
||||
|
||||
const rejected = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
transport: async () => response(Buffer.alloc(DEVICE_MAX_RESPONSE_BYTES + 1, 0x20)),
|
||||
});
|
||||
await assert.rejects(rejected.getDevices(), { code: 'ALTA_RESPONSE_TOO_LARGE' });
|
||||
});
|
||||
|
||||
test('rejects plain array responses containing more than 10,000 objects', async () => {
|
||||
const client = new AltaClient({
|
||||
sessionStore: readyStore(),
|
||||
transport: async () => response(Array.from({ length: MAX_ARRAY_OBJECTS + 1 }, () => ({}))),
|
||||
});
|
||||
await assert.rejects(client.getDevices(), { code: 'ALTA_RESPONSE_TOO_MANY_OBJECTS' });
|
||||
});
|
||||
|
||||
test('rejects renderer-supplied URL/cookie parameters before transport', async () => {
|
||||
let calls = 0;
|
||||
const client = new AltaClient({
|
||||
|
||||
@@ -11,7 +11,7 @@ const ROOT = path.join(__dirname, '..');
|
||||
const read = (name) => fs.readFileSync(path.join(ROOT, name), 'utf8');
|
||||
const readJson = (name) => JSON.parse(read(name));
|
||||
|
||||
function writeKit(root, { version = '1.1.0', legacy = false } = {}) {
|
||||
function writeKit(root, { version = '1.2.5', legacy = false } = {}) {
|
||||
const extension = path.join(root, 'chrome-extension');
|
||||
fs.mkdirSync(extension, { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'AltaCameraProxy.exe'), 'synthetic executable');
|
||||
@@ -31,7 +31,7 @@ test('application and extension versions and supported dependencies stay coordin
|
||||
const lock = readJson('package-lock.json');
|
||||
const manifest = readJson('chrome-extension/manifest.json');
|
||||
|
||||
assert.equal(pkg.version, '1.1.0');
|
||||
assert.equal(pkg.version, '1.2.5');
|
||||
assert.equal(manifest.version, pkg.version);
|
||||
assert.equal(lock.version, pkg.version);
|
||||
assert.equal(lock.packages[''].version, pkg.version);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
MAX_PROJECTED_PAYLOAD_BYTES,
|
||||
projectDeviceHierarchy,
|
||||
} = require('../src/device-projection');
|
||||
const { buildDeviceTree } = require('../device-tree');
|
||||
|
||||
function uuid(index) {
|
||||
return `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`;
|
||||
}
|
||||
|
||||
function rawDevice(index, overrides = {}) {
|
||||
return {
|
||||
guid: uuid(index),
|
||||
name: `Camera ${index}`,
|
||||
type: 'camera',
|
||||
model: 'Synthetic',
|
||||
address: `10.0.${Math.floor(index / 255)}.${index % 255}`,
|
||||
server_group_id: 'server-group-1',
|
||||
device_group_id: 'group-1',
|
||||
capabilities: { localStorage: index % 2 === 0, secretCapability: 'drop-me' },
|
||||
live: { display_status: 'online', private: 'drop-me' },
|
||||
cookie: 'must-not-cross-ipc',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('projects only allowlisted fields and honestly diagnoses ineligible devices', () => {
|
||||
const payload = projectDeviceHierarchy({
|
||||
devices: [
|
||||
rawDevice(1),
|
||||
rawDevice(2, { guid: 'not-a-uuid' }),
|
||||
rawDevice(4),
|
||||
],
|
||||
sites: [{ id: 'site-1', name: 'HQ', pending_deletion_start: null, secret: 'drop-me' }],
|
||||
groups: [{ id: 'group-1', name: 'Lobby', parent_id: null, pending_deletion_start: null, secret: 'drop-me' }],
|
||||
});
|
||||
|
||||
assert.deepEqual(payload.devices, [{
|
||||
id: uuid(1), name: 'Camera 1', type: 'camera', model: 'Synthetic', address: '10.0.0.1',
|
||||
siteId: null, deviceGroupId: 'group-1', displayStatus: 'online', localStorage: false,
|
||||
}]);
|
||||
assert.deepEqual(payload.sites, [{ id: 'site-1', name: 'HQ', pendingDeletionStart: null }]);
|
||||
assert.deepEqual(payload.groups, [{ id: 'group-1', name: 'Lobby', parentId: null, pendingDeletionStart: null }]);
|
||||
assert.deepEqual(payload.diagnostics.devices, {
|
||||
received: 3, eligible: 1, invalid: 1, cloudNativeExcluded: 1,
|
||||
});
|
||||
assert.doesNotMatch(JSON.stringify(payload), /drop-me|cookie|secretCapability|private/);
|
||||
});
|
||||
|
||||
test('infers camera sites from device-group parents instead of server groups', () => {
|
||||
const payload = projectDeviceHierarchy({
|
||||
devices: [rawDevice(3, {
|
||||
name: 'Front Door',
|
||||
server_group_id: 'cloud-connector-server-group',
|
||||
device_group_id: 'entrances',
|
||||
})],
|
||||
sites: [{ id: 'test-site', name: 'Test Site' }],
|
||||
groups: [{ id: 'entrances', name: 'Entrances', parent_id: 'test-site' }],
|
||||
});
|
||||
const model = buildDeviceTree(payload);
|
||||
const camera = [...model.cameraByKey.values()][0];
|
||||
|
||||
assert.equal(payload.devices[0].siteId, null);
|
||||
assert.equal(camera.site.canonicalId, 'test-site');
|
||||
assert.equal(camera.group.canonicalId, 'entrances');
|
||||
assert.equal(camera.hierarchyStatus, 'inferred-site');
|
||||
assert.deepEqual(model.diagnostics, []);
|
||||
});
|
||||
|
||||
test('excludes cloud-native cameras while retaining Cloud Connector and unknown-storage devices', () => {
|
||||
const payload = projectDeviceHierarchy({
|
||||
devices: [
|
||||
rawDevice(10, { name: 'Cloud Native', capabilities: { localStorage: true } }),
|
||||
rawDevice(11, { name: 'Cloud Connector', capabilities: { localStorage: false } }),
|
||||
rawDevice(12, { name: 'Unknown Storage', capabilities: {} }),
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(payload.devices.map((device) => device.name), ['Cloud Connector', 'Unknown Storage']);
|
||||
assert.deepEqual(payload.diagnostics.devices, {
|
||||
received: 3, eligible: 2, invalid: 0, cloudNativeExcluded: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('projects synthetic 1,500 and 10,000 camera deployments below the 8 MiB IPC cap', () => {
|
||||
for (const count of [1_500, 10_000]) {
|
||||
const payload = projectDeviceHierarchy({
|
||||
devices: Array.from({ length: count }, (_, index) => rawDevice(index, {
|
||||
capabilities: { localStorage: false },
|
||||
})),
|
||||
sites: [],
|
||||
groups: [],
|
||||
});
|
||||
assert.equal(payload.devices.length, count);
|
||||
assert.equal(payload.diagnostics.devices.received, count);
|
||||
assert.ok(Buffer.byteLength(JSON.stringify(payload)) <= MAX_PROJECTED_PAYLOAD_BYTES);
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed hierarchy metadata does not hide valid cameras', () => {
|
||||
const payload = projectDeviceHierarchy({
|
||||
devices: [rawDevice(7)],
|
||||
sites: [null, { id: {}, name: 'bad' }],
|
||||
groups: ['bad'],
|
||||
metadataErrors: { sites: 'Unavailable', groups: 'Unavailable' },
|
||||
});
|
||||
assert.equal(payload.devices.length, 1);
|
||||
assert.deepEqual(payload.sites, []);
|
||||
assert.deepEqual(payload.groups, []);
|
||||
assert.equal(payload.diagnostics.sites.invalid, 2);
|
||||
assert.equal(payload.diagnostics.groups.invalid, 1);
|
||||
assert.equal(payload.diagnostics.sites.error, 'Unavailable');
|
||||
});
|
||||
|
||||
test('repairs lone UTF-16 surrogates without changing valid Unicode or hiding cameras', () => {
|
||||
const payload = projectDeviceHierarchy({
|
||||
devices: [rawDevice(8, {
|
||||
name: 'Front \ud800 camera',
|
||||
model: 'Model \udc00',
|
||||
address: 'Hall \ud800\udc00 / \udc00',
|
||||
server_group_id: 'site-\ud800',
|
||||
device_group_id: 'group-\udc00',
|
||||
capabilities: { localStorage: false },
|
||||
})],
|
||||
sites: [{ id: 'site-\ud800', name: 'Valid \ud83d\udcf7 \udc00', pending_deletion_start: '\ud800' }],
|
||||
groups: [{ id: 'group-\udc00', name: 'Group \ud83d\udcf7 \ud800', parent_id: 'site-\ud800' }],
|
||||
metadataErrors: { sites: 'warning \ud800', groups: 'warning \udc00' },
|
||||
});
|
||||
|
||||
assert.equal(payload.devices.length, 1);
|
||||
assert.equal(payload.devices[0].name, 'Front \ufffd camera');
|
||||
assert.equal(payload.devices[0].model, 'Model \ufffd');
|
||||
assert.equal(payload.devices[0].address, 'Hall \ud800\udc00 / \ufffd');
|
||||
assert.equal(payload.devices[0].siteId, null);
|
||||
assert.equal(payload.devices[0].deviceGroupId, 'group-\ufffd');
|
||||
assert.deepEqual(payload.sites, [{ id: 'site-\ufffd', name: 'Valid \ud83d\udcf7 \ufffd', pendingDeletionStart: '\ufffd' }]);
|
||||
assert.deepEqual(payload.groups, [{ id: 'group-\ufffd', name: 'Group \ud83d\udcf7 \ufffd', parentId: 'site-\ufffd', pendingDeletionStart: null }]);
|
||||
assert.equal(payload.diagnostics.sites.error, 'warning \ufffd');
|
||||
assert.doesNotMatch(JSON.stringify(payload), /[\ud800-\udfff]/u);
|
||||
});
|
||||
|
||||
test('rejects a projected hierarchy payload above 8 MiB', () => {
|
||||
const devices = Array.from({ length: 10_000 }, (_, index) => rawDevice(index, {
|
||||
name: `Camera ${index} ${'x'.repeat(900)}`,
|
||||
capabilities: { localStorage: false },
|
||||
}));
|
||||
assert.throws(
|
||||
() => projectDeviceHierarchy({ devices, sites: [], groups: [] }),
|
||||
{ code: 'PROJECTED_PAYLOAD_TOO_LARGE' },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
buildDeviceTree,
|
||||
flattenVisibleRows,
|
||||
createSearchRunner,
|
||||
calculateVirtualWindow,
|
||||
} = require('../device-tree');
|
||||
|
||||
function baseFixture() {
|
||||
return {
|
||||
sites: [
|
||||
{ id: 's2', name: 'Zulu' },
|
||||
{ id: 's1', name: 'Alpha', pendingDeletionStart: '2026-08-19T12:00:00Z' },
|
||||
],
|
||||
groups: [
|
||||
{ id: 'g2', name: 'Doors', parentId: 's2' },
|
||||
{ id: 'g1', name: 'Lobby', parentId: 's1', pendingDeletionStart: '2026-08-19T12:00:00Z' },
|
||||
],
|
||||
devices: [
|
||||
{ id: 'c4', name: 'No Group', siteId: 's1', type: 'camera' },
|
||||
{ id: 'c2', name: 'Inferred', deviceGroupId: 'g2', model: 'H5A' },
|
||||
{ id: 'c1', name: 'Front', siteId: 's1', deviceGroupId: 'g1', address: '10.0.0.1' },
|
||||
{ id: 'c3', name: 'Conflict', siteId: 's1', deviceGroupId: 'g2' },
|
||||
{ id: 'c5', name: 'Unknown Group', siteId: 's1', deviceGroupId: 'missing-g' },
|
||||
{ id: 'c6', name: 'Unknown Site', siteId: 'missing-s' },
|
||||
{ id: 'c7', name: 'Orphan' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test('builds deterministic hierarchy with explicit exceptional buckets and diagnostics', () => {
|
||||
const model = buildDeviceTree(baseFixture());
|
||||
const camera = (id) => [...model.cameraByKey.values()].find((entry) => entry.canonicalId === id);
|
||||
assert.deepEqual(model.sites.map((site) => site.name), ['Alpha', 'Orphaned cameras', 'Unknown site: missing-s', 'Zulu']);
|
||||
assert.equal(model.sites[0].pendingDeletion, true);
|
||||
assert.deepEqual(model.sites[0].groups.map((group) => group.name), ['Doors', 'Lobby', 'Ungrouped', 'Unknown group: missing-g']);
|
||||
assert.equal(model.sites[0].groups.find((group) => group.id === 'g1').pendingDeletion, true);
|
||||
assert.deepEqual(model.sites[3].groups[0].cameras.map((camera) => camera.name), ['Inferred']);
|
||||
|
||||
const conflict = camera('c3');
|
||||
assert.equal(conflict.site.id, 's1');
|
||||
assert.equal(conflict.group.name, 'Doors');
|
||||
assert.equal(conflict.hierarchyStatus, 'conflict');
|
||||
assert.equal(camera('c2').hierarchyStatus, 'inferred-site');
|
||||
assert.ok(model.diagnostics.some((entry) => entry.code === 'site-group-conflict' && entry.key === conflict.key));
|
||||
});
|
||||
|
||||
test('duplicate and malformed IDs get stable unique keys and diagnostics', () => {
|
||||
const fixture = {
|
||||
sites: [{ id: 's', name: 'A' }, { id: 's', name: 'B' }, { name: 'Bad' }],
|
||||
groups: [{ id: 'g', name: 'G', parentId: 's' }, { id: 'g', name: 'G2', parentId: 's' }],
|
||||
devices: [
|
||||
{ id: 'c', name: 'Same', siteId: 's', deviceGroupId: 'g' },
|
||||
{ id: 'c', name: 'Same', siteId: 's', deviceGroupId: 'g' },
|
||||
{ name: 'Missing', siteId: 's' },
|
||||
],
|
||||
};
|
||||
const first = buildDeviceTree(fixture);
|
||||
const second = buildDeviceTree(fixture);
|
||||
assert.deepEqual([...first.rowByKey.keys()], [...second.rowByKey.keys()]);
|
||||
assert.equal(new Set(first.rowByKey.keys()).size, first.rowByKey.size);
|
||||
assert.ok(first.diagnostics.filter((entry) => entry.code === 'duplicate-id').length >= 3);
|
||||
assert.ok(first.diagnostics.filter((entry) => entry.code === 'malformed-id').length >= 2);
|
||||
});
|
||||
|
||||
test('sites start collapsed; expansion produces levels and complete ARIA metadata', () => {
|
||||
const model = buildDeviceTree(baseFixture());
|
||||
const site = model.sites.find((entry) => entry.canonicalId === 's1' && !entry.synthetic);
|
||||
const group = site.groups.find((entry) => entry.canonicalId === 'g1' && !entry.synthetic);
|
||||
const selected = [...model.cameraByKey.values()].find((entry) => entry.canonicalId === 'c1');
|
||||
let rows = flattenVisibleRows(model, { expandedKeys: new Set(), selectedKey: selected.key });
|
||||
assert.equal(rows.length, 4);
|
||||
assert.ok(rows.every((row) => row.kind === 'site' && row.aria.level === 1 && row.aria.expanded === false));
|
||||
assert.ok(rows.every((row) => row.aria.setsize === 4));
|
||||
assert.equal(rows.hiddenSelected, true);
|
||||
|
||||
rows = flattenVisibleRows(model, { expandedKeys: new Set([site.key]) });
|
||||
assert.deepEqual(rows.slice(0, 5).map((row) => row.kind), ['site', 'group', 'group', 'group', 'group']);
|
||||
assert.ok(rows.slice(1, 5).every((row) => row.aria.level === 2 && row.aria.expanded === false));
|
||||
|
||||
rows = flattenVisibleRows(model, {
|
||||
expandedKeys: new Set([site.key, group.key]),
|
||||
selectedKey: selected.key,
|
||||
});
|
||||
const camera = rows.find((row) => row.key === selected.key);
|
||||
assert.equal(camera.parentKey, group.key);
|
||||
assert.deepEqual(camera.aria, { level: 3, expanded: undefined, selected: true, posinset: 1, setsize: 1 });
|
||||
assert.equal(rows.hiddenSelected, false);
|
||||
});
|
||||
|
||||
test('search covers camera fields and ancestors without mutating saved expansion', async () => {
|
||||
const model = buildDeviceTree(baseFixture());
|
||||
const site = (id) => model.sites.find((entry) => entry.canonicalId === id && !entry.synthetic);
|
||||
const camera = (id) => [...model.cameraByKey.values()].find((entry) => entry.canonicalId === id);
|
||||
const scheduled = [];
|
||||
const runner = createSearchRunner({ scheduler: (work) => scheduled.push(work), chunkSize: 2 });
|
||||
const saved = new Set([site('s2').key]);
|
||||
const promise = runner.search(model, 'alpha lobby 10.0.0.1', { expandedKeys: saved });
|
||||
while (scheduled.length) scheduled.shift()();
|
||||
const result = await promise;
|
||||
assert.equal(result.count, 1);
|
||||
assert.deepEqual([...result.cameraKeys], [camera('c1').key]);
|
||||
assert.ok(result.expandedKeys.has(site('s1').key));
|
||||
assert.ok(result.expandedKeys.has(camera('c1').group.key));
|
||||
assert.deepEqual([...saved], [site('s2').key]);
|
||||
|
||||
const ancestorPromise = runner.search(model, 'zulu', { expandedKeys: new Set() });
|
||||
while (scheduled.length) scheduled.shift()();
|
||||
const ancestor = await ancestorPromise;
|
||||
assert.deepEqual([...ancestor.cameraKeys], [camera('c2').key]);
|
||||
});
|
||||
|
||||
test('new search cancels stale chunked generation', async () => {
|
||||
const model = buildDeviceTree({
|
||||
sites: [{ id: 's', name: 'Site' }],
|
||||
groups: [],
|
||||
devices: Array.from({ length: 700 }, (_, i) => ({ id: `c${i}`, name: `Camera ${i}`, siteId: 's' })),
|
||||
});
|
||||
const scheduled = [];
|
||||
const runner = createSearchRunner({ scheduler: (work) => scheduled.push(work), chunkSize: 250 });
|
||||
const stale = runner.search(model, 'camera', {});
|
||||
scheduled.shift()();
|
||||
const current = runner.search(model, 'camera 699', {});
|
||||
while (scheduled.length) scheduled.shift()();
|
||||
const [staleResult, currentResult] = await Promise.all([stale, current]);
|
||||
assert.equal(staleResult.cancelled, true);
|
||||
assert.equal(currentResult.cancelled, false);
|
||||
assert.equal(currentResult.count, 1);
|
||||
});
|
||||
|
||||
test('virtual window remains structurally bounded for 5,000 expanded shuffled cameras', () => {
|
||||
const devices = Array.from({ length: 5000 }, (_, i) => ({ id: `id-${i}`, name: `Camera ${String(i).padStart(4, '0')}`, siteId: 's' }));
|
||||
for (let i = devices.length - 1; i > 0; i -= 1) {
|
||||
const j = (i * 48271) % (i + 1);
|
||||
[devices[i], devices[j]] = [devices[j], devices[i]];
|
||||
}
|
||||
const model = buildDeviceTree({ sites: [{ id: 's', name: 'Large' }], groups: [], devices });
|
||||
const rows = flattenVisibleRows(model, {
|
||||
expandedKeys: new Set([model.sites[0].key, model.sites[0].groups[0].key]),
|
||||
});
|
||||
const window = calculateVirtualWindow(rows, { scrollTop: 50000, viewportHeight: 320, rowHeight: 28 });
|
||||
assert.ok(window.rows.length >= 40 && window.rows.length <= 50, `mounted ${window.rows.length}`);
|
||||
assert.equal(window.totalHeight, rows.length * 28);
|
||||
assert.equal(window.offsetTop, window.startIndex * 28);
|
||||
});
|
||||
|
||||
test('1,500-site/group fixture has deterministic stable ordering', () => {
|
||||
const sites = Array.from({ length: 1500 }, (_, i) => ({ id: `s-${i}`, name: `Site ${i % 17}` }));
|
||||
const groups = sites.map((site, i) => ({ id: `g-${i}`, name: `Group ${i % 11}`, parentId: site.id }));
|
||||
const model = buildDeviceTree({ sites: sites.reverse(), groups: groups.reverse(), devices: [] });
|
||||
const namesAndIds = model.sites.map((site) => `${site.normalizedName}|${site.id}`);
|
||||
assert.deepEqual(namesAndIds, [...namesAndIds].sort((a, b) => a.localeCompare(b)));
|
||||
});
|
||||
|
||||
test('tenant and synthetic namespaces stay distinct for reserved and ambiguous IDs', () => {
|
||||
const model = buildDeviceTree({
|
||||
sites: [
|
||||
{ id: '__orphaned__', name: 'Tenant Reserved Site' },
|
||||
{ id: '__unknown_site__:missing', name: 'Tenant Unknown-shaped Site' },
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'a:b', name: 'A colon B' },
|
||||
{ id: 'percent%:site', name: 'Percent Site' },
|
||||
],
|
||||
groups: [
|
||||
{ id: '__ungrouped__', name: 'Tenant Reserved Group', parentId: '__orphaned__' },
|
||||
{ id: 'b:c', name: 'Tuple One', parentId: 'a' },
|
||||
{ id: 'c', name: 'Tuple Two', parentId: 'a:b' },
|
||||
{ id: 'g:%', name: 'Escaped Group', parentId: 'percent%:site' },
|
||||
],
|
||||
devices: [
|
||||
{ id: 'tenant-group', siteId: '__orphaned__', deviceGroupId: '__ungrouped__' },
|
||||
{ id: 'synthetic-group', siteId: '__orphaned__' },
|
||||
{ id: 'orphan' },
|
||||
{ id: 'unknown-site', siteId: 'missing' },
|
||||
{ id: 'tuple-one', siteId: 'a', deviceGroupId: 'b:c' },
|
||||
{ id: 'tuple-two', siteId: 'a:b', deviceGroupId: 'c' },
|
||||
{ id: 'escaped', siteId: 'percent%:site', deviceGroupId: 'g:%' },
|
||||
],
|
||||
});
|
||||
|
||||
const tenantReserved = model.sites.find((site) => !site.synthetic && site.id === '__orphaned__');
|
||||
const orphanBucket = model.sites.find((site) => site.synthetic && site.hierarchyStatus === 'orphan');
|
||||
const unknownBucket = model.sites.find((site) => site.synthetic && site.hierarchyStatus === 'unknown-site');
|
||||
assert.ok(tenantReserved && orphanBucket && unknownBucket);
|
||||
assert.notEqual(tenantReserved.key, orphanBucket.key);
|
||||
const camera = (id) => [...model.cameraByKey.values()].find((entry) => entry.canonicalId === id);
|
||||
assert.notEqual(camera('tenant-group').group.key, camera('synthetic-group').group.key);
|
||||
assert.notEqual(camera('tuple-one').group.key, camera('tuple-two').group.key);
|
||||
assert.equal(model.rowByKey.size, model.sites.length +
|
||||
model.sites.reduce((count, site) => count + site.groups.length, 0) + model.cameraCount);
|
||||
assert.ok([...model.rowByKey.keys()].every((key) => !/\s/.test(key)));
|
||||
});
|
||||
|
||||
test('malformed Unicode metadata builds deterministically and remains searchable and visible', async () => {
|
||||
const fixture = {
|
||||
sites: [{ id: 'site-\ud800', name: 'Site \ud83d\udcf7 \udc00' }],
|
||||
groups: [{ id: 'group-\udc00', name: 'Group \ud83d\udcf7 \ud800', parentId: 'site-\ud800' }],
|
||||
devices: [{
|
||||
id: 'camera-valid', name: 'Lobby \ud800 camera', siteId: 'site-\ud800',
|
||||
deviceGroupId: 'group-\udc00', address: 'Floor \udc00',
|
||||
}],
|
||||
};
|
||||
|
||||
const first = buildDeviceTree(fixture);
|
||||
const second = buildDeviceTree(fixture);
|
||||
assert.equal(first.cameraCount, 1);
|
||||
assert.deepEqual([...first.rowByKey.keys()], [...second.rowByKey.keys()]);
|
||||
assert.doesNotMatch([...first.rowByKey.keys()].join(''), /[\ud800-\udfff]/u);
|
||||
const camera = [...first.cameraByKey.values()][0];
|
||||
assert.equal(camera.name, 'Lobby \ufffd camera');
|
||||
assert.equal(camera.site.name, 'Site \ud83d\udcf7 \ufffd');
|
||||
assert.equal(camera.group.name, 'Group \ud83d\udcf7 \ufffd');
|
||||
assert.equal(camera.source.address, 'Floor \ufffd');
|
||||
assert.doesNotMatch(JSON.stringify(camera.source), /[\ud800-\udfff]/u);
|
||||
assert.match(camera.searchCorpus, /floor \ufffd/);
|
||||
|
||||
const result = await createSearchRunner().search(first, 'lobby \ufffd');
|
||||
assert.deepEqual([...result.cameraKeys], [camera.key]);
|
||||
});
|
||||
|
||||
test('virtual window clamps an obsolete scroll offset after rows shrink', () => {
|
||||
const rows = [{ key: 'only-row' }];
|
||||
const window = calculateVirtualWindow(rows, {
|
||||
scrollTop: 10000,
|
||||
viewportHeight: 320,
|
||||
rowHeight: 28,
|
||||
overscan: 0,
|
||||
});
|
||||
assert.deepEqual(window.rows, rows);
|
||||
assert.equal(window.startIndex, 0);
|
||||
assert.equal(window.endIndex, 1);
|
||||
});
|
||||
@@ -39,10 +39,11 @@ function makeElement() {
|
||||
|
||||
function makePopupHarness({ paired = true, confirmCopy = false, clipboardReject = null, validServerProof = true } = {}) {
|
||||
const elements = Object.fromEntries(
|
||||
['tabInfo', 'pairingInfo', 'sendBtn', 'copyBtn', 'statusMsg', 'copyWarning', 'confirmCopy', 'openOptionsBtn']
|
||||
['tabInfo', 'pairingInfo', 'sendBtn', 'copyBtn', 'statusMsg', 'openOptionsBtn']
|
||||
.map((id) => [id, makeElement()])
|
||||
);
|
||||
const clipboardWrites = [];
|
||||
const confirmationMessages = [];
|
||||
const fetchCalls = [];
|
||||
let cookieReads = 0;
|
||||
const chromeApi = {
|
||||
@@ -65,7 +66,7 @@ function makePopupHarness({ paired = true, confirmCopy = false, clipboardReject
|
||||
chromeApi,
|
||||
documentApi,
|
||||
navigatorApi,
|
||||
confirmCopy: () => confirmCopy,
|
||||
confirmCopy: (message) => { confirmationMessages.push(message); return confirmCopy; },
|
||||
cryptoApi: crypto.webcrypto,
|
||||
fetchImpl: async (...args) => {
|
||||
fetchCalls.push(args);
|
||||
@@ -81,7 +82,7 @@ function makePopupHarness({ paired = true, confirmCopy = false, clipboardReject
|
||||
return { ok: true, json: async () => ({ success: true }) };
|
||||
}
|
||||
});
|
||||
return { controller, elements, clipboardWrites, fetchCalls, get cookieReads() { return cookieReads; } };
|
||||
return { controller, elements, clipboardWrites, confirmationMessages, fetchCalls, get cookieReads() { return cookieReads; } };
|
||||
}
|
||||
|
||||
test('manifest commits only a public key, stable ID, local storage, and exact loopback host access', () => {
|
||||
@@ -174,11 +175,11 @@ test('copy cancellation occurs before cookie access and never writes the token',
|
||||
assert.match(harness.elements.statusMsg.textContent, /cancelled/i);
|
||||
});
|
||||
|
||||
test('confirmed copy warns about clipboard history and reports success without exposing token', async () => {
|
||||
test('confirmed copy uses one concise prompt and reports success without exposing token', async () => {
|
||||
const harness = makePopupHarness({ paired: true, confirmCopy: true });
|
||||
await harness.controller.init();
|
||||
assert.match(harness.elements.copyWarning.textContent, /clipboard (?:history|sync)/i);
|
||||
await harness.controller.copyToken();
|
||||
assert.deepEqual(harness.confirmationMessages, ['Copy VA token?']);
|
||||
assert.deepEqual(harness.clipboardWrites, ['sensitive-va-token']);
|
||||
assert.match(harness.elements.statusMsg.textContent, /copied/i);
|
||||
assert.equal(harness.elements.statusMsg.textContent.includes('sensitive-va-token'), false);
|
||||
@@ -201,6 +202,7 @@ test('options UI stores a validated pairing secret locally and can forget pairin
|
||||
const js = read('options.js');
|
||||
assert.match(html, /pairingSecret/);
|
||||
assert.match(html, /type="password"/);
|
||||
assert.doesNotMatch(html, /treat it like a password|privacy-note/i);
|
||||
assert.match(js, /chrome\.storage\.local\.set/);
|
||||
assert.match(js, /chrome\.storage\.local\.remove/);
|
||||
assert.match(js, /aptPairingSecret/);
|
||||
|
||||
+22
-22
@@ -11,7 +11,7 @@ const APPROVED_DIRECTORY = 'C:\\Program Files\\Alta Proxy Tool';
|
||||
const APPROVED_HELPER = 'C:\\Program Files\\Alta Proxy Tool\\aware-cam-proxy.exe';
|
||||
const VALID_HOST = 'tenant.avasecurity.com';
|
||||
const VALID_DEVICE_ID = '123e4567-e89b-42d3-a456-426614174000';
|
||||
const VALID_USERNAME = 'proxy.operator+apt@example.com';
|
||||
const SYNTHETIC_COOKIE = 'HERMES_SENTINEL_SESSION_COOKIE';
|
||||
|
||||
function loadModule() {
|
||||
return require(MODULE_PATH);
|
||||
@@ -67,7 +67,7 @@ function validRequest(overrides = {}) {
|
||||
return {
|
||||
deploymentHost: VALID_HOST,
|
||||
deviceId: VALID_DEVICE_ID,
|
||||
username: VALID_USERNAME,
|
||||
cookie: SYNTHETIC_COOKIE,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
@@ -76,18 +76,18 @@ test('exports the proxy manager module', () => {
|
||||
assert.doesNotThrow(() => loadModule());
|
||||
});
|
||||
|
||||
test('launches the approved helper directly in a visible interactive console', () => {
|
||||
test('launches the approved helper directly with the paired Alta session', () => {
|
||||
const { manager, calls } = createHarness();
|
||||
|
||||
const result = manager.launchProxy(validRequest());
|
||||
|
||||
assert.deepEqual(calls, [[
|
||||
APPROVED_HELPER,
|
||||
['-a', VALID_HOST, '-u', VALID_USERNAME, '-d', VALID_DEVICE_ID],
|
||||
['-a', VALID_HOST, '-d', VALID_DEVICE_ID, '-k', SYNTHETIC_COOKIE],
|
||||
{
|
||||
shell: false,
|
||||
detached: true,
|
||||
stdio: 'inherit',
|
||||
stdio: 'ignore',
|
||||
windowsHide: false
|
||||
}
|
||||
]]);
|
||||
@@ -100,16 +100,15 @@ test('launches the approved helper directly in a visible interactive console', (
|
||||
});
|
||||
});
|
||||
|
||||
test('passes username punctuation literally in argv without invoking a shell', () => {
|
||||
const username = 'proxy+apt&literal|name@example.com';
|
||||
test('passes the paired cookie literally without invoking a shell or console prompt', () => {
|
||||
const cookie = 'abc_DEF-123.456==';
|
||||
const { manager, calls } = createHarness();
|
||||
|
||||
manager.launchProxy(validRequest({ username }));
|
||||
manager.launchProxy(validRequest({ cookie }));
|
||||
|
||||
assert.equal(calls[0][1][3], username);
|
||||
assert.equal(calls[0][1][5], cookie);
|
||||
assert.equal(calls[0][2].shell, false);
|
||||
assert.equal(calls[0][2].stdio, 'inherit');
|
||||
assert.notEqual(calls[0][2].stdio, 'ignore');
|
||||
assert.equal(calls[0][2].stdio, 'ignore');
|
||||
});
|
||||
|
||||
test('rejects the CRLF calc.exe reproducer in every structured input', () => {
|
||||
@@ -117,8 +116,8 @@ test('rejects the CRLF calc.exe reproducer in every structured input', () => {
|
||||
const attacks = [
|
||||
{ deploymentHost: `${VALID_HOST}\r\ncalc.exe` },
|
||||
{ deviceId: `${VALID_DEVICE_ID}\r\ncalc.exe` },
|
||||
{ username: `${VALID_USERNAME}\r\ncalc.exe` },
|
||||
{ username: `${VALID_USERNAME}\0calc.exe` }
|
||||
{ cookie: `${SYNTHETIC_COOKIE}\r\ncalc.exe` },
|
||||
{ cookie: `${SYNTHETIC_COOKIE}\0calc.exe` }
|
||||
];
|
||||
|
||||
for (const attack of attacks) {
|
||||
@@ -191,10 +190,10 @@ test('normalizes a valid Alta hostname to lowercase', () => {
|
||||
assert.equal(calls[0][1][1], 'tenant.avigilon.com');
|
||||
});
|
||||
|
||||
test('rejects empty, oversized, non-string, and control-character usernames', () => {
|
||||
for (const username of ['', 'x'.repeat(255), 42, 'user\nname', 'user\rname', 'user\0name', 'user\u007fname']) {
|
||||
test('rejects empty, oversized, non-string, and control-character cookies', () => {
|
||||
for (const cookie of ['', 'x'.repeat(4097), 42, 'abc\nxyz', 'abc\rxyz', 'abc\0xyz', 'abc;xyz']) {
|
||||
const { manager } = createHarness();
|
||||
assert.throws(() => manager.launchProxy(validRequest({ username })), /username/i);
|
||||
assert.throws(() => manager.launchProxy(validRequest({ cookie })), /cookie/i);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -218,25 +217,27 @@ test('requires an absolute approved application directory and Windows platform',
|
||||
);
|
||||
});
|
||||
|
||||
test('reports a bounded spawn failure without credential redaction machinery', () => {
|
||||
test('redacts the cookie if spawn throws an error containing it', () => {
|
||||
const { createProxyManager } = loadModule();
|
||||
const manager = createProxyManager({
|
||||
appDirectory: APPROVED_DIRECTORY,
|
||||
fs: { existsSync: () => true },
|
||||
platform: 'win32',
|
||||
spawn: () => { throw new Error('spawn failed'); }
|
||||
spawn: () => { throw new Error(`spawn failed for ${SYNTHETIC_COOKIE}`); }
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => manager.launchProxy(validRequest()),
|
||||
(error) => {
|
||||
assert.match(error.message, /spawn failed/);
|
||||
assert.match(error.message, /\[REDACTED\]/);
|
||||
assert.doesNotMatch(error.message, /HERMES_SENTINEL_SESSION_COOKIE/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('tracks only safe process metadata and never exposes usernames', () => {
|
||||
test('tracks only safe process metadata and never exposes cookies', () => {
|
||||
const { manager } = createHarness();
|
||||
manager.launchProxy(validRequest());
|
||||
|
||||
@@ -247,7 +248,7 @@ test('tracks only safe process metadata and never exposes usernames', () => {
|
||||
startedAt: 1_777_777_777_777,
|
||||
status: 'running'
|
||||
}]);
|
||||
assert.doesNotMatch(JSON.stringify(tracked), /proxy\.operator/);
|
||||
assert.doesNotMatch(JSON.stringify(tracked), /HERMES_SENTINEL_SESSION_COOKIE/);
|
||||
});
|
||||
|
||||
test('stopping one tracked process waits for confirmed exit and leaves the other alive', async () => {
|
||||
@@ -381,9 +382,8 @@ test('exit events remove only the matching owned child', () => {
|
||||
assert.deepEqual(manager.listTrackedProxies().map((item) => item.processId), [4101]);
|
||||
});
|
||||
|
||||
test('source contains no bearer argv, shell launchers, broad process killers, or persistence APIs', () => {
|
||||
test('source contains no shell launchers, broad process killers, or credential persistence APIs', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'proxy-launch.js'), 'utf8');
|
||||
assert.doesNotMatch(source, /cookie|bearer|token|-k/i);
|
||||
assert.doesNotMatch(source, /\b(?:cmd(?:\.exe)?|powershell|taskkill|pkill|wmic)\b/i);
|
||||
assert.doesNotMatch(source, /\.(?:bat|command)\b/i);
|
||||
assert.doesNotMatch(source, /(?:writeFile|appendFile|mkdtemp|tmpdir)/);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createRendererController } = require('../renderer-controller');
|
||||
const { createRendererController, shouldShowPairingOnboarding } = require('../renderer-controller');
|
||||
|
||||
function harness(result) {
|
||||
const calls = [];
|
||||
@@ -39,3 +39,11 @@ test('successful disconnect renders disconnected state and clears device state',
|
||||
['status', 'Disconnected from Alta.', 'info'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('pairing onboarding is temporary but remains recoverable', () => {
|
||||
assert.equal(shouldShowPairingOnboarding({ paired: false }), true);
|
||||
assert.equal(shouldShowPairingOnboarding({ paired: true, secretVisible: true }), true);
|
||||
assert.equal(shouldShowPairingOnboarding({ paired: true }), false);
|
||||
assert.equal(shouldShowPairingOnboarding({ paired: true, secretVisible: true, connected: true }), false);
|
||||
assert.equal(shouldShowPairingOnboarding({ paired: true, connected: true, manuallyOpen: true }), true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { describeVirtualRows } = require('../sidebar-view');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const read = (name) => fs.readFileSync(path.join(ROOT, name), 'utf8');
|
||||
|
||||
function syntheticSnapshot(count) {
|
||||
const rows = Array.from({ length: count }, (_, index) => ({
|
||||
key: `camera:00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
|
||||
id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
|
||||
kind: 'camera', name: `Camera ${index}`, ariaLevel: 3, ariaSelected: index === 4,
|
||||
ariaPosInSet: index + 1, ariaSetSize: count,
|
||||
}));
|
||||
const startIndex = Math.max(0, Math.floor(count / 2) - 20);
|
||||
return {
|
||||
state: { activeKey: rows[startIndex].key, selectedKey: rows[4].key },
|
||||
rows: Object.assign(rows, { hiddenSelected: true }),
|
||||
window: { rows: rows.slice(startIndex, startIndex + 60), startIndex, offsetTop: startIndex * 30, totalHeight: count * 30 },
|
||||
};
|
||||
}
|
||||
|
||||
test('renderer loads hierarchy foundations in order and exposes an accessible virtual tree', () => {
|
||||
const html = read('index.html');
|
||||
const treeIndex = html.indexOf('device-tree.js');
|
||||
const controllerIndex = html.indexOf('sidebar-controller.js');
|
||||
const viewIndex = html.indexOf('sidebar-view.js');
|
||||
const rendererIndex = html.indexOf('renderer.js');
|
||||
assert.ok(treeIndex >= 0 && treeIndex < controllerIndex && controllerIndex < viewIndex && viewIndex < rendererIndex);
|
||||
assert.match(html, /id="deviceList"[^>]*role="tree"[^>]*tabindex="0"[^>]*aria-activedescendant/);
|
||||
assert.match(html, /id="deviceResults"[^>]*role="status"[^>]*aria-live="polite"/);
|
||||
});
|
||||
|
||||
test('renderer uses one hierarchy request and no parallel flat discovery', () => {
|
||||
const renderer = read('renderer.js');
|
||||
assert.match(renderer, /electronAPI\.getDeviceHierarchy\(\)/);
|
||||
assert.doesNotMatch(renderer, /electronAPI\.getDevices\(|electronAPI\.getDeviceSites\(|Promise\.all\(\s*\[\s*window\.electronAPI\.get/);
|
||||
assert.doesNotMatch(renderer, /\ballDevices\b|\ballSites\b|\bcollapsedSites\b|groupDevicesBySite/);
|
||||
assert.doesNotMatch(renderer, /Selected camera is hidden|hidden by the current view/i);
|
||||
assert.match(renderer, /loadGeneration/);
|
||||
assert.match(renderer, /generation\s*!==\s*loadGeneration/);
|
||||
});
|
||||
|
||||
test('renderer uses delegated tree events, throttled scroll, and debounced search', () => {
|
||||
const renderer = read('renderer.js');
|
||||
assert.equal((renderer.match(/deviceList\.addEventListener\('click'/g) || []).length, 1);
|
||||
assert.equal((renderer.match(/deviceList\.addEventListener\('keydown'/g) || []).length, 1);
|
||||
assert.equal((renderer.match(/deviceList\.addEventListener\('scroll'/g) || []).length, 1);
|
||||
assert.match(renderer, /requestAnimationFrame/);
|
||||
assert.match(renderer, /setTimeout\([^,]+,\s*125\)/s);
|
||||
});
|
||||
|
||||
test('virtual row descriptors stay bounded for 1,500 and 5,000 visible rows', () => {
|
||||
for (const count of [1_500, 5_000]) {
|
||||
const descriptors = describeVirtualRows(syntheticSnapshot(count), { activeDeviceIds: new Set() });
|
||||
assert.ok(descriptors.length <= 80, `${count} rows mounted ${descriptors.length} descriptors`);
|
||||
assert.equal(descriptors[0].role, 'treeitem');
|
||||
assert.equal(descriptors[0].aria.level, 3);
|
||||
assert.match(descriptors[0].domId, /^apt-tree-row-/);
|
||||
}
|
||||
});
|
||||
|
||||
test('view descriptors preserve selection and refresh proxy/status classes without rebuilding rows', () => {
|
||||
const snapshot = syntheticSnapshot(1_500);
|
||||
const selectedId = snapshot.rows[4].id;
|
||||
const before = describeVirtualRows(snapshot, { activeDeviceIds: new Set() });
|
||||
const camera = before.find((row) => row.kind === 'camera');
|
||||
const after = describeVirtualRows(snapshot, { activeDeviceIds: new Set([camera.deviceId]), isOnline: () => true });
|
||||
assert.ok(after.find((row) => row.key === camera.key).classes.includes('proxy-active'));
|
||||
assert.ok(after.find((row) => row.key === camera.key).classes.includes('online'));
|
||||
assert.equal(snapshot.rows[4].id, selectedId);
|
||||
assert.equal(snapshot.rows.hiddenSelected, true);
|
||||
});
|
||||
+159
-25
@@ -78,15 +78,25 @@ test('runtime keeps Alta credentials in main-owned modules and exposes only non-
|
||||
openExternal: async () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(await runtime.getDevices(), { success: true, devices: [{ guid: '550e8400-e29b-41d4-a716-446655440000' }] });
|
||||
const launched = await runtime.launchProxy(
|
||||
'550e8400-e29b-41d4-a716-446655440000',
|
||||
'proxy.operator@example.com'
|
||||
);
|
||||
assert.deepEqual(await runtime.getDevices(), {
|
||||
success: true,
|
||||
devices: [{
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
name: null,
|
||||
type: null,
|
||||
model: null,
|
||||
address: null,
|
||||
siteId: null,
|
||||
deviceGroupId: null,
|
||||
displayStatus: null,
|
||||
localStorage: null,
|
||||
}],
|
||||
});
|
||||
const launched = await runtime.launchProxy('550e8400-e29b-41d4-a716-446655440000');
|
||||
assert.equal(launched.success, true);
|
||||
assert.deepEqual(calls[0], {
|
||||
deploymentHost: 'customer.avasecurity.com',
|
||||
username: 'proxy.operator@example.com',
|
||||
cookie: 'top-secret-cookie',
|
||||
deviceId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
});
|
||||
assert.deepEqual(runtime.getConnectionState(), {
|
||||
@@ -102,29 +112,148 @@ test('runtime keeps Alta credentials in main-owned modules and exposes only non-
|
||||
assert.equal((await runtime.stopProxy(999)).success, false);
|
||||
});
|
||||
|
||||
test('runtime rejects invalid usernames before calling the proxy manager', async () => {
|
||||
const sessionStore = createSessionStore();
|
||||
sessionStore.establish('https://customer.avasecurity.com', 'top-secret-cookie');
|
||||
test('runtime discovers and projects hierarchy while metadata failures preserve cameras', async () => {
|
||||
const deviceId = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const runtime = new AppRuntime({
|
||||
sessionStore: createSessionStore(),
|
||||
altaClient: {
|
||||
getDevices: async () => [{
|
||||
guid: deviceId,
|
||||
name: 'Camera',
|
||||
server_group_id: 'site-1',
|
||||
secret: 'drop-me',
|
||||
}],
|
||||
getDeviceSites: async () => { throw Object.assign(new Error('tenant secret'), { code: 'ALTA_HTTP_ERROR' }); },
|
||||
getDeviceGroups: async () => [{ id: 'group-1', name: 'Lobby', parent_id: null }],
|
||||
},
|
||||
proxyManager: { listTrackedProxies: () => [] },
|
||||
});
|
||||
|
||||
const result = await runtime.getDeviceHierarchy();
|
||||
assert.equal(result.success, true);
|
||||
assert.deepEqual(result.hierarchy.devices.map(({ id }) => id), [deviceId]);
|
||||
assert.deepEqual(result.hierarchy.sites, []);
|
||||
assert.deepEqual(result.hierarchy.groups, [{
|
||||
id: 'group-1', name: 'Lobby', parentId: null, pendingDeletionStart: null,
|
||||
}]);
|
||||
assert.equal(result.hierarchy.diagnostics.sites.error, 'Device sites unavailable');
|
||||
assert.doesNotMatch(JSON.stringify(result), /drop-me|tenant secret/);
|
||||
});
|
||||
|
||||
test('runtime enforces a bounded whole-discovery deadline', async () => {
|
||||
const runtime = new AppRuntime({
|
||||
sessionStore: createSessionStore(),
|
||||
altaClient: {
|
||||
getDevices: async () => new Promise(() => {}),
|
||||
getDeviceSites: async () => [],
|
||||
getDeviceGroups: async () => [],
|
||||
},
|
||||
proxyManager: { listTrackedProxies: () => [] },
|
||||
discoveryTimeoutMs: 25,
|
||||
});
|
||||
const result = await runtime.getDeviceHierarchy();
|
||||
assert.deepEqual(result, {
|
||||
success: false,
|
||||
hierarchy: { devices: [], sites: [], groups: [] },
|
||||
message: 'Alta device discovery timed out',
|
||||
});
|
||||
});
|
||||
|
||||
test('newest hierarchy discovery owns the launch allowlist when completions arrive out of order', async () => {
|
||||
const sessionStore = createSessionStore();
|
||||
sessionStore.establish('https://customer.avasecurity.com', 'synthetic-cookie');
|
||||
const deviceA = '550e8400-e29b-41d4-a716-44665544000a';
|
||||
const deviceB = '550e8400-e29b-41d4-a716-44665544000b';
|
||||
const pending = [];
|
||||
const launches = [];
|
||||
const runtime = new AppRuntime({
|
||||
sessionStore,
|
||||
altaClient: {
|
||||
getDevices: () => new Promise((resolve) => pending.push(resolve)),
|
||||
getDeviceSites: async () => [],
|
||||
getDeviceGroups: async () => [],
|
||||
},
|
||||
proxyManager: {
|
||||
launchProxy(request) {
|
||||
launches.push(request.deviceId);
|
||||
return { processId: 5000 + launches.length, deviceId: request.deviceId, status: 'running' };
|
||||
},
|
||||
listTrackedProxies: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const staleA = runtime.getDeviceHierarchy();
|
||||
const freshB = runtime.getDeviceHierarchy();
|
||||
pending[1]([{ guid: deviceB }]);
|
||||
assert.equal((await freshB).success, true);
|
||||
assert.equal((await runtime.launchProxy(deviceB)).success, true);
|
||||
pending[0]([{ guid: deviceA }]);
|
||||
assert.deepEqual(await staleA, {
|
||||
success: false,
|
||||
stale: true,
|
||||
hierarchy: { devices: [], sites: [], groups: [] },
|
||||
message: 'Alta device discovery result is stale',
|
||||
});
|
||||
assert.equal((await runtime.launchProxy(deviceA)).success, false);
|
||||
assert.deepEqual(launches, [deviceB]);
|
||||
});
|
||||
|
||||
test('disconnect and session changes invalidate pending discovery without repopulating the allowlist', async () => {
|
||||
const deviceId = '550e8400-e29b-41d4-a716-44665544000c';
|
||||
for (const invalidate of ['disconnect', 'session-change']) {
|
||||
const sessionStore = createSessionStore();
|
||||
sessionStore.establish('https://first.avasecurity.com', 'synthetic-cookie');
|
||||
let resolveDevices;
|
||||
const runtime = new AppRuntime({
|
||||
sessionStore,
|
||||
altaClient: {
|
||||
getDevices: () => new Promise((resolve) => { resolveDevices = resolve; }),
|
||||
getDeviceSites: async () => [],
|
||||
getDeviceGroups: async () => [],
|
||||
},
|
||||
proxyManager: { listTrackedProxies: () => [] },
|
||||
});
|
||||
const discovery = runtime.getDeviceHierarchy();
|
||||
if (invalidate === 'disconnect') {
|
||||
assert.equal((await runtime.disconnect()).success, true);
|
||||
} else {
|
||||
sessionStore.establish('https://second.avasecurity.com', 'replacement-cookie');
|
||||
runtime.onSessionChanged();
|
||||
}
|
||||
resolveDevices([{ guid: deviceId }]);
|
||||
assert.equal((await discovery).stale, true, invalidate);
|
||||
assert.equal(runtime.allowedDeviceIds.size, 0, invalidate);
|
||||
}
|
||||
});
|
||||
|
||||
test('launch allowlist remains bound to the session origin that produced it', async () => {
|
||||
const deviceId = '550e8400-e29b-41d4-a716-44665544000d';
|
||||
const sessionStore = createSessionStore();
|
||||
sessionStore.establish('https://first.avasecurity.com', 'synthetic-cookie');
|
||||
let launches = 0;
|
||||
const runtime = new AppRuntime({
|
||||
sessionStore,
|
||||
altaClient: { getDevices: async () => [{ guid: deviceId }] },
|
||||
altaClient: {
|
||||
getDevices: async () => [{ guid: deviceId }],
|
||||
getDeviceSites: async () => [],
|
||||
getDeviceGroups: async () => [],
|
||||
},
|
||||
proxyManager: {
|
||||
listTrackedProxies: () => [],
|
||||
launchProxy() { launches += 1; },
|
||||
listTrackedProxies() { return []; },
|
||||
},
|
||||
});
|
||||
await runtime.getDevices();
|
||||
|
||||
for (const username of ['', 'x'.repeat(255), 'operator@example.com\r\n-k secret', null]) {
|
||||
const result = await runtime.launchProxy(deviceId, username);
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.message, /username/i);
|
||||
}
|
||||
assert.equal((await runtime.getDeviceHierarchy()).success, true);
|
||||
sessionStore.establish('https://second.avasecurity.com', 'replacement-cookie');
|
||||
assert.equal((await runtime.launchProxy(deviceId)).success, false);
|
||||
assert.equal(launches, 0);
|
||||
});
|
||||
|
||||
test('runtime rejects discovery deadlines above 60 seconds', () => {
|
||||
assert.throws(() => new AppRuntime({ discoveryTimeoutMs: 60_001 }), /discovery timeout/i);
|
||||
});
|
||||
|
||||
|
||||
test('bridge authenticates itself before accepting a one-time HMAC cookie request', async () => {
|
||||
const protect = (value) => Buffer.from(`protected:${value}`);
|
||||
const unprotect = (value) => Buffer.from(value).toString().slice('protected:'.length);
|
||||
@@ -249,10 +378,10 @@ test('runtime reconciles exited children and permits relaunch for the same devic
|
||||
},
|
||||
});
|
||||
await runtime.getDevices();
|
||||
assert.equal((await runtime.launchProxy(deviceId, 'operator@example.com')).processId, 4101);
|
||||
assert.equal((await runtime.launchProxy(deviceId)).processId, 4101);
|
||||
tracked.length = 0;
|
||||
assert.deepEqual(runtime.getConnectionState().activeProxies, []);
|
||||
assert.equal((await runtime.launchProxy(deviceId, 'operator@example.com')).processId, 4102);
|
||||
assert.equal((await runtime.launchProxy(deviceId)).processId, 4102);
|
||||
});
|
||||
|
||||
test('disconnect stops every owned proxy before clearing the Alta session', async () => {
|
||||
@@ -320,7 +449,7 @@ test('kill request without exit keeps proxy tracked and session connected until
|
||||
setTimeout(callback) { timeoutCallback = callback; return 1; },
|
||||
clearTimeout() {},
|
||||
});
|
||||
proxyManager.launchProxy({ deploymentHost: 'customer.avasecurity.com', username: 'operator@example.com', deviceId });
|
||||
proxyManager.launchProxy({ deploymentHost: 'customer.avasecurity.com', cookie: 'synthetic-cookie', deviceId });
|
||||
const runtime = new AppRuntime({ sessionStore, altaClient: {}, proxyManager });
|
||||
|
||||
const firstDisconnect = runtime.disconnect();
|
||||
@@ -364,18 +493,23 @@ test('preload and renderer expose only narrow, credential-free contracts', () =>
|
||||
const renderer = read('renderer.js');
|
||||
const html = read('index.html');
|
||||
const expectedMethods = [
|
||||
'getDevices', 'getDeviceSites', 'getAuthInfo', 'launchProxy', 'stopProxy', 'disconnect',
|
||||
'getDevices', 'getDeviceSites', 'getDeviceGroups', 'getDeviceHierarchy', 'getAuthInfo',
|
||||
'launchProxy', 'stopProxy', 'disconnect',
|
||||
'getConnectionState', 'checkForUpdates', 'openFixedReleasesPage', 'rotatePairing',
|
||||
'revokePairing', 'getPairingStatus', 'onConnectionStateChanged',
|
||||
];
|
||||
for (const method of expectedMethods) assert.match(preload, new RegExp(`\\b${method}\\b`));
|
||||
assert.doesNotMatch(preload, /downloadAndInstall|download-and-install|onUpdateDownloadProgress|onExtensionCookie/);
|
||||
assert.match(preload, /launchProxy:\s*\(deviceId, username\)/);
|
||||
assert.match(preload, /launchProxy:\s*\(deviceId\)/);
|
||||
assert.doesNotMatch(preload, /launchProxy:\s*\([^)]*(?:cookie|origin)/i);
|
||||
assert.doesNotMatch(renderer, /cookieValue|sessionData\.cookies|cookies\s*:/);
|
||||
assert.match(renderer, /state\.connected\s*&&\s*\(!wasConnected\s*\|\|\s*state\.origin\s*!==\s*previousOrigin\)/);
|
||||
assert.doesNotMatch(html, /id="cookieKey"|updateProgress|Install Update/);
|
||||
assert.match(html, /id="altaUsername"/);
|
||||
assert.match(read('main.js'), /onConnectionStateChanged:\s*\(\)\s*=>\s*\{\s*runtime\.onSessionChanged\(\)/);
|
||||
assert.doesNotMatch(html, /id="cookieKey"|id="altaUsername"|type="password"|updateProgress|Install Update/);
|
||||
assert.match(html, /paired Chrome extension session/i);
|
||||
assert.match(html, /id="pairingSection"[^>]*hidden/);
|
||||
assert.match(html, /id="managePairingBtn"/);
|
||||
assert.match(renderer, /shouldShowPairingOnboarding/);
|
||||
assert.match(renderer, /openFixedReleasesPage/);
|
||||
assert.match(html, /Bridge Pairing/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildDeviceTree, flattenVisibleRows } = require('../device-tree');
|
||||
const { createInitialState, reduceSidebar, createSidebarController } = require('../sidebar-controller');
|
||||
|
||||
function fixture() {
|
||||
const model = buildDeviceTree({
|
||||
sites: [{ id: 's', name: 'Site' }],
|
||||
groups: [{ id: 'g', name: 'Group', parentId: 's' }],
|
||||
devices: [
|
||||
{ id: 'a', name: 'Alpha', siteId: 's', deviceGroupId: 'g' },
|
||||
{ id: 'b', name: 'Beta', siteId: 's', deviceGroupId: 'g' },
|
||||
],
|
||||
});
|
||||
return model;
|
||||
}
|
||||
|
||||
function visible(model, state) {
|
||||
return flattenVisibleRows(model, state);
|
||||
}
|
||||
|
||||
function fixtureKeys(model) {
|
||||
const site = model.sites[0];
|
||||
const group = site.groups[0];
|
||||
const cameras = Object.fromEntries(group.cameras.map((camera) => [camera.canonicalId, camera.key]));
|
||||
return { site: site.key, group: group.key, cameras };
|
||||
}
|
||||
|
||||
test('reducer expands, navigates, selects, collapses, and preserves hidden selection', () => {
|
||||
const model = fixture();
|
||||
const keys = fixtureKeys(model);
|
||||
let state = createInitialState();
|
||||
let rows = visible(model, state);
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'Home' }, rows);
|
||||
assert.equal(state.activeKey, keys.site);
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowRight' }, rows);
|
||||
assert.ok(state.expandedKeys.has(keys.site));
|
||||
|
||||
rows = visible(model, state);
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowDown' }, rows);
|
||||
assert.equal(state.activeKey, keys.group);
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowRight' }, rows);
|
||||
rows = visible(model, state);
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowDown' }, rows);
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'Enter' }, rows);
|
||||
assert.equal(state.selectedKey, keys.cameras.a);
|
||||
|
||||
state = reduceSidebar(state, { type: 'ACTIVATE', rowKey: keys.site }, rows);
|
||||
assert.equal(state.selectedKey, keys.cameras.a);
|
||||
assert.equal(visible(model, state).hiddenSelected, true);
|
||||
});
|
||||
|
||||
test('keyboard Left/Right parent behavior, bounds, Space and Escape', () => {
|
||||
const model = fixture();
|
||||
const keys = fixtureKeys(model);
|
||||
let state = createInitialState({
|
||||
expandedKeys: new Set([keys.site, keys.group]),
|
||||
activeKey: keys.cameras.a,
|
||||
searchQuery: 'alpha',
|
||||
});
|
||||
let rows = visible(model, state);
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowLeft' }, rows);
|
||||
assert.equal(state.activeKey, keys.group);
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'ArrowLeft' }, rows);
|
||||
assert.equal(state.expandedKeys.has(keys.group), false);
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'End' }, visible(model, state));
|
||||
assert.equal(state.activeKey, keys.group);
|
||||
state = reduceSidebar(state, { type: 'KEY', key: ' ' }, visible(model, state));
|
||||
assert.equal(state.expandedKeys.has(keys.group), true);
|
||||
state = reduceSidebar(state, { type: 'KEY', key: 'Escape' }, visible(model, state));
|
||||
assert.equal(state.searchQuery, '');
|
||||
});
|
||||
|
||||
test('malformed and unknown delegated row keys are no-ops', () => {
|
||||
const model = fixture();
|
||||
const state = createInitialState({ activeKey: fixtureKeys(model).site });
|
||||
const rows = visible(model, state);
|
||||
assert.equal(reduceSidebar(state, { type: 'ACTIVATE', rowKey: '__proto__' }, rows), state);
|
||||
assert.equal(reduceSidebar(state, { type: 'CLICK', rowKey: 'camera:not-there' }, rows), state);
|
||||
});
|
||||
|
||||
test('controller commits exactly once per dispatch through view adapter', () => {
|
||||
const model = fixture();
|
||||
const keys = fixtureKeys(model);
|
||||
const commits = [];
|
||||
const view = { commit: (snapshot) => commits.push(snapshot) };
|
||||
const controller = createSidebarController({ model, view, rowHeight: 28, overscan: 3 });
|
||||
assert.equal(commits.length, 1);
|
||||
controller.dispatch({ type: 'ACTIVATE', rowKey: keys.site });
|
||||
assert.equal(commits.length, 2);
|
||||
assert.equal(commits[1].state.expandedKeys.has(keys.site), true);
|
||||
assert.ok(Array.isArray(commits[1].window.rows));
|
||||
const modelReference = controller.getSnapshot().rows;
|
||||
controller.refresh();
|
||||
assert.equal(commits.length, 3);
|
||||
assert.notEqual(controller.getSnapshot().rows, modelReference);
|
||||
assert.equal(controller.getSnapshot().rows.length, modelReference.length);
|
||||
});
|
||||
|
||||
test('active and selected state survive virtualization and selection is indicated when hidden', () => {
|
||||
const model = fixture();
|
||||
const keys = fixtureKeys(model);
|
||||
const commits = [];
|
||||
const controller = createSidebarController({ model, view: { commit: (value) => commits.push(value) } });
|
||||
controller.dispatch({ type: 'SET_SELECTION', rowKey: keys.cameras.b });
|
||||
controller.dispatch({ type: 'SET_VIEWPORT', scrollTop: 9999, viewportHeight: 20 });
|
||||
const snapshot = commits.at(-1);
|
||||
assert.equal(snapshot.state.selectedKey, keys.cameras.b);
|
||||
assert.equal(snapshot.rows.hiddenSelected, true);
|
||||
});
|
||||
|
||||
test('controller search temporarily expands matches with one completion commit', async () => {
|
||||
const model = fixture();
|
||||
const keys = fixtureKeys(model);
|
||||
const commits = [];
|
||||
const scheduled = [];
|
||||
const controller = createSidebarController({
|
||||
model,
|
||||
view: { commit: (value) => commits.push(value) },
|
||||
scheduler: (work) => scheduled.push(work),
|
||||
chunkSize: 1,
|
||||
});
|
||||
const pending = controller.search('beta group');
|
||||
while (scheduled.length) scheduled.shift()();
|
||||
const result = await pending;
|
||||
assert.equal(result.count, 1);
|
||||
assert.equal(commits.length, 2);
|
||||
assert.deepEqual(commits.at(-1).rows.map((row) => row.key), [keys.site, keys.group, keys.cameras.b]);
|
||||
assert.equal(controller.getState().expandedKeys.size, 0);
|
||||
});
|
||||
|
||||
test('first arrow press chooses the directional edge when no row is active', () => {
|
||||
const model = buildDeviceTree({
|
||||
sites: [{ id: 'b', name: 'B' }, { id: 'a', name: 'A' }, { id: 'c', name: 'C' }],
|
||||
});
|
||||
const rows = visible(model, createInitialState());
|
||||
assert.equal(reduceSidebar(createInitialState(), { type: 'KEY', key: 'ArrowDown' }, rows).activeKey, rows[0].key);
|
||||
assert.equal(reduceSidebar(createInitialState(), { type: 'KEY', key: 'ArrowUp' }, rows).activeKey, rows.at(-1).key);
|
||||
});
|
||||
|
||||
test('manual viewport scrolling may virtualize selection until keyboard navigation restores the active row', () => {
|
||||
const model = buildDeviceTree({
|
||||
sites: [{ id: 'large', name: 'Large' }],
|
||||
groups: [],
|
||||
devices: Array.from({ length: 1500 }, (_, index) => ({
|
||||
id: `c${String(index).padStart(4, '0')}`,
|
||||
name: `Camera ${String(index).padStart(4, '0')}`,
|
||||
siteId: 'large',
|
||||
})),
|
||||
});
|
||||
const site = model.sites[0];
|
||||
const group = site.groups[0];
|
||||
const firstCamera = group.cameras[0];
|
||||
const controller = createSidebarController({
|
||||
model,
|
||||
view: { commit() {} },
|
||||
rowHeight: 20,
|
||||
overscan: 0,
|
||||
initialState: { expandedKeys: new Set([site.key, group.key]), viewportHeight: 100 },
|
||||
});
|
||||
|
||||
controller.dispatch({ type: 'SET_SELECTION', rowKey: firstCamera.key });
|
||||
controller.dispatch({ type: 'SET_VIEWPORT', scrollTop: 30000, viewportHeight: 100 });
|
||||
let snapshot = controller.getSnapshot();
|
||||
assert.equal(snapshot.state.selectedKey, firstCamera.key);
|
||||
assert.equal(snapshot.state.activeKey, firstCamera.key);
|
||||
assert.equal(snapshot.state.scrollTop, 29940);
|
||||
assert.equal(snapshot.window.rows.some((row) => row.key === firstCamera.key), false);
|
||||
assert.equal(snapshot.rows.hiddenSelected, false);
|
||||
|
||||
controller.dispatch({ type: 'KEY', key: 'Home' });
|
||||
snapshot = controller.getSnapshot();
|
||||
assert.equal(snapshot.state.activeKey, site.key);
|
||||
assert.equal(snapshot.state.scrollTop, 0);
|
||||
assert.equal(snapshot.window.rows.some((row) => row.key === site.key), true);
|
||||
});
|
||||
|
||||
test('collapse clamps stale scroll and keyboard navigation scrolls active rows into the virtual window', () => {
|
||||
const model = buildDeviceTree({
|
||||
sites: [{ id: 'large', name: 'Large' }],
|
||||
groups: [],
|
||||
devices: Array.from({ length: 500 }, (_, index) => ({ id: `c${index}`, name: `Camera ${index}`, siteId: 'large' })),
|
||||
});
|
||||
const site = model.sites[0];
|
||||
const group = site.groups[0];
|
||||
const controller = createSidebarController({
|
||||
model,
|
||||
view: { commit() {} },
|
||||
rowHeight: 20,
|
||||
overscan: 0,
|
||||
initialState: { expandedKeys: new Set([site.key, group.key]), scrollTop: 10000, viewportHeight: 100 },
|
||||
});
|
||||
|
||||
controller.dispatch({ type: 'ACTIVATE', rowKey: site.key });
|
||||
let snapshot = controller.getSnapshot();
|
||||
assert.deepEqual(snapshot.window.rows.map((row) => row.key), [site.key]);
|
||||
assert.equal(snapshot.state.scrollTop, 0);
|
||||
|
||||
controller.dispatch({ type: 'ACTIVATE', rowKey: site.key });
|
||||
controller.dispatch({ type: 'SET_ACTIVE', rowKey: site.key });
|
||||
controller.dispatch({ type: 'KEY', key: 'End' });
|
||||
snapshot = controller.getSnapshot();
|
||||
assert.equal(snapshot.window.rows.some((row) => row.key === snapshot.state.activeKey), true);
|
||||
assert.ok(snapshot.state.scrollTop > 0);
|
||||
});
|
||||
|
||||
test('search expansion controls rendered temporary state and restores saved expansion', async () => {
|
||||
const model = fixture();
|
||||
const scheduled = [];
|
||||
const site = model.sites[0];
|
||||
const group = site.groups[0];
|
||||
const controller = createSidebarController({
|
||||
model,
|
||||
view: { commit() {} },
|
||||
scheduler: (work) => scheduled.push(work),
|
||||
initialState: { expandedKeys: new Set([site.key]) },
|
||||
});
|
||||
const pending = controller.search('alpha');
|
||||
while (scheduled.length) scheduled.shift()();
|
||||
await pending;
|
||||
|
||||
controller.dispatch({ type: 'SET_ACTIVE', rowKey: group.key });
|
||||
controller.dispatch({ type: 'KEY', key: 'ArrowLeft' });
|
||||
assert.equal(controller.getState().searchExpandedKeys.has(group.key), false);
|
||||
assert.equal(controller.getSnapshot().rows.some((row) => row.kind === 'camera'), false);
|
||||
assert.deepEqual(controller.getState().expandedKeys, new Set([site.key]));
|
||||
|
||||
controller.dispatch({ type: 'KEY', key: 'ArrowRight' });
|
||||
assert.equal(controller.getState().searchExpandedKeys.has(group.key), true);
|
||||
assert.equal(controller.getSnapshot().rows.some((row) => row.kind === 'camera'), true);
|
||||
assert.deepEqual(controller.getState().expandedKeys, new Set([site.key]));
|
||||
|
||||
await controller.search('');
|
||||
assert.deepEqual(controller.getState().expandedKeys, new Set([site.key]));
|
||||
assert.equal(controller.getState().searchExpandedKeys, null);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
(function exposeWellFormedString(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
if (root) root.AptWellFormedString = api;
|
||||
}(typeof window !== 'undefined' ? window : undefined, function wellFormedStringFactory() {
|
||||
function manualToWellFormed(value) {
|
||||
let result = '';
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const codeUnit = value.charCodeAt(index);
|
||||
if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
result += value[index] + value[index + 1];
|
||||
index += 1;
|
||||
} else {
|
||||
result += '\ufffd';
|
||||
}
|
||||
} else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
|
||||
result += '\ufffd';
|
||||
} else {
|
||||
result += value[index];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toWellFormedString(value, fallback = '') {
|
||||
let string;
|
||||
try {
|
||||
string = String(value);
|
||||
} catch {
|
||||
string = String(fallback);
|
||||
}
|
||||
return typeof string.toWellFormed === 'function'
|
||||
? string.toWellFormed()
|
||||
: manualToWellFormed(string);
|
||||
}
|
||||
|
||||
return Object.freeze({ toWellFormedString });
|
||||
}));
|
||||
Reference in New Issue
Block a user