livedesk 0.1.689 → 0.1.691
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hub/package.json +1 -1
- package/hub/src/console-direct-frame-admission.mjs +80 -0
- package/hub/src/console-direct-frame-admission.test.mjs +60 -0
- package/hub/src/console-direct.js +75 -2
- package/hub/src/console-direct.test.mjs +77 -9
- package/package.json +1 -1
- package/scripts/sync-web-dist.js +1 -0
- package/web/dist/app.html +1 -1
- package/web/dist/app.webmanifest +1 -1
- package/web/dist/assets/{AgentSettingsTab-DqcDj-on.js → AgentSettingsTab-CV_0za4F.js} +1 -1
- package/web/dist/assets/{AgentsPage-DtUpg-_-.js → AgentsPage-BtrLYt9x.js} +1 -1
- package/web/dist/assets/{CaptureGallery-BDTCKOQU.js → CaptureGallery-CBc58XJD.js} +1 -1
- package/web/dist/assets/HubApp-D_DdfH3v.js +182 -0
- package/web/dist/assets/MonitorStackIcon-NxxMF_Ag.js +8 -0
- package/web/dist/assets/{SettingsPage-CDQjf6HM.js → SettingsPage-Dlkeg_u1.js} +1 -1
- package/web/dist/assets/{ShareFilesPage-DbPDQMhc.js → ShareFilesPage-DBmE6Ewf.js} +1 -1
- package/web/dist/assets/{SupportPage-DaPaJB2u.js → SupportPage-BfijaHuW.js} +1 -1
- package/web/dist/assets/{VuvoDeskApp-DBUI3UMT.js → VuvoDeskApp-CFq0Z-xx.js} +1 -1
- package/web/dist/assets/{app-D8_G4B_m.js → app-CVjARKwD.js} +2 -2
- package/web/dist/assets/{main-BwD1pIhw.js → main-B_PtY6Hx.js} +2 -2
- package/web/dist/index.html +2 -2
- package/web/dist/sw.js +2 -2
- package/web/dist/vuvodesk-build-evidence.json +43 -43
- package/web/dist/assets/HubApp-oCD8fLqO.js +0 -182
- package/web/dist/assets/MonitorStackIcon-4RlZK1aS.js +0 -8
package/hub/package.json
CHANGED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export const CONSOLE_MEDIA_ADMISSION = Object.freeze({
|
|
2
|
+
laneBufferedBytes: 512 * 1024,
|
|
3
|
+
peerBufferedBytes: 1024 * 1024,
|
|
4
|
+
bufferedAgeMs: 120,
|
|
5
|
+
maxStreams: 64,
|
|
6
|
+
maxMetadataBytes: 64 * 1024
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
function h264Identity(packet) {
|
|
10
|
+
if (!Buffer.isBuffer(packet) || packet.length < 5) return null;
|
|
11
|
+
const size = packet.readUInt32BE(0);
|
|
12
|
+
if (size < 2 || size > CONSOLE_MEDIA_ADMISSION.maxMetadataBytes || 4 + size >= packet.length) return null;
|
|
13
|
+
let frame;
|
|
14
|
+
try { frame = JSON.parse(packet.toString('utf8', 4, 4 + size)); } catch { return null; }
|
|
15
|
+
if (frame?.frameMode !== 'mode3-h264-hw'
|
|
16
|
+
|| !['wall', 'control'].includes(frame.streamPurpose)
|
|
17
|
+
|| !Number.isSafeInteger(frame.captureGeneration) || frame.captureGeneration <= 0
|
|
18
|
+
|| !Number.isSafeInteger(frame.monitorIndex) || frame.monitorIndex < 0
|
|
19
|
+
|| !['deviceId', 'sessionId', 'streamId', 'commandId'].every(key => (
|
|
20
|
+
typeof frame[key] === 'string' && frame[key].length > 0 && frame[key].length <= 256
|
|
21
|
+
))) return null;
|
|
22
|
+
return {
|
|
23
|
+
stream: JSON.stringify([frame.deviceId, frame.streamPurpose]),
|
|
24
|
+
owner: JSON.stringify([frame.sessionId, frame.streamId, frame.commandId, frame.captureGeneration, frame.monitorIndex]),
|
|
25
|
+
key: frame.isKeyFrame === true || frame.chunkType === 'key'
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// This is send admission, not authentication or presentation authority. Those
|
|
30
|
+
// exact binding gates still run at the Hub and browser. Retain no payload,
|
|
31
|
+
// timer, task, or outbound queue; every decision precedes wire fragmentation.
|
|
32
|
+
export function createConsoleFrameAdmission() {
|
|
33
|
+
const streams = new Map();
|
|
34
|
+
let bufferedSince = null;
|
|
35
|
+
let droppedFrames = 0;
|
|
36
|
+
let recoveredStreams = 0;
|
|
37
|
+
return {
|
|
38
|
+
admit(packet, { laneBufferedBytes, peerBufferedBytes, now }) {
|
|
39
|
+
const identity = h264Identity(packet);
|
|
40
|
+
if (!identity) return 'unmanaged';
|
|
41
|
+
let state = streams.get(identity.stream);
|
|
42
|
+
if (!state) {
|
|
43
|
+
if (streams.size >= CONSOLE_MEDIA_ADMISSION.maxStreams) return 'capacity';
|
|
44
|
+
state = { owner: identity.owner, awaitingKey: false };
|
|
45
|
+
streams.set(identity.stream, state);
|
|
46
|
+
} else if (state.owner !== identity.owner) {
|
|
47
|
+
state.owner = identity.owner;
|
|
48
|
+
state.awaitingKey = true;
|
|
49
|
+
}
|
|
50
|
+
if (laneBufferedBytes <= 0) bufferedSince = null;
|
|
51
|
+
else if (bufferedSince === null) bufferedSince = now;
|
|
52
|
+
const pressured = (laneBufferedBytes > 0 && (
|
|
53
|
+
laneBufferedBytes + packet.length > CONSOLE_MEDIA_ADMISSION.laneBufferedBytes
|
|
54
|
+
|| now - bufferedSince >= CONSOLE_MEDIA_ADMISSION.bufferedAgeMs
|
|
55
|
+
)) || (peerBufferedBytes > 0
|
|
56
|
+
&& peerBufferedBytes + packet.length > CONSOLE_MEDIA_ADMISSION.peerBufferedBytes);
|
|
57
|
+
if (pressured || (state.awaitingKey && !identity.key)) {
|
|
58
|
+
state.awaitingKey = true;
|
|
59
|
+
droppedFrames += 1;
|
|
60
|
+
return 'drop';
|
|
61
|
+
}
|
|
62
|
+
if (state.awaitingKey) recoveredStreams += 1;
|
|
63
|
+
state.awaitingKey = false;
|
|
64
|
+
return 'send';
|
|
65
|
+
},
|
|
66
|
+
inspect() {
|
|
67
|
+
return {
|
|
68
|
+
streamOwners: streams.size,
|
|
69
|
+
awaitingKeyStreams: [...streams.values()].filter(state => state.awaitingKey).length,
|
|
70
|
+
droppedFrames,
|
|
71
|
+
recoveredStreams,
|
|
72
|
+
retainedPayloadBytes: 0
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
clear() {
|
|
76
|
+
streams.clear();
|
|
77
|
+
bufferedSince = null;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { CONSOLE_MEDIA_ADMISSION, createConsoleFrameAdmission } from './console-direct-frame-admission.mjs';
|
|
4
|
+
import { buildImmutableRemoteFramePacket } from './frame-packet-contract.mjs';
|
|
5
|
+
|
|
6
|
+
function packet({ key = false, device = 'a', generation = 1, monitor = 0, bytes = 1024 } = {}) {
|
|
7
|
+
return buildImmutableRemoteFramePacket({
|
|
8
|
+
deviceId: device, sessionId: 'session', streamId: 'stream', commandId: 'command',
|
|
9
|
+
captureGeneration: generation, monitorIndex: monitor, streamPurpose: 'wall',
|
|
10
|
+
frameMode: 'mode3-h264-hw', isKeyFrame: key
|
|
11
|
+
}, Buffer.alloc(bytes)).packet;
|
|
12
|
+
}
|
|
13
|
+
const empty = { laneBufferedBytes: 0, peerBufferedBytes: 0, now: 0 };
|
|
14
|
+
|
|
15
|
+
test('a small persistent backlog is bounded by age without a timer or retained frame', () => {
|
|
16
|
+
const gate = createConsoleFrameAdmission();
|
|
17
|
+
assert.equal(gate.admit(packet({ key: true }), empty), 'send');
|
|
18
|
+
const pending = { laneBufferedBytes: 1024, peerBufferedBytes: 1024, now: 1 };
|
|
19
|
+
assert.equal(gate.admit(packet(), pending), 'send');
|
|
20
|
+
assert.equal(gate.admit(packet(), { ...pending, now: 1 + CONSOLE_MEDIA_ADMISSION.bufferedAgeMs }), 'drop');
|
|
21
|
+
assert.equal(gate.admit(packet(), { ...empty, now: 200 }), 'drop');
|
|
22
|
+
assert.equal(gate.admit(packet({ key: true }), { ...empty, now: 201 }), 'send');
|
|
23
|
+
assert.equal(gate.inspect().retainedPayloadBytes, 0);
|
|
24
|
+
assert.equal(gate.inspect().recoveredStreams, 1);
|
|
25
|
+
gate.clear();
|
|
26
|
+
assert.equal(gate.inspect().streamOwners, 0);
|
|
27
|
+
assert.equal(gate.inspect().awaitingKeyStreams, 0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('a pressured viewer cannot poison another viewer, device, or replacement binding', () => {
|
|
31
|
+
const phone = createConsoleFrameAdmission();
|
|
32
|
+
const desktop = createConsoleFrameAdmission();
|
|
33
|
+
const pressure = { ...empty, laneBufferedBytes: CONSOLE_MEDIA_ADMISSION.laneBufferedBytes, peerBufferedBytes: CONSOLE_MEDIA_ADMISSION.laneBufferedBytes };
|
|
34
|
+
assert.equal(phone.admit(packet(), pressure), 'drop');
|
|
35
|
+
assert.equal(desktop.admit(packet(), empty), 'send');
|
|
36
|
+
assert.equal(phone.admit(packet({ device: 'b', key: true }), empty), 'send');
|
|
37
|
+
assert.equal(phone.admit(packet(), empty), 'drop');
|
|
38
|
+
assert.equal(phone.admit(packet({ generation: 2, monitor: 1 }), empty), 'drop');
|
|
39
|
+
assert.equal(phone.admit(packet({ generation: 2, monitor: 1, key: true }), empty), 'send');
|
|
40
|
+
assert.equal(phone.inspect().awaitingKeyStreams, 0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('one complete key may exceed the soft watermark on an empty peer without admitting a second backlog', () => {
|
|
44
|
+
const gate = createConsoleFrameAdmission();
|
|
45
|
+
const key = packet({ key: true, bytes: 700 * 1024 });
|
|
46
|
+
assert.equal(gate.admit(key, empty), 'send');
|
|
47
|
+
assert.equal(gate.admit(key, { ...empty, laneBufferedBytes: key.length, peerBufferedBytes: key.length }), 'drop');
|
|
48
|
+
assert.equal(gate.admit(key, empty), 'send');
|
|
49
|
+
assert.equal(gate.admit(Buffer.from('legacy-binary'), empty), 'unmanaged');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a multiplexed lane has a fixed owner bound and releases it on close', () => {
|
|
53
|
+
const gate = createConsoleFrameAdmission();
|
|
54
|
+
for (let n = 0; n < CONSOLE_MEDIA_ADMISSION.maxStreams; n++) {
|
|
55
|
+
assert.equal(gate.admit(packet({ device: `device-${n}`, key: true }), empty), 'send');
|
|
56
|
+
}
|
|
57
|
+
assert.equal(gate.admit(packet({ device: 'overflow', key: true }), empty), 'capacity');
|
|
58
|
+
gate.clear();
|
|
59
|
+
assert.equal(gate.admit(packet({ device: 'replacement', key: true }), empty), 'send');
|
|
60
|
+
});
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
encodeDirectConsoleWireMessage
|
|
10
10
|
} from '../../runtime-core/src/console-direct-wire.js';
|
|
11
11
|
import { workspaceRoleCanControl, workspaceRoleCanRequest } from './auth/workspace-access.js';
|
|
12
|
+
import { createConsoleFrameAdmission } from './console-direct-frame-admission.mjs';
|
|
12
13
|
|
|
13
14
|
const DEFAULT_SIGNAL_URL = 'https://livedesk-wake.lovecrdm.workers.dev';
|
|
14
15
|
const DEFAULT_STUN_URLS = Object.freeze(['stun:stun.cloudflare.com:3478']);
|
|
@@ -87,6 +88,34 @@ function byteLength(value) {
|
|
|
87
88
|
return Buffer.byteLength(String(value || ''), 'utf8');
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
function optionalPeerMetric(peer, method) {
|
|
92
|
+
try {
|
|
93
|
+
const value = peer[method]?.();
|
|
94
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
|
95
|
+
} catch { return null; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function candidateAddressScope(value) {
|
|
99
|
+
if (typeof value !== 'string' || !value || value === '?') return 'unknown';
|
|
100
|
+
if (/^(127\.|::1$)/i.test(value)) return 'loopback';
|
|
101
|
+
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|f[cd][0-9a-f]{2}:|fe80:)/i.test(value)) return 'private';
|
|
102
|
+
return 'public';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function inspectPeerPath(peer) {
|
|
106
|
+
try {
|
|
107
|
+
const pair = peer.getSelectedCandidatePair?.();
|
|
108
|
+
if (!pair?.local || !pair?.remote) return null;
|
|
109
|
+
return {
|
|
110
|
+
localType: String(pair.local.type || 'unknown').slice(0, 24),
|
|
111
|
+
remoteType: String(pair.remote.type || 'unknown').slice(0, 24),
|
|
112
|
+
localScope: candidateAddressScope(pair.local.address),
|
|
113
|
+
remoteScope: candidateAddressScope(pair.remote.address),
|
|
114
|
+
transport: String(pair.remote.transportType || 'unknown').slice(0, 24)
|
|
115
|
+
};
|
|
116
|
+
} catch { return null; }
|
|
117
|
+
}
|
|
118
|
+
|
|
90
119
|
function parseJson(value) {
|
|
91
120
|
try {
|
|
92
121
|
return JSON.parse(String(value || ''));
|
|
@@ -527,6 +556,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
527
556
|
disposePendingLogicalControl(owner, channelState.channelId);
|
|
528
557
|
disposeAssemblerTimer(channelState);
|
|
529
558
|
channelState.assembler.dispose();
|
|
559
|
+
channelState.frameAdmission?.clear();
|
|
530
560
|
closeLocalSocket(
|
|
531
561
|
owner,
|
|
532
562
|
channelState,
|
|
@@ -667,6 +697,18 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
667
697
|
|
|
668
698
|
function sendWire(owner, channelState, kind, value, maxMessageBytes, options = {}) {
|
|
669
699
|
if (!isOwnerActive(owner) || channelState.closed || !channelIsOpen(channelState.channel)) return false;
|
|
700
|
+
if (kind === 'binary' && channelState.frameAdmission && value.byteLength <= maxMessageBytes) {
|
|
701
|
+
const admission = channelState.frameAdmission.admit(value, {
|
|
702
|
+
laneBufferedBytes: channelBufferedAmount(channelState),
|
|
703
|
+
peerBufferedBytes: ownerBufferedAmount(owner),
|
|
704
|
+
now: performance.now()
|
|
705
|
+
});
|
|
706
|
+
if (admission === 'drop') return true;
|
|
707
|
+
if (admission === 'capacity') {
|
|
708
|
+
closeLogicalChannel(owner, channelState, { code: 1009, reason: 'console-direct-frame-owner-capacity' });
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
670
712
|
let chunks;
|
|
671
713
|
try {
|
|
672
714
|
const messageId = channelState.nextSendMessageId >>> 0;
|
|
@@ -973,7 +1015,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
973
1015
|
socket.on('message', (data, isBinary) => {
|
|
974
1016
|
if (!isOwnerActive(owner) || owner.channels.get(channelId) !== channelState || channelState.localSocket !== socket) return;
|
|
975
1017
|
const kind = isBinary ? 'binary' : 'text';
|
|
976
|
-
const value = isBinary ?
|
|
1018
|
+
const value = isBinary ? (Buffer.isBuffer(data) ? data : Buffer.from(data)) : String(data);
|
|
977
1019
|
if (!sendWire(owner, channelState, kind, value, channelState.maxMessageBytes)) {
|
|
978
1020
|
closeLogicalChannel(owner, channelState, {
|
|
979
1021
|
code: 1013,
|
|
@@ -1182,6 +1224,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1182
1224
|
nextSendMessageId: 1,
|
|
1183
1225
|
openRequested: false,
|
|
1184
1226
|
localSocket: null,
|
|
1227
|
+
frameAdmission: identity.purpose === 'frame' ? createConsoleFrameAdmission() : null,
|
|
1185
1228
|
closed: false
|
|
1186
1229
|
};
|
|
1187
1230
|
owner.channels.set(identity.channelId, channelState);
|
|
@@ -1300,7 +1343,11 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1300
1343
|
iceTransportPolicy: 'all',
|
|
1301
1344
|
disableAutoNegotiation: false,
|
|
1302
1345
|
disableFingerprintVerification: false,
|
|
1303
|
-
|
|
1346
|
+
// Each native send is one wire chunk. libdatachannel also uses this
|
|
1347
|
+
// setting as the minimum SCTP socket buffer size, below bufferedAmount.
|
|
1348
|
+
// A whole-frame limit here hides megabytes from our admission gate.
|
|
1349
|
+
// Complete frame/HTTP limits remain enforced by the wire assemblers.
|
|
1350
|
+
maxMessageSize: DIRECT_CONSOLE_WIRE_CHUNK_BYTES
|
|
1304
1351
|
});
|
|
1305
1352
|
} catch (error) {
|
|
1306
1353
|
lastError = error instanceof Error ? error.message : String(error);
|
|
@@ -1938,6 +1985,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1938
1985
|
};
|
|
1939
1986
|
|
|
1940
1987
|
const inspect = () => {
|
|
1988
|
+
const peerDiagnostics = [];
|
|
1941
1989
|
let logicalWebSocketChannels = 0;
|
|
1942
1990
|
let localWebSocketChannels = 0;
|
|
1943
1991
|
let pendingHttpRequests = 0;
|
|
@@ -1964,6 +2012,30 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1964
2012
|
}
|
|
1965
2013
|
retainedAssemblyBytes += Number(owner.wireBudget.inspect().retainedBytes || 0);
|
|
1966
2014
|
bufferedSendBytes += ownerBufferedAmount(owner);
|
|
2015
|
+
peerDiagnostics.push({
|
|
2016
|
+
consoleId: owner.consoleId,
|
|
2017
|
+
connectionId: owner.connectionId,
|
|
2018
|
+
generation: owner.generation,
|
|
2019
|
+
peerState: owner.peerState,
|
|
2020
|
+
iceState: owner.iceState,
|
|
2021
|
+
rttMs: optionalPeerMetric(owner.peer, 'rtt'),
|
|
2022
|
+
bytesSent: optionalPeerMetric(owner.peer, 'bytesSent'),
|
|
2023
|
+
bytesReceived: optionalPeerMetric(owner.peer, 'bytesReceived'),
|
|
2024
|
+
selectedPath: inspectPeerPath(owner.peer),
|
|
2025
|
+
bufferedSendBytes: (() => {
|
|
2026
|
+
const values = [owner.control, ...owner.channels.values()]
|
|
2027
|
+
.filter(lane => lane && !lane.closed)
|
|
2028
|
+
.map(lane => optionalPeerMetric(lane.channel, 'bufferedAmount'));
|
|
2029
|
+
return values.some(value => value === null) ? null : values.reduce((sum, value) => sum + value, 0);
|
|
2030
|
+
})(),
|
|
2031
|
+
pendingHttpRequests: owner.pendingHttp.size,
|
|
2032
|
+
lanes: [...owner.channels.values()].map(lane => ({
|
|
2033
|
+
channelId: lane.channelId,
|
|
2034
|
+
purpose: lane.purpose,
|
|
2035
|
+
bufferedBytes: optionalPeerMetric(lane.channel, 'bufferedAmount'),
|
|
2036
|
+
frameAdmission: lane.frameAdmission?.inspect() ?? null
|
|
2037
|
+
}))
|
|
2038
|
+
});
|
|
1967
2039
|
}
|
|
1968
2040
|
const reconnectTimerActive = Boolean(retryTimer);
|
|
1969
2041
|
const accessTokenDeadlineActive = Boolean(accessTokenAttempt);
|
|
@@ -1982,6 +2054,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1982
2054
|
pendingLogicalControlRequests,
|
|
1983
2055
|
retainedAssemblyBytes,
|
|
1984
2056
|
bufferedSendBytes,
|
|
2057
|
+
peerDiagnostics,
|
|
1985
2058
|
iceConnectTimers,
|
|
1986
2059
|
peerDisconnectTimers,
|
|
1987
2060
|
assemblyDeadlineTimers,
|
|
@@ -4,11 +4,13 @@ import { readFileSync } from 'node:fs';
|
|
|
4
4
|
import test from 'node:test';
|
|
5
5
|
import nodeDataChannel from 'node-datachannel';
|
|
6
6
|
import {
|
|
7
|
+
DIRECT_CONSOLE_WIRE_CHUNK_BYTES,
|
|
7
8
|
createDirectConsoleWireAssembler,
|
|
8
9
|
encodeDirectConsoleWireMessage
|
|
9
10
|
} from '../../runtime-core/src/console-direct-wire.js';
|
|
10
11
|
import { consoleDirectContract, createHubConsoleDirect } from './console-direct.js';
|
|
11
12
|
import { workspaceRoleCanRequest } from './auth/workspace-access.js';
|
|
13
|
+
import { buildImmutableRemoteFramePacket } from './frame-packet-contract.mjs';
|
|
12
14
|
|
|
13
15
|
assert.equal(consoleDirectContract.maxDefaultRetryDelayMs, 20_000);
|
|
14
16
|
assert.equal(consoleDirectContract.peerDisconnectedTimeoutMs, 5_000);
|
|
@@ -357,6 +359,8 @@ test('STUN-only peer answers and rejects stale owner callbacks after replacement
|
|
|
357
359
|
assert.deepEqual(firstPeer.config.iceServers, ['stun:stun.example.test:3478']);
|
|
358
360
|
assert.equal(firstPeer.config.iceTransportPolicy, 'all');
|
|
359
361
|
assert.equal(firstPeer.config.disableFingerprintVerification, false);
|
|
362
|
+
assert.equal(firstPeer.config.maxMessageSize, DIRECT_CONSOLE_WIRE_CHUNK_BYTES,
|
|
363
|
+
'native SCTP accepts wire chunks, not whole frames; a frame-sized setting silently enlarges its hidden buffers');
|
|
360
364
|
assert.equal(signalingMessages(signal).some(message => (
|
|
361
365
|
message.type === 'rtc-answer' && message.connectionId === CONNECTION_ONE
|
|
362
366
|
)), true);
|
|
@@ -1336,6 +1340,64 @@ test('media pressure retires only its stale logical lane while preserving the co
|
|
|
1336
1340
|
direct.close();
|
|
1337
1341
|
});
|
|
1338
1342
|
|
|
1343
|
+
test('a pressured H.264 viewer drops whole dependent frames and resumes on a key without reconnecting', async () => {
|
|
1344
|
+
const { direct, signal } = await connectedDirect();
|
|
1345
|
+
const peer = offer(signal);
|
|
1346
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
1347
|
+
peer.emitDataChannel(control);
|
|
1348
|
+
control.open();
|
|
1349
|
+
const lane = new FakeDataChannel(`livedesk-ws-v1:${CHANNEL_ID}:frame`);
|
|
1350
|
+
peer.emitDataChannel(lane);
|
|
1351
|
+
lane.open();
|
|
1352
|
+
sendWire(control, {
|
|
1353
|
+
type: 'ws-open', connectionId: CONNECTION_ONE, hubEpoch: HUB_EPOCH,
|
|
1354
|
+
channelId: CHANNEL_ID, purpose: 'frame', path: '/api/remote/frames/ws'
|
|
1355
|
+
});
|
|
1356
|
+
const local = FakeLocalSocket.instances[0];
|
|
1357
|
+
local.open();
|
|
1358
|
+
const frame = (sequence, key, generation = 1) => buildImmutableRemoteFramePacket({
|
|
1359
|
+
deviceId: 'device-a', sessionId: 'session-a', streamId: 'wall-a', commandId: 'command-a',
|
|
1360
|
+
captureGeneration: generation, monitorIndex: 0, streamPurpose: 'wall',
|
|
1361
|
+
frameMode: 'mode3-h264-hw', chunkType: key ? 'key' : 'delta',
|
|
1362
|
+
isKeyFrame: key, streamFrameSeq: sequence
|
|
1363
|
+
}, Buffer.alloc(32 * 1024)).packet;
|
|
1364
|
+
local.receive(frame(1, true));
|
|
1365
|
+
const initialChunks = lane.sent.length;
|
|
1366
|
+
lane.buffered = consoleDirectContract.controlHeadroomBufferedBytes;
|
|
1367
|
+
local.receive(frame(2, false));
|
|
1368
|
+
assert.equal(lane.closed, false, 'congestion must not create another logical handshake or key-frame burst');
|
|
1369
|
+
assert.equal(lane.sent.length, initialChunks, 'no fragment of a dropped frame enters SCTP');
|
|
1370
|
+
lane.buffered = 0;
|
|
1371
|
+
local.receive(frame(3, false));
|
|
1372
|
+
assert.equal(lane.sent.length, initialChunks, 'a dependent delta cannot follow a discarded delta');
|
|
1373
|
+
local.receive(frame(4, true));
|
|
1374
|
+
assert.ok(lane.sent.length > initialChunks);
|
|
1375
|
+
const recoveredChunks = lane.sent.length;
|
|
1376
|
+
local.receive(frame(5, false));
|
|
1377
|
+
assert.ok(lane.sent.length > recoveredChunks);
|
|
1378
|
+
assert.equal(control.closed, false);
|
|
1379
|
+
assert.equal(peer.closed, false);
|
|
1380
|
+
assert.equal(direct.inspect().dataChannelBackpressureCloses, 0);
|
|
1381
|
+
const diagnostics = direct.inspect().peerDiagnostics[0];
|
|
1382
|
+
assert.equal(diagnostics.rttMs, null, 'missing native RTT is unknown');
|
|
1383
|
+
assert.equal(diagnostics.bytesSent, null, 'missing native byte counters are unknown');
|
|
1384
|
+
assert.equal(diagnostics.lanes[0].frameAdmission.droppedFrames, 2);
|
|
1385
|
+
assert.equal(diagnostics.lanes[0].frameAdmission.recoveredStreams, 1);
|
|
1386
|
+
peer.rtt = () => 12;
|
|
1387
|
+
peer.bytesSent = () => 1234;
|
|
1388
|
+
peer.selectedPair = {
|
|
1389
|
+
local: { type: 'host', address: '192.168.0.4', candidate: 'must-not-leak' },
|
|
1390
|
+
remote: { type: 'host', address: '192.168.0.8', transportType: 'Udp' }
|
|
1391
|
+
};
|
|
1392
|
+
const measured = direct.inspect().peerDiagnostics[0];
|
|
1393
|
+
assert.equal(measured.rttMs, 12);
|
|
1394
|
+
assert.equal(measured.bytesSent, 1234);
|
|
1395
|
+
assert.equal(measured.selectedPath.remoteScope, 'private');
|
|
1396
|
+
assert.doesNotMatch(JSON.stringify(measured), /192\.168\.|must-not-leak/);
|
|
1397
|
+
direct.close();
|
|
1398
|
+
assert.deepEqual(direct.inspect().peerDiagnostics, []);
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1339
1401
|
test('a reliable control response drops stale media backlog without retiring the peer', async () => {
|
|
1340
1402
|
const { direct, signal } = await connectedDirect({
|
|
1341
1403
|
fetchImpl: async () => new Response('{"online":true}', {
|
|
@@ -1507,18 +1569,19 @@ test('server session refresh preserves peers while logout uses the revoking clos
|
|
|
1507
1569
|
assert.doesNotMatch(logoutBlock, /hubConsoleDirect\?\.refresh\(\)/);
|
|
1508
1570
|
});
|
|
1509
1571
|
|
|
1510
|
-
test('native
|
|
1572
|
+
test('native chunk-sized SCTP accepts a full-size fragmented frame over loopback', { timeout: 8_000 }, async t => {
|
|
1511
1573
|
let offerer = null;
|
|
1512
1574
|
let answerer = null;
|
|
1513
1575
|
let offerChannel = null;
|
|
1514
1576
|
let answerChannel = null;
|
|
1515
|
-
const assembler = createDirectConsoleWireAssembler();
|
|
1577
|
+
const assembler = createDirectConsoleWireAssembler({ maxMessageBytes: consoleDirectContract.maxFrameMessageBytes });
|
|
1516
1578
|
let openTimer = null;
|
|
1517
1579
|
let messageTimer = null;
|
|
1518
1580
|
try {
|
|
1519
1581
|
nodeDataChannel.setSctpSettings({ sendBufferSize: 64 * 1024 });
|
|
1520
|
-
|
|
1521
|
-
|
|
1582
|
+
const config = { iceServers: [], maxMessageSize: DIRECT_CONSOLE_WIRE_CHUNK_BYTES };
|
|
1583
|
+
offerer = new nodeDataChannel.PeerConnection('console-direct-native-offerer', config);
|
|
1584
|
+
answerer = new nodeDataChannel.PeerConnection('console-direct-native-answerer', config);
|
|
1522
1585
|
offerer.onLocalDescription((sdp, type) => {
|
|
1523
1586
|
answerer.setRemoteDescription(sdp, type);
|
|
1524
1587
|
});
|
|
@@ -1552,11 +1615,16 @@ test('native node-datachannel buffered sends deliver the complete fragmented wir
|
|
|
1552
1615
|
await opened;
|
|
1553
1616
|
clearTimeout(openTimer);
|
|
1554
1617
|
openTimer = null;
|
|
1555
|
-
|
|
1556
|
-
|
|
1618
|
+
assert.match(offerer.localDescription().sdp, /a=max-message-size:16384(?:\r?\n|$)/);
|
|
1619
|
+
assert.match(answerer.localDescription().sdp, /a=max-message-size:16384(?:\r?\n|$)/);
|
|
1620
|
+
assert.throws(() => offerChannel.sendMessageBinary(Buffer.alloc(DIRECT_CONSOLE_WIRE_CHUNK_BYTES + 1)),
|
|
1621
|
+
/[Mm]essage.*large|[Ss]ize/);
|
|
1622
|
+
const expected = Buffer.alloc(consoleDirectContract.maxFrameMessageBytes);
|
|
1623
|
+
for (let n = 0; n < expected.length; n += 1) expected[n] = n % 251;
|
|
1557
1624
|
const chunks = encodeDirectConsoleWireMessage(expected, {
|
|
1558
1625
|
messageId: 77,
|
|
1559
|
-
kind: '
|
|
1626
|
+
kind: 'binary',
|
|
1627
|
+
maxMessageBytes: consoleDirectContract.maxFrameMessageBytes
|
|
1560
1628
|
});
|
|
1561
1629
|
assert.ok(chunks.length > 1);
|
|
1562
1630
|
let bufferedSends = 0;
|
|
@@ -1567,8 +1635,8 @@ test('native node-datachannel buffered sends deliver the complete fragmented wir
|
|
|
1567
1635
|
const message = await received;
|
|
1568
1636
|
clearTimeout(messageTimer);
|
|
1569
1637
|
messageTimer = null;
|
|
1570
|
-
assert.equal(message.kind, '
|
|
1571
|
-
assert.
|
|
1638
|
+
assert.equal(message.kind, 'binary');
|
|
1639
|
+
assert.deepEqual(Buffer.from(message.data), expected);
|
|
1572
1640
|
t.diagnostic(`native buffered sends=${bufferedSends}; complete bytes=${message.byteLength}; chunks=${chunks.length}`);
|
|
1573
1641
|
} finally {
|
|
1574
1642
|
if (openTimer) clearTimeout(openTimer);
|
package/package.json
CHANGED
package/scripts/sync-web-dist.js
CHANGED
|
@@ -32,6 +32,7 @@ const packagedRuntimeAssets = [
|
|
|
32
32
|
['web UI', resolve(targetDist, 'index.html')],
|
|
33
33
|
['Hub source', resolve(targetHub, 'src', 'server.js')],
|
|
34
34
|
['Hub mobile Console direct transport', resolve(targetHub, 'src', 'console-direct.js')],
|
|
35
|
+
['Hub H.264 media admission', resolve(targetHub, 'src', 'console-direct-frame-admission.mjs')],
|
|
35
36
|
['Hub remote bridge', resolve(targetHub, 'src', 'remote-hub.js')],
|
|
36
37
|
['Hub immutable frame packet contract', resolve(targetHub, 'src', 'frame-packet-contract.mjs')],
|
|
37
38
|
['Hub manifest', resolve(targetHub, 'package.json')],
|
package/web/dist/app.html
CHANGED
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
.vuvodesk-static-boot [hidden] { display: none; }
|
|
53
53
|
@keyframes vuvodesk-static-boot-spin { to { transform: rotate(360deg); } }
|
|
54
54
|
</style>
|
|
55
|
-
<script type="module" crossorigin src="/assets/app-
|
|
55
|
+
<script type="module" crossorigin src="/assets/app-CVjARKwD.js" data-vuvodesk-entry></script>
|
|
56
56
|
<link rel="modulepreload" crossorigin href="/assets/preload-helper-DD1OZmLC.js">
|
|
57
57
|
<link rel="modulepreload" crossorigin href="/assets/pwa-bootstrap-ljOQTt2e.js">
|
|
58
58
|
</head>
|
package/web/dist/app.webmanifest
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{j as t}from"./pwa-startup-DvbrYEQi.js";import{r as c,d as A,S as q,T as I,e as z}from"./icons-CuXJn-Hz.js";import{h as x}from"./MonitorStackIcon-
|
|
1
|
+
import{j as t}from"./pwa-startup-DvbrYEQi.js";import{r as c,d as A,S as q,T as I,e as z}from"./icons-CuXJn-Hz.js";import{h as x}from"./MonitorStackIcon-NxxMF_Ag.js";import"./pwa-bootstrap-ljOQTt2e.js";const E={enabled:!0};function w(i){return{enabled:i?.enabled===!0}}async function L(i,p,f){let l=null;for(let s=0;s<2;s+=1){const g=await x("/api/settings",{signal:f},p);if(g.settings.agent?.enabled===i)return;try{await x("/api/settings",{method:"PATCH",signal:f,body:JSON.stringify({revision:g.revision,agent:{enabled:i}})},p);return}catch(a){if(l=a,s!==0||!String(a instanceof Error?a.message:a).startsWith("409 "))throw a}}throw l}function H({hubUrl:i,showToast:p}){const[f,l]=c.useState(E),[s,g]=c.useState(null),[a,m]=c.useState(""),[j,v]=c.useState(""),[y,d]=c.useState(""),b=c.useRef(0),u=c.useRef(null);c.useEffect(()=>{let e=!0;u.current?.abort();const n=new AbortController;u.current=n;const h=++b.current;return g(null),l(E),m(""),d(""),v(""),x("/api/settings/agent",{signal:n.signal},i).then(o=>{!e||n.signal.aborted||h!==b.current||(g(o.settings),l(w(o.settings)))}).catch(o=>{e&&!n.signal.aborted&&h===b.current&&d(o instanceof Error?o.message:String(o))}),()=>{e=!1,n.abort(),u.current===n&&(u.current=null)}},[i]);const k=(e,n)=>l(h=>({...h,[e]:n})),C=e=>{g(e),l(w(e))},S=()=>({generation:b.current,controller:u.current}),r=e=>e.generation===b.current&&e.controller!==null&&e.controller===u.current&&!e.controller.signal.aborted,R=async()=>{if(!s){d("Agent settings are not available yet.");return}const e=S();if(r(e)){m("save"),d(""),v("");try{if(await L(f.enabled,i,e.controller.signal),!r(e))return;const n=await x("/api/settings/agent",{signal:e.controller.signal},i);if(!r(e))return;C(n.settings),v("Codex Agent settings saved.")}catch(n){r(e)&&d(n instanceof Error?n.message:String(n))}finally{r(e)&&m("")}}},T=async()=>{const e=S();if(r(e)){m("test"),d(""),v("");try{const n=await x("/api/settings/agent/test",{method:"POST",signal:e.controller.signal,body:JSON.stringify({})},i);if(!r(e))return;const h=await x("/api/settings/agent",{signal:e.controller.signal},i);if(!r(e))return;C(h.settings);const o=Number.isFinite(n.connection?.latencyMs)?Math.max(0,Math.round(n.connection.latencyMs)):null;p(`Codex Agent connection verified${o!==null?` in ${o} ms.`:"."}`,"success")}catch(n){r(e)&&d(n instanceof Error?n.message:String(n))}finally{r(e)&&m("")}}},D=s?.codexInstallation==="installed"&&s.codexAuth==="signed-in",N=s!==null;return t.jsxs("div",{className:"settings-tab-content agent-settings-content",children:[t.jsxs("section",{className:"settings-section-card agent-enable-card",children:[t.jsxs("label",{className:"settings-toggle agent-enable-toggle",children:[t.jsx("input",{type:"checkbox",checked:f.enabled,onChange:e=>k("enabled",e.target.checked),disabled:!N||a!==""}),t.jsx("span",{children:"Enable Codex Agent"})]}),t.jsxs("p",{className:"settings-help",children:[t.jsx(A,{size:12})," Enabled by default. Agent commands use only the Codex account signed in on this Hub."]})]}),y&&t.jsx("div",{className:"agent-settings-alert error",role:"alert",children:y}),j&&t.jsx("div",{className:"agent-settings-alert success",role:"status",children:j}),t.jsxs("section",{className:"settings-section-card",children:[t.jsxs("div",{className:"settings-section-heading",children:[t.jsxs("div",{children:[t.jsx("span",{children:"Connection"}),t.jsx("h3",{children:"Codex SDK / CLI"})]}),t.jsx(q,{size:20})]}),t.jsxs("div",{className:"agent-connection-row",children:[t.jsx("span",{className:`agent-connection-dot ${D?"connected":s?.codexAuth==="not-signed-in"?"unavailable":""}`}),t.jsx("span",{children:s?.codexInstallation==="not-installed"?"Codex SDK not installed":s?.codexAuth==="signed-in"?"Codex CLI signed in":s?.codexAuth==="not-signed-in"?"Codex CLI sign-in required":"Codex status not tested"}),t.jsxs("button",{className:"settings-reset",onClick:()=>{T()},disabled:a!=="",type:"button",children:[t.jsx(I,{size:15})," ",a==="test"?"Testing":"Test connection"]})]}),t.jsxs("p",{className:"settings-help",children:[t.jsx(A,{size:12})," The Hub uses the installed Codex SDK / CLI to plan approved commands and run them through VuvoDesk Agent tools on selected computers. VuvoDesk never asks for separate credentials."]})]}),t.jsx("div",{className:"settings-action-row agent-settings-actions",children:t.jsxs("button",{className:"text-button primary",onClick:()=>{R()},disabled:!N||a!=="",type:"button",children:[t.jsx(z,{size:15})," ",a==="save"?"Saving":"Save Agent settings"]})})]})}export{H as AgentSettingsTab};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{j as t}from"./pwa-startup-DvbrYEQi.js";import{r as d,ae as ht,af as Ce,z as yt,G as ee,X as xe,ag as vt,ah as wt,ai as Ze,aj as kt,ak as bt,al as Se,b as H,am as Ct,p as xt,an as _e,o as St,ao as jt,S as Je,ap as At,a9 as Rt,c as Nt,aq as Pt,ar as It,A as Tt,as as ue,M as Xe}from"./icons-CuXJn-Hz.js";import{r as Et,h as F}from"./MonitorStackIcon-
|
|
1
|
+
import{j as t}from"./pwa-startup-DvbrYEQi.js";import{r as d,ae as ht,af as Ce,z as yt,G as ee,X as xe,ag as vt,ah as wt,ai as Ze,aj as kt,ak as bt,al as Se,b as H,am as Ct,p as xt,an as _e,o as St,ao as jt,S as Je,ap as At,a9 as Rt,c as Nt,aq as Pt,ar as It,A as Tt,as as ue,M as Xe}from"./icons-CuXJn-Hz.js";import{r as Et,h as F}from"./MonitorStackIcon-NxxMF_Ag.js";import"./pwa-bootstrap-ljOQTt2e.js";const Ot=d.forwardRef(function({value:s,onChange:n,onSubmit:r,onOpenSuggestedTasks:i,suggestedOpen:f=!1,suggestedTaskCount:c,disabled:v=!1,placeholder:S="Ask VuvoDesk to work across your machines...",compact:g=!1},x){return t.jsxs("form",{className:"agents-composer",onSubmit:w=>{w.preventDefault(),r()},children:[t.jsx("button",{className:`agents-suggested-trigger ${f?"active":""}`,type:"button","aria-label":c===void 0?"Open Suggested tasks":`Open Suggested tasks (${c} available)`,"aria-expanded":f,onClick:i,disabled:v,children:t.jsx(ht,{size:17})}),t.jsx("textarea",{ref:x,value:s,onChange:w=>n(w.target.value.slice(0,4e3)),placeholder:S,"aria-label":"Ask VuvoDesk",rows:g?1:2,disabled:v}),t.jsxs("button",{className:"agents-run-button",type:"submit",disabled:v||!s.trim(),children:[v?t.jsx(Ce,{className:"spin",size:16}):t.jsx(yt,{size:16}),v?"Running":"Run"]})]})}),$t=["read","processControl","serviceControl","applicationControl","fileRead","fileWrite","fileDelete","shell","script","softwareInstall","network","systemPower","systemConfiguration","userAccount"];function de(e){return e.deviceName?.trim()||e.hostname?.trim()||e.deviceId}function pe(e){return e.connected!==!0?!1:e.synthetic===!0?!0:!e.channels||e.channels.control===!0}function Dt(e){return e==="running"||e==="completed"||e==="failed"||e==="cancelled"?e:"queued"}function _t(e){switch(e){case"process.list":return"Process list";case"system.health":return"System health";case"gpu.status":return"GPU status";case"disk.status":return"Disk status";case"service.status":return"Service status";case"diagnostics.collect":return"Diagnostics";case"process.control":return"Process control";case"service.control":return"Service control";case"application.launch":return"Launch application";case"application.close":return"Close application";case"file.read":return"Read file";case"file.write":return"Write file";case"file.delete":return"Delete file";case"file.list":return"List directory";case"command.run":return"Run command";case"script.run":return"Run script";case"software.install":return"Install software";case"network.status":return"Network status";case"system.power":return"Power action";case"system.configure":return"System configuration";case"logs.collect":return"Collect logs"}}const Mt=64*1024,Me="[Earlier log output hidden by PWA display limit.]",Qe=new TextEncoder,zt=new TextDecoder("utf-8",{fatal:!0}),ze=16*1024,Lt=200,Le="(?:access[_-]?(?:token|key)|refresh[_-]?token|id[_-]?token|pair[_-]?(?:token|key)|authorization|api[_-]?key|private[_-]?key|connection[_-]?string|credential|password|passwd|secret|cookie|set-cookie)",Ft="(?:access[_-]?(?:token|key)|refresh[_-]?token|id[_-]?token|pair[_-]?(?:token|key)|token|authorization|api[_-]?key|private[_-]?key|connection[_-]?string|credential|password|passwd|secret|cookie|set-cookie)",Bt="(?:access[_-]?(?:token|key)|refresh[_-]?token|id[_-]?token|pair[_-]?(?:token|key)|token|authorization|api[_-]?key|credential|password|secret|cookie)",qt=/\b(?:sk-(?:proj-|ant-)?[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,}|(?:AKIA|ASIA)[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{35}|npm_[A-Za-z0-9]{8,}|xox[a-z]-[A-Za-z0-9-]{8,}|(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{8,})\b/g;function Ut(e){return e&&typeof e=="object"&&!Array.isArray(e)?e:null}function Fe(e){return Qe.encode(e).byteLength}function Be(e,s){const n=Qe.encode(e);if(n.byteLength<=s)return{value:e,truncated:!1};let r=n.byteLength-s;for(;r<n.byteLength&&(n[r]&192)===128;)r+=1;let i=zt.decode(n.subarray(r));const f=i.indexOf(`
|
|
2
2
|
`);return f>=0&&f<i.length-1&&(i=i.slice(f+1)),{value:i,truncated:!0}}function et(e){return e.replace(/-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*?-----END [^-\r\n]*PRIVATE KEY-----/gi,"[REDACTED PRIVATE KEY]").replace(/\b(?:proxy-)?authorization\s*[:=]\s*[^\r\n]*|\b(?:set-)?cookie\s*[:=]\s*[^\r\n]*/gi,"credential-header=[REDACTED]").replace(/\bBearer\s+(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi,"Bearer [REDACTED]").replace(new RegExp(`("${Le}"\\s*:\\s*")[^"\\r\\n]*(")`,"gi"),"$1[REDACTED]$2").replace(new RegExp(`('${Le}'\\s*:\\s*')[^'\\r\\n]*(')`,"gi"),"$1[REDACTED]$2").replace(new RegExp(`((?:[A-Za-z0-9.-]+[_-])?${Ft}\\s*[:=]\\s*)(?!\\[REDACTED\\])[^\\s,;&]+`,"gi"),"$1[REDACTED]").replace(new RegExp(`([?&]${Bt}=)[^&\\s]+`,"gi"),"$1[REDACTED]").replace(/\b(?:eyJ|[A-Za-z0-9_-]{8,}\.)[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,"[REDACTED TOKEN]").replace(qt,"[REDACTED KEY]")}function Gt(e){return typeof e!="string"?"source unknown":et(e).replace(/[\u0000-\u001f\u007f]/g," ").trim().slice(0,64)||"source unknown"}function qe(e){return typeof e=="number"&&Number.isSafeInteger(e)&&e>=0?Math.floor(e):null}function Vt(e){if(typeof e!="string"||e.length===0)return{output:"",displayTruncated:!1};const s=Be(e.replace(/\r\n?/g,`
|
|
3
3
|
`),Mt);let n=et(s.value).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g," ").trim(),r=s.truncated;const i=n?n.split(`
|
|
4
4
|
`):[];if(i.length>Lt-1&&(n=i.slice(-199).join(`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{j as e}from"./pwa-startup-DvbrYEQi.js";import{r as i,R as x,E as j,D as u,f as g,h as f}from"./icons-CuXJn-Hz.js";import{l as b,c as d,d as y}from"./HubApp-
|
|
1
|
+
import{j as e}from"./pwa-startup-DvbrYEQi.js";import{r as i,R as x,E as j,D as u,f as g,h as f}from"./icons-CuXJn-Hz.js";import{l as b,c as d,d as y}from"./HubApp-D_DdfH3v.js";import"./pwa-bootstrap-ljOQTt2e.js";import"./preload-helper-DD1OZmLC.js";import"./MonitorStackIcon-NxxMF_Ag.js";import"./supabase-C7qjtLN3.js";import"./frame-lifecycle-D91nchvm.js";function z({hubUrl:s,onToast:n}){const[o,m]=i.useState([]),[h,c]=i.useState(!0),r=i.useCallback(async()=>{c(!0);try{m((await b(s)).captures||[])}catch(t){n(t instanceof Error?t.message:String(t))}finally{c(!1)}},[s,n]);i.useEffect(()=>{r()},[r]);const p=async t=>{const a=d(t.id,s);if(navigator.share)try{await navigator.share({title:t.fileName,url:a});return}catch{}window.open(a,"_blank","noopener,noreferrer")};return e.jsxs("section",{className:"capture-gallery",children:[e.jsxs("header",{children:[e.jsxs("div",{children:[e.jsx("span",{className:"settings-eyebrow",children:"Capture Gallery"}),e.jsx("h2",{children:"Saved captures"})]}),e.jsxs("button",{className:"text-button",onClick:()=>{r()},type:"button",children:[e.jsx(x,{size:15})," Refresh"]})]}),h?e.jsx("p",{className:"settings-help",children:"Loading captures..."}):o.length===0?e.jsx("p",{className:"settings-help",children:"No captures saved yet."}):e.jsx("div",{className:"capture-gallery-grid",children:o.map(t=>{const a=d(t.id,s);return e.jsxs("article",{className:"capture-gallery-card",children:[t.mimeType.startsWith("image/")?e.jsx("img",{src:a,alt:t.target.label}):e.jsx("video",{src:a,controls:!0,preload:"metadata"}),e.jsxs("div",{className:"capture-gallery-card-body",children:[e.jsx("strong",{children:t.fileName}),e.jsxs("small",{children:[t.target.label," · ",new Date(t.createdAt).toLocaleString()," · ",Math.ceil(t.sizeBytes/1024)," KB"]}),e.jsxs("div",{children:[e.jsx("button",{className:"icon-button",title:"Open","aria-label":"Open",onClick:()=>window.open(a,"_blank","noopener,noreferrer"),type:"button",children:e.jsx(j,{size:15})}),e.jsx("a",{className:"icon-button",title:"Download","aria-label":"Download",href:`${a}?download=1`,download:t.fileName,children:e.jsx(u,{size:15})}),e.jsx("button",{className:"icon-button",title:"Share","aria-label":"Share",onClick:()=>{p(t)},type:"button",children:e.jsx(g,{size:15})}),e.jsx("button",{className:"icon-button danger",title:"Delete","aria-label":"Delete",onClick:()=>{window.confirm(`Delete ${t.fileName}?`)&&y(t.id,s).then(()=>r()).catch(l=>n(l instanceof Error?l.message:String(l)))},type:"button",children:e.jsx(f,{size:15})})]})]})]},t.id)})})]})}export{z as CaptureGallery};
|