7 Commits

Author SHA1 Message Date
peji c08d2ad381 chore: version APT hierarchy test build 1.2.0
APT build checks / build-checks (push) Successful in 45s
APT build checks / build-checks (pull_request) Successful in 39s
2026-08-20 02:07:38 +00:00
peji 43892865c3 fix: allow manual virtual tree scrolling 2026-08-20 01:54:21 +00:00
peji 7d484a3998 fix: contain malformed metadata and stale discovery 2026-08-20 01:44:23 +00:00
peji 3b5e2acb75 fix: harden hierarchy keys and virtual navigation 2026-08-20 01:26:00 +00:00
peji 8d256cd4ab feat: render scalable Alta site and group hierarchy 2026-08-20 01:15:01 +00:00
peji c65a813e5f feat: add scalable device hierarchy model 2026-08-20 01:06:51 +00:00
peji 6a8a4cf95a feat: add bounded large-deployment discovery 2026-08-20 01:06:51 +00:00
24 changed files with 2333 additions and 190 deletions
+6 -1
View File
@@ -35,6 +35,10 @@ npm start
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 +58,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 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Alta Proxy Tool Bridge",
"version": "1.1.0",
"version": "1.2.0",
"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"],
+368
View File
@@ -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));
+14 -4
View File
@@ -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>
@@ -102,7 +108,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>
+6 -1
View File
@@ -78,6 +78,8 @@ 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('stop-proxy', async (key) => {
@@ -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(() => {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "alta-api-client",
"version": "1.1.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "alta-api-client",
"version": "1.1.0",
"version": "1.2.0",
"license": "MIT",
"dependencies": {
"axios": "1.19.0"
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "alta-api-client",
"version": "1.1.0",
"version": "1.2.0",
"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/**/*",
+2
View File
@@ -12,6 +12,8 @@ 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),
stopProxy: (key) => ipcRenderer.invoke('stop-proxy', key),
+179 -150
View File
@@ -3,6 +3,9 @@
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');
@@ -21,12 +24,14 @@ const pairingSecretRow = document.getElementById('pairingSecretRow');
const rotatePairingBtn = document.getElementById('rotatePairingBtn');
const revokePairingBtn = document.getElementById('revokePairingBtn');
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;
function showStatus(element, message, type) {
element.textContent = message;
@@ -35,7 +40,20 @@ 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;
const hasUsername = altaUsername.value.trim().length > 0;
startProxyBtn.disabled = !connection.connected || !id || !hasUsername || active;
stopProxyBtn.disabled = !id || !active;
}
function renderConnectionState(state) {
@@ -50,171 +68,141 @@ function renderConnectionState(state) {
text.textContent = connection.connected ? 'Connected' : 'Disconnected';
disconnectBtn.disabled = !connection.connected;
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;
const hidden = snapshot.rows.hiddenSelected ? ' Selected camera is hidden by the current view.' : '';
deviceResults.textContent = snapshot.state.searchQuery
? `${count} exact search result${count === 1 ? '' : 's'}.${hidden}`
: `${count} camera${count === 1 ? '' : 's'}.${hidden}`;
},
});
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 +212,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);
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 +227,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');
}
@@ -288,6 +272,17 @@ function renderPairingStatus(result) {
}
}
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() {
renderConnectionState(await window.electronAPI.getConnectionState());
renderPairingStatus(await window.electronAPI.getPairingStatus());
@@ -296,10 +291,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,10 +304,42 @@ 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());
+4 -1
View File
@@ -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))
));
+229
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
+130
View File
@@ -0,0 +1,130 @@
'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),
siteId: nullableId(raw.server_group_id),
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 siteProjection = projectArray(sites, projectSite);
const groupProjection = projectArray(groups, projectGroup);
const payload = {
devices: deviceProjection.projected,
sites: siteProjection.projected,
groups: groupProjection.projected,
diagnostics: {
devices: {
received: devices.length,
eligible: deviceProjection.projected.length,
invalid: deviceProjection.invalid,
},
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,
};
+121 -9
View File
@@ -10,14 +10,18 @@ const {
readJsonBody,
} = require('./bridge-auth');
const { RELEASES_PAGE_URL } = require('./update-policy');
const { projectDeviceHierarchy, projectGroup, projectSite } = require('./device-projection');
const { validateDeviceId, validateUsername } = require('./proxy-launch');
const DEFAULT_DISCOVERY_TIMEOUT_MS = 60_000;
const PAIRING_ENVELOPE_FILENAME = 'bridge-pairing.json';
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',
]);
@@ -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() };
@@ -258,7 +368,8 @@ class AppRuntime {
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)) {
@@ -298,6 +409,7 @@ class AppRuntime {
}
async disconnect() {
this.invalidateDiscovery();
const tracked = this._reconcileProxies();
await Promise.all(tracked.map(async (proxy) => {
try {
+170 -2
View File
@@ -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 {
+55 -2
View File
@@ -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({
+2 -2
View File
@@ -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.0', 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.0');
assert.equal(manifest.version, pkg.version);
assert.equal(lock.version, pkg.version);
assert.equal(lock.packages[''].version, pkg.version);
+109
View File
@@ -0,0 +1,109 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
MAX_PROJECTED_PAYLOAD_BYTES,
projectDeviceHierarchy,
} = require('../src/device-projection');
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: 'site-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' })],
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: 'site-1', 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: 2, eligible: 1, invalid: 1 });
assert.doesNotMatch(JSON.stringify(payload), /drop-me|cookie|secretCapability|private/);
});
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)),
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',
})],
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, 'site-\ufffd');
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)}`,
}));
assert.throws(
() => projectDeviceHierarchy({ devices, sites: [], groups: [] }),
{ code: 'PROJECTED_PAYLOAD_TOO_LARGE' },
);
});
+235
View File
@@ -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);
});
+76
View File
@@ -0,0 +1,76 @@
'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.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);
});
+158 -2
View File
@@ -78,7 +78,20 @@ 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' }] });
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',
'proxy.operator@example.com'
@@ -102,6 +115,147 @@ test('runtime keeps Alta credentials in main-owned modules and exposes only non-
assert.equal((await runtime.stopProxy(999)).success, false);
});
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, 'operator@example.com')).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, 'operator@example.com')).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 }],
getDeviceSites: async () => [],
getDeviceGroups: async () => [],
},
proxyManager: {
listTrackedProxies: () => [],
launchProxy() { launches += 1; },
},
});
assert.equal((await runtime.getDeviceHierarchy()).success, true);
sessionStore.establish('https://second.avasecurity.com', 'replacement-cookie');
assert.equal((await runtime.launchProxy(deviceId, 'operator@example.com')).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('runtime rejects invalid usernames before calling the proxy manager', async () => {
const sessionStore = createSessionStore();
sessionStore.establish('https://customer.avasecurity.com', 'top-secret-cookie');
@@ -364,7 +518,8 @@ 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',
];
@@ -374,6 +529,7 @@ test('preload and renderer expose only narrow, credential-free contracts', () =>
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.match(read('main.js'), /onConnectionStateChanged:\s*\(\)\s*=>\s*\{\s*runtime\.onSessionChanged\(\)/);
assert.doesNotMatch(html, /id="cookieKey"|updateProgress|Install Update/);
assert.match(html, /id="altaUsername"/);
assert.match(renderer, /openFixedReleasesPage/);
+238
View File
@@ -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);
});
+42
View File
@@ -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 });
}));