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 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).
@@ -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.6",
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",
@@ -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
 
@@ -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']);
@@ -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})});
@@ -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
- // actions -> { quit(), setAutostart(bool), setEffects(bool), clearRecent(), showConfigFile() }
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
  }
@@ -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
+ }
@@ -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.
@@ -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()`.
@@ -28,4 +28,5 @@ export const dataDir = () => {
28
28
  };
29
29
  export { Layer, textMask } from './layer.js';
30
30
  export { Tray } from './tray.js';
31
+ export * as unlock from './unlock.js';
31
32
  export { autostart } from './autostart.js';
@@ -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
+ }