livedesk 0.1.227 → 0.1.228

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,9 @@ 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 { buildEffectiveDevicePolicy, effectiveDevicePolicy } from './settings/settings-schema.js';
25
27
 
26
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
27
29
  const webDistCandidates = [
@@ -140,9 +142,25 @@ function activeDeviceLimit() {
140
142
  return plan === 'pro' ? Number.POSITIVE_INFINITY : plan === 'ltd' ? PLUS_DEVICE_LIMIT : FREE_DEVICE_LIMIT;
141
143
  }
142
144
 
143
- function hasHubFeatureAccess() {
144
- return connectedDeviceCount <= activeDeviceLimit();
145
- }
145
+ function hasHubFeatureAccess() {
146
+ return connectedDeviceCount <= activeDeviceLimit();
147
+ }
148
+
149
+ function hasHubFeatureAccessForRequest(req) {
150
+ const limit = activeDeviceLimit();
151
+ if (!Number.isFinite(limit)) return true;
152
+ const ids = new Set();
153
+ const parameterId = String(req?.params?.deviceId || '').trim();
154
+ if (parameterId) ids.add(parameterId);
155
+ const bodyIds = Array.isArray(req?.body?.deviceIds) ? req.body.deviceIds : [];
156
+ for (const value of bodyIds) {
157
+ const id = String(value || '').trim();
158
+ if (id) ids.add(id);
159
+ }
160
+ // A user may operate on any selected device, but a single request may not
161
+ // fan out to more devices than the active plan includes.
162
+ return ids.size <= limit;
163
+ }
146
164
 
147
165
  function licenseSnapshot() {
148
166
  const plan = activeLicensePlan();
@@ -201,15 +219,26 @@ async function syncVerifiedLicense(accessToken) {
201
219
  return licenseSnapshot();
202
220
  }
203
221
 
204
- function requireHubFeatureAccess(_req, res, next) {
205
- if (hasHubFeatureAccess()) {
206
- next();
222
+ function requireHubFeatureAccess(_req, res, next) {
223
+ if (hasHubFeatureAccessForRequest(_req)) {
224
+ const policy = effectiveDevicePolicy(liveDeskSettingsStore.getCached());
225
+ if (policy.accessMode === 'block-remote-access') {
226
+ res.status(403).json({ ok: false, error: 'remote-access-blocked-by-settings' });
227
+ return;
228
+ }
229
+ next();
207
230
  return;
208
231
  }
209
232
  res.status(402).json({ ok: false, error: 'livedesk-plan-device-limit', license: licenseSnapshot() });
210
233
  }
211
234
 
212
- const remoteHub = createRemoteHub({
235
+ const agentDataDir = process.env.LIVEDESK_DATA_DIR || undefined;
236
+ const liveDeskSettingsStore = new LiveDeskSettingsStore({ dataDir: agentDataDir });
237
+ void liveDeskSettingsStore.getRecord().catch(error => {
238
+ console.warn(`[LiveDesk Hub] settings load failed: ${error instanceof Error ? error.message : String(error)}`);
239
+ });
240
+
241
+ const remoteHub = createRemoteHub({
213
242
  managerPackage: '@livedesk/hub',
214
243
  managerVersion: packageInfo.version,
215
244
  env: {
@@ -218,12 +247,12 @@ const remoteHub = createRemoteHub({
218
247
  MINDEXEC_MANAGER_VERSION: packageInfo.version
219
248
  },
220
249
  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;
250
+ logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`),
251
+ emitEvent: handleRemoteHubEvent,
252
+ emitFrame: broadcastRemoteBinaryFrame,
253
+ emitAudio: broadcastRemoteBinaryAudio,
254
+ getEffectiveDevicePolicy: ({ deviceId, capabilities }) => buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities })
255
+ });
227
256
  const agentMcpSessions = new Map();
228
257
  const agentMcpRunBatches = new Map();
229
258
  const agentMcpRunCleanupTimers = new Map();
@@ -1218,12 +1247,8 @@ function updateFrameSubscription(ws, payload = {}) {
1218
1247
  }
1219
1248
  }
1220
1249
 
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 || [])];
1250
+ function startFrameSubscriptionLive(ws, reason = 'subscribe', onlyDeviceId = '', overrideLiveOptions = null) {
1251
+ const subscribedIds = [...(ws.liveDeskDeviceIds || [])];
1227
1252
  if (!ws.liveDeskAutoStart || subscribedIds.length === 0) {
1228
1253
  return;
1229
1254
  }
@@ -1439,13 +1464,10 @@ function buildRemoteAudioBinaryPacket(audioEvent) {
1439
1464
  return Buffer.concat([header, metaBuffer, payload], 4 + metaBuffer.length + payload.length);
1440
1465
  }
1441
1466
 
1442
- function broadcastRemoteBinaryFrame(frameEvent) {
1467
+ function broadcastRemoteBinaryFrame(frameEvent) {
1443
1468
  for (const session of atlasClients.values()) {
1444
1469
  session.ingest(frameEvent);
1445
1470
  }
1446
- if (!hasHubFeatureAccess()) {
1447
- return;
1448
- }
1449
1471
  const deviceId = String(frameEvent?.deviceId || frameEvent?.frame?.deviceId || '').trim();
1450
1472
  if (!deviceId || frameClients.size === 0) {
1451
1473
  return;
@@ -1490,10 +1512,7 @@ function broadcastRemoteBinaryFrame(frameEvent) {
1490
1512
  }
1491
1513
  }
1492
1514
 
1493
- function broadcastRemoteBinaryAudio(audioEvent) {
1494
- if (!hasHubFeatureAccess()) {
1495
- return;
1496
- }
1515
+ function broadcastRemoteBinaryAudio(audioEvent) {
1497
1516
  const deviceId = String(audioEvent?.deviceId || audioEvent?.frame?.deviceId || '').trim();
1498
1517
  if (!deviceId || audioClients.size === 0) {
1499
1518
  return;
@@ -1517,8 +1536,8 @@ function broadcastRemoteBinaryAudio(audioEvent) {
1517
1536
  }
1518
1537
  }
1519
1538
 
1520
- function sendMode4AtlasFrame(ws, output) {
1521
- if (!hasHubFeatureAccess() || ws.readyState !== ws.OPEN || !output?.payload?.length) return;
1539
+ function sendMode4AtlasFrame(ws, output) {
1540
+ if (ws.readyState !== ws.OPEN || !output?.payload?.length) return;
1522
1541
  const metadata = output.metadata || {};
1523
1542
  const frameSeq = Number(metadata.frameSeq || 0) || 0;
1524
1543
  const isKeyFrame = metadata.isKeyFrame === true;
@@ -1697,12 +1716,83 @@ function isCapabilityEnabled(device, key) {
1697
1716
  return /^(1|true|yes|on)$/i.test(String(value || '').trim());
1698
1717
  }
1699
1718
 
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) => {
1719
+ app.get('/api/health', (_req, res) => {
1720
+ noStore(res);
1721
+ res.json({ ok: true, product: 'LiveDesk', timestamp: new Date().toISOString() });
1722
+ });
1723
+
1724
+ app.get('/api/settings', async (_req, res) => {
1725
+ noStore(res);
1726
+ try {
1727
+ const record = await liveDeskSettingsStore.getRecord();
1728
+ res.json({ ok: true, revision: record.revision, updatedAt: record.updatedAt, settings: record.settings });
1729
+ } catch (error) {
1730
+ res.status(500).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
1731
+ }
1732
+ });
1733
+
1734
+ app.patch('/api/settings', async (req, res) => {
1735
+ noStore(res);
1736
+ try {
1737
+ const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
1738
+ const revision = body.revision === undefined ? undefined : Number(body.revision);
1739
+ const patch = { ...body };
1740
+ delete patch.revision;
1741
+ const record = await liveDeskSettingsStore.update(patch, revision);
1742
+ res.json({ ok: true, revision: record.revision, updatedAt: record.updatedAt, settings: record.settings });
1743
+ } catch (error) {
1744
+ if (error instanceof SettingsConflictError) {
1745
+ res.status(409).json({ ok: false, error: error.code, revision: error.settings.revision, updatedAt: error.settings.updatedAt, settings: error.settings.settings });
1746
+ return;
1747
+ }
1748
+ res.status(error?.status || 400).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
1749
+ }
1750
+ });
1751
+
1752
+ app.get('/api/settings/capabilities', async (_req, res) => {
1753
+ noStore(res);
1754
+ const settings = await liveDeskSettingsStore.get();
1755
+ res.json({
1756
+ ok: true,
1757
+ settingsSchemaVersion: settings.settingsSchemaVersion,
1758
+ sections: ['connection', 'security', 'control', 'wall', 'filesAudio', 'agent', 'advanced'],
1759
+ policy: effectiveDevicePolicy(settings),
1760
+ externalTransport: 'encrypted-only'
1761
+ });
1762
+ });
1763
+
1764
+ app.get('/api/security/trusted-devices', (_req, res) => {
1765
+ noStore(res);
1766
+ const devices = remoteHub.listDevices({ includeDataUrl: false }).map(device => ({
1767
+ deviceId: device.deviceId,
1768
+ deviceName: device.deviceName,
1769
+ platform: device.platform,
1770
+ connected: device.connected === true,
1771
+ trusted: device.connected === true,
1772
+ firstConnectedAt: device.connectedAt || device.lastSeenAt || '',
1773
+ lastSeenAt: device.lastSeenAt || '',
1774
+ ipRange: String(device.remoteAddress || '').replace(/^::ffff:/, '').split(':')[0]
1775
+ }));
1776
+ res.json({ ok: true, devices });
1777
+ });
1778
+
1779
+ app.delete('/api/security/trusted-devices/:deviceId', (req, res) => {
1780
+ noStore(res);
1781
+ const result = remoteHub.disconnectDevice(req.params.deviceId, 'trusted-device-revoked');
1782
+ res.json({ ok: result === true, revoked: result === true, deviceId: req.params.deviceId });
1783
+ });
1784
+
1785
+ app.post('/api/security/revoke-all', (_req, res) => {
1786
+ noStore(res);
1787
+ const devices = remoteHub.listDevices({ includeDataUrl: false });
1788
+ let revoked = 0;
1789
+ for (const device of devices) {
1790
+ if (device.connected && remoteHub.disconnectDevice(device.deviceId, 'trusted-devices-revoked')) revoked += 1;
1791
+ }
1792
+ res.json({ ok: true, revoked });
1793
+ });
1794
+
1795
+ app.get('/api/settings/agent', async (_req, res) => {
1706
1796
  noStore(res);
1707
1797
  try {
1708
1798
  res.json({ ok: true, settings: await agentManager.getSettings() });
@@ -1798,10 +1888,6 @@ app.post('/api/internal/agent-mcp/tool', async (req, res) => {
1798
1888
  res.status(401).json({ ok: false, error: 'agent-mcp-session-invalid' });
1799
1889
  return;
1800
1890
  }
1801
- if (!hasHubFeatureAccess()) {
1802
- res.status(402).json({ ok: false, error: 'livedesk-plan-device-limit' });
1803
- return;
1804
- }
1805
1891
  try {
1806
1892
  const name = String(req.body?.name || '').slice(0, 120);
1807
1893
  const result = await dispatchAgentMcpTool(session, name, req.body?.arguments || {});
@@ -2605,12 +2691,8 @@ audioWss.on('connection', (ws, req) => {
2605
2691
  inputWss.on('connection', ws => {
2606
2692
  inputClients.add(ws);
2607
2693
  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;
2694
+ ws.on('message', data => {
2695
+ let payload;
2614
2696
  try {
2615
2697
  payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
2616
2698
  } 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
+ }