lightman-agent 1.0.18 → 1.0.21
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/agent.config.json +22 -23
- package/agent.config.template.json +30 -31
- package/bin/cms-agent.js +269 -248
- package/package.json +1 -1
- package/public/assets/index-CcBNCz6h.css +1 -1
- package/public/assets/index-D9QHMG8k.js +1 -1
- package/public/assets/index-H-8HDl46.js +1 -1
- package/public/assets/index-YodeiCia.css +1 -1
- package/public/assets/index-legacy-DWtNM8y7.js +41 -41
- package/public/assets/polyfills-legacy-DyVYWHbW.js +4 -4
- package/scripts/guardian.ps1 +50 -124
- package/scripts/install-windows.ps1 +60 -116
- package/scripts/lightman-agent.logrotate +12 -12
- package/scripts/lightman-agent.service +38 -38
- package/scripts/reinstall-windows.ps1 +26 -26
- package/scripts/restore-desktop.ps1 +32 -32
- package/scripts/setup.ps1 +17 -22
- package/scripts/sync-display.mjs +20 -20
- package/scripts/uninstall-windows.ps1 +54 -54
- package/src/commands/display.ts +177 -177
- package/src/commands/kiosk.ts +113 -113
- package/src/commands/maintenance.ts +106 -106
- package/src/commands/network.ts +129 -129
- package/src/commands/power.ts +163 -163
- package/src/commands/rpi.ts +45 -45
- package/src/commands/screenshot.ts +166 -166
- package/src/commands/serial.ts +17 -17
- package/src/commands/update.ts +124 -124
- package/src/index.ts +173 -90
- package/src/lib/config.ts +2 -3
- package/src/lib/identity.ts +40 -40
- package/src/lib/logger.ts +137 -137
- package/src/lib/platform.ts +10 -10
- package/src/lib/rpi.ts +180 -180
- package/src/lib/screenMap.ts +135 -0
- package/src/lib/screens.ts +128 -128
- package/src/lib/types.ts +176 -177
- package/src/services/commands.ts +107 -107
- package/src/services/health.ts +161 -161
- package/src/services/localEvents.ts +60 -60
- package/src/services/logForwarder.ts +72 -72
- package/src/services/multiScreenKiosk.ts +116 -83
- package/src/services/oscBridge.ts +186 -186
- package/src/services/powerScheduler.ts +260 -260
- package/src/services/provisioning.ts +120 -122
- package/src/services/serialBridge.ts +230 -230
- package/src/services/serviceLauncher.ts +183 -183
- package/src/services/staticServer.ts +226 -226
- package/src/services/updater.ts +249 -249
- package/src/services/watchdog.ts +310 -310
- package/src/services/websocket.ts +152 -152
- package/tsconfig.json +28 -28
package/scripts/setup.ps1
CHANGED
|
@@ -15,14 +15,11 @@ param(
|
|
|
15
15
|
[Parameter(Mandatory=$true)]
|
|
16
16
|
[string]$Server,
|
|
17
17
|
|
|
18
|
-
[Parameter(Mandatory=$false)]
|
|
19
|
-
[string]$Timezone = "Asia/Kolkata",
|
|
20
|
-
|
|
21
|
-
[Parameter(Mandatory=$false)]
|
|
22
|
-
[
|
|
23
|
-
|
|
24
|
-
[Parameter(Mandatory=$false)]
|
|
25
|
-
[string]$InstallDir = $null,
|
|
18
|
+
[Parameter(Mandatory=$false)]
|
|
19
|
+
[string]$Timezone = "Asia/Kolkata",
|
|
20
|
+
|
|
21
|
+
[Parameter(Mandatory=$false)]
|
|
22
|
+
[string]$InstallDir = $null,
|
|
26
23
|
|
|
27
24
|
[Parameter(Mandatory=$false)]
|
|
28
25
|
[switch]$ShellMode = $false
|
|
@@ -39,12 +36,11 @@ if (-not $InstallDir) {
|
|
|
39
36
|
|
|
40
37
|
Write-Host ""
|
|
41
38
|
Write-Host "=== LIGHTMAN Agent - Device Setup ===" -ForegroundColor Cyan
|
|
42
|
-
Write-Host " Slug: $Slug"
|
|
43
|
-
Write-Host " Server: $Server"
|
|
44
|
-
Write-Host " Install dir: $InstallDir"
|
|
45
|
-
Write-Host " Timezone: $Timezone"
|
|
46
|
-
Write-Host "
|
|
47
|
-
Write-Host ""
|
|
39
|
+
Write-Host " Slug: $Slug"
|
|
40
|
+
Write-Host " Server: $Server"
|
|
41
|
+
Write-Host " Install dir: $InstallDir"
|
|
42
|
+
Write-Host " Timezone: $Timezone"
|
|
43
|
+
Write-Host ""
|
|
48
44
|
|
|
49
45
|
# 1. Clear cached identity (CRITICAL - prevents old device credentials leaking)
|
|
50
46
|
$IdentityFile = Join-Path $InstallDir ".lightman-identity.json"
|
|
@@ -87,14 +83,13 @@ $Template = Get-Content $TemplatePath -Raw
|
|
|
87
83
|
$BrowserEscaped = $BrowserPath -replace '\\', '\\'
|
|
88
84
|
$ChromeDirEscaped = $ChromeDataDir -replace '\\', '\\'
|
|
89
85
|
|
|
90
|
-
$Config = $Template `
|
|
91
|
-
-replace '__SERVER_URL__', $Server `
|
|
92
|
-
-replace '__DEVICE_SLUG__', $Slug `
|
|
93
|
-
-replace '__KIOSK_URL__', $KioskUrl `
|
|
94
|
-
-replace '__BROWSER_PATH__', $BrowserEscaped `
|
|
95
|
-
-replace '__CHROME_DATA_DIR__', $ChromeDirEscaped `
|
|
96
|
-
-replace '
|
|
97
|
-
-replace 'Asia/Kolkata', $Timezone
|
|
86
|
+
$Config = $Template `
|
|
87
|
+
-replace '__SERVER_URL__', $Server `
|
|
88
|
+
-replace '__DEVICE_SLUG__', $Slug `
|
|
89
|
+
-replace '__KIOSK_URL__', $KioskUrl `
|
|
90
|
+
-replace '__BROWSER_PATH__', $BrowserEscaped `
|
|
91
|
+
-replace '__CHROME_DATA_DIR__', $ChromeDirEscaped `
|
|
92
|
+
-replace 'Asia/Kolkata', $Timezone
|
|
98
93
|
|
|
99
94
|
# 6. Inject shellMode into kiosk config if requested
|
|
100
95
|
if ($ShellMode) {
|
package/scripts/sync-display.mjs
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import { cpSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
3
|
-
|
|
4
|
-
const rootDir = resolve(import.meta.dirname, '..');
|
|
5
|
-
const displayDistDir = resolve(rootDir, '..', 'display', 'dist');
|
|
6
|
-
const agentPublicDir = resolve(rootDir, 'public');
|
|
7
|
-
|
|
8
|
-
if (!existsSync(displayDistDir)) {
|
|
9
|
-
console.error(`Display build not found: ${displayDistDir}`);
|
|
10
|
-
process.exit(1);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
mkdirSync(agentPublicDir, { recursive: true });
|
|
14
|
-
|
|
15
|
-
cpSync(displayDistDir, agentPublicDir, {
|
|
16
|
-
recursive: true,
|
|
17
|
-
force: true,
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
console.log('Display synced to agent/public/');
|
|
1
|
+
import { cpSync, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const rootDir = resolve(import.meta.dirname, '..');
|
|
5
|
+
const displayDistDir = resolve(rootDir, '..', 'display', 'dist');
|
|
6
|
+
const agentPublicDir = resolve(rootDir, 'public');
|
|
7
|
+
|
|
8
|
+
if (!existsSync(displayDistDir)) {
|
|
9
|
+
console.error(`Display build not found: ${displayDistDir}`);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
mkdirSync(agentPublicDir, { recursive: true });
|
|
14
|
+
|
|
15
|
+
cpSync(displayDistDir, agentPublicDir, {
|
|
16
|
+
recursive: true,
|
|
17
|
+
force: true,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
console.log('Display synced to agent/public/');
|
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
# LIGHTMAN Agent - Windows Uninstaller
|
|
2
|
-
# Removes everything: service, tasks, processes, files, shell.
|
|
3
|
-
#Requires -RunAsAdministrator
|
|
4
|
-
|
|
5
|
-
$ErrorActionPreference = "Continue"
|
|
6
|
-
$NssmExe = "C:\ProgramData\Lightman\nssm\nssm.exe"
|
|
7
|
-
$ServiceName = "LightmanAgent"
|
|
8
|
-
|
|
9
|
-
Write-Host ""
|
|
10
|
-
Write-Host "=== LIGHTMAN Agent - Uninstaller ===" -ForegroundColor Cyan
|
|
11
|
-
|
|
12
|
-
# 1. Service
|
|
13
|
-
Write-Host "[1/6] Removing service..." -ForegroundColor Yellow
|
|
14
|
-
if (Test-Path $NssmExe) { & $NssmExe stop $ServiceName 2>$null; & $NssmExe remove $ServiceName confirm 2>$null }
|
|
15
|
-
foreach ($sn in @($ServiceName,"lightmanagent.exe")) { sc.exe stop $sn 2>$null; sc.exe delete $sn 2>$null }
|
|
16
|
-
$s = Get-Service -DisplayName "LIGHTMAN*" -ErrorAction SilentlyContinue
|
|
17
|
-
if ($s) { Stop-Service $s.Name -Force -ErrorAction SilentlyContinue; sc.exe delete $s.Name 2>$null }
|
|
18
|
-
|
|
19
|
-
# 2. Tasks
|
|
20
|
-
Write-Host "[2/6] Removing tasks..." -ForegroundColor Yellow
|
|
21
|
-
foreach ($tn in @("LIGHTMAN Agent","LIGHTMAN Kiosk Browser","LIGHTMAN Guardian")) {
|
|
22
|
-
$t = Get-ScheduledTask -TaskName $tn -ErrorAction SilentlyContinue
|
|
23
|
-
if ($t) { Stop-ScheduledTask -TaskName $tn -ErrorAction SilentlyContinue; Unregister-ScheduledTask -TaskName $tn -Confirm:$false -ErrorAction SilentlyContinue }
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
# 3. Processes
|
|
27
|
-
Write-Host "[3/6] Killing processes..." -ForegroundColor Yellow
|
|
28
|
-
Get-Process -Name "node" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
|
29
|
-
Get-Process -Name "chrome" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
|
30
|
-
|
|
31
|
-
# 4. Firewall
|
|
32
|
-
Write-Host "[4/6] Removing firewall rule..." -ForegroundColor Yellow
|
|
33
|
-
Remove-NetFirewallRule -DisplayName "LIGHTMAN Agent WebSocket" -ErrorAction SilentlyContinue
|
|
34
|
-
|
|
35
|
-
# 5. Shell
|
|
36
|
-
Write-Host "[5/6] Restoring shell..." -ForegroundColor Yellow
|
|
37
|
-
$HKLMPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
|
|
38
|
-
$shell = (Get-ItemProperty -Path $HKLMPath -Name "Shell" -ErrorAction SilentlyContinue).Shell
|
|
39
|
-
if ($shell -and $shell -like "*lightman*") {
|
|
40
|
-
$orig = (Get-ItemProperty -Path $HKLMPath -Name "Shell_Original" -ErrorAction SilentlyContinue).Shell_Original
|
|
41
|
-
Set-ItemProperty -Path $HKLMPath -Name "Shell" -Value $(if ($orig) { $orig } else { "explorer.exe" })
|
|
42
|
-
Remove-ItemProperty -Path "HKCU:\Software\Microsoft\Windows NT\CurrentVersion\Winlogon" -Name "Shell" -ErrorAction SilentlyContinue
|
|
43
|
-
Write-Host " Shell restored"
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
# 6. Files
|
|
47
|
-
Write-Host "[6/6] Removing files..." -ForegroundColor Yellow
|
|
48
|
-
Remove-Item "C:\Program Files\Lightman" -Recurse -Force -ErrorAction SilentlyContinue
|
|
49
|
-
$choice = Read-Host "Remove all data (logs, chrome cache, nssm)? [y/N]"
|
|
50
|
-
if ($choice -eq 'y') { Remove-Item "C:\ProgramData\Lightman" -Recurse -Force -ErrorAction SilentlyContinue }
|
|
51
|
-
|
|
52
|
-
Write-Host ""
|
|
53
|
-
Write-Host "=== Done. Reboot: Restart-Computer ===" -ForegroundColor Green
|
|
54
|
-
Write-Host ""
|
|
1
|
+
# LIGHTMAN Agent - Windows Uninstaller
|
|
2
|
+
# Removes everything: service, tasks, processes, files, shell.
|
|
3
|
+
#Requires -RunAsAdministrator
|
|
4
|
+
|
|
5
|
+
$ErrorActionPreference = "Continue"
|
|
6
|
+
$NssmExe = "C:\ProgramData\Lightman\nssm\nssm.exe"
|
|
7
|
+
$ServiceName = "LightmanAgent"
|
|
8
|
+
|
|
9
|
+
Write-Host ""
|
|
10
|
+
Write-Host "=== LIGHTMAN Agent - Uninstaller ===" -ForegroundColor Cyan
|
|
11
|
+
|
|
12
|
+
# 1. Service
|
|
13
|
+
Write-Host "[1/6] Removing service..." -ForegroundColor Yellow
|
|
14
|
+
if (Test-Path $NssmExe) { & $NssmExe stop $ServiceName 2>$null; & $NssmExe remove $ServiceName confirm 2>$null }
|
|
15
|
+
foreach ($sn in @($ServiceName,"lightmanagent.exe")) { sc.exe stop $sn 2>$null; sc.exe delete $sn 2>$null }
|
|
16
|
+
$s = Get-Service -DisplayName "LIGHTMAN*" -ErrorAction SilentlyContinue
|
|
17
|
+
if ($s) { Stop-Service $s.Name -Force -ErrorAction SilentlyContinue; sc.exe delete $s.Name 2>$null }
|
|
18
|
+
|
|
19
|
+
# 2. Tasks
|
|
20
|
+
Write-Host "[2/6] Removing tasks..." -ForegroundColor Yellow
|
|
21
|
+
foreach ($tn in @("LIGHTMAN Agent","LIGHTMAN Kiosk Browser","LIGHTMAN Guardian")) {
|
|
22
|
+
$t = Get-ScheduledTask -TaskName $tn -ErrorAction SilentlyContinue
|
|
23
|
+
if ($t) { Stop-ScheduledTask -TaskName $tn -ErrorAction SilentlyContinue; Unregister-ScheduledTask -TaskName $tn -Confirm:$false -ErrorAction SilentlyContinue }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# 3. Processes
|
|
27
|
+
Write-Host "[3/6] Killing processes..." -ForegroundColor Yellow
|
|
28
|
+
Get-Process -Name "node" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
|
29
|
+
Get-Process -Name "chrome" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
|
30
|
+
|
|
31
|
+
# 4. Firewall
|
|
32
|
+
Write-Host "[4/6] Removing firewall rule..." -ForegroundColor Yellow
|
|
33
|
+
Remove-NetFirewallRule -DisplayName "LIGHTMAN Agent WebSocket" -ErrorAction SilentlyContinue
|
|
34
|
+
|
|
35
|
+
# 5. Shell
|
|
36
|
+
Write-Host "[5/6] Restoring shell..." -ForegroundColor Yellow
|
|
37
|
+
$HKLMPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
|
|
38
|
+
$shell = (Get-ItemProperty -Path $HKLMPath -Name "Shell" -ErrorAction SilentlyContinue).Shell
|
|
39
|
+
if ($shell -and $shell -like "*lightman*") {
|
|
40
|
+
$orig = (Get-ItemProperty -Path $HKLMPath -Name "Shell_Original" -ErrorAction SilentlyContinue).Shell_Original
|
|
41
|
+
Set-ItemProperty -Path $HKLMPath -Name "Shell" -Value $(if ($orig) { $orig } else { "explorer.exe" })
|
|
42
|
+
Remove-ItemProperty -Path "HKCU:\Software\Microsoft\Windows NT\CurrentVersion\Winlogon" -Name "Shell" -ErrorAction SilentlyContinue
|
|
43
|
+
Write-Host " Shell restored"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# 6. Files
|
|
47
|
+
Write-Host "[6/6] Removing files..." -ForegroundColor Yellow
|
|
48
|
+
Remove-Item "C:\Program Files\Lightman" -Recurse -Force -ErrorAction SilentlyContinue
|
|
49
|
+
$choice = Read-Host "Remove all data (logs, chrome cache, nssm)? [y/N]"
|
|
50
|
+
if ($choice -eq 'y') { Remove-Item "C:\ProgramData\Lightman" -Recurse -Force -ErrorAction SilentlyContinue }
|
|
51
|
+
|
|
52
|
+
Write-Host ""
|
|
53
|
+
Write-Host "=== Done. Reboot: Restart-Computer ===" -ForegroundColor Green
|
|
54
|
+
Write-Host ""
|
package/src/commands/display.ts
CHANGED
|
@@ -1,177 +1,177 @@
|
|
|
1
|
-
import { execFile, spawn } from 'child_process';
|
|
2
|
-
import { z } from 'zod';
|
|
3
|
-
import type { CommandHandler } from '../lib/types.js';
|
|
4
|
-
import { getPlatform } from '../lib/platform.js';
|
|
5
|
-
import type { Logger } from '../lib/logger.js';
|
|
6
|
-
|
|
7
|
-
// --- Zod Schemas ---
|
|
8
|
-
const BrightnessArgsSchema = z.object({
|
|
9
|
-
level: z.number().int().min(0).max(100),
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
const PowerArgsSchema = z.object({
|
|
13
|
-
state: z.enum(['on', 'off', 'standby']),
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
const RotateArgsSchema = z.object({
|
|
17
|
-
rotation: z.enum(['normal', 'left', 'right', 'inverted']),
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
const VolumeArgsSchema = z.object({
|
|
21
|
-
level: z.number().int().min(0).max(100),
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
function execFilePromise(cmd: string, args: string[]): Promise<string> {
|
|
25
|
-
return new Promise((resolve, reject) => {
|
|
26
|
-
execFile(cmd, args, (err, stdout, stderr) => {
|
|
27
|
-
if (err) {
|
|
28
|
-
reject(new Error(stderr || err.message));
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
resolve(stdout.trim());
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Pipe data to a command via stdin using spawn (no shell).
|
|
38
|
-
*/
|
|
39
|
-
function spawnWithStdin(cmd: string, args: string[], stdinData: string): Promise<string> {
|
|
40
|
-
return new Promise((resolve, reject) => {
|
|
41
|
-
const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
42
|
-
let stdout = '';
|
|
43
|
-
let stderr = '';
|
|
44
|
-
|
|
45
|
-
child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
|
|
46
|
-
child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
|
47
|
-
|
|
48
|
-
child.on('error', (err) => reject(new Error(err.message)));
|
|
49
|
-
child.on('close', (code) => {
|
|
50
|
-
if (code !== 0) {
|
|
51
|
-
reject(new Error(stderr || `Process exited with code ${code}`));
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
resolve(stdout.trim());
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
child.stdin.write(stdinData);
|
|
58
|
-
child.stdin.end();
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Detect the primary connected display output name from xrandr.
|
|
64
|
-
*/
|
|
65
|
-
async function getConnectedDisplay(): Promise<string> {
|
|
66
|
-
const output = await execFilePromise('xrandr', []);
|
|
67
|
-
const match = output.match(/^(\S+)\s+connected/m);
|
|
68
|
-
if (!match) {
|
|
69
|
-
throw new Error('No connected display found');
|
|
70
|
-
}
|
|
71
|
-
return match[1];
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function registerDisplayCommands(
|
|
75
|
-
register: (command: string, handler: CommandHandler) => void,
|
|
76
|
-
logger: Logger
|
|
77
|
-
): void {
|
|
78
|
-
// display:brightness — set screen brightness (Linux only)
|
|
79
|
-
register('display:brightness', async (args) => {
|
|
80
|
-
if (getPlatform() !== 'linux') {
|
|
81
|
-
throw new Error('Not supported on this platform');
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const parsed = BrightnessArgsSchema.safeParse(args ?? {});
|
|
85
|
-
if (!parsed.success) {
|
|
86
|
-
throw new Error('Invalid brightness level, must be 0-100');
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const { level } = parsed.data;
|
|
90
|
-
const brightness = (level / 100).toFixed(2);
|
|
91
|
-
|
|
92
|
-
logger.info(`Setting brightness to ${level}%`);
|
|
93
|
-
|
|
94
|
-
const display = await getConnectedDisplay();
|
|
95
|
-
await execFilePromise('xrandr', ['--output', display, '--brightness', brightness]);
|
|
96
|
-
return { level };
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
// display:power — control display power via CEC (Linux only)
|
|
100
|
-
register('display:power', async (args) => {
|
|
101
|
-
if (getPlatform() !== 'linux') {
|
|
102
|
-
throw new Error('Not supported on this platform');
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const parsed = PowerArgsSchema.safeParse(args ?? {});
|
|
106
|
-
if (!parsed.success) {
|
|
107
|
-
throw new Error('Invalid state, must be on | off | standby');
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const { state } = parsed.data;
|
|
111
|
-
|
|
112
|
-
const cecCommands: Readonly<Record<string, string>> = {
|
|
113
|
-
on: 'on 0',
|
|
114
|
-
off: 'standby 0',
|
|
115
|
-
standby: 'standby 0',
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
const cecCmd = cecCommands[state];
|
|
119
|
-
|
|
120
|
-
logger.info(`Setting display power to ${state}`);
|
|
121
|
-
|
|
122
|
-
// Pipe CEC command via spawn stdin (no shell)
|
|
123
|
-
await spawnWithStdin('cec-client', ['-s', '-d', '1'], cecCmd + '\n');
|
|
124
|
-
return { state };
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
// display:rotate — rotate display output (Linux only)
|
|
128
|
-
register('display:rotate', async (args) => {
|
|
129
|
-
if (getPlatform() !== 'linux') {
|
|
130
|
-
throw new Error('Not supported on this platform');
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const parsed = RotateArgsSchema.safeParse(args ?? {});
|
|
134
|
-
if (!parsed.success) {
|
|
135
|
-
throw new Error('Invalid rotation, must be normal | left | right | inverted');
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const { rotation } = parsed.data;
|
|
139
|
-
|
|
140
|
-
logger.info(`Setting display rotation to ${rotation}`);
|
|
141
|
-
|
|
142
|
-
const display = await getConnectedDisplay();
|
|
143
|
-
await execFilePromise('xrandr', ['--output', display, '--rotate', rotation]);
|
|
144
|
-
return { rotation };
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
// display:volume — set audio volume (Linux only)
|
|
148
|
-
register('display:volume', async (args) => {
|
|
149
|
-
if (getPlatform() !== 'linux') {
|
|
150
|
-
throw new Error('Not supported on this platform');
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const parsed = VolumeArgsSchema.safeParse(args ?? {});
|
|
154
|
-
if (!parsed.success) {
|
|
155
|
-
throw new Error('Invalid volume level, must be 0-100');
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const { level } = parsed.data;
|
|
159
|
-
|
|
160
|
-
logger.info(`Setting volume to ${level}%`);
|
|
161
|
-
|
|
162
|
-
await execFilePromise('amixer', ['set', 'Master', `${level}%`]);
|
|
163
|
-
return { level };
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
// display:info — query display information (Linux with fallback)
|
|
167
|
-
register('display:info', async () => {
|
|
168
|
-
if (getPlatform() !== 'linux') {
|
|
169
|
-
throw new Error('Not supported on this platform');
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
logger.info('Querying display info');
|
|
173
|
-
|
|
174
|
-
const stdout = await execFilePromise('xrandr', ['--query']);
|
|
175
|
-
return { raw: stdout };
|
|
176
|
-
});
|
|
177
|
-
}
|
|
1
|
+
import { execFile, spawn } from 'child_process';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { CommandHandler } from '../lib/types.js';
|
|
4
|
+
import { getPlatform } from '../lib/platform.js';
|
|
5
|
+
import type { Logger } from '../lib/logger.js';
|
|
6
|
+
|
|
7
|
+
// --- Zod Schemas ---
|
|
8
|
+
const BrightnessArgsSchema = z.object({
|
|
9
|
+
level: z.number().int().min(0).max(100),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const PowerArgsSchema = z.object({
|
|
13
|
+
state: z.enum(['on', 'off', 'standby']),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const RotateArgsSchema = z.object({
|
|
17
|
+
rotation: z.enum(['normal', 'left', 'right', 'inverted']),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const VolumeArgsSchema = z.object({
|
|
21
|
+
level: z.number().int().min(0).max(100),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function execFilePromise(cmd: string, args: string[]): Promise<string> {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
execFile(cmd, args, (err, stdout, stderr) => {
|
|
27
|
+
if (err) {
|
|
28
|
+
reject(new Error(stderr || err.message));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
resolve(stdout.trim());
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Pipe data to a command via stdin using spawn (no shell).
|
|
38
|
+
*/
|
|
39
|
+
function spawnWithStdin(cmd: string, args: string[], stdinData: string): Promise<string> {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
42
|
+
let stdout = '';
|
|
43
|
+
let stderr = '';
|
|
44
|
+
|
|
45
|
+
child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
|
|
46
|
+
child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
|
47
|
+
|
|
48
|
+
child.on('error', (err) => reject(new Error(err.message)));
|
|
49
|
+
child.on('close', (code) => {
|
|
50
|
+
if (code !== 0) {
|
|
51
|
+
reject(new Error(stderr || `Process exited with code ${code}`));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
resolve(stdout.trim());
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
child.stdin.write(stdinData);
|
|
58
|
+
child.stdin.end();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Detect the primary connected display output name from xrandr.
|
|
64
|
+
*/
|
|
65
|
+
async function getConnectedDisplay(): Promise<string> {
|
|
66
|
+
const output = await execFilePromise('xrandr', []);
|
|
67
|
+
const match = output.match(/^(\S+)\s+connected/m);
|
|
68
|
+
if (!match) {
|
|
69
|
+
throw new Error('No connected display found');
|
|
70
|
+
}
|
|
71
|
+
return match[1];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function registerDisplayCommands(
|
|
75
|
+
register: (command: string, handler: CommandHandler) => void,
|
|
76
|
+
logger: Logger
|
|
77
|
+
): void {
|
|
78
|
+
// display:brightness — set screen brightness (Linux only)
|
|
79
|
+
register('display:brightness', async (args) => {
|
|
80
|
+
if (getPlatform() !== 'linux') {
|
|
81
|
+
throw new Error('Not supported on this platform');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const parsed = BrightnessArgsSchema.safeParse(args ?? {});
|
|
85
|
+
if (!parsed.success) {
|
|
86
|
+
throw new Error('Invalid brightness level, must be 0-100');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { level } = parsed.data;
|
|
90
|
+
const brightness = (level / 100).toFixed(2);
|
|
91
|
+
|
|
92
|
+
logger.info(`Setting brightness to ${level}%`);
|
|
93
|
+
|
|
94
|
+
const display = await getConnectedDisplay();
|
|
95
|
+
await execFilePromise('xrandr', ['--output', display, '--brightness', brightness]);
|
|
96
|
+
return { level };
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// display:power — control display power via CEC (Linux only)
|
|
100
|
+
register('display:power', async (args) => {
|
|
101
|
+
if (getPlatform() !== 'linux') {
|
|
102
|
+
throw new Error('Not supported on this platform');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const parsed = PowerArgsSchema.safeParse(args ?? {});
|
|
106
|
+
if (!parsed.success) {
|
|
107
|
+
throw new Error('Invalid state, must be on | off | standby');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const { state } = parsed.data;
|
|
111
|
+
|
|
112
|
+
const cecCommands: Readonly<Record<string, string>> = {
|
|
113
|
+
on: 'on 0',
|
|
114
|
+
off: 'standby 0',
|
|
115
|
+
standby: 'standby 0',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const cecCmd = cecCommands[state];
|
|
119
|
+
|
|
120
|
+
logger.info(`Setting display power to ${state}`);
|
|
121
|
+
|
|
122
|
+
// Pipe CEC command via spawn stdin (no shell)
|
|
123
|
+
await spawnWithStdin('cec-client', ['-s', '-d', '1'], cecCmd + '\n');
|
|
124
|
+
return { state };
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// display:rotate — rotate display output (Linux only)
|
|
128
|
+
register('display:rotate', async (args) => {
|
|
129
|
+
if (getPlatform() !== 'linux') {
|
|
130
|
+
throw new Error('Not supported on this platform');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const parsed = RotateArgsSchema.safeParse(args ?? {});
|
|
134
|
+
if (!parsed.success) {
|
|
135
|
+
throw new Error('Invalid rotation, must be normal | left | right | inverted');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { rotation } = parsed.data;
|
|
139
|
+
|
|
140
|
+
logger.info(`Setting display rotation to ${rotation}`);
|
|
141
|
+
|
|
142
|
+
const display = await getConnectedDisplay();
|
|
143
|
+
await execFilePromise('xrandr', ['--output', display, '--rotate', rotation]);
|
|
144
|
+
return { rotation };
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// display:volume — set audio volume (Linux only)
|
|
148
|
+
register('display:volume', async (args) => {
|
|
149
|
+
if (getPlatform() !== 'linux') {
|
|
150
|
+
throw new Error('Not supported on this platform');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const parsed = VolumeArgsSchema.safeParse(args ?? {});
|
|
154
|
+
if (!parsed.success) {
|
|
155
|
+
throw new Error('Invalid volume level, must be 0-100');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const { level } = parsed.data;
|
|
159
|
+
|
|
160
|
+
logger.info(`Setting volume to ${level}%`);
|
|
161
|
+
|
|
162
|
+
await execFilePromise('amixer', ['set', 'Master', `${level}%`]);
|
|
163
|
+
return { level };
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// display:info — query display information (Linux with fallback)
|
|
167
|
+
register('display:info', async () => {
|
|
168
|
+
if (getPlatform() !== 'linux') {
|
|
169
|
+
throw new Error('Not supported on this platform');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
logger.info('Querying display info');
|
|
173
|
+
|
|
174
|
+
const stdout = await execFilePromise('xrandr', ['--query']);
|
|
175
|
+
return { raw: stdout };
|
|
176
|
+
});
|
|
177
|
+
}
|