ispyx 1.0.7 → 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.7",
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"
@@ -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']);
@@ -156,6 +156,14 @@ button.link:hover{background:none;color:var(--fg)}
156
156
  </div>
157
157
  </section>
158
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
+
159
167
  <dialog id="dlg">
160
168
  <form id="dlgForm">
161
169
  <h3 id="dlgT"></h3>
@@ -317,6 +325,10 @@ function render(s){
317
325
  show("sSafe",hasPw||hasDeny||hasAuth);show("rPw",hasPw);show("rAuth",hasAuth);show("rDeny",hasDeny);
318
326
  if(hasPw){setText("pwTxt",s.passwordSet?"Password is set":"No password yet");setText("pwBtn",s.passwordSet?"Change…":"Set…")}
319
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;
320
332
  if(hasDeny){
321
333
  var d=$("deny"),sig=s.denylist.join("\n")+"|"+!!s.passwordSet;
322
334
  if(d.dataset.sig!==sig){ // rebuild only on change so a click is never lost to the 1 s refresh
@@ -417,6 +429,22 @@ $("authOn").addEventListener("change",function(){
417
429
  }
418
430
  act("setAuthRequired",{on:want,current:undefined}).catch(function(){box.checked=!want}).then(done);
419
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
+ });
420
448
  function removeDeny(name){
421
449
  if(last&&last.passwordSet){
422
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
  }
@@ -812,6 +812,21 @@ server.registerTool('set_clipboard', {
812
812
  return json({ ok: true, kind: 'file', path: p });
813
813
  }, { session: false }));
814
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
+
815
830
  // ---------------------------------------------------------------- run
816
831
 
817
832
  process.on('exit', () => guard?.releaseAll());
@@ -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
+ }