livedesk 0.1.689 → 0.1.690

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.70",
3
+ "version": "0.1.71",
4
4
  "description": "VuvoDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -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 ? new Uint8Array(Buffer.from(data)) : String(data);
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);
@@ -1938,6 +1981,7 @@ export function createHubConsoleDirect(options = {}) {
1938
1981
  };
1939
1982
 
1940
1983
  const inspect = () => {
1984
+ const peerDiagnostics = [];
1941
1985
  let logicalWebSocketChannels = 0;
1942
1986
  let localWebSocketChannels = 0;
1943
1987
  let pendingHttpRequests = 0;
@@ -1964,6 +2008,30 @@ export function createHubConsoleDirect(options = {}) {
1964
2008
  }
1965
2009
  retainedAssemblyBytes += Number(owner.wireBudget.inspect().retainedBytes || 0);
1966
2010
  bufferedSendBytes += ownerBufferedAmount(owner);
2011
+ peerDiagnostics.push({
2012
+ consoleId: owner.consoleId,
2013
+ connectionId: owner.connectionId,
2014
+ generation: owner.generation,
2015
+ peerState: owner.peerState,
2016
+ iceState: owner.iceState,
2017
+ rttMs: optionalPeerMetric(owner.peer, 'rtt'),
2018
+ bytesSent: optionalPeerMetric(owner.peer, 'bytesSent'),
2019
+ bytesReceived: optionalPeerMetric(owner.peer, 'bytesReceived'),
2020
+ selectedPath: inspectPeerPath(owner.peer),
2021
+ bufferedSendBytes: (() => {
2022
+ const values = [owner.control, ...owner.channels.values()]
2023
+ .filter(lane => lane && !lane.closed)
2024
+ .map(lane => optionalPeerMetric(lane.channel, 'bufferedAmount'));
2025
+ return values.some(value => value === null) ? null : values.reduce((sum, value) => sum + value, 0);
2026
+ })(),
2027
+ pendingHttpRequests: owner.pendingHttp.size,
2028
+ lanes: [...owner.channels.values()].map(lane => ({
2029
+ channelId: lane.channelId,
2030
+ purpose: lane.purpose,
2031
+ bufferedBytes: optionalPeerMetric(lane.channel, 'bufferedAmount'),
2032
+ frameAdmission: lane.frameAdmission?.inspect() ?? null
2033
+ }))
2034
+ });
1967
2035
  }
1968
2036
  const reconnectTimerActive = Boolean(retryTimer);
1969
2037
  const accessTokenDeadlineActive = Boolean(accessTokenAttempt);
@@ -1982,6 +2050,7 @@ export function createHubConsoleDirect(options = {}) {
1982
2050
  pendingLogicalControlRequests,
1983
2051
  retainedAssemblyBytes,
1984
2052
  bufferedSendBytes,
2053
+ peerDiagnostics,
1985
2054
  iceConnectTimers,
1986
2055
  peerDisconnectTimers,
1987
2056
  assemblyDeadlineTimers,
@@ -9,6 +9,7 @@ import {
9
9
  } from '../../runtime-core/src/console-direct-wire.js';
10
10
  import { consoleDirectContract, createHubConsoleDirect } from './console-direct.js';
11
11
  import { workspaceRoleCanRequest } from './auth/workspace-access.js';
12
+ import { buildImmutableRemoteFramePacket } from './frame-packet-contract.mjs';
12
13
 
13
14
  assert.equal(consoleDirectContract.maxDefaultRetryDelayMs, 20_000);
14
15
  assert.equal(consoleDirectContract.peerDisconnectedTimeoutMs, 5_000);
@@ -1336,6 +1337,64 @@ test('media pressure retires only its stale logical lane while preserving the co
1336
1337
  direct.close();
1337
1338
  });
1338
1339
 
1340
+ test('a pressured H.264 viewer drops whole dependent frames and resumes on a key without reconnecting', async () => {
1341
+ const { direct, signal } = await connectedDirect();
1342
+ const peer = offer(signal);
1343
+ const control = new FakeDataChannel('livedesk-control-v1');
1344
+ peer.emitDataChannel(control);
1345
+ control.open();
1346
+ const lane = new FakeDataChannel(`livedesk-ws-v1:${CHANNEL_ID}:frame`);
1347
+ peer.emitDataChannel(lane);
1348
+ lane.open();
1349
+ sendWire(control, {
1350
+ type: 'ws-open', connectionId: CONNECTION_ONE, hubEpoch: HUB_EPOCH,
1351
+ channelId: CHANNEL_ID, purpose: 'frame', path: '/api/remote/frames/ws'
1352
+ });
1353
+ const local = FakeLocalSocket.instances[0];
1354
+ local.open();
1355
+ const frame = (sequence, key, generation = 1) => buildImmutableRemoteFramePacket({
1356
+ deviceId: 'device-a', sessionId: 'session-a', streamId: 'wall-a', commandId: 'command-a',
1357
+ captureGeneration: generation, monitorIndex: 0, streamPurpose: 'wall',
1358
+ frameMode: 'mode3-h264-hw', chunkType: key ? 'key' : 'delta',
1359
+ isKeyFrame: key, streamFrameSeq: sequence
1360
+ }, Buffer.alloc(32 * 1024)).packet;
1361
+ local.receive(frame(1, true));
1362
+ const initialChunks = lane.sent.length;
1363
+ lane.buffered = consoleDirectContract.controlHeadroomBufferedBytes;
1364
+ local.receive(frame(2, false));
1365
+ assert.equal(lane.closed, false, 'congestion must not create another logical handshake or key-frame burst');
1366
+ assert.equal(lane.sent.length, initialChunks, 'no fragment of a dropped frame enters SCTP');
1367
+ lane.buffered = 0;
1368
+ local.receive(frame(3, false));
1369
+ assert.equal(lane.sent.length, initialChunks, 'a dependent delta cannot follow a discarded delta');
1370
+ local.receive(frame(4, true));
1371
+ assert.ok(lane.sent.length > initialChunks);
1372
+ const recoveredChunks = lane.sent.length;
1373
+ local.receive(frame(5, false));
1374
+ assert.ok(lane.sent.length > recoveredChunks);
1375
+ assert.equal(control.closed, false);
1376
+ assert.equal(peer.closed, false);
1377
+ assert.equal(direct.inspect().dataChannelBackpressureCloses, 0);
1378
+ const diagnostics = direct.inspect().peerDiagnostics[0];
1379
+ assert.equal(diagnostics.rttMs, null, 'missing native RTT is unknown');
1380
+ assert.equal(diagnostics.bytesSent, null, 'missing native byte counters are unknown');
1381
+ assert.equal(diagnostics.lanes[0].frameAdmission.droppedFrames, 2);
1382
+ assert.equal(diagnostics.lanes[0].frameAdmission.recoveredStreams, 1);
1383
+ peer.rtt = () => 12;
1384
+ peer.bytesSent = () => 1234;
1385
+ peer.selectedPair = {
1386
+ local: { type: 'host', address: '192.168.0.4', candidate: 'must-not-leak' },
1387
+ remote: { type: 'host', address: '192.168.0.8', transportType: 'Udp' }
1388
+ };
1389
+ const measured = direct.inspect().peerDiagnostics[0];
1390
+ assert.equal(measured.rttMs, 12);
1391
+ assert.equal(measured.bytesSent, 1234);
1392
+ assert.equal(measured.selectedPath.remoteScope, 'private');
1393
+ assert.doesNotMatch(JSON.stringify(measured), /192\.168\.|must-not-leak/);
1394
+ direct.close();
1395
+ assert.deepEqual(direct.inspect().peerDiagnostics, []);
1396
+ });
1397
+
1339
1398
  test('a reliable control response drops stale media backlog without retiring the peer', async () => {
1340
1399
  const { direct, signal } = await connectedDirect({
1341
1400
  fetchImpl: async () => new Response('{"online":true}', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livedesk",
3
- "version": "0.1.689",
3
+ "version": "0.1.690",
4
4
  "livedeskClientVersion": "0.1.275",
5
5
  "buildFlavor": "production",
6
6
  "description": "VuvoDesk Hub and client launcher",
@@ -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-D8_G4B_m.js" data-vuvodesk-entry></script>
55
+ <script type="module" crossorigin src="/assets/app-pSkBesmR.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>
@@ -4,7 +4,7 @@
4
4
  "short_name": "VuvoDesk",
5
5
  "description": "Monitor and control your VuvoDesk computers from your phone.",
6
6
  "lang": "en",
7
- "start_url": "/app?pwa=0.1.689",
7
+ "start_url": "/app?pwa=0.1.690",
8
8
  "scope": "/",
9
9
  "display": "standalone",
10
10
  "orientation": "any",
@@ -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-4RlZK1aS.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
+ 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-B-za6UQS.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-4RlZK1aS.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(`
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-B-za6UQS.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-oCD8fLqO.js";import"./pwa-bootstrap-ljOQTt2e.js";import"./preload-helper-DD1OZmLC.js";import"./MonitorStackIcon-4RlZK1aS.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};
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-CDv_1fSd.js";import"./pwa-bootstrap-ljOQTt2e.js";import"./preload-helper-DD1OZmLC.js";import"./MonitorStackIcon-B-za6UQS.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};