ispyx 1.0.6 → 1.1.0
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 +2 -0
- package/native/win32-unlock/bin/ispyx_unlock.dll +0 -0
- package/native/win32-unlock/provider/install.ps1 +84 -0
- package/native/win32-unlock/provider/uninstall.ps1 +17 -0
- package/package.json +5 -1
- package/src/core/panel/README.md +2 -0
- package/src/core/panel/index.js +4 -4
- package/src/core/panel/page.html +29 -0
- package/src/core/resident.js +15 -2
- package/src/core/server.js +27 -1
- package/src/effects/laser.js +182 -0
- package/src/effects/scan.js +94 -2
- package/src/platform/CONTRACT.md +6 -0
- package/src/platform/win32/index.js +1 -0
- package/src/platform/win32/unlock.js +140 -0
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
# ispyx
|
|
4
4
|
|
|
5
|
+
<p align="center"><a href="docs/showcase/ispyx-demo.mp4"><img src="docs/showcase/ispyx-demo.gif" alt="ispyx demo: an AI clicks, drags cards between columns, types and scrolls while an eye fires a beam at the pointer" width="720"></a></p>
|
|
6
|
+
|
|
5
7
|
A local desktop-control [MCP](https://modelcontextprotocol.io) server. It lets an AI assistant use your computer the way a person does: look at the screen, move the mouse, click, drag, scroll, type, press hotkeys and use the clipboard.
|
|
6
8
|
|
|
7
9
|
- Runs on your machine, talks to the AI app over **stdio**, opens **no network port** (the settings panel only listens while it is open).
|
|
Binary file
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Elevated setup step for the "let the AI unlock this PC" feature. Run by src/platform/win32/unlock.js via
|
|
2
|
+
# Start-Process -Verb RunAs (one UAC prompt, per docs/unlock-windows.md). Never called with the password as a
|
|
3
|
+
# plain argument: SecretFile is a path to a temp file (domain\nusername\npassword) that this script reads once
|
|
4
|
+
# and deletes, so the password never appears in a process command line (visible via Task Manager / WMI).
|
|
5
|
+
# ResultFile: Start-Process -Verb RunAs runs this in its own console with no stdout/stderr redirection back to
|
|
6
|
+
# the caller (confirmed: -RedirectStandardOutput is rejected together with -Verb, same as -WindowStyle), so the
|
|
7
|
+
# only way the caller learns what happened is this file.
|
|
8
|
+
param(
|
|
9
|
+
[Parameter(Mandatory)][string]$SecretFile,
|
|
10
|
+
[Parameter(Mandatory)][string]$DllPath,
|
|
11
|
+
[Parameter(Mandatory)][string]$ResultFile
|
|
12
|
+
)
|
|
13
|
+
Set-StrictMode -Version 2
|
|
14
|
+
$ErrorActionPreference = 'Stop'
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
$lines = Get-Content -Path $SecretFile -Encoding UTF8
|
|
18
|
+
$domain = $lines[0]
|
|
19
|
+
$username = $lines[1]
|
|
20
|
+
$password = $lines[2]
|
|
21
|
+
if ([string]::IsNullOrEmpty($domain)) { $domain = $env:COMPUTERNAME }
|
|
22
|
+
if ([string]::IsNullOrEmpty($username) -or [string]::IsNullOrEmpty($password)) {
|
|
23
|
+
throw 'Missing username or password.'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
Add-Type -AssemblyName System.Security
|
|
27
|
+
|
|
28
|
+
$root = 'C:\ProgramData\ispyx'
|
|
29
|
+
New-Item -ItemType Directory -Force -Path $root | Out-Null
|
|
30
|
+
icacls $root /inheritance:r | Out-Null
|
|
31
|
+
icacls $root /grant 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' | Out-Null
|
|
32
|
+
|
|
33
|
+
function Write-LenPrefixed([System.IO.MemoryStream]$ms, [string]$s) {
|
|
34
|
+
$bytes = [System.Text.Encoding]::UTF8.GetBytes($s)
|
|
35
|
+
$ms.Write([BitConverter]::GetBytes([uint32]$bytes.Length), 0, 4)
|
|
36
|
+
$ms.Write($bytes, 0, $bytes.Length)
|
|
37
|
+
}
|
|
38
|
+
$ms = New-Object System.IO.MemoryStream
|
|
39
|
+
Write-LenPrefixed $ms $domain
|
|
40
|
+
Write-LenPrefixed $ms $username
|
|
41
|
+
Write-LenPrefixed $ms $password
|
|
42
|
+
$plain = $ms.ToArray()
|
|
43
|
+
$protected = [System.Security.Cryptography.ProtectedData]::Protect($plain, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine)
|
|
44
|
+
[Array]::Clear($plain, 0, $plain.Length) # best effort; managed GC may have copied it already
|
|
45
|
+
|
|
46
|
+
$secretPath = Join-Path $root 'unlock.secret'
|
|
47
|
+
[System.IO.File]::WriteAllBytes($secretPath, $protected)
|
|
48
|
+
icacls $secretPath /inheritance:r | Out-Null
|
|
49
|
+
icacls $secretPath /grant 'SYSTEM:F' 'Administrators:F' | Out-Null
|
|
50
|
+
|
|
51
|
+
# The resident (this same user, not elevated day to day) needs write access to just this one file. (A
|
|
52
|
+
# named Win32 event was tried here instead, to drop the watcher's poll loop entirely -- reverted: a named
|
|
53
|
+
# kernel object only exists while some process holds a handle open, and nothing in this architecture runs
|
|
54
|
+
# continuously between an enable() and a much later signal() to be that holder. See docs/unlock-windows.md.
|
|
55
|
+
# The watcher still avoids polling, via FindFirstChangeNotificationW on this directory instead.)
|
|
56
|
+
$signalPath = Join-Path $root 'unlock.signal'
|
|
57
|
+
if (-not (Test-Path $signalPath)) { New-Item -ItemType File -Path $signalPath | Out-Null }
|
|
58
|
+
$me = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
59
|
+
icacls $signalPath /inheritance:r | Out-Null
|
|
60
|
+
icacls $signalPath /grant 'SYSTEM:F' 'Administrators:F' "${me}:M" | Out-Null
|
|
61
|
+
|
|
62
|
+
# regsvr32's exit code is not trustworthy with /s (confirmed in docs/unlock-windows.md); verify by reading
|
|
63
|
+
# the registry back instead of trusting $LASTEXITCODE.
|
|
64
|
+
# LogonUI loads this DLL as SYSTEM, so it must live somewhere only admins can write -- never in the npm
|
|
65
|
+
# package dir (user-writable): that would let any local user swap it and get code running as SYSTEM.
|
|
66
|
+
$installedDll = Join-Path $root 'ispyx_unlock.dll'
|
|
67
|
+
try { Copy-Item -Path $DllPath -Destination $installedDll -Force } catch {
|
|
68
|
+
# Already loaded by LogonUI from a previous install: keep it if it is byte-identical.
|
|
69
|
+
if (-not (Test-Path $installedDll) -or (Get-FileHash $installedDll).Hash -ne (Get-FileHash $DllPath).Hash) { throw }
|
|
70
|
+
}
|
|
71
|
+
regsvr32.exe /s $installedDll 2>$null
|
|
72
|
+
$actual = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Classes\CLSID\{1aa7e1e6-21de-4ad5-ae6e-454401b4b427}\InprocServer32' -Name '(default)' -ErrorAction SilentlyContinue).'(default)'
|
|
73
|
+
if ($actual -ne $installedDll) {
|
|
74
|
+
throw "Registration did not take effect (registry has '$actual', expected '$installedDll')."
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
Set-Content -Path $ResultFile -Value 'OK' -NoNewline
|
|
78
|
+
exit 0
|
|
79
|
+
} catch {
|
|
80
|
+
Set-Content -Path $ResultFile -Value "ERROR: $($_.Exception.Message)" -NoNewline
|
|
81
|
+
exit 1
|
|
82
|
+
} finally {
|
|
83
|
+
Remove-Item -Path $SecretFile -Force -ErrorAction SilentlyContinue
|
|
84
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Elevated teardown for the "let the AI unlock this PC" feature: unregisters the Credential Provider and deletes
|
|
2
|
+
# the stored secret and the installed DLL copy. Run by src/platform/win32/unlock.js via Start-Process -Verb
|
|
3
|
+
# RunAs. See install.ps1 for why ResultFile exists (no stdout/stderr redirection back to the caller with -Verb RunAs).
|
|
4
|
+
param(
|
|
5
|
+
[Parameter(Mandatory)][string]$ResultFile
|
|
6
|
+
)
|
|
7
|
+
Set-StrictMode -Version 2
|
|
8
|
+
$ErrorActionPreference = 'Continue' # best effort: still clean up what we can if one step fails
|
|
9
|
+
|
|
10
|
+
$root = 'C:\ProgramData\ispyx'
|
|
11
|
+
regsvr32.exe /u /s (Join-Path $root 'ispyx_unlock.dll') 2>$null
|
|
12
|
+
Remove-Item -Path (Join-Path $root 'unlock.secret') -Force -ErrorAction SilentlyContinue
|
|
13
|
+
Remove-Item -Path (Join-Path $root 'unlock.signal') -Force -ErrorAction SilentlyContinue
|
|
14
|
+
# May be held open by a running LogonUI until the next lock cycle; harmless once unregistered.
|
|
15
|
+
Remove-Item -Path (Join-Path $root 'ispyx_unlock.dll') -Force -ErrorAction SilentlyContinue
|
|
16
|
+
Set-Content -Path $ResultFile -Value 'OK' -NoNewline
|
|
17
|
+
exit 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ispyx",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Local desktop-control MCP server: lets an AI use your keyboard, mouse and screen (windows, screenshots, clicks, typing, clipboard). stdio only, Windows and macOS.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/server.js",
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"src/",
|
|
12
|
+
"native/win32-unlock/bin/",
|
|
13
|
+
"native/win32-unlock/provider/install.ps1",
|
|
14
|
+
"native/win32-unlock/provider/uninstall.ps1",
|
|
12
15
|
"README.md",
|
|
13
16
|
"LICENSE",
|
|
14
17
|
"config.example.json"
|
|
@@ -48,6 +51,7 @@
|
|
|
48
51
|
"hook-test": "node test/hook-life.js",
|
|
49
52
|
"shutdown-test": "node test/shutdown-all.js",
|
|
50
53
|
"scan-test": "node test/scan.js",
|
|
54
|
+
"laser-test": "node test/laser.js",
|
|
51
55
|
"radar-multi-test": "node test/radar-multi.js",
|
|
52
56
|
"scan-multi-test": "node test/scan-multi.js",
|
|
53
57
|
"auth-test": "node test/auth.js",
|
package/src/core/panel/README.md
CHANGED
|
@@ -31,6 +31,7 @@ Only `version` and `platform` are required. Every other key is optional and the
|
|
|
31
31
|
recent: [{ time, tool, summary }], // time = epoch ms
|
|
32
32
|
stopLabel, // e.g. "Esc x3" or "⌥ Option + Esc"
|
|
33
33
|
effectsEnabled, passwordSet, denylist: [],
|
|
34
|
+
unlockSupported, unlockEnabled, // win32 only: "Let the AI unlock this PC"
|
|
34
35
|
autostart: boolean, autostartLabel?, // label defaults by platform
|
|
35
36
|
permissions: [{ id: 'accessibility' | 'screen-recording', title, detail, granted }],
|
|
36
37
|
connect: { claudeCodeCommand, desktopConfigJson, desktopConfigPath } }
|
|
@@ -51,6 +52,7 @@ Sections, top to bottom: header (icon, headline, status, version/uptime/pid), Pe
|
|
|
51
52
|
| `openPermissionSettings` | permission id | "Open Settings" |
|
|
52
53
|
| `showConfigFile` | none | reveal `desktopConfigPath` (or its folder) |
|
|
53
54
|
| `copy` | text | clipboard fallback; the page tries `navigator.clipboard` first |
|
|
55
|
+
| `setUnlock` | `{on, password, current}` | win32 only; "Let the AI unlock this PC". Always the resident's own account. `current` is the ispyx panel password, asked for when one is set (same as `removeDeny`) |
|
|
54
56
|
|
|
55
57
|
## Windows server (`index.js`)
|
|
56
58
|
|
package/src/core/panel/index.js
CHANGED
|
@@ -8,14 +8,14 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
const PAGE = fileURLToPath(new URL('./page.html', import.meta.url));
|
|
9
9
|
const COOKIE = 'ispyx_panel';
|
|
10
10
|
// Explicit whitelist; an action is also callable only if the host's `actions` object defines it.
|
|
11
|
-
const ACTIONS = ['quit', 'setAutostart', 'setEffects', 'openLog', 'copy', 'close', 'requestPermission', 'openPermissionSettings', 'showConfigFile', 'clearRecent', 'addDeny', 'removeDeny', 'setPassword', 'setAuthRequired', 'update', 'checkUpdate'];
|
|
11
|
+
const ACTIONS = ['quit', 'setAutostart', 'setEffects', 'openLog', 'copy', 'close', 'requestPermission', 'openPermissionSettings', 'showConfigFile', 'clearRecent', 'addDeny', 'removeDeny', 'setPassword', 'setAuthRequired', 'setUnlock', 'update', 'checkUpdate'];
|
|
12
12
|
const BOOL_ACTIONS = new Set(['setAutostart', 'setEffects']);
|
|
13
13
|
const STRING_ACTIONS = new Set(['copy', 'requestPermission', 'openPermissionSettings', 'addDeny']); // value must be a short string
|
|
14
|
-
// value is an object of short strings (and a boolean for setAuthRequired.on)
|
|
15
|
-
const OBJECT_ACTIONS = { setPassword: ['current', 'next'], setAuthRequired: ['current'], removeDeny: ['name', 'current'] };
|
|
14
|
+
// value is an object of short strings (and a boolean for setAuthRequired.on / setUnlock.on)
|
|
15
|
+
const OBJECT_ACTIONS = { setPassword: ['current', 'next'], setAuthRequired: ['current'], removeDeny: ['name', 'current'], setUnlock: ['password', 'current'] };
|
|
16
16
|
const objectOk = (name, v) => {
|
|
17
17
|
if (!v || typeof v !== 'object' || Array.isArray(v)) return false;
|
|
18
|
-
if (name === 'setAuthRequired' && typeof v.on !== 'boolean') return false;
|
|
18
|
+
if ((name === 'setAuthRequired' || name === 'setUnlock') && typeof v.on !== 'boolean') return false;
|
|
19
19
|
return OBJECT_ACTIONS[name].every((k) => v[k] === undefined || (typeof v[k] === 'string' && v[k].length <= 200));
|
|
20
20
|
};
|
|
21
21
|
const PERMISSION_IDS = new Set(['accessibility', 'screen-recording']);
|
package/src/core/panel/page.html
CHANGED
|
@@ -37,6 +37,7 @@ h2{font-size:11px;letter-spacing:.05em;text-transform:uppercase;color:var(--mute
|
|
|
37
37
|
button{font:inherit;color:var(--fg);background:var(--card);border:1px solid var(--line);border-radius:7px;padding:4px 12px;cursor:pointer;box-shadow:0 1px 0 rgba(0,0,0,.04)}
|
|
38
38
|
button:hover{background:var(--fill)}
|
|
39
39
|
button:active{filter:brightness(.95)}
|
|
40
|
+
button:disabled{opacity:.55;cursor:default;filter:none}
|
|
40
41
|
button:focus-visible,.sw input:focus-visible+i{outline:2px solid var(--acc);outline-offset:2px}
|
|
41
42
|
button.pri{background:var(--acc);border-color:var(--acc);color:var(--accfg);font-weight:600}
|
|
42
43
|
button.pri:hover{filter:brightness(.96);background:var(--acc)}
|
|
@@ -155,6 +156,14 @@ button.link:hover{background:none;color:var(--fg)}
|
|
|
155
156
|
</div>
|
|
156
157
|
</section>
|
|
157
158
|
|
|
159
|
+
<section id="sUnlock" hidden>
|
|
160
|
+
<h2>Unattended unlock</h2>
|
|
161
|
+
<div class="card">
|
|
162
|
+
<div class="item line" id="rUnlock" hidden><label for="unlockOn">Let the AI unlock this PC</label><span class="sw"><input type="checkbox" id="unlockOn"><i></i></span></div>
|
|
163
|
+
<div class="item" id="rUnlockNote" hidden><p class="note">Weakens the lock screen on purpose: stores this account's Windows password so the AI can unlock a locked session without you there. Needs a local account with a password (not a Microsoft-account PIN sign-in).</p></div>
|
|
164
|
+
</div>
|
|
165
|
+
</section>
|
|
166
|
+
|
|
158
167
|
<dialog id="dlg">
|
|
159
168
|
<form id="dlgForm">
|
|
160
169
|
<h3 id="dlgT"></h3>
|
|
@@ -316,6 +325,10 @@ function render(s){
|
|
|
316
325
|
show("sSafe",hasPw||hasDeny||hasAuth);show("rPw",hasPw);show("rAuth",hasAuth);show("rDeny",hasDeny);
|
|
317
326
|
if(hasPw){setText("pwTxt",s.passwordSet?"Password is set":"No password yet");setText("pwBtn",s.passwordSet?"Change…":"Set…")}
|
|
318
327
|
if(hasAuth&&!busy.authOn)$("authOn").checked=s.authRequired;
|
|
328
|
+
|
|
329
|
+
var hasUnlock=typeof s.unlockSupported==="boolean";
|
|
330
|
+
show("sUnlock",hasUnlock);show("rUnlock",hasUnlock);show("rUnlockNote",hasUnlock);
|
|
331
|
+
if(hasUnlock&&!busy.unlockOn)$("unlockOn").checked=!!s.unlockEnabled;
|
|
319
332
|
if(hasDeny){
|
|
320
333
|
var d=$("deny"),sig=s.denylist.join("\n")+"|"+!!s.passwordSet;
|
|
321
334
|
if(d.dataset.sig!==sig){ // rebuild only on change so a click is never lost to the 1 s refresh
|
|
@@ -416,6 +429,22 @@ $("authOn").addEventListener("change",function(){
|
|
|
416
429
|
}
|
|
417
430
|
act("setAuthRequired",{on:want,current:undefined}).catch(function(){box.checked=!want}).then(done);
|
|
418
431
|
});
|
|
432
|
+
$("unlockOn").addEventListener("change",function(){
|
|
433
|
+
var want=$("unlockOn").checked,box=$("unlockOn");
|
|
434
|
+
busy.unlockOn=true;
|
|
435
|
+
var done=function(){busy.unlockOn=false;poll()};
|
|
436
|
+
if(want){
|
|
437
|
+
box.checked=false;
|
|
438
|
+
ask("Let the AI unlock this PC",[{id:"password",label:"Windows password"}],"Turn on",function(v){return act("setUnlock",{on:true,password:v.password})});
|
|
439
|
+
done();return;
|
|
440
|
+
}
|
|
441
|
+
if(last&&last.passwordSet){
|
|
442
|
+
box.checked=true;
|
|
443
|
+
ask("Turn off unattended unlock",[{id:"current",label:"Ispyx password"}],"Turn off",function(v){return act("setUnlock",{on:false,current:v.current})});
|
|
444
|
+
done();return;
|
|
445
|
+
}
|
|
446
|
+
act("setUnlock",{on:false}).catch(function(){box.checked=true}).then(done);
|
|
447
|
+
});
|
|
419
448
|
function removeDeny(name){
|
|
420
449
|
if(last&&last.passwordSet){
|
|
421
450
|
ask("Remove "+name,[{id:"current",label:"Password"}],"Remove",function(v){return act("removeDeny",{name:name,current:v.current})});
|
package/src/core/resident.js
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
// ./panel/index.js startPanel({ getState, actions }) -> { url, close() } (local web panel)
|
|
12
12
|
//
|
|
13
13
|
// getState() -> { version, platform, pid, uptimeMs, clients, recent: [{ time, tool, summary }],
|
|
14
|
-
// stopLabel, effectsEnabled, passwordSet, denylist, autostart, connect (win32)
|
|
15
|
-
//
|
|
14
|
+
// stopLabel, effectsEnabled, passwordSet, denylist, autostart, connect (win32),
|
|
15
|
+
// unlockSupported, unlockEnabled (win32 only, when platform.unlock exists) }
|
|
16
|
+
// actions -> { quit(), setAutostart(bool), setEffects(bool), clearRecent(), showConfigFile(),
|
|
17
|
+
// setUnlock({on, password, current}) (win32 only) }
|
|
16
18
|
//
|
|
17
19
|
// Control calls over the pipe (names start with "__", handled here, never reach the tools):
|
|
18
20
|
// __ping -> 'pong' __panel -> panel url ('' if none) __quit -> 'bye' and the resident shuts down.
|
|
@@ -139,6 +141,7 @@ export function panelModel(ctx) {
|
|
|
139
141
|
passwordSet: safe(() => auth.passwordSet(), false),
|
|
140
142
|
authRequired: !!config.auth,
|
|
141
143
|
denylist: config.denylist.slice(),
|
|
144
|
+
...(w.unlock ? { unlockSupported: true, unlockEnabled: safe(() => w.unlock.isEnabled(), false) } : {}),
|
|
142
145
|
...(updates.state() ? { update: { ...updates.state(), ...(running ? running.progress() : {}) } } : {}),
|
|
143
146
|
});
|
|
144
147
|
const actions = {
|
|
@@ -176,6 +179,16 @@ export function panelModel(ctx) {
|
|
|
176
179
|
return true;
|
|
177
180
|
},
|
|
178
181
|
checkUpdate: async () => { await updates.check(); return updates.state(); },
|
|
182
|
+
// Turning this on lowers the lock screen's protection, so it asks for the ispyx password when one is set,
|
|
183
|
+
// same as removeDeny. w.unlock.enable/disable throw a plain message (wrong account type, UAC cancelled, ...).
|
|
184
|
+
setUnlock: async ({ on, password, current } = {}) => {
|
|
185
|
+
if (!w.unlock) throw new PanelError('Not available on this platform.');
|
|
186
|
+
if (auth.passwordSet() && !auth.verify(current)) throw new PanelError('The password is wrong.');
|
|
187
|
+
try {
|
|
188
|
+
if (on) await w.unlock.enable({ password });
|
|
189
|
+
else await w.unlock.disable();
|
|
190
|
+
} catch (e) { throw new PanelError(e.message); }
|
|
191
|
+
},
|
|
179
192
|
};
|
|
180
193
|
return { getState, actions, stop: () => updates.stop() };
|
|
181
194
|
}
|
package/src/core/server.js
CHANGED
|
@@ -101,6 +101,15 @@ function playTrail(a, z, button, opts) {
|
|
|
101
101
|
effects.add(run);
|
|
102
102
|
return stop;
|
|
103
103
|
}
|
|
104
|
+
/** drag: an eye beside the line fires a beam at the pointer's heels (scorch fades after a couple of seconds); the returned function ends it. */
|
|
105
|
+
function playLaser(a, z) {
|
|
106
|
+
const epoch = guard.epoch;
|
|
107
|
+
let stop;
|
|
108
|
+
const stopped = new Promise((r) => { stop = r; });
|
|
109
|
+
const run = scan.laser({ x: a.sx, y: a.sy }, { x: z.sx, y: z.sy }, stopped, () => guard.epoch !== epoch).finally(() => effects.delete(run));
|
|
110
|
+
effects.add(run);
|
|
111
|
+
return stop;
|
|
112
|
+
}
|
|
104
113
|
/** scroll: chevrons beside the pointer while the wheel turns; the returned function ends it. */
|
|
105
114
|
function playWheelCue(p, vert, horiz) {
|
|
106
115
|
const epoch = guard.epoch;
|
|
@@ -594,7 +603,8 @@ server.registerTool('drag', {
|
|
|
594
603
|
await w.sleep(40);
|
|
595
604
|
w.mouseDown(b);
|
|
596
605
|
guard.heldButtons.add(b);
|
|
597
|
-
const stopTrail = playTrail(a, z2, b);
|
|
606
|
+
const stopTrail = playTrail(a, z2, b, { line: false }); // start ring and end ring; the beam draws the path
|
|
607
|
+
const stopLaser = playLaser(a, z2);
|
|
598
608
|
try {
|
|
599
609
|
await w.sleep(40);
|
|
600
610
|
await glide(z2, args, { trail: false });
|
|
@@ -603,6 +613,7 @@ server.registerTool('drag', {
|
|
|
603
613
|
w.mouseUp(b);
|
|
604
614
|
guard.heldButtons.delete(b);
|
|
605
615
|
stopTrail();
|
|
616
|
+
stopLaser();
|
|
606
617
|
}
|
|
607
618
|
});
|
|
608
619
|
return json({ ok: true, from: { x: a.sx, y: a.sy }, to: { x: z2.sx, y: z2.sy } });
|
|
@@ -801,6 +812,21 @@ server.registerTool('set_clipboard', {
|
|
|
801
812
|
return json({ ok: true, kind: 'file', path: p });
|
|
802
813
|
}, { session: false }));
|
|
803
814
|
|
|
815
|
+
server.registerTool('unlock', {
|
|
816
|
+
description: 'Unlocks the Windows session if it is currently locked, using the account password stored earlier in the panel ("Let the AI unlock this PC"). Takes no input. Fails if that feature was never turned on, or if the screen is not actually locked.',
|
|
817
|
+
}, action('unlock', async () => {
|
|
818
|
+
if (!w.unlock) throw new ToolError('Unattended unlock is not available on this platform.');
|
|
819
|
+
if (!w.unlock.isEnabled()) throw new ToolError('Unattended unlock was never turned on. Ask the user to enable "Let the AI unlock this PC" in the panel.');
|
|
820
|
+
if (!w.isScreenLocked()) return json({ ok: true, alreadyUnlocked: true });
|
|
821
|
+
w.unlock.signal();
|
|
822
|
+
const deadline = Date.now() + 10000;
|
|
823
|
+
while (Date.now() < deadline) {
|
|
824
|
+
await w.sleep(300);
|
|
825
|
+
if (!w.isScreenLocked()) return json({ ok: true });
|
|
826
|
+
}
|
|
827
|
+
throw new ToolError('Signalled the unlock but the screen is still locked after 10 s. The stored password may be wrong, or the account may no longer qualify.');
|
|
828
|
+
}, { session: false }));
|
|
829
|
+
|
|
804
830
|
// ---------------------------------------------------------------- run
|
|
805
831
|
|
|
806
832
|
process.on('exit', () => guard?.releaseAll());
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// The drag laser: an eye beside the drag line fires a thick beam at the spot just behind the pointer, as if the pointer
|
|
2
|
+
// were running from it. Pure drawing into premultiplied BGRA buffers (Layer.px) and the placement rules; scan.js
|
|
3
|
+
// runs it (ScanAnimation.laser). Colours stay in the ispyx green family, brighter and glassier than the flat trail.
|
|
4
|
+
|
|
5
|
+
const clamp = (v, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
|
|
6
|
+
const mix = (a, b, t) => a + (b - a) * t;
|
|
7
|
+
const smooth = (t) => t * t * (3 - 2 * t);
|
|
8
|
+
|
|
9
|
+
export const LASER_EYE_SIZE = 112;
|
|
10
|
+
export const BURN_LIFE_MS = 2000;
|
|
11
|
+
export const BEAM_WIDTH = 26; // half-width in px of the full beam
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where the eye goes: beside the line a -> b (never on it), at a random place along it and on a random side, as far
|
|
15
|
+
* from the line as the monitor `mon` {x, y, width, height} allows (up to size/2 + 190 px). Returns the eye centre.
|
|
16
|
+
*/
|
|
17
|
+
export function placeEye(a, b, mon, size = LASER_EYE_SIZE, rand = Math.random) {
|
|
18
|
+
const len = Math.hypot(b.x - a.x, b.y - a.y) || 1;
|
|
19
|
+
const nx = -(b.y - a.y) / len, ny = (b.x - a.x) / len;
|
|
20
|
+
const f = 0.3 + 0.4 * rand();
|
|
21
|
+
const mx = a.x + (b.x - a.x) * f, my = a.y + (b.y - a.y) * f;
|
|
22
|
+
const half = size / 2, margin = 10;
|
|
23
|
+
const fits = (c) => c.x - half >= mon.x + margin && c.x + half <= mon.x + mon.width - margin && c.y - half >= mon.y + margin && c.y + half <= mon.y + mon.height - margin;
|
|
24
|
+
const first = rand() < 0.5 ? 1 : -1;
|
|
25
|
+
for (const d of [half + 190, half + 130, half + 80]) {
|
|
26
|
+
for (const s of [first, -first]) {
|
|
27
|
+
const c = { x: mx + nx * s * d, y: my + ny * s * d };
|
|
28
|
+
if (fits(c)) return c;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// no room on either side (a drag across a whole small screen): the side with more room, kept on the monitor
|
|
32
|
+
const c = { x: mx + nx * first * (half + 80), y: my + ny * first * (half + 80) };
|
|
33
|
+
return { x: clamp(c.x, mon.x + margin + half, mon.x + mon.width - margin - half), y: clamp(c.y, mon.y + margin + half, mon.y + mon.height - margin - half) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Premultiplied "over": add colour (r, g, b) with coverage a at pixel index i. */
|
|
37
|
+
function over(px, i, r, g, b, a) {
|
|
38
|
+
if (a <= 0) return;
|
|
39
|
+
const k = 1 - a;
|
|
40
|
+
px[i] = b * a + px[i] * k; px[i + 1] = g * a + px[i + 1] * k; px[i + 2] = r * a + px[i + 2] * k; px[i + 3] = a * 255 + px[i + 3] * k;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A glowing flare at (cx, cy): white core, green/cyan halo, a ring and slowly turning spikes. `w` is the beam half-width. */
|
|
44
|
+
function flare(px, W, H, cx, cy, w, tMs, strength) {
|
|
45
|
+
const R = Math.ceil(w * 3.6);
|
|
46
|
+
const rot = tMs * 0.0011;
|
|
47
|
+
// cos(6 * (angle + rot)) without atan2/cos per pixel: (cos a + i sin a)^6 rotated by 6 * rot
|
|
48
|
+
const cr = Math.cos(6 * rot), sr = Math.sin(6 * rot);
|
|
49
|
+
for (let y = Math.max(0, Math.floor(cy - R)); y <= Math.min(H - 1, Math.ceil(cy + R)); y++) {
|
|
50
|
+
for (let x = Math.max(0, Math.floor(cx - R)); x <= Math.min(W - 1, Math.ceil(cx + R)); x++) {
|
|
51
|
+
const dx = x + 0.5 - cx, dy = y + 0.5 - cy;
|
|
52
|
+
const r = Math.sqrt(dx * dx + dy * dy);
|
|
53
|
+
if (r > R) continue;
|
|
54
|
+
const i = (y * W + x) * 4;
|
|
55
|
+
const halo = Math.exp(-((r / (w * 2.1)) ** 2)) * 0.55 * strength;
|
|
56
|
+
over(px, i, 40, 235, 170, halo);
|
|
57
|
+
const ring = clamp(1 - Math.abs(r - w * 1.9) / 1.6) * 0.55 * strength;
|
|
58
|
+
over(px, i, 170, 255, 220, ring);
|
|
59
|
+
const c1 = r > 0 ? dx / r : 1, s1 = r > 0 ? dy / r : 0;
|
|
60
|
+
const c2 = c1 * c1 - s1 * s1, s2 = 2 * c1 * s1; // angle x2
|
|
61
|
+
const c6 = c2 * (c2 * c2 - s2 * s2) - s2 * (2 * c2 * s2), s6 = s2 * (c2 * c2 - s2 * s2) + c2 * (2 * c2 * s2); // x2 * x3 = x6
|
|
62
|
+
const k = Math.max(0, c6 * cr - s6 * sr);
|
|
63
|
+
const k2 = k * k, k4 = k2 * k2, k16 = k4 * k4 * k4 * k4; // k^22 = k^16 * k^4 * k^2
|
|
64
|
+
const spike = k16 * k4 * k2 * Math.exp(-r / (w * 1.5)) * 0.9 * strength;
|
|
65
|
+
over(px, i, 200, 255, 225, spike);
|
|
66
|
+
const core = Math.exp(-((r / (w * 1.05)) ** 2)) * strength;
|
|
67
|
+
over(px, i, 240, 255, 246, core);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The beam from (ax, ay) to (bx, by) with half-width `w` (0 = off): rounded ends and the same width all along, a soft green glow, an emerald body that
|
|
74
|
+
* darkens towards its rim, a white-green core, energy bands running along it, and a flare at each end.
|
|
75
|
+
* `tMs` animates the bands and the spikes.
|
|
76
|
+
*/
|
|
77
|
+
export function renderBeam(px, W, H, ax, ay, bx, by, w, tMs) {
|
|
78
|
+
if (w < 0.5) return;
|
|
79
|
+
const len = Math.hypot(bx - ax, by - ay) || 1;
|
|
80
|
+
const ux = (bx - ax) / len, uy = (by - ay) / len;
|
|
81
|
+
const pad = Math.ceil(w * 3.2);
|
|
82
|
+
const x0 = Math.max(0, Math.floor(Math.min(ax, bx) - pad)), x1 = Math.min(W - 1, Math.ceil(Math.max(ax, bx) + pad));
|
|
83
|
+
const y0 = Math.max(0, Math.floor(Math.min(ay, by) - pad)), y1 = Math.min(H - 1, Math.ceil(Math.max(ay, by) + pad));
|
|
84
|
+
const reach2 = (w * 3.1) ** 2;
|
|
85
|
+
for (let y = y0; y <= y1; y++) {
|
|
86
|
+
for (let x = x0; x <= x1; x++) {
|
|
87
|
+
const dx = x + 0.5 - ax, dy = y + 0.5 - ay;
|
|
88
|
+
const s = clamp(dx * ux + dy * uy, 0, len); // rounded ends: distance to the segment
|
|
89
|
+
const ex = dx - s * ux, ey = dy - s * uy;
|
|
90
|
+
const d2 = ex * ex + ey * ey;
|
|
91
|
+
if (d2 > reach2) continue; // most of a diagonal beam's box: no square root, nothing drawn
|
|
92
|
+
const d = Math.sqrt(d2);
|
|
93
|
+
const i = (y * W + x) * 4;
|
|
94
|
+
const hw = w; // the same width along its whole length
|
|
95
|
+
over(px, i, 20, 200, 120, Math.exp(-((d / (hw * 2.3)) ** 2)) * 0.5); // outer glow
|
|
96
|
+
const body = clamp(hw - d + 0.5);
|
|
97
|
+
if (body > 0) {
|
|
98
|
+
const band = 0.86 + 0.14 * Math.sin(s * 0.11 - tMs * 0.045);
|
|
99
|
+
const e = clamp(d / hw);
|
|
100
|
+
const k = e * e; // 0 in the middle, 1 on the rim
|
|
101
|
+
over(px, i, mix(70, 8, k) * band, mix(255, 130, k) * band, mix(150, 80, k) * band, body * 0.97);
|
|
102
|
+
const core = clamp(hw * 0.52 - d + 0.5) * (0.9 + 0.1 * band);
|
|
103
|
+
over(px, i, 238, 255, 244, core);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
flare(px, W, H, ax, ay, w, tMs, 0.85);
|
|
108
|
+
flare(px, W, H, bx, by, w, tMs + 400, 1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A thick line piece (a capsule); the stronger alpha wins where pieces overlap (so a burn trail has no beads at its
|
|
113
|
+
* joints). Each pixel of the bounding box is visited once and shaded by its distance to the segment: the burn
|
|
114
|
+
* redraws every piece every frame, and stamping a disc at each pixel along the line cost ~30x more.
|
|
115
|
+
*/
|
|
116
|
+
export function stroke(px, W, H, x0, y0, x1, y1, radius, rgb, alpha) {
|
|
117
|
+
const R = radius + 1;
|
|
118
|
+
const xa = Math.max(0, Math.floor(Math.min(x0, x1) - R)), xb = Math.min(W - 1, Math.ceil(Math.max(x0, x1) + R));
|
|
119
|
+
const ya = Math.max(0, Math.floor(Math.min(y0, y1) - R)), yb = Math.min(H - 1, Math.ceil(Math.max(y0, y1) + R));
|
|
120
|
+
const dx = x1 - x0, dy = y1 - y0, len2 = dx * dx + dy * dy;
|
|
121
|
+
const b = rgb[2], g = rgb[1], r = rgb[0];
|
|
122
|
+
for (let y = ya; y <= yb; y++) {
|
|
123
|
+
const py = y + 0.5 - y0;
|
|
124
|
+
for (let x = xa; x <= xb; x++) {
|
|
125
|
+
const qx = x + 0.5 - x0;
|
|
126
|
+
const u = len2 > 0 ? Math.min(1, Math.max(0, (qx * dx + py * dy) / len2)) : 0;
|
|
127
|
+
const ex = qx - u * dx, ey = py - u * dy;
|
|
128
|
+
const a = clamp(radius + 0.5 - Math.sqrt(ex * ex + ey * ey)) * alpha;
|
|
129
|
+
if (a <= 0) continue;
|
|
130
|
+
const i = (y * W + x) * 4;
|
|
131
|
+
if (a * 255 <= px[i + 3]) continue;
|
|
132
|
+
px[i] = b * a; px[i + 1] = g * a; px[i + 2] = r * a; px[i + 3] = a * 255;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The scorch the beam leaves: pts = [{ x, y, t }] (buffer pixels, ms stamps), now = ms. Fresh pieces glow lime-white,
|
|
139
|
+
* cool to a charred dark line with a faint green ember rim, and disappear after BURN_LIFE_MS.
|
|
140
|
+
*/
|
|
141
|
+
export function renderBurn(px, W, H, pts, now) {
|
|
142
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
143
|
+
for (let k = 1; k < pts.length; k++) {
|
|
144
|
+
const age = now - pts[k].t;
|
|
145
|
+
const fade = Math.pow(clamp(1 - age / BURN_LIFE_MS), 0.8);
|
|
146
|
+
if (fade <= 0.02) continue;
|
|
147
|
+
const A = pts[k - 1], B = pts[k];
|
|
148
|
+
if (pass === 0) stroke(px, W, H, A.x, A.y, B.x, B.y, 13, [22, 120, 66], 0.3 * fade);
|
|
149
|
+
else if (pass === 1) stroke(px, W, H, A.x, A.y, B.x, B.y, 9, [10, 16, 13], 0.82 * fade);
|
|
150
|
+
else {
|
|
151
|
+
const heat = clamp(1 - age / 320);
|
|
152
|
+
if (heat <= 0.03) continue;
|
|
153
|
+
stroke(px, W, H, A.x, A.y, B.x, B.y, 3.5 + 5 * heat, [mix(90, 235, heat), 255, mix(130, 215, heat)], heat * 0.95);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Sparks flying off the impact point: [{ x, y, vx, vy, t0, life }] (px, px/ms, ms). Returns the ones still alive. */
|
|
160
|
+
export function renderSparks(px, W, H, sparks, now) {
|
|
161
|
+
const alive = [];
|
|
162
|
+
for (const s of sparks) {
|
|
163
|
+
const age = now - s.t0;
|
|
164
|
+
if (age >= s.life) continue;
|
|
165
|
+
alive.push(s);
|
|
166
|
+
const f = 1 - age / s.life;
|
|
167
|
+
const x = s.x + s.vx * age, y = s.y + s.vy * age + 0.0005 * age * age;
|
|
168
|
+
const px0 = x - s.vx * 26 * f, py0 = y - s.vy * 26 * f;
|
|
169
|
+
stroke(px, W, H, px0, py0, x, y, 0.9 + 1.2 * f, [210, 255, 225], f);
|
|
170
|
+
}
|
|
171
|
+
return alive;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** New sparks at (x, y), flying away from the beam source (ax, ay) in a fan. */
|
|
175
|
+
export function spawnSparks(list, x, y, ax, ay, now, count = 2, rand = Math.random) {
|
|
176
|
+
const base = Math.atan2(y - ay, x - ax);
|
|
177
|
+
for (let n = 0; n < count; n++) {
|
|
178
|
+
const ang = base + (rand() - 0.5) * 2.4;
|
|
179
|
+
const v = 0.12 + rand() * 0.34;
|
|
180
|
+
list.push({ x, y, vx: Math.cos(ang) * v, vy: Math.sin(ang) * v, t0: now, life: 260 + rand() * 260 });
|
|
181
|
+
}
|
|
182
|
+
}
|
package/src/effects/scan.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// a glowing green scan line that sweeps the region from top to bottom. Two click-through, per-pixel-alpha windows;
|
|
3
3
|
// pixels are drawn in software into DIB sections, so nothing here depends on GDI+.
|
|
4
4
|
import platform from '../platform/index.js';
|
|
5
|
+
import { LASER_EYE_SIZE, BEAM_WIDTH, BURN_LIFE_MS, placeEye, renderBeam, renderBurn, renderSparks, spawnSparks } from './laser.js';
|
|
5
6
|
|
|
6
7
|
const clamp = (v, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
|
|
7
8
|
const mix = (a, b, t) => a + (b - a) * t;
|
|
@@ -310,7 +311,7 @@ export class ScanAnimation {
|
|
|
310
311
|
* fading with age. When `stopped` resolves (button released) the trail fades out and rings mark the end.
|
|
311
312
|
* `bounds` {left, top, right, bottom}: where the drag can go. Never throws.
|
|
312
313
|
*/
|
|
313
|
-
async trail(start, bounds, stopped, cancelled = () => false, button = 'left', { marker: showMarker = true, life = TRAIL_LIFE_MS, ring = true } = {}) {
|
|
314
|
+
async trail(start, bounds, stopped, cancelled = () => false, button = 'left', { marker: showMarker = true, life = TRAIL_LIFE_MS, ring = true, line = true } = {}) {
|
|
314
315
|
if (!this.enabled) return;
|
|
315
316
|
let layer = null;
|
|
316
317
|
let ended = false;
|
|
@@ -338,7 +339,7 @@ export class ScanAnimation {
|
|
|
338
339
|
}
|
|
339
340
|
layer.px.fill(0);
|
|
340
341
|
const fade = endAt ? 1 - clamp((t - endAt) / TRAIL_FADE_MS) : 1;
|
|
341
|
-
for (let pass = 0; pass < 2; pass++) {
|
|
342
|
+
for (let pass = 0; pass < (line ? 2 : 0); pass++) {
|
|
342
343
|
for (let k = 1; k < pts.length; k++) {
|
|
343
344
|
const age = (endAt ? Math.min(t, endAt) : t) - pts[k].t;
|
|
344
345
|
const a = clamp(1 - age / life) * fade;
|
|
@@ -370,6 +371,97 @@ export class ScanAnimation {
|
|
|
370
371
|
}
|
|
371
372
|
}
|
|
372
373
|
|
|
374
|
+
/**
|
|
375
|
+
* drag: an eye opens beside the drag line (random side, random place along it) and fires a thick beam at the spot just
|
|
376
|
+
* behind the pointer, as if the pointer were running from it; the beam leaves a scorch that fades after a couple of
|
|
377
|
+
* seconds. Runs until the beam and the scorch are gone. `start`/`end` are desktop pixels. Never throws.
|
|
378
|
+
*/
|
|
379
|
+
async laser(start, end, stopped, cancelled = () => false) {
|
|
380
|
+
if (!this.enabled || Math.hypot(end.x - start.x, end.y - start.y) < 80) return;
|
|
381
|
+
const FIRE = 260, LAG = 130, HOLD = 260, MIN_BEAM = 520, CUT = 180, EYE_CLOSE = 300;
|
|
382
|
+
let beam = null, eye = null;
|
|
383
|
+
let ended = false;
|
|
384
|
+
stopped.then(() => { ended = true; });
|
|
385
|
+
try {
|
|
386
|
+
platform.ensurePump();
|
|
387
|
+
const v = platform.virtualScreen();
|
|
388
|
+
const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
|
|
389
|
+
const mon = platform.listMonitors().find((q) => mid.x >= q.left && mid.x < q.left + q.width && mid.y >= q.top && mid.y < q.top + q.height);
|
|
390
|
+
const area = mon ? { x: mon.left, y: mon.top, width: mon.width, height: mon.height } : v;
|
|
391
|
+
const size = LASER_EYE_SIZE;
|
|
392
|
+
const ec = placeEye(start, end, area, size);
|
|
393
|
+
const ex = Math.round(ec.x - size / 2), ey = Math.round(ec.y - size / 2);
|
|
394
|
+
const pad = 150;
|
|
395
|
+
const left = Math.max(v.x, Math.floor(Math.min(start.x, end.x, ex) - pad)), top = Math.max(v.y, Math.floor(Math.min(start.y, end.y, ey) - pad));
|
|
396
|
+
const right = Math.min(v.x + v.width, Math.ceil(Math.max(start.x, end.x, ex + size) + pad)), bottom = Math.min(v.y + v.height, Math.ceil(Math.max(start.y, end.y, ey + size) + pad));
|
|
397
|
+
beam = new platform.Layer(Math.max(40, right - left), Math.max(40, bottom - top));
|
|
398
|
+
eye = new platform.Layer(size, size);
|
|
399
|
+
const samples = [{ x: start.x, y: start.y, t: 0 }]; // where the pointer was, and when
|
|
400
|
+
const burn = [];
|
|
401
|
+
let sparks = [];
|
|
402
|
+
let endAt = 0, cutAt = 0, tau = 0, lastT = 0, lastBurn = 0, last = { x: start.x, y: start.y };
|
|
403
|
+
const pointAt = (u) => {
|
|
404
|
+
for (let k = 1; k < samples.length; k++) {
|
|
405
|
+
if (samples[k].t >= u) {
|
|
406
|
+
const a = samples[k - 1], b = samples[k];
|
|
407
|
+
const f = b.t > a.t ? (u - a.t) / (b.t - a.t) : 1;
|
|
408
|
+
return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return { x: samples[samples.length - 1].x, y: samples[samples.length - 1].y };
|
|
412
|
+
};
|
|
413
|
+
const t0 = performance.now();
|
|
414
|
+
for (;;) {
|
|
415
|
+
const tf = performance.now(), t = tf - t0;
|
|
416
|
+
if (cancelled()) break;
|
|
417
|
+
if (!endAt) {
|
|
418
|
+
const c = platform.cursorPos();
|
|
419
|
+
if (Math.hypot(c.x - samples[samples.length - 1].x, c.y - samples[samples.length - 1].y) >= 1) samples.push({ x: c.x, y: c.y, t });
|
|
420
|
+
if (ended) { endAt = t; cutAt = Math.max(endAt + HOLD, FIRE + MIN_BEAM); samples.push({ x: samples[samples.length - 1].x, y: samples[samples.length - 1].y, t }); }
|
|
421
|
+
}
|
|
422
|
+
const eyeEnd = cutAt ? cutAt + CUT + EYE_CLOSE : Infinity;
|
|
423
|
+
const firing = t >= FIRE && !(cutAt && t >= cutAt + CUT);
|
|
424
|
+
const grow = ease(clamp((t - FIRE) / 120));
|
|
425
|
+
const shrink = cutAt ? 1 - ease(clamp((t - cutAt) / CUT)) : 1;
|
|
426
|
+
const w = BEAM_WIDTH * grow * shrink;
|
|
427
|
+
// the beam's end follows the pointer's path about LAG ms behind it, catching up fast from the start point
|
|
428
|
+
if (t >= FIRE) tau = Math.min(tau + (t - lastT) * 2.2, Math.max(0, t - LAG), samples[samples.length - 1].t);
|
|
429
|
+
lastT = t;
|
|
430
|
+
const T = t >= FIRE ? pointAt(tau) : samples[samples.length - 1];
|
|
431
|
+
const open = t < FIRE ? ease(t / FIRE) : cutAt && t >= cutAt + CUT ? 1 - ease(clamp((t - cutAt - CUT) / EYE_CLOSE)) : 1;
|
|
432
|
+
// the pupil looks at the beam's end
|
|
433
|
+
const dx = T.x - ec.x, dy = T.y - ec.y, dl = Math.hypot(dx, dy) || 1;
|
|
434
|
+
const gx = (dx / dl) * 0.95, gy = (dy / dl) * 0.95;
|
|
435
|
+
renderEye(eye.px, size, open, gx, gy, t < FIRE ? clamp((t - 70) / (FIRE - 70)) : 1);
|
|
436
|
+
const pp = pupilPos(size, gx, gy);
|
|
437
|
+
// the eye trembles while it fires (the beam starts at the shaken pupil)
|
|
438
|
+
const sh = firing ? 4.5 * grow * shrink : 0;
|
|
439
|
+
const sx = (Math.random() - 0.5) * 2 * sh, sy = (Math.random() - 0.5) * 2 * sh;
|
|
440
|
+
beam.px.fill(0);
|
|
441
|
+
if (firing && w >= 1 && Math.hypot(T.x - last.x, T.y - last.y) >= 1) {
|
|
442
|
+
burn.push({ x: T.x - left, y: T.y - top, t });
|
|
443
|
+
lastBurn = t;
|
|
444
|
+
last = { x: T.x, y: T.y };
|
|
445
|
+
spawnSparks(sparks, T.x - left, T.y - top, ex + pp.x - left, ey + pp.y - top, t);
|
|
446
|
+
}
|
|
447
|
+
renderBurn(beam.px, beam.width, beam.height, burn, t);
|
|
448
|
+
if (firing && w >= 1) renderBeam(beam.px, beam.width, beam.height, ex + sx + pp.x - left, ey + sy + pp.y - top, T.x - left, T.y - top, w, t);
|
|
449
|
+
sparks = renderSparks(beam.px, beam.width, beam.height, sparks, t);
|
|
450
|
+
beam.present(left, top, 255);
|
|
451
|
+
if (t < eyeEnd) eye.present(Math.round(ex + sx), Math.round(ey + sy), 255 * open);
|
|
452
|
+
else if (eye.shown) eye.present(ex, ey, 0, 1);
|
|
453
|
+
if (t >= eyeEnd && t - lastBurn > BURN_LIFE_MS && !sparks.length) break;
|
|
454
|
+
await new Promise((r) => setTimeout(r, Math.max(1, 16 - (performance.now() - tf))));
|
|
455
|
+
}
|
|
456
|
+
} catch (e) {
|
|
457
|
+
process.stderr.write(`ispyx: laser effect failed (${e.message})
|
|
458
|
+
`);
|
|
459
|
+
} finally {
|
|
460
|
+
beam?.destroy();
|
|
461
|
+
eye?.destroy();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
373
465
|
/**
|
|
374
466
|
* scroll: chevrons beside the pointer travel in the scroll direction while the wheel turns (until `stopped`
|
|
375
467
|
* resolves, at least 380 ms, then a short fade). dirV: +1 up / -1 down / 0, dirH: +1 right / -1 left / 0. Never throws.
|
package/src/platform/CONTRACT.md
CHANGED
|
@@ -129,3 +129,9 @@ Other platforms may lack these; callers must feature-test (`if (platform.Tray)`)
|
|
|
129
129
|
Quit. Methods: `setTooltip(s)`, `destroy()`. Re-adds itself when Explorer restarts. Callbacks never throw into the host.
|
|
130
130
|
- `autostart` `{ get(), set(on), command() }`: per-user HKCU Run entry `Ispyx` that starts `src/server.js` hidden
|
|
131
131
|
via `wscript.exe` + `%APPDATA%\ispyx\start.vbs`. `ISPYX_AUTOSTART_VALUE` overrides the value name (tests).
|
|
132
|
+
- `unlock` (`src/platform/win32/unlock.js`): `{ isEnabled(), checkAccount(), enable({password}), disable(), signal() }`.
|
|
133
|
+
"Let the AI unlock this PC" (see `docs/unlock-windows.md`). Only ever unlocks the account the resident itself
|
|
134
|
+
runs as, so `enable` asks for just the password. `enable`/`disable` run an
|
|
135
|
+
elevated `.ps1` in `native/win32-unlock/provider/` (one UAC prompt each); `signal()` never needs elevation.
|
|
136
|
+
`checkAccount()` refuses PIN/Microsoft-account and passwordless sign-ins (no replayable secret exists for
|
|
137
|
+
those). The `unlock` MCP tool (`src/core/server.js`) calls `signal()` then polls `isScreenLocked()`.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Optional win32 feature: "Let the AI unlock this PC" (see docs/unlock-windows.md for the full design and what
|
|
2
|
+
// was validated). All native work lives in native/win32-unlock/provider (Rust Credential Provider COM DLL,
|
|
3
|
+
// built separately, not part of this package's install) and its install.ps1/uninstall.ps1, each one elevated
|
|
4
|
+
// UAC prompt, never run automatically.
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const ROOT = 'C:\\ProgramData\\ispyx';
|
|
13
|
+
const SECRET_PATH = path.join(ROOT, 'unlock.secret');
|
|
14
|
+
const SIGNAL_PATH = path.join(ROOT, 'unlock.signal');
|
|
15
|
+
const PROVIDER_DIR = fileURLToPath(new URL('../../../native/win32-unlock/provider/', import.meta.url));
|
|
16
|
+
const DLL_PATH = fileURLToPath(new URL('../../../native/win32-unlock/bin/ispyx_unlock.dll', import.meta.url));
|
|
17
|
+
|
|
18
|
+
function run(cmd, args) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const p = spawn(cmd, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
21
|
+
let out = '', err = '';
|
|
22
|
+
p.stdout.on('data', (d) => { out += d; });
|
|
23
|
+
p.stderr.on('data', (d) => { err += d; });
|
|
24
|
+
p.on('close', (code) => resolve({ code, out, err }));
|
|
25
|
+
p.on('error', (e) => resolve({ code: -1, out, err: e.message }));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Runs a .ps1 elevated (one UAC prompt) and waits for it. Resolves { code, out }.
|
|
31
|
+
* -WindowStyle cannot be combined with -Verb RunAs in Windows PowerShell 5.1 (Start-Process throws a parameter
|
|
32
|
+
* error before the UAC prompt even appears) -- confirmed hitting exactly this, so it is deliberately omitted.
|
|
33
|
+
*/
|
|
34
|
+
function runElevated(scriptPath, args) {
|
|
35
|
+
const psArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args];
|
|
36
|
+
const quoted = psArgs.map((a) => `'${String(a).replace(/'/g, "''")}'`).join(',');
|
|
37
|
+
const outer = `try { $p = Start-Process -FilePath powershell.exe -Verb RunAs -Wait -PassThru -ArgumentList @(${quoted}); exit $p.ExitCode } catch { Write-Output "ELEVATE-FAILED: $($_.Exception.Message)"; exit 1 }`;
|
|
38
|
+
return run('powershell.exe', ['-NoProfile', '-Command', outer]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Runs an elevated .ps1 that takes a trailing -ResultFile path and writes 'OK' or 'ERROR: ...' there (the
|
|
43
|
+
* elevated child's own stdout/stderr never reaches this process -- see install.ps1's header comment). Resolves
|
|
44
|
+
* { ok, message }.
|
|
45
|
+
*/
|
|
46
|
+
async function runElevatedScript(scriptPath, args) {
|
|
47
|
+
const resultFile = path.join(os.tmpdir(), `ispyx-unlock-result-${crypto.randomBytes(8).toString('hex')}.tmp`);
|
|
48
|
+
try {
|
|
49
|
+
const { out } = await runElevated(scriptPath, [...args, resultFile]);
|
|
50
|
+
let result = '';
|
|
51
|
+
try { result = fs.readFileSync(resultFile, 'utf8'); } catch { /* elevation itself never ran the script */ }
|
|
52
|
+
if (result) {
|
|
53
|
+
return result.startsWith('ERROR:') ? { ok: false, message: result.slice(6).trim() } : { ok: true, message: result };
|
|
54
|
+
}
|
|
55
|
+
const outer = out.trim().split('\n').pop() || '';
|
|
56
|
+
return { ok: false, message: outer.replace(/^ELEVATE-FAILED:\s*/, '') || 'Setup was cancelled or failed.' };
|
|
57
|
+
} finally {
|
|
58
|
+
try { fs.unlinkSync(resultFile); } catch { /* did not get created, or the script already removed it */ }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `fs.existsSync` returns false on ANY stat error, not just "missing" -- and the secret file's ACL (by design,
|
|
64
|
+
* see install.ps1) denies read to this account's normal, non-elevated token, which is exactly the context the
|
|
65
|
+
* resident always runs in. So existsSync would report "not enabled" right after a successful install. statSync
|
|
66
|
+
* + checking the error code distinguishes "really missing" (ENOENT) from "there, just can't read it" (anything
|
|
67
|
+
* else) -- confirmed hitting this directly: a real install left this returning false until fixed.
|
|
68
|
+
*/
|
|
69
|
+
export function isEnabled() {
|
|
70
|
+
try {
|
|
71
|
+
fs.statSync(SECRET_PATH);
|
|
72
|
+
return true;
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return e.code !== 'ENOENT';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Local-account check (see docs/unlock-windows.md "Known gaps" for why PIN/Microsoft-account and passwordless
|
|
80
|
+
* accounts cannot work here). Resolves { ok: true } or { ok: false, reason }.
|
|
81
|
+
*
|
|
82
|
+
* Deliberately does NOT try to detect "no password set" up front: the one signal Windows exposes for that,
|
|
83
|
+
* UserFlags' UF_PASSWD_NOTREQD bit ("net user"'s "Password required"), means the password-policy check is
|
|
84
|
+
* waived for this account, not that the password is blank -- confirmed a false positive on a real account with
|
|
85
|
+
* a working password. A wrong/blank stored secret is already caught safely at unlock time (one attempt, no
|
|
86
|
+
* retry -- see credential.rs), so there is no need to predict it here.
|
|
87
|
+
*/
|
|
88
|
+
export async function checkAccount() {
|
|
89
|
+
const ps = `
|
|
90
|
+
$src = $null
|
|
91
|
+
try { $src = (Get-LocalUser -Name $env:USERNAME -ErrorAction Stop).PrincipalSource.ToString() } catch {}
|
|
92
|
+
[PSCustomObject]@{ principalSource = $src } | ConvertTo-Json -Compress
|
|
93
|
+
`;
|
|
94
|
+
const { code, out } = await run('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps]);
|
|
95
|
+
if (code !== 0) return { ok: false, reason: 'Could not check the account type.' };
|
|
96
|
+
let info;
|
|
97
|
+
try { info = JSON.parse(out.trim().split('\n').pop()); } catch { return { ok: false, reason: 'Could not check the account type.' }; }
|
|
98
|
+
if (info.principalSource && info.principalSource !== 'Local') {
|
|
99
|
+
return { ok: false, reason: 'This looks like a Microsoft-account or PIN-only sign-in. Unattended unlock only supports a local account with a password.' };
|
|
100
|
+
}
|
|
101
|
+
return { ok: true };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Panel action: writes the DPAPI secret and registers the Credential Provider (one UAC prompt). Only the
|
|
106
|
+
* password is asked for in the panel -- the account is always the one the resident itself runs as, since that
|
|
107
|
+
* is the only session this mechanism can ever unlock.
|
|
108
|
+
*/
|
|
109
|
+
export async function enable({ password }) {
|
|
110
|
+
if (!password) throw new Error('Enter the Windows account password.');
|
|
111
|
+
const check = await checkAccount();
|
|
112
|
+
if (!check.ok) throw new Error(check.reason);
|
|
113
|
+
const username = os.userInfo().username;
|
|
114
|
+
const domain = process.env.USERDOMAIN || '';
|
|
115
|
+
const tmp = path.join(os.tmpdir(), `ispyx-unlock-${crypto.randomBytes(8).toString('hex')}.tmp`);
|
|
116
|
+
fs.writeFileSync(tmp, `${domain}\n${username}\n${password}\n`);
|
|
117
|
+
try {
|
|
118
|
+
const r = await runElevatedScript(path.join(PROVIDER_DIR, 'install.ps1'), [tmp, DLL_PATH]);
|
|
119
|
+
if (!r.ok) throw new Error(r.message);
|
|
120
|
+
} finally {
|
|
121
|
+
try { fs.unlinkSync(tmp); } catch { /* install.ps1 deletes it too either way */ }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Panel action: unregisters the Credential Provider and deletes the stored secret (one UAC prompt). */
|
|
126
|
+
export async function disable() {
|
|
127
|
+
const r = await runElevatedScript(path.join(PROVIDER_DIR, 'uninstall.ps1'), []);
|
|
128
|
+
if (!r.ok) throw new Error(r.message);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Called by the `unlock` tool while the screen is locked: touches the signal file the Credential Provider
|
|
133
|
+
* watches (a named Win32 event was tried instead, to drop the watcher's poll loop entirely -- reverted, see
|
|
134
|
+
* docs/unlock-windows.md: a named kernel object only exists while some process holds a handle open, and
|
|
135
|
+
* nothing here runs continuously between an enable() and a much later signal() to be that holder). Never
|
|
136
|
+
* needs elevation -- the install step already granted this account write access to this one file.
|
|
137
|
+
*/
|
|
138
|
+
export function signal() {
|
|
139
|
+
fs.writeFileSync(SIGNAL_PATH, '1');
|
|
140
|
+
}
|