livedesk 0.1.227 → 0.1.229

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.
@@ -1388,6 +1388,29 @@ export function createRemoteHub(options = {}) {
1388
1388
  const emitEvent = options.emitEvent || (() => {});
1389
1389
  const emitFrame = typeof options.emitFrame === 'function' ? options.emitFrame : (() => {});
1390
1390
  const emitAudio = typeof options.emitAudio === 'function' ? options.emitAudio : (() => {});
1391
+ const getEffectiveDevicePolicy = typeof options.getEffectiveDevicePolicy === 'function'
1392
+ ? options.getEffectiveDevicePolicy
1393
+ : (() => ({}));
1394
+
1395
+ function getDevicePolicy(device) {
1396
+ try {
1397
+ return getEffectiveDevicePolicy({
1398
+ deviceId: device?.deviceId || '',
1399
+ capabilities: device?.capabilities || {}
1400
+ }) || {};
1401
+ } catch {
1402
+ return {};
1403
+ }
1404
+ }
1405
+
1406
+ function policyError(device, permission = '') {
1407
+ const policy = getDevicePolicy(device);
1408
+ if (policy.accessMode === 'block-remote-access') return 'remote-access-blocked-by-settings';
1409
+ if (permission && policy[permission] === false) {
1410
+ return `${permission}-blocked-by-settings`;
1411
+ }
1412
+ return '';
1413
+ }
1391
1414
 
1392
1415
  const enabled = isEnabledValue(env.LIVEDESK_REMOTE_HUB ?? env.MINDEXEC_REMOTE_HUB ?? env.REMOTE_HUB_ENABLED, true)
1393
1416
  && !isDisabledValue(env.REMOTE_HUB_DISABLED);
@@ -2644,6 +2667,7 @@ export function createRemoteHub(options = {}) {
2644
2667
  heartbeatMs,
2645
2668
  frameProtocol: buildRemoteFrameProtocolDescriptor(),
2646
2669
  frameModes: getSupportedRemoteFrameModeProfiles(),
2670
+ effectivePolicy: getEffectiveDevicePolicy({ deviceId, capabilities }),
2647
2671
  serverTime: now
2648
2672
  });
2649
2673
 
@@ -4425,6 +4449,18 @@ export function createRemoteHub(options = {}) {
4425
4449
 
4426
4450
  function sendCommand(deviceId, command) {
4427
4451
  const device = devices.get(String(deviceId || ''));
4452
+ const commandName = safeString(command?.command || 'ping', 80);
4453
+ const requiredPermission = commandName === 'input.control'
4454
+ ? 'allowControl'
4455
+ : commandName.startsWith('file.transfer')
4456
+ ? 'allowFileTransfer'
4457
+ : commandName === 'audio.start'
4458
+ ? 'allowRemoteAudio'
4459
+ : commandName.startsWith('agent.')
4460
+ ? 'allowAgent'
4461
+ : '';
4462
+ const denied = policyError(device, requiredPermission);
4463
+ if (denied) return { ok: false, error: denied };
4428
4464
  if (device?.synthetic === true && device.connected) {
4429
4465
  const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
4430
4466
  device.counters.commandsSent += 1;
@@ -4437,7 +4473,7 @@ export function createRemoteHub(options = {}) {
4437
4473
  }
4438
4474
  emitRemoteEvent('RemoteCommandQueued', device, {
4439
4475
  commandId,
4440
- command: safeString(command?.command || 'ping', 80),
4476
+ command: commandName,
4441
4477
  synthetic: true
4442
4478
  });
4443
4479
  emitRemoteEvent('RemoteCommandResult', device, {
@@ -4453,7 +4489,6 @@ export function createRemoteHub(options = {}) {
4453
4489
  }
4454
4490
 
4455
4491
  const commandId = safeString(command?.commandId, 128) || crypto.randomUUID();
4456
- const commandName = safeString(command?.command || 'ping', 80);
4457
4492
  const payload = {
4458
4493
  type: 'command',
4459
4494
  commandId,
@@ -4487,6 +4522,9 @@ export function createRemoteHub(options = {}) {
4487
4522
  return { ok: false, error: 'device-not-connected' };
4488
4523
  }
4489
4524
 
4525
+ const denied = policyError(device, 'allowControl');
4526
+ if (denied) return { ok: false, error: denied };
4527
+
4490
4528
  if (!readCapabilityFlag(device.capabilities, 'control')) {
4491
4529
  return { ok: false, error: 'device-input-control-unavailable' };
4492
4530
  }
@@ -4527,6 +4565,8 @@ export function createRemoteHub(options = {}) {
4527
4565
 
4528
4566
  function requestAgentTask(deviceId, options = {}) {
4529
4567
  const device = devices.get(String(deviceId || ''));
4568
+ const denied = policyError(device, 'allowAgent');
4569
+ if (denied) return { ok: false, error: denied };
4530
4570
  const requestedOperation = safeString(options.operation, 80);
4531
4571
  const operation = normalizeAgentOperation(requestedOperation);
4532
4572
  if (requestedOperation && !operation) {
@@ -4890,6 +4930,8 @@ export function createRemoteHub(options = {}) {
4890
4930
 
4891
4931
  function startLiveStream(deviceId, options = {}) {
4892
4932
  const device = devices.get(String(deviceId || ''));
4933
+ const denied = policyError(device);
4934
+ if (denied) return { ok: false, error: denied };
4893
4935
  if (device?.synthetic === true && device.connected) {
4894
4936
  if (!readCapabilityFlag(device.capabilities, 'liveStream')) {
4895
4937
  return { ok: false, error: 'device-live-stream-unavailable' };
@@ -5186,6 +5228,8 @@ export function createRemoteHub(options = {}) {
5186
5228
  if (!device.connected) {
5187
5229
  return { ok: false, error: 'device-not-connected' };
5188
5230
  }
5231
+ const denied = policyError(device, 'allowRemoteAudio');
5232
+ if (denied) return { ok: false, error: denied };
5189
5233
  if (!readCapabilityFlag(device.capabilities, 'audio') && !readCapabilityFlag(device.capabilities, 'remoteAudio')) {
5190
5234
  return { ok: false, error: 'device-audio-unavailable' };
5191
5235
  }
package/hub/src/server.js CHANGED
@@ -21,7 +21,10 @@ import { AgentProviderError } from './agents/provider-errors.js';
21
21
  import { AGENT_PERMISSION_MODES, createAgentPermissionPolicy, evaluateAgentToolPermission, hashAgentPermissionPolicy } from './agents/agent-permissions.js';
22
22
  import { createAgentPermissionStore } from './agents/agent-permission-store.js';
23
23
  import { createAgentAuditStore } from './agents/agent-audit-store.js';
24
- import { getAgentToolDefinition } from './agents/agent-tool-registry.js';
24
+ import { getAgentToolDefinition } from './agents/agent-tool-registry.js';
25
+ import { LiveDeskSettingsStore, SettingsConflictError } from './settings/settings-store.js';
26
+ import { effectiveDevicePolicy } from './settings/settings-schema.js';
27
+ import { buildEffectiveDevicePolicy } from './settings/effective-device-policy.js';
25
28
 
26
29
  const __dirname = dirname(fileURLToPath(import.meta.url));
27
30
  const webDistCandidates = [
@@ -140,9 +143,25 @@ function activeDeviceLimit() {
140
143
  return plan === 'pro' ? Number.POSITIVE_INFINITY : plan === 'ltd' ? PLUS_DEVICE_LIMIT : FREE_DEVICE_LIMIT;
141
144
  }
142
145
 
143
- function hasHubFeatureAccess() {
144
- return connectedDeviceCount <= activeDeviceLimit();
145
- }
146
+ function hasHubFeatureAccess() {
147
+ return connectedDeviceCount <= activeDeviceLimit();
148
+ }
149
+
150
+ function hasHubFeatureAccessForRequest(req) {
151
+ const limit = activeDeviceLimit();
152
+ if (!Number.isFinite(limit)) return true;
153
+ const ids = new Set();
154
+ const parameterId = String(req?.params?.deviceId || '').trim();
155
+ if (parameterId) ids.add(parameterId);
156
+ const bodyIds = Array.isArray(req?.body?.deviceIds) ? req.body.deviceIds : [];
157
+ for (const value of bodyIds) {
158
+ const id = String(value || '').trim();
159
+ if (id) ids.add(id);
160
+ }
161
+ // A user may operate on any selected device, but a single request may not
162
+ // fan out to more devices than the active plan includes.
163
+ return ids.size <= limit;
164
+ }
146
165
 
147
166
  function licenseSnapshot() {
148
167
  const plan = activeLicensePlan();
@@ -201,15 +220,26 @@ async function syncVerifiedLicense(accessToken) {
201
220
  return licenseSnapshot();
202
221
  }
203
222
 
204
- function requireHubFeatureAccess(_req, res, next) {
205
- if (hasHubFeatureAccess()) {
206
- next();
223
+ function requireHubFeatureAccess(_req, res, next) {
224
+ if (hasHubFeatureAccessForRequest(_req)) {
225
+ const policy = effectiveDevicePolicy(liveDeskSettingsStore.getCached());
226
+ if (policy.accessMode === 'block-remote-access') {
227
+ res.status(403).json({ ok: false, error: 'remote-access-blocked-by-settings' });
228
+ return;
229
+ }
230
+ next();
207
231
  return;
208
232
  }
209
233
  res.status(402).json({ ok: false, error: 'livedesk-plan-device-limit', license: licenseSnapshot() });
210
234
  }
211
235
 
212
- const remoteHub = createRemoteHub({
236
+ const agentDataDir = process.env.LIVEDESK_DATA_DIR || undefined;
237
+ const liveDeskSettingsStore = new LiveDeskSettingsStore({ dataDir: agentDataDir });
238
+ void liveDeskSettingsStore.getRecord().catch(error => {
239
+ console.warn(`[LiveDesk Hub] settings load failed: ${error instanceof Error ? error.message : String(error)}`);
240
+ });
241
+
242
+ const remoteHub = createRemoteHub({
213
243
  managerPackage: '@livedesk/hub',
214
244
  managerVersion: packageInfo.version,
215
245
  env: {
@@ -218,12 +248,12 @@ const remoteHub = createRemoteHub({
218
248
  MINDEXEC_MANAGER_VERSION: packageInfo.version
219
249
  },
220
250
  logEvent: (_scope, message) => console.log(`[LiveDesk Hub] ${message}`),
221
- logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`),
222
- emitEvent: handleRemoteHubEvent,
223
- emitFrame: broadcastRemoteBinaryFrame,
224
- emitAudio: broadcastRemoteBinaryAudio
225
- });
226
- const agentDataDir = process.env.LIVEDESK_DATA_DIR || undefined;
251
+ logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`),
252
+ emitEvent: handleRemoteHubEvent,
253
+ emitFrame: broadcastRemoteBinaryFrame,
254
+ emitAudio: broadcastRemoteBinaryAudio,
255
+ getEffectiveDevicePolicy: ({ deviceId, capabilities }) => buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities })
256
+ });
227
257
  const agentMcpSessions = new Map();
228
258
  const agentMcpRunBatches = new Map();
229
259
  const agentMcpRunCleanupTimers = new Map();
@@ -1218,12 +1248,8 @@ function updateFrameSubscription(ws, payload = {}) {
1218
1248
  }
1219
1249
  }
1220
1250
 
1221
- function startFrameSubscriptionLive(ws, reason = 'subscribe', onlyDeviceId = '', overrideLiveOptions = null) {
1222
- if (!hasHubFeatureAccess()) {
1223
- sendJson(ws, { type: 'RemoteFrameSubscriptionError', error: 'livedesk-plan-device-limit', license: licenseSnapshot() });
1224
- return;
1225
- }
1226
- const subscribedIds = [...(ws.liveDeskDeviceIds || [])];
1251
+ function startFrameSubscriptionLive(ws, reason = 'subscribe', onlyDeviceId = '', overrideLiveOptions = null) {
1252
+ const subscribedIds = [...(ws.liveDeskDeviceIds || [])];
1227
1253
  if (!ws.liveDeskAutoStart || subscribedIds.length === 0) {
1228
1254
  return;
1229
1255
  }
@@ -1439,13 +1465,10 @@ function buildRemoteAudioBinaryPacket(audioEvent) {
1439
1465
  return Buffer.concat([header, metaBuffer, payload], 4 + metaBuffer.length + payload.length);
1440
1466
  }
1441
1467
 
1442
- function broadcastRemoteBinaryFrame(frameEvent) {
1468
+ function broadcastRemoteBinaryFrame(frameEvent) {
1443
1469
  for (const session of atlasClients.values()) {
1444
1470
  session.ingest(frameEvent);
1445
1471
  }
1446
- if (!hasHubFeatureAccess()) {
1447
- return;
1448
- }
1449
1472
  const deviceId = String(frameEvent?.deviceId || frameEvent?.frame?.deviceId || '').trim();
1450
1473
  if (!deviceId || frameClients.size === 0) {
1451
1474
  return;
@@ -1490,10 +1513,7 @@ function broadcastRemoteBinaryFrame(frameEvent) {
1490
1513
  }
1491
1514
  }
1492
1515
 
1493
- function broadcastRemoteBinaryAudio(audioEvent) {
1494
- if (!hasHubFeatureAccess()) {
1495
- return;
1496
- }
1516
+ function broadcastRemoteBinaryAudio(audioEvent) {
1497
1517
  const deviceId = String(audioEvent?.deviceId || audioEvent?.frame?.deviceId || '').trim();
1498
1518
  if (!deviceId || audioClients.size === 0) {
1499
1519
  return;
@@ -1517,8 +1537,8 @@ function broadcastRemoteBinaryAudio(audioEvent) {
1517
1537
  }
1518
1538
  }
1519
1539
 
1520
- function sendMode4AtlasFrame(ws, output) {
1521
- if (!hasHubFeatureAccess() || ws.readyState !== ws.OPEN || !output?.payload?.length) return;
1540
+ function sendMode4AtlasFrame(ws, output) {
1541
+ if (ws.readyState !== ws.OPEN || !output?.payload?.length) return;
1522
1542
  const metadata = output.metadata || {};
1523
1543
  const frameSeq = Number(metadata.frameSeq || 0) || 0;
1524
1544
  const isKeyFrame = metadata.isKeyFrame === true;
@@ -1697,12 +1717,83 @@ function isCapabilityEnabled(device, key) {
1697
1717
  return /^(1|true|yes|on)$/i.test(String(value || '').trim());
1698
1718
  }
1699
1719
 
1700
- app.get('/api/health', (_req, res) => {
1701
- noStore(res);
1702
- res.json({ ok: true, product: 'LiveDesk', timestamp: new Date().toISOString() });
1703
- });
1704
-
1705
- app.get('/api/settings/agent', async (_req, res) => {
1720
+ app.get('/api/health', (_req, res) => {
1721
+ noStore(res);
1722
+ res.json({ ok: true, product: 'LiveDesk', timestamp: new Date().toISOString() });
1723
+ });
1724
+
1725
+ app.get('/api/settings', async (_req, res) => {
1726
+ noStore(res);
1727
+ try {
1728
+ const record = await liveDeskSettingsStore.getRecord();
1729
+ res.json({ ok: true, revision: record.revision, updatedAt: record.updatedAt, settings: record.settings });
1730
+ } catch (error) {
1731
+ res.status(500).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
1732
+ }
1733
+ });
1734
+
1735
+ app.patch('/api/settings', async (req, res) => {
1736
+ noStore(res);
1737
+ try {
1738
+ const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
1739
+ const revision = body.revision === undefined ? undefined : Number(body.revision);
1740
+ const patch = { ...body };
1741
+ delete patch.revision;
1742
+ const record = await liveDeskSettingsStore.update(patch, revision);
1743
+ res.json({ ok: true, revision: record.revision, updatedAt: record.updatedAt, settings: record.settings });
1744
+ } catch (error) {
1745
+ if (error instanceof SettingsConflictError) {
1746
+ res.status(409).json({ ok: false, error: error.code, revision: error.settings.revision, updatedAt: error.settings.updatedAt, settings: error.settings.settings });
1747
+ return;
1748
+ }
1749
+ res.status(error?.status || 400).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
1750
+ }
1751
+ });
1752
+
1753
+ app.get('/api/settings/capabilities', async (_req, res) => {
1754
+ noStore(res);
1755
+ const settings = await liveDeskSettingsStore.get();
1756
+ res.json({
1757
+ ok: true,
1758
+ settingsSchemaVersion: settings.settingsSchemaVersion,
1759
+ sections: ['connection', 'security', 'control', 'wall', 'filesAudio', 'agent', 'advanced'],
1760
+ policy: effectiveDevicePolicy(settings),
1761
+ externalTransport: 'encrypted-only'
1762
+ });
1763
+ });
1764
+
1765
+ app.get('/api/security/trusted-devices', (_req, res) => {
1766
+ noStore(res);
1767
+ const devices = remoteHub.listDevices({ includeDataUrl: false }).map(device => ({
1768
+ deviceId: device.deviceId,
1769
+ deviceName: device.deviceName,
1770
+ platform: device.platform,
1771
+ connected: device.connected === true,
1772
+ trusted: device.connected === true,
1773
+ firstConnectedAt: device.connectedAt || device.lastSeenAt || '',
1774
+ lastSeenAt: device.lastSeenAt || '',
1775
+ ipRange: String(device.remoteAddress || '').replace(/^::ffff:/, '').split(':')[0]
1776
+ }));
1777
+ res.json({ ok: true, devices });
1778
+ });
1779
+
1780
+ app.delete('/api/security/trusted-devices/:deviceId', (req, res) => {
1781
+ noStore(res);
1782
+ const result = remoteHub.disconnectDevice(req.params.deviceId, 'trusted-device-revoked');
1783
+ res.json({ ok: result === true, revoked: result === true, deviceId: req.params.deviceId });
1784
+ });
1785
+
1786
+ app.post('/api/security/revoke-all', (_req, res) => {
1787
+ noStore(res);
1788
+ const devices = remoteHub.listDevices({ includeDataUrl: false });
1789
+ let revoked = 0;
1790
+ for (const device of devices) {
1791
+ if (device.connected && remoteHub.disconnectDevice(device.deviceId, 'trusted-devices-revoked')) revoked += 1;
1792
+ }
1793
+ res.json({ ok: true, revoked });
1794
+ });
1795
+
1796
+ app.get('/api/settings/agent', async (_req, res) => {
1706
1797
  noStore(res);
1707
1798
  try {
1708
1799
  res.json({ ok: true, settings: await agentManager.getSettings() });
@@ -1798,10 +1889,6 @@ app.post('/api/internal/agent-mcp/tool', async (req, res) => {
1798
1889
  res.status(401).json({ ok: false, error: 'agent-mcp-session-invalid' });
1799
1890
  return;
1800
1891
  }
1801
- if (!hasHubFeatureAccess()) {
1802
- res.status(402).json({ ok: false, error: 'livedesk-plan-device-limit' });
1803
- return;
1804
- }
1805
1892
  try {
1806
1893
  const name = String(req.body?.name || '').slice(0, 120);
1807
1894
  const result = await dispatchAgentMcpTool(session, name, req.body?.arguments || {});
@@ -2605,12 +2692,8 @@ audioWss.on('connection', (ws, req) => {
2605
2692
  inputWss.on('connection', ws => {
2606
2693
  inputClients.add(ws);
2607
2694
  ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
2608
- ws.on('message', data => {
2609
- if (!hasHubFeatureAccess()) {
2610
- sendJson(ws, { type: 'RemoteInputError', error: 'livedesk-plan-device-limit', license: licenseSnapshot() });
2611
- return;
2612
- }
2613
- let payload;
2695
+ ws.on('message', data => {
2696
+ let payload;
2614
2697
  try {
2615
2698
  payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
2616
2699
  } catch {
@@ -0,0 +1,16 @@
1
+ import { effectiveDevicePolicy } from './settings-schema.js';
2
+
3
+ export function buildEffectiveDevicePolicy(settings, { deviceId = '', capabilities = {} } = {}) {
4
+ const policy = effectiveDevicePolicy(settings);
5
+ return {
6
+ ...policy,
7
+ deviceId: String(deviceId || ''),
8
+ supported: {
9
+ control: capabilities.control !== false,
10
+ clipboardText: capabilities.clipboardText !== false,
11
+ fileTransfer: capabilities.fileTransfer !== false,
12
+ remoteAudio: capabilities.audio === true || capabilities.remoteAudio === true,
13
+ agent: Array.isArray(capabilities.agentTools) && capabilities.agentTools.length > 0
14
+ }
15
+ };
16
+ }
@@ -0,0 +1,236 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ export const SETTINGS_SCHEMA_VERSION = 1;
5
+
6
+ export const DEFAULT_LIVEDESK_SETTINGS = Object.freeze({
7
+ settingsSchemaVersion: SETTINGS_SCHEMA_VERSION,
8
+ connection: {
9
+ usePinToAddDevices: true,
10
+ pinValidityMinutes: 30,
11
+ rotatePinAfterPairing: true,
12
+ allowNewDevices: true,
13
+ approveNewDevices: true,
14
+ startWithComputer: true,
15
+ keepRunningInTray: true,
16
+ showConnectionNotifications: true
17
+ },
18
+ security: {
19
+ accessMode: 'trusted-only',
20
+ requireEncryptedConnections: true,
21
+ allowInternetConnections: false,
22
+ allowLanConnections: true,
23
+ allowUnencryptedLanFallback: false,
24
+ requireNewDeviceApproval: true,
25
+ requireAdministratorForSecurityChanges: true,
26
+ visibleControlIndicator: true,
27
+ lockOnControlEnd: true,
28
+ disconnectIdleControlSessions: true,
29
+ idleControlMinutes: 30,
30
+ keepSessionHistory: true,
31
+ sessionHistoryDays: 30
32
+ },
33
+ control: {
34
+ allowKeyboardMouse: true,
35
+ allowSystemShortcuts: true,
36
+ allowClipboardText: true,
37
+ allowRemoteRestart: false,
38
+ reconnectAfterRemoteRestart: true,
39
+ allowSwitchingMonitors: true,
40
+ showConnectionToolbar: true,
41
+ showRemoteCursor: true,
42
+ openControlOnDoubleClick: true,
43
+ startRemoteAudioWithControl: false,
44
+ fitRemoteScreen: true,
45
+ rememberLastMonitor: true,
46
+ keepControlReadyBetweenPages: true
47
+ },
48
+ wall: {
49
+ performanceMode: 'auto',
50
+ autoStart: true,
51
+ connectedOnly: false,
52
+ keepEmptySlots: true,
53
+ showDeviceStatus: true,
54
+ showPerformanceDetails: false,
55
+ pauseHiddenTiles: true,
56
+ reduceWhenHidden: true,
57
+ autoAdjustTileQuality: true,
58
+ rememberDevicePositions: true
59
+ },
60
+ filesAudio: {
61
+ allowFileTransfer: true,
62
+ allowFolderSync: false,
63
+ askBeforeReceivingFiles: true,
64
+ openReceivedFolder: false,
65
+ notifyTransferComplete: true,
66
+ allowOverwrite: false,
67
+ defaultReceiveFolder: 'Desktop/LiveDeskFiles',
68
+ maxFileSizeBytes: 1024 * 1024 * 1024,
69
+ allowRemoteAudio: true,
70
+ startAudioMuted: false,
71
+ rememberVolume: true,
72
+ automaticallyRecoverAudio: true,
73
+ showAudioTroubleshooting: false
74
+ },
75
+ agent: {
76
+ enabled: false,
77
+ defaultPermissionMode: 'safe-auto',
78
+ askBeforeDestructive: true,
79
+ allowProcessManagement: true,
80
+ allowServiceManagement: true,
81
+ allowFileChanges: true,
82
+ allowCommandExecution: false,
83
+ allowSoftwareInstallation: false,
84
+ allowPowerActions: false,
85
+ keepAuditHistory: true,
86
+ timeoutMinutes: 10
87
+ },
88
+ advanced: {
89
+ wallFrameMode: 'auto',
90
+ wallFps: 20,
91
+ wallMaxWidth: 960,
92
+ wallMaxHeight: 540,
93
+ wallQuality: 55,
94
+ controlFrameMode: 'mode3-h264-hw',
95
+ controlFps: 30,
96
+ controlMaxWidth: 1920,
97
+ controlMaxHeight: 1080,
98
+ controlQuality: 60,
99
+ transport: 'auto',
100
+ allowPlainLanFallback: false,
101
+ verboseLogs: false,
102
+ frameStatistics: false
103
+ }
104
+ });
105
+
106
+ const ENUMS = {
107
+ accessMode: new Set(['trusted-only', 'ask-every-time', 'view-only', 'block-remote-access']),
108
+ performanceMode: new Set(['auto', 'responsive', 'quality', 'custom']),
109
+ permissionMode: new Set(['ask', 'safe-auto', 'full-access', 'custom']),
110
+ wallFrameMode: new Set(['auto', 'mode2-lzo', 'mode3-h264-hw', 'mode4-h264-atlas']),
111
+ controlFrameMode: new Set(['mode3-h264-hw', 'mode5-lzo-delta']),
112
+ transport: new Set(['auto', 'encrypted', 'plain-lan'])
113
+ };
114
+
115
+ function booleanValue(value, fallback) {
116
+ return typeof value === 'boolean' ? value : fallback;
117
+ }
118
+
119
+ function numberValue(value, min, max, fallback) {
120
+ const number = Number(value);
121
+ return Number.isFinite(number) ? Math.max(min, Math.min(max, Math.round(number))) : fallback;
122
+ }
123
+
124
+ function enumValue(value, allowed, fallback) {
125
+ const normalized = String(value ?? fallback).trim().toLowerCase();
126
+ return allowed.has(normalized) ? normalized : fallback;
127
+ }
128
+
129
+ function stringValue(value, fallback, maxLength = 600) {
130
+ return typeof value === 'string' ? value.replace(/[\0\r\n]/g, ' ').trim().slice(0, maxLength) : fallback;
131
+ }
132
+
133
+ function normalizeSection(source, defaults, rules = {}) {
134
+ const input = source && typeof source === 'object' && !Array.isArray(source) ? source : {};
135
+ const result = {};
136
+ for (const [key, fallback] of Object.entries(defaults)) {
137
+ const rule = rules[key];
138
+ if (rule?.type === 'boolean') result[key] = booleanValue(input[key], fallback);
139
+ else if (rule?.type === 'number') result[key] = numberValue(input[key], rule.min, rule.max, fallback);
140
+ else if (rule?.type === 'enum') result[key] = enumValue(input[key], rule.values, fallback);
141
+ else if (rule?.type === 'string') result[key] = stringValue(input[key], fallback, rule.maxLength);
142
+ else result[key] = input[key] === undefined ? fallback : fallback;
143
+ }
144
+ return result;
145
+ }
146
+
147
+ const bools = keys => Object.fromEntries(keys.map(key => [key, { type: 'boolean' }]));
148
+ const numbers = (entries) => Object.fromEntries(entries.map(([key, min, max]) => [key, { type: 'number', min, max }]));
149
+
150
+ const RULES = {
151
+ connection: {
152
+ ...bools(['usePinToAddDevices', 'rotatePinAfterPairing', 'allowNewDevices', 'approveNewDevices', 'startWithComputer', 'keepRunningInTray', 'showConnectionNotifications']),
153
+ ...numbers([['pinValidityMinutes', 10, 1440]])
154
+ },
155
+ security: {
156
+ accessMode: { type: 'enum', values: ENUMS.accessMode },
157
+ ...bools(['requireEncryptedConnections', 'allowInternetConnections', 'allowLanConnections', 'allowUnencryptedLanFallback', 'requireNewDeviceApproval', 'requireAdministratorForSecurityChanges', 'visibleControlIndicator', 'lockOnControlEnd', 'disconnectIdleControlSessions', 'keepSessionHistory']),
158
+ ...numbers([['idleControlMinutes', 5, 1440], ['sessionHistoryDays', 7, 365]])
159
+ },
160
+ control: {
161
+ ...bools(['allowKeyboardMouse', 'allowSystemShortcuts', 'allowClipboardText', 'allowRemoteRestart', 'reconnectAfterRemoteRestart', 'allowSwitchingMonitors', 'showConnectionToolbar', 'showRemoteCursor', 'openControlOnDoubleClick', 'startRemoteAudioWithControl', 'fitRemoteScreen', 'rememberLastMonitor', 'keepControlReadyBetweenPages'])
162
+ },
163
+ wall: {
164
+ performanceMode: { type: 'enum', values: ENUMS.performanceMode },
165
+ ...bools(['autoStart', 'connectedOnly', 'keepEmptySlots', 'showDeviceStatus', 'showPerformanceDetails', 'pauseHiddenTiles', 'reduceWhenHidden', 'autoAdjustTileQuality', 'rememberDevicePositions'])
166
+ },
167
+ filesAudio: {
168
+ ...bools(['allowFileTransfer', 'allowFolderSync', 'askBeforeReceivingFiles', 'openReceivedFolder', 'notifyTransferComplete', 'allowOverwrite', 'allowRemoteAudio', 'startAudioMuted', 'rememberVolume', 'automaticallyRecoverAudio', 'showAudioTroubleshooting']),
169
+ defaultReceiveFolder: { type: 'string', maxLength: 600 },
170
+ maxFileSizeBytes: { type: 'number', min: 1, max: Number.MAX_SAFE_INTEGER }
171
+ },
172
+ agent: {
173
+ defaultPermissionMode: { type: 'enum', values: ENUMS.permissionMode },
174
+ ...bools(['enabled', 'askBeforeDestructive', 'allowProcessManagement', 'allowServiceManagement', 'allowFileChanges', 'allowCommandExecution', 'allowSoftwareInstallation', 'allowPowerActions', 'keepAuditHistory']),
175
+ timeoutMinutes: { type: 'number', min: 1, max: 120 }
176
+ },
177
+ advanced: {
178
+ wallFrameMode: { type: 'enum', values: ENUMS.wallFrameMode },
179
+ controlFrameMode: { type: 'enum', values: ENUMS.controlFrameMode },
180
+ transport: { type: 'enum', values: ENUMS.transport },
181
+ ...bools(['allowPlainLanFallback', 'verboseLogs', 'frameStatistics']),
182
+ ...numbers([['wallFps', 1, 60], ['wallMaxWidth', 320, 3840], ['wallMaxHeight', 180, 2160], ['wallQuality', 20, 95], ['controlFps', 20, 60], ['controlMaxWidth', 640, 3840], ['controlMaxHeight', 360, 2160], ['controlQuality', 20, 95]])
183
+ }
184
+ };
185
+
186
+ export function normalizeLiveDeskSettings(value = {}) {
187
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
188
+ const settings = {
189
+ settingsSchemaVersion: SETTINGS_SCHEMA_VERSION,
190
+ connection: normalizeSection(source.connection, DEFAULT_LIVEDESK_SETTINGS.connection, RULES.connection),
191
+ security: normalizeSection(source.security, DEFAULT_LIVEDESK_SETTINGS.security, RULES.security),
192
+ control: normalizeSection(source.control, DEFAULT_LIVEDESK_SETTINGS.control, RULES.control),
193
+ wall: normalizeSection(source.wall, DEFAULT_LIVEDESK_SETTINGS.wall, RULES.wall),
194
+ filesAudio: normalizeSection(source.filesAudio, DEFAULT_LIVEDESK_SETTINGS.filesAudio, RULES.filesAudio),
195
+ agent: normalizeSection(source.agent, DEFAULT_LIVEDESK_SETTINGS.agent, RULES.agent),
196
+ advanced: normalizeSection(source.advanced, DEFAULT_LIVEDESK_SETTINGS.advanced, RULES.advanced)
197
+ };
198
+ // Security invariants are enforced at the authority boundary, not just in
199
+ // the browser. Internet access can never use an unencrypted transport.
200
+ if (settings.security.requireEncryptedConnections) {
201
+ settings.security.allowUnencryptedLanFallback = false;
202
+ }
203
+ if (!settings.security.allowInternetConnections) {
204
+ settings.advanced.transport = settings.advanced.transport === 'encrypted' ? 'encrypted' : 'auto';
205
+ }
206
+ if (settings.agent.defaultPermissionMode === 'safe-auto') {
207
+ settings.agent.askBeforeDestructive = true;
208
+ }
209
+ return settings;
210
+ }
211
+
212
+ export function defaultSettingsPath(dataDir = undefined) {
213
+ return path.join(dataDir || path.join(os.homedir(), '.livedesk'), 'settings.json');
214
+ }
215
+
216
+ export function publicSettings(settings) {
217
+ return normalizeLiveDeskSettings(settings);
218
+ }
219
+
220
+ export function effectiveDevicePolicy(settings = DEFAULT_LIVEDESK_SETTINGS) {
221
+ const normalized = normalizeLiveDeskSettings(settings);
222
+ return {
223
+ settingsSchemaVersion: normalized.settingsSchemaVersion,
224
+ policyRevision: Number(settings?.revision || 0),
225
+ accessMode: normalized.security.accessMode,
226
+ allowInternetConnections: normalized.security.allowInternetConnections,
227
+ allowLanConnections: normalized.security.allowLanConnections,
228
+ requireEncryptedConnections: normalized.security.requireEncryptedConnections,
229
+ allowUnencryptedLanFallback: normalized.security.allowUnencryptedLanFallback,
230
+ allowControl: normalized.security.accessMode !== 'block-remote-access' && normalized.control.allowKeyboardMouse,
231
+ allowClipboardText: normalized.security.accessMode !== 'block-remote-access' && normalized.control.allowClipboardText,
232
+ allowFileTransfer: normalized.security.accessMode !== 'block-remote-access' && normalized.filesAudio.allowFileTransfer,
233
+ allowRemoteAudio: normalized.security.accessMode !== 'block-remote-access' && normalized.filesAudio.allowRemoteAudio,
234
+ allowAgent: normalized.security.accessMode !== 'block-remote-access' && normalized.agent.enabled
235
+ };
236
+ }