@anusornneal/chat-relay 0.7.2 → 0.8.1
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/README.md +353 -320
- package/agent/capability-scheduler.mjs +121 -0
- package/agent/connection-state.mjs +141 -0
- package/agent/desktop-manager.mjs +220 -115
- package/agent/file-manager.mjs +527 -382
- package/agent/lifecycle.mjs +101 -0
- package/agent/local-agent.mjs +494 -242
- package/agent/macos-desktop-helper.js +243 -0
- package/agent/macos-desktop-runner.mjs +292 -0
- package/agent/process-manager.mjs +110 -26
- package/agent/protocol.mjs +67 -0
- package/agent/terminal-manager.mjs +587 -550
- package/agent/windows-desktop-runner.mjs +109 -0
- package/agent/windows-desktop-worker.ps1 +215 -13
- package/cli/config.mjs +114 -114
- package/cli/device-login.mjs +170 -170
- package/cli/index.mjs +241 -201
- package/cli/remote.mjs +139 -94
- package/package.json +76 -75
- package/agent/windows-desktop.ps1 +0 -288
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import readline from "node:readline";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
const POWERSHELL_TIMEOUT_MS = 15_000;
|
|
8
|
+
const helperPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "windows-desktop-worker.ps1");
|
|
9
|
+
|
|
10
|
+
export class PersistentDesktopRunner {
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.platform = options.platform ?? process.platform;
|
|
13
|
+
this.timeoutMs = Number(options.timeoutMs ?? POWERSHELL_TIMEOUT_MS);
|
|
14
|
+
this.process = null;
|
|
15
|
+
this.stdout = null;
|
|
16
|
+
this.pending = new Map();
|
|
17
|
+
this.stderr = "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
ensureStarted() {
|
|
21
|
+
if (this.platform !== "win32") return false;
|
|
22
|
+
if (this.process && !this.process.killed) return true;
|
|
23
|
+
|
|
24
|
+
const child = spawn("powershell.exe", [
|
|
25
|
+
"-NoLogo",
|
|
26
|
+
"-NoProfile",
|
|
27
|
+
"-NonInteractive",
|
|
28
|
+
"-Sta",
|
|
29
|
+
"-ExecutionPolicy",
|
|
30
|
+
"Bypass",
|
|
31
|
+
"-File",
|
|
32
|
+
helperPath,
|
|
33
|
+
], {
|
|
34
|
+
windowsHide: true,
|
|
35
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
this.process = child;
|
|
39
|
+
this.stderr = "";
|
|
40
|
+
this.stdout = readline.createInterface({ input: child.stdout });
|
|
41
|
+
|
|
42
|
+
this.stdout.on("line", (line) => {
|
|
43
|
+
let message;
|
|
44
|
+
try {
|
|
45
|
+
message = JSON.parse(line);
|
|
46
|
+
} catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const pending = this.pending.get(message?.id);
|
|
50
|
+
if (!pending) return;
|
|
51
|
+
clearTimeout(pending.timer);
|
|
52
|
+
this.pending.delete(message.id);
|
|
53
|
+
pending.resolve(message.result ?? { ok: false, error: "desktop_worker_error" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
child.stderr.on("data", (chunk) => {
|
|
57
|
+
this.stderr = (this.stderr + String(chunk)).slice(-8192);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
child.on("exit", () => {
|
|
61
|
+
const error = { ok: false, error: "desktop_worker_exited" };
|
|
62
|
+
for (const pending of this.pending.values()) {
|
|
63
|
+
clearTimeout(pending.timer);
|
|
64
|
+
pending.resolve(error);
|
|
65
|
+
}
|
|
66
|
+
this.pending.clear();
|
|
67
|
+
this.stdout?.close();
|
|
68
|
+
this.stdout = null;
|
|
69
|
+
this.process = null;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
child.on("error", () => {
|
|
73
|
+
// exit handler settles pending work.
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async run(operation, args) {
|
|
80
|
+
if (!this.ensureStarted() || !this.process?.stdin?.writable) {
|
|
81
|
+
return { ok: false, error: operation === "screenshot" ? "capture_failed" : "input_failed" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const id = randomUUID();
|
|
85
|
+
const request = JSON.stringify({ id, operation, args: args || {} }) + "\n";
|
|
86
|
+
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
const timer = setTimeout(() => {
|
|
89
|
+
this.pending.delete(id);
|
|
90
|
+
resolve({ ok: false, error: operation === "screenshot" ? "capture_timeout" : "input_timeout" });
|
|
91
|
+
}, this.timeoutMs);
|
|
92
|
+
|
|
93
|
+
this.pending.set(id, { resolve, timer });
|
|
94
|
+
this.process.stdin.write(request, (error) => {
|
|
95
|
+
if (!error) return;
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
this.pending.delete(id);
|
|
98
|
+
resolve({ ok: false, error: operation === "screenshot" ? "capture_failed" : "input_failed" });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
close() {
|
|
104
|
+
try { this.stdout?.close(); } catch {}
|
|
105
|
+
try { this.process?.kill(); } catch {}
|
|
106
|
+
this.stdout = null;
|
|
107
|
+
this.process = null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -8,7 +8,9 @@ Add-Type -AssemblyName System.Drawing
|
|
|
8
8
|
|
|
9
9
|
Add-Type @"
|
|
10
10
|
using System;
|
|
11
|
+
using System.Collections.Generic;
|
|
11
12
|
using System.Runtime.InteropServices;
|
|
13
|
+
using System.Text;
|
|
12
14
|
|
|
13
15
|
public static class ChatRelayNative {
|
|
14
16
|
public const uint INPUT_MOUSE = 0;
|
|
@@ -47,10 +49,78 @@ public static class ChatRelayNative {
|
|
|
47
49
|
public INPUTUNION U;
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
[StructLayout(LayoutKind.Sequential)]
|
|
53
|
+
public struct RECT {
|
|
54
|
+
public int Left;
|
|
55
|
+
public int Top;
|
|
56
|
+
public int Right;
|
|
57
|
+
public int Bottom;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public sealed class WindowInfo {
|
|
61
|
+
public string WindowId;
|
|
62
|
+
public uint ProcessId;
|
|
63
|
+
public string Title;
|
|
64
|
+
public int X;
|
|
65
|
+
public int Y;
|
|
66
|
+
public int Width;
|
|
67
|
+
public int Height;
|
|
68
|
+
public bool IsForeground;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
|
72
|
+
|
|
50
73
|
[DllImport("user32.dll")] public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
|
|
51
74
|
[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
|
|
52
75
|
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
|
|
53
76
|
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
77
|
+
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc callback, IntPtr lParam);
|
|
78
|
+
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
|
|
79
|
+
[DllImport("user32.dll")] public static extern bool IsWindow(IntPtr hWnd);
|
|
80
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowTextLength(IntPtr hWnd);
|
|
81
|
+
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int maxCount);
|
|
82
|
+
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
|
83
|
+
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
|
|
84
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
|
|
85
|
+
[DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int command);
|
|
86
|
+
|
|
87
|
+
public static WindowInfo[] ListWindows(int limit) {
|
|
88
|
+
var items = new List<WindowInfo>();
|
|
89
|
+
var foreground = GetForegroundWindow();
|
|
90
|
+
EnumWindows(delegate(IntPtr hWnd, IntPtr lParam) {
|
|
91
|
+
if (items.Count >= limit) return false;
|
|
92
|
+
if (!IsWindowVisible(hWnd)) return true;
|
|
93
|
+
int titleLength = GetWindowTextLength(hWnd);
|
|
94
|
+
if (titleLength <= 0) return true;
|
|
95
|
+
var title = new StringBuilder(titleLength + 1);
|
|
96
|
+
if (GetWindowText(hWnd, title, title.Capacity) <= 0) return true;
|
|
97
|
+
uint pid;
|
|
98
|
+
GetWindowThreadProcessId(hWnd, out pid);
|
|
99
|
+
RECT rect;
|
|
100
|
+
if (!GetWindowRect(hWnd, out rect)) return true;
|
|
101
|
+
items.Add(new WindowInfo {
|
|
102
|
+
WindowId = unchecked((ulong)hWnd.ToInt64()).ToString(),
|
|
103
|
+
ProcessId = pid,
|
|
104
|
+
Title = title.ToString(),
|
|
105
|
+
X = rect.Left,
|
|
106
|
+
Y = rect.Top,
|
|
107
|
+
Width = Math.Max(0, rect.Right - rect.Left),
|
|
108
|
+
Height = Math.Max(0, rect.Bottom - rect.Top),
|
|
109
|
+
IsForeground = hWnd == foreground,
|
|
110
|
+
});
|
|
111
|
+
return true;
|
|
112
|
+
}, IntPtr.Zero);
|
|
113
|
+
return items.ToArray();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
public static bool FocusWindow(string windowId) {
|
|
117
|
+
ulong raw;
|
|
118
|
+
if (!UInt64.TryParse(windowId, out raw) || raw == 0) return false;
|
|
119
|
+
var hWnd = new IntPtr(unchecked((long)raw));
|
|
120
|
+
if (!IsWindow(hWnd)) return false;
|
|
121
|
+
ShowWindowAsync(hWnd, 9);
|
|
122
|
+
return SetForegroundWindow(hWnd);
|
|
123
|
+
}
|
|
54
124
|
|
|
55
125
|
public static bool MouseButton(uint downFlag, uint upFlag) {
|
|
56
126
|
var inputs = new INPUT[2];
|
|
@@ -125,6 +195,7 @@ function Resolve-Screen($Monitor) {
|
|
|
125
195
|
}
|
|
126
196
|
|
|
127
197
|
function Invoke-Screenshot($Payload) {
|
|
198
|
+
$totalTimer = [System.Diagnostics.Stopwatch]::StartNew()
|
|
128
199
|
if (-not [System.Windows.Forms.SystemInformation]::UserInteractive) {
|
|
129
200
|
return Result-Error "session_unavailable"
|
|
130
201
|
}
|
|
@@ -142,11 +213,15 @@ function Invoke-Screenshot($Payload) {
|
|
|
142
213
|
try {
|
|
143
214
|
$source = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
|
|
144
215
|
$graphics = [System.Drawing.Graphics]::FromImage($source)
|
|
216
|
+
$captureTimer = [System.Diagnostics.Stopwatch]::StartNew()
|
|
145
217
|
try {
|
|
146
218
|
$graphics.CopyFromScreen($bounds.Left, $bounds.Top, 0, 0, $bounds.Size)
|
|
147
219
|
} finally {
|
|
220
|
+
$captureTimer.Stop()
|
|
148
221
|
$graphics.Dispose()
|
|
149
222
|
}
|
|
223
|
+
$captureMs = [int][Math]::Round($captureTimer.Elapsed.TotalMilliseconds)
|
|
224
|
+
$encodeTimer = [System.Diagnostics.Stopwatch]::StartNew()
|
|
150
225
|
|
|
151
226
|
$codec = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() |
|
|
152
227
|
Where-Object { $_.MimeType -eq "image/jpeg" } |
|
|
@@ -154,12 +229,17 @@ function Invoke-Screenshot($Payload) {
|
|
|
154
229
|
if ($null -eq $codec) { return Result-Error "capture_failed" }
|
|
155
230
|
|
|
156
231
|
$maxBytes = 32768
|
|
157
|
-
$
|
|
232
|
+
$requestedMaxWidth = if ($null -eq $Payload.maxWidth) { 960 } else { [int]$Payload.maxWidth }
|
|
233
|
+
if ($requestedMaxWidth -lt 320 -or $requestedMaxWidth -gt 1920) { return Result-Error "invalid_max_width" }
|
|
234
|
+
$requestedQuality = if ($null -eq $Payload.quality) { 58 } else { [int]$Payload.quality }
|
|
235
|
+
if ($requestedQuality -lt 20 -or $requestedQuality -gt 85) { return Result-Error "invalid_quality" }
|
|
236
|
+
$targetWidth = [Math]::Min($bounds.Width, $requestedMaxWidth)
|
|
158
237
|
$targetHeight = [Math]::Max(1, [int][Math]::Round($bounds.Height * ($targetWidth / [double]$bounds.Width)))
|
|
159
|
-
$qualities = @(
|
|
238
|
+
$qualities = @([long]$requestedQuality, [long][Math]::Min($requestedQuality, 42), [long][Math]::Min($requestedQuality, 30), 20L) | Select-Object -Unique
|
|
160
239
|
$bytes = $null
|
|
161
240
|
$finalWidth = $targetWidth
|
|
162
241
|
$finalHeight = $targetHeight
|
|
242
|
+
$finalQuality = $requestedQuality
|
|
163
243
|
|
|
164
244
|
for ($sizeAttempt = 0; $sizeAttempt -lt 3 -and $null -eq $bytes; $sizeAttempt++) {
|
|
165
245
|
$resized = New-Object System.Drawing.Bitmap $targetWidth, $targetHeight
|
|
@@ -188,6 +268,7 @@ function Invoke-Screenshot($Payload) {
|
|
|
188
268
|
$bytes = $stream.ToArray()
|
|
189
269
|
$finalWidth = $targetWidth
|
|
190
270
|
$finalHeight = $targetHeight
|
|
271
|
+
$finalQuality = [int]$quality
|
|
191
272
|
break
|
|
192
273
|
}
|
|
193
274
|
} finally {
|
|
@@ -206,13 +287,24 @@ function Invoke-Screenshot($Payload) {
|
|
|
206
287
|
}
|
|
207
288
|
|
|
208
289
|
if ($null -eq $bytes) { return Result-Error "screenshot_too_large" }
|
|
290
|
+
$base64 = [Convert]::ToBase64String($bytes)
|
|
291
|
+
$encodeTimer.Stop()
|
|
292
|
+
$totalTimer.Stop()
|
|
293
|
+
$virtualBounds = [System.Windows.Forms.SystemInformation]::VirtualScreen
|
|
294
|
+
$monitorCount = @([System.Windows.Forms.Screen]::AllScreens).Count
|
|
209
295
|
|
|
210
296
|
return [pscustomobject]@{
|
|
211
297
|
ok = $true
|
|
212
298
|
mimeType = "image/jpeg"
|
|
213
|
-
data =
|
|
299
|
+
data = $base64
|
|
214
300
|
width = $finalWidth
|
|
215
301
|
height = $finalHeight
|
|
302
|
+
virtualDesktopOriginX = $virtualBounds.Left
|
|
303
|
+
virtualDesktopOriginY = $virtualBounds.Top
|
|
304
|
+
virtualDesktopWidth = $virtualBounds.Width
|
|
305
|
+
virtualDesktopHeight = $virtualBounds.Height
|
|
306
|
+
monitorCount = $monitorCount
|
|
307
|
+
jpegQuality = $finalQuality
|
|
216
308
|
desktopOriginX = $bounds.Left
|
|
217
309
|
desktopOriginY = $bounds.Top
|
|
218
310
|
desktopWidth = $bounds.Width
|
|
@@ -223,6 +315,11 @@ function Invoke-Screenshot($Payload) {
|
|
|
223
315
|
isPrimary = [bool]$screen.Primary
|
|
224
316
|
deviceName = [string]$screen.DeviceName
|
|
225
317
|
byteLength = $bytes.Length
|
|
318
|
+
timing = [pscustomobject]@{
|
|
319
|
+
captureMs = $captureMs
|
|
320
|
+
encodeMs = [int][Math]::Round($encodeTimer.Elapsed.TotalMilliseconds)
|
|
321
|
+
screenshotTotalMs = [int][Math]::Round($totalTimer.Elapsed.TotalMilliseconds)
|
|
322
|
+
}
|
|
226
323
|
}
|
|
227
324
|
} catch {
|
|
228
325
|
return Result-Error "capture_failed"
|
|
@@ -295,61 +392,166 @@ function Invoke-KeyboardInput($Payload) {
|
|
|
295
392
|
return [pscustomobject]@{ ok = $true; action = "keyboard_input" }
|
|
296
393
|
}
|
|
297
394
|
|
|
395
|
+
function Invoke-ClipboardRead($Payload) {
|
|
396
|
+
if (-not [System.Windows.Forms.SystemInformation]::UserInteractive) { return Result-Error "session_unavailable" }
|
|
397
|
+
try {
|
|
398
|
+
$text = if ([System.Windows.Forms.Clipboard]::ContainsText()) { [System.Windows.Forms.Clipboard]::GetText() } else { "" }
|
|
399
|
+
$truncated = $text.Length -gt 8192
|
|
400
|
+
if ($truncated) { $text = $text.Substring(0, 8192) }
|
|
401
|
+
return [pscustomobject]@{ ok = $true; text = $text; truncated = $truncated }
|
|
402
|
+
} catch { return Result-Error "clipboard_failed" }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function Invoke-ClipboardWrite($Payload) {
|
|
406
|
+
if (-not (Test-InteractiveSession)) { return Result-Error "session_unavailable" }
|
|
407
|
+
try {
|
|
408
|
+
$text = [string]$Payload.text
|
|
409
|
+
if ($text.Length -gt 8192) { return Result-Error "text_too_large" }
|
|
410
|
+
if ($text.Length -eq 0) { [System.Windows.Forms.Clipboard]::Clear() } else { [System.Windows.Forms.Clipboard]::SetText($text) }
|
|
411
|
+
return [pscustomobject]@{ ok = $true; action = "clipboard_write"; length = $text.Length }
|
|
412
|
+
} catch { return Result-Error "clipboard_failed" }
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function Invoke-WindowList($Payload) {
|
|
416
|
+
if (-not [System.Windows.Forms.SystemInformation]::UserInteractive) { return Result-Error "session_unavailable" }
|
|
417
|
+
$limit = if ($null -eq $Payload.limit) { 50 } else { [int]$Payload.limit }
|
|
418
|
+
if ($limit -lt 1 -or $limit -gt 100) { return Result-Error "invalid_window_limit" }
|
|
419
|
+
try {
|
|
420
|
+
$windows = @([ChatRelayNative]::ListWindows($limit))
|
|
421
|
+
return [pscustomobject]@{ ok = $true; windows = $windows; count = $windows.Count }
|
|
422
|
+
} catch { return Result-Error "window_list_failed" }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function Invoke-WindowFocus($Payload) {
|
|
426
|
+
if (-not (Test-InteractiveSession)) { return Result-Error "session_unavailable" }
|
|
427
|
+
$windowId = [string]$Payload.windowId
|
|
428
|
+
if ($windowId -notmatch '^[1-9][0-9]{0,19}$') { return Result-Error "invalid_window_id" }
|
|
429
|
+
try {
|
|
430
|
+
if (-not [ChatRelayNative]::FocusWindow($windowId)) { return Result-Error "focus_failed" }
|
|
431
|
+
return [pscustomobject]@{ ok = $true; action = "window_focus"; windowId = $windowId }
|
|
432
|
+
} catch { return Result-Error "focus_failed" }
|
|
433
|
+
}
|
|
434
|
+
|
|
298
435
|
function Invoke-Step($Payload) {
|
|
436
|
+
$totalTimer = [System.Diagnostics.Stopwatch]::StartNew()
|
|
299
437
|
$completed = 0
|
|
438
|
+
$inputMs = 0
|
|
439
|
+
$explicitWaitMs = 0
|
|
440
|
+
$settleMs = 0
|
|
441
|
+
|
|
300
442
|
foreach ($action in @($Payload.actions)) {
|
|
301
443
|
$result = $null
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
444
|
+
if ([string]$action.type -eq "wait") {
|
|
445
|
+
$waitTimer = [System.Diagnostics.Stopwatch]::StartNew()
|
|
446
|
+
Start-Sleep -Milliseconds ([int]$action.ms)
|
|
447
|
+
$waitTimer.Stop()
|
|
448
|
+
$explicitWaitMs += [int][Math]::Round($waitTimer.Elapsed.TotalMilliseconds)
|
|
449
|
+
$result = [pscustomobject]@{ ok = $true; action = "wait" }
|
|
450
|
+
} else {
|
|
451
|
+
$inputTimer = [System.Diagnostics.Stopwatch]::StartNew()
|
|
452
|
+
switch ([string]$action.type) {
|
|
453
|
+
"click" { $result = Invoke-MouseClick $action }
|
|
454
|
+
"text" { $result = Invoke-KeyboardInput $action }
|
|
455
|
+
"key" { $result = Invoke-KeyboardInput $action }
|
|
456
|
+
default { $result = Result-Error "invalid_step_action" }
|
|
309
457
|
}
|
|
310
|
-
|
|
458
|
+
$inputTimer.Stop()
|
|
459
|
+
$inputMs += [int][Math]::Round($inputTimer.Elapsed.TotalMilliseconds)
|
|
311
460
|
}
|
|
312
461
|
|
|
313
462
|
if (-not $result.ok) {
|
|
463
|
+
$totalTimer.Stop()
|
|
314
464
|
return [pscustomobject]@{
|
|
315
465
|
ok = $false
|
|
316
466
|
error = $result.error
|
|
317
467
|
actionsCompleted = $completed
|
|
468
|
+
timing = [pscustomobject]@{
|
|
469
|
+
inputMs = $inputMs
|
|
470
|
+
explicitWaitMs = $explicitWaitMs
|
|
471
|
+
settleMs = $settleMs
|
|
472
|
+
captureMs = 0
|
|
473
|
+
encodeMs = 0
|
|
474
|
+
workerMs = [int][Math]::Round($totalTimer.Elapsed.TotalMilliseconds)
|
|
475
|
+
}
|
|
318
476
|
}
|
|
319
477
|
}
|
|
478
|
+
|
|
320
479
|
$completed++
|
|
480
|
+
$actionSettleMs = [int]$action.settleAfterMs
|
|
481
|
+
if ($actionSettleMs -gt 0) {
|
|
482
|
+
$settleTimer = [System.Diagnostics.Stopwatch]::StartNew()
|
|
483
|
+
Start-Sleep -Milliseconds $actionSettleMs
|
|
484
|
+
$settleTimer.Stop()
|
|
485
|
+
$settleMs += [int][Math]::Round($settleTimer.Elapsed.TotalMilliseconds)
|
|
486
|
+
}
|
|
321
487
|
}
|
|
322
488
|
|
|
323
489
|
if ([int]$Payload.settleMs -gt 0) {
|
|
490
|
+
$finalSettleTimer = [System.Diagnostics.Stopwatch]::StartNew()
|
|
324
491
|
Start-Sleep -Milliseconds ([int]$Payload.settleMs)
|
|
492
|
+
$finalSettleTimer.Stop()
|
|
493
|
+
$settleMs += [int][Math]::Round($finalSettleTimer.Elapsed.TotalMilliseconds)
|
|
325
494
|
}
|
|
326
495
|
|
|
327
|
-
if ($Payload.captureAfter -
|
|
496
|
+
if ($Payload.captureAfter -ne $true) {
|
|
497
|
+
$totalTimer.Stop()
|
|
328
498
|
return [pscustomobject]@{
|
|
329
499
|
ok = $true
|
|
330
500
|
action = "step"
|
|
331
501
|
actionsCompleted = $completed
|
|
502
|
+
timing = [pscustomobject]@{
|
|
503
|
+
inputMs = $inputMs
|
|
504
|
+
explicitWaitMs = $explicitWaitMs
|
|
505
|
+
settleMs = $settleMs
|
|
506
|
+
captureMs = 0
|
|
507
|
+
encodeMs = 0
|
|
508
|
+
workerMs = [int][Math]::Round($totalTimer.Elapsed.TotalMilliseconds)
|
|
509
|
+
}
|
|
332
510
|
}
|
|
333
511
|
}
|
|
334
512
|
|
|
335
|
-
$shotPayload = [pscustomobject]@{ monitor = $Payload.monitor }
|
|
513
|
+
$shotPayload = [pscustomobject]@{ monitor = $Payload.monitor; maxWidth = $Payload.maxWidth; quality = $Payload.quality }
|
|
336
514
|
$shot = Invoke-Screenshot $shotPayload
|
|
337
515
|
if (-not $shot.ok) {
|
|
516
|
+
$totalTimer.Stop()
|
|
338
517
|
return [pscustomobject]@{
|
|
339
518
|
ok = $false
|
|
340
519
|
error = $shot.error
|
|
341
520
|
actionsCompleted = $completed
|
|
521
|
+
timing = [pscustomobject]@{
|
|
522
|
+
inputMs = $inputMs
|
|
523
|
+
explicitWaitMs = $explicitWaitMs
|
|
524
|
+
settleMs = $settleMs
|
|
525
|
+
captureMs = 0
|
|
526
|
+
encodeMs = 0
|
|
527
|
+
workerMs = [int][Math]::Round($totalTimer.Elapsed.TotalMilliseconds)
|
|
528
|
+
}
|
|
342
529
|
}
|
|
343
530
|
}
|
|
344
531
|
|
|
532
|
+
$totalTimer.Stop()
|
|
533
|
+
$captureMs = if ($null -ne $shot.timing) { [int]$shot.timing.captureMs } else { 0 }
|
|
534
|
+
$encodeMs = if ($null -ne $shot.timing) { [int]$shot.timing.encodeMs } else { 0 }
|
|
345
535
|
$shot | Add-Member -NotePropertyName action -NotePropertyValue "step" -Force
|
|
346
536
|
$shot | Add-Member -NotePropertyName actionsCompleted -NotePropertyValue $completed -Force
|
|
537
|
+
$shot | Add-Member -NotePropertyName timing -NotePropertyValue ([pscustomobject]@{
|
|
538
|
+
inputMs = $inputMs
|
|
539
|
+
explicitWaitMs = $explicitWaitMs
|
|
540
|
+
settleMs = $settleMs
|
|
541
|
+
captureMs = $captureMs
|
|
542
|
+
encodeMs = $encodeMs
|
|
543
|
+
workerMs = [int][Math]::Round($totalTimer.Elapsed.TotalMilliseconds)
|
|
544
|
+
}) -Force
|
|
347
545
|
return $shot
|
|
348
546
|
}
|
|
349
547
|
|
|
350
548
|
function Invoke-Operation([string]$Operation, $Payload) {
|
|
351
549
|
switch ($Operation) {
|
|
352
550
|
"screenshot" { return Invoke-Screenshot $Payload }
|
|
551
|
+
"clipboard_read" { return Invoke-ClipboardRead $Payload }
|
|
552
|
+
"clipboard_write" { return Invoke-ClipboardWrite $Payload }
|
|
553
|
+
"window_list" { return Invoke-WindowList $Payload }
|
|
554
|
+
"window_focus" { return Invoke-WindowFocus $Payload }
|
|
353
555
|
"mouse_click" { return Invoke-MouseClick $Payload }
|
|
354
556
|
"keyboard_input" { return Invoke-KeyboardInput $Payload }
|
|
355
557
|
"step" { return Invoke-Step $Payload }
|
package/cli/config.mjs
CHANGED
|
@@ -1,114 +1,114 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
|
|
5
|
-
export function configDir() {
|
|
6
|
-
if (process.env.CHAT_RELAY_HOME) return path.resolve(process.env.CHAT_RELAY_HOME);
|
|
7
|
-
if (process.platform === "win32") {
|
|
8
|
-
const base = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
|
9
|
-
return path.join(base, "chat-relay");
|
|
10
|
-
}
|
|
11
|
-
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
12
|
-
return path.join(base, "chat-relay");
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function configPath() {
|
|
16
|
-
return path.join(configDir(), "config.json");
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function identityPath() {
|
|
20
|
-
return path.join(configDir(), "identity.json");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function loadIdentity() {
|
|
24
|
-
const file = identityPath();
|
|
25
|
-
if (!fs.existsSync(file)) return null;
|
|
26
|
-
try {
|
|
27
|
-
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
28
|
-
} catch {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function saveIdentity(identity) {
|
|
34
|
-
const dir = configDir();
|
|
35
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
-
const file = identityPath();
|
|
37
|
-
fs.writeFileSync(file, JSON.stringify(identity, null, 2) + os.EOL, {
|
|
38
|
-
encoding: "utf8",
|
|
39
|
-
mode: 0o600,
|
|
40
|
-
});
|
|
41
|
-
try { fs.chmodSync(file, 0o600); } catch {}
|
|
42
|
-
return file;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function parseEnvFile(file) {
|
|
46
|
-
const result = {};
|
|
47
|
-
for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
48
|
-
const line = raw.trim();
|
|
49
|
-
if (!line || line.startsWith("#") || !line.includes("=")) continue;
|
|
50
|
-
const index = line.indexOf("=");
|
|
51
|
-
result[line.slice(0, index)] = line.slice(index + 1);
|
|
52
|
-
}
|
|
53
|
-
return result;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function migrateLegacyConfig() {
|
|
57
|
-
const legacyPath = path.resolve(".dev.vars");
|
|
58
|
-
if (!fs.existsSync(legacyPath)) return null;
|
|
59
|
-
const legacy = parseEnvFile(legacyPath);
|
|
60
|
-
if (!legacy.RELAY_URL || !legacy.CALLER_TOKEN || !legacy.AGENT_TOKEN) return null;
|
|
61
|
-
|
|
62
|
-
const config = {
|
|
63
|
-
relayUrl: legacy.RELAY_URL.replace(/\/$/, ""),
|
|
64
|
-
user: { id: "owner", name: "Owner", login: null },
|
|
65
|
-
userToken: legacy.CALLER_TOKEN,
|
|
66
|
-
agentId: legacy.AGENT_ID || "default",
|
|
67
|
-
agentName: legacy.AGENT_NAME || "Primary PC",
|
|
68
|
-
agentToken: legacy.AGENT_TOKEN,
|
|
69
|
-
allowedRoots: legacy.ALLOWED_ROOTS || defaultAllowedRoot(),
|
|
70
|
-
terminalEnabled: legacy.TERMINAL_ENABLED !== "0",
|
|
71
|
-
desktopEnabled: legacy.DESKTOP_ENABLED === "1",
|
|
72
|
-
migratedAt: new Date().toISOString(),
|
|
73
|
-
};
|
|
74
|
-
saveConfig(config);
|
|
75
|
-
saveIdentity({
|
|
76
|
-
agentId: config.agentId,
|
|
77
|
-
createdAt: new Date().toISOString(),
|
|
78
|
-
updatedAt: new Date().toISOString(),
|
|
79
|
-
});
|
|
80
|
-
return config;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function loadConfig() {
|
|
84
|
-
const file = configPath();
|
|
85
|
-
if (!fs.existsSync(file)) return migrateLegacyConfig();
|
|
86
|
-
try {
|
|
87
|
-
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
88
|
-
} catch {
|
|
89
|
-
throw new Error(`Invalid Chat Relay config: ${file}`);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function saveConfig(config) {
|
|
94
|
-
const dir = configDir();
|
|
95
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
96
|
-
const file = configPath();
|
|
97
|
-
fs.writeFileSync(file, JSON.stringify(config, null, 2) + os.EOL, {
|
|
98
|
-
encoding: "utf8",
|
|
99
|
-
mode: 0o600,
|
|
100
|
-
});
|
|
101
|
-
try { fs.chmodSync(file, 0o600); } catch {}
|
|
102
|
-
return file;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export function clearConfig() {
|
|
106
|
-
const file = configPath();
|
|
107
|
-
if (fs.existsSync(file)) fs.rmSync(file, { force: true });
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function defaultAllowedRoot() {
|
|
111
|
-
const projects = path.join(os.homedir(), "Projects");
|
|
112
|
-
if (fs.existsSync(projects)) return projects;
|
|
113
|
-
return os.homedir();
|
|
114
|
-
}
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export function configDir() {
|
|
6
|
+
if (process.env.CHAT_RELAY_HOME) return path.resolve(process.env.CHAT_RELAY_HOME);
|
|
7
|
+
if (process.platform === "win32") {
|
|
8
|
+
const base = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
|
9
|
+
return path.join(base, "chat-relay");
|
|
10
|
+
}
|
|
11
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
12
|
+
return path.join(base, "chat-relay");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function configPath() {
|
|
16
|
+
return path.join(configDir(), "config.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function identityPath() {
|
|
20
|
+
return path.join(configDir(), "identity.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function loadIdentity() {
|
|
24
|
+
const file = identityPath();
|
|
25
|
+
if (!fs.existsSync(file)) return null;
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function saveIdentity(identity) {
|
|
34
|
+
const dir = configDir();
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
const file = identityPath();
|
|
37
|
+
fs.writeFileSync(file, JSON.stringify(identity, null, 2) + os.EOL, {
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
mode: 0o600,
|
|
40
|
+
});
|
|
41
|
+
try { fs.chmodSync(file, 0o600); } catch {}
|
|
42
|
+
return file;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseEnvFile(file) {
|
|
46
|
+
const result = {};
|
|
47
|
+
for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
48
|
+
const line = raw.trim();
|
|
49
|
+
if (!line || line.startsWith("#") || !line.includes("=")) continue;
|
|
50
|
+
const index = line.indexOf("=");
|
|
51
|
+
result[line.slice(0, index)] = line.slice(index + 1);
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function migrateLegacyConfig() {
|
|
57
|
+
const legacyPath = path.resolve(".dev.vars");
|
|
58
|
+
if (!fs.existsSync(legacyPath)) return null;
|
|
59
|
+
const legacy = parseEnvFile(legacyPath);
|
|
60
|
+
if (!legacy.RELAY_URL || !legacy.CALLER_TOKEN || !legacy.AGENT_TOKEN) return null;
|
|
61
|
+
|
|
62
|
+
const config = {
|
|
63
|
+
relayUrl: legacy.RELAY_URL.replace(/\/$/, ""),
|
|
64
|
+
user: { id: "owner", name: "Owner", login: null },
|
|
65
|
+
userToken: legacy.CALLER_TOKEN,
|
|
66
|
+
agentId: legacy.AGENT_ID || "default",
|
|
67
|
+
agentName: legacy.AGENT_NAME || "Primary PC",
|
|
68
|
+
agentToken: legacy.AGENT_TOKEN,
|
|
69
|
+
allowedRoots: legacy.ALLOWED_ROOTS || defaultAllowedRoot(),
|
|
70
|
+
terminalEnabled: legacy.TERMINAL_ENABLED !== "0",
|
|
71
|
+
desktopEnabled: legacy.DESKTOP_ENABLED === "1",
|
|
72
|
+
migratedAt: new Date().toISOString(),
|
|
73
|
+
};
|
|
74
|
+
saveConfig(config);
|
|
75
|
+
saveIdentity({
|
|
76
|
+
agentId: config.agentId,
|
|
77
|
+
createdAt: new Date().toISOString(),
|
|
78
|
+
updatedAt: new Date().toISOString(),
|
|
79
|
+
});
|
|
80
|
+
return config;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function loadConfig() {
|
|
84
|
+
const file = configPath();
|
|
85
|
+
if (!fs.existsSync(file)) return migrateLegacyConfig();
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
88
|
+
} catch {
|
|
89
|
+
throw new Error(`Invalid Chat Relay config: ${file}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function saveConfig(config) {
|
|
94
|
+
const dir = configDir();
|
|
95
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
96
|
+
const file = configPath();
|
|
97
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2) + os.EOL, {
|
|
98
|
+
encoding: "utf8",
|
|
99
|
+
mode: 0o600,
|
|
100
|
+
});
|
|
101
|
+
try { fs.chmodSync(file, 0o600); } catch {}
|
|
102
|
+
return file;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function clearConfig() {
|
|
106
|
+
const file = configPath();
|
|
107
|
+
if (fs.existsSync(file)) fs.rmSync(file, { force: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function defaultAllowedRoot() {
|
|
111
|
+
const projects = path.join(os.homedir(), "Projects");
|
|
112
|
+
if (fs.existsSync(projects)) return projects;
|
|
113
|
+
return os.homedir();
|
|
114
|
+
}
|