ispyx 1.0.4 → 1.0.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ispyx",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
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",
@@ -98,12 +98,17 @@ export async function runUpdate() {
98
98
  });
99
99
  process.exit(code);
100
100
  }
101
+ // "==> <step>" lines: the panel shows them while it runs this (core/updates.js readUpdateLog)
102
+ const step = (s) => process.stdout.write(`\n==> ${s}\n`);
101
103
  const pipe = pipeName();
102
104
  const wasRunning = await residentRunning(pipe);
103
105
  if (wasRunning) {
104
- try { await callOwner(pipe, '__quit', {}); } catch { /* gone before answering */ }
106
+ step('Stopping ispyx');
107
+ // quietly: the `ispyx mcp` forwarders stay connected and reach the new resident on their next call
108
+ try { await callOwner(pipe, '__restart', {}); } catch { /* gone before answering */ }
105
109
  for (let i = 0; i < 40 && await residentRunning(pipe); i++) await sleep(150);
106
110
  }
111
+ step('Installing the latest ispyx (npm install -g ispyx@latest)');
107
112
  const code = await new Promise((resolve) => {
108
113
  const c = spawn('npm', ['install', '-g', 'ispyx@latest'], { stdio: 'inherit', shell: true });
109
114
  c.on('error', () => resolve(1));
@@ -111,7 +116,7 @@ export async function runUpdate() {
111
116
  });
112
117
  if (code !== 0) process.stderr.write('ispyx: the update failed (see the npm output above).\n');
113
118
  // Start the new copy: npm replaced the files in place, so a fresh process loads them.
114
- if (wasRunning) spawnResident();
119
+ if (wasRunning) { step('Starting ispyx'); spawnResident(); }
115
120
  process.exit(code);
116
121
  }
117
122
 
@@ -275,6 +275,12 @@ function render(s){
275
275
  var u=s.update;
276
276
  show("upd",!!(u&&u.available));
277
277
  if(u&&u.available&&!updating)setText("updTxt","Version "+u.latest+" is available.");
278
+ // while it runs: the installer's current step, or why it gave up; no progress for two minutes means it is stuck
279
+ if(updating&&u){
280
+ if(u.error)updateFailed("Update failed: "+u.error);
281
+ else if(u.step&&u.step!==updStep){updStep=u.step;updAt=Date.now();setText("updTxt",u.step+"\u2026")}
282
+ }
283
+ if(updating&&Date.now()-updAt>120000)updateFailed("The update did not finish. Details: update.log in the ispyx data folder.");
278
284
 
279
285
  show("sPerm",!!(perms&&perms.length));
280
286
  if(perms&&perms.length)renderPerms(perms);
@@ -428,13 +434,12 @@ $("log").addEventListener("click",function(){act("openLog")});
428
434
  $("showAll").addEventListener("click",function(){expanded=!expanded;if(last&&last.recent)renderRecent(last.recent,last.recentTotal)});
429
435
  $("clearAct").addEventListener("click",function(){act("clearRecent").then(poll)});
430
436
  // the update quits and restarts ispyx: this window then reports it has quit (macOS opens it again)
431
- var updating=false;
437
+ // a successful update quits this ispyx (this window reports it has quit; macOS opens the new version)
438
+ var updating=false,updStep="",updAt=0;
432
439
  function updateFailed(msg){updating=false;$("updBtn").disabled=false;setText("updTxt",msg)}
433
440
  $("updBtn").addEventListener("click",function(){
434
- updating=true;$("updBtn").disabled=true;setText("updTxt","Updating, ispyx restarts in a moment…");
441
+ updating=true;updStep="";updAt=Date.now();$("updBtn").disabled=true;setText("updTxt","Updating, ispyx restarts in a moment…");
435
442
  act("update").catch(function(err){updateFailed(err.message)});
436
- // a successful update quits this ispyx; still here after two minutes means it did not go through
437
- setTimeout(function(){if(updating)updateFailed("The update did not finish. Details: update.log in the ispyx data folder.")},120000);
438
443
  });
439
444
  $("end").addEventListener("click",function(){
440
445
  if(last&&last.platform==="darwin"){act("close");return}
@@ -122,6 +122,7 @@ export function panelModel(ctx) {
122
122
  const t0 = Date.now();
123
123
  // never from tests (fake platform): they must not depend on the network
124
124
  const updates = startUpdateCheck({ current: VERSION, enabled: config.updateCheck !== false && w.name !== 'fake' });
125
+ let running = null; // the update this panel started: its progress() is in the state
125
126
  const save = (patch) => {
126
127
  try { saveUserConfig(patch); } catch (e) { throw new PanelError(`Could not save the setting: ${e.message}`); }
127
128
  };
@@ -138,7 +139,7 @@ export function panelModel(ctx) {
138
139
  passwordSet: safe(() => auth.passwordSet(), false),
139
140
  authRequired: !!config.auth,
140
141
  denylist: config.denylist.slice(),
141
- ...(updates.state() ? { update: updates.state() } : {}),
142
+ ...(updates.state() ? { update: { ...updates.state(), ...(running ? running.progress() : {}) } } : {}),
142
143
  });
143
144
  const actions = {
144
145
  setEffects: (on) => { scan.enabled = !!on; config.animations = !!on; save({ animations: !!on }); return !!on; },
@@ -170,7 +171,8 @@ export function panelModel(ctx) {
170
171
  // the newer release: installs it in a process of its own, which quits and restarts ispyx (this one included)
171
172
  update: () => {
172
173
  if (!updates.state()?.available) throw new PanelError('This is the newest version.');
173
- launchUpdate({ platform: w.name, dataDir: w.dataDir() });
174
+ if (running && !running.progress().error) return true; // already on its way
175
+ running = launchUpdate({ platform: w.name, dataDir: w.dataDir() });
174
176
  return true;
175
177
  },
176
178
  checkUpdate: async () => { await updates.check(); return updates.state(); },
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // ispyx: local desktop-control MCP server (Windows; macOS in development). stdio only; opens no network port.
2
+ // ispyx: local desktop-control MCP server (Windows and macOS). stdio only; opens no network port.
3
3
  import fs from 'node:fs';
4
+ import path from 'node:path';
4
5
  import { spawn } from 'node:child_process';
5
6
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
@@ -44,6 +45,15 @@ function initCore() {
44
45
  guard.log({ tool: 'server_start', auth: config.auth, denylist: config.denylist, pid: process.pid });
45
46
  }
46
47
 
48
+ /**
49
+ * Leaves without ordering the forwarders to exit (an update): they stay connected to their AI apps and reach the
50
+ * new resident on their next call, which starts it if needed. `ispyx quit`, the tray Quit and the stop key end them.
51
+ */
52
+ function restartQuietly() {
53
+ residentUi?.stop();
54
+ setTimeout(() => process.exit(0), 100);
55
+ }
56
+
47
57
  /** Orders every connected server to exit, then leaves (second stop press, tray Quit, `ispyx quit`). */
48
58
  function shutdownAll() {
49
59
  residentUi?.stop();
@@ -179,6 +189,7 @@ function control(name) {
179
189
  if (name === '__ping') return 'pong';
180
190
  if (name === '__panel') return Promise.resolve(residentUi?.openPanel?.()).then(() => '');
181
191
  if (name === '__quit') { setTimeout(shutdownAll, 50); return 'bye'; }
192
+ if (name === '__restart') { setTimeout(restartQuietly, 50); return 'bye'; } // `ispyx update`: forwarders stay
182
193
  throw new Error(`Unknown control call ${name}`);
183
194
  }
184
195
 
@@ -808,7 +819,10 @@ if (mode === 'resident') {
808
819
  // forwarders too, so tell them first. If the app is gone without that (crash, kill), leave as well.
809
820
  const appPid = Number(process.env.ISPYX_APP_PID) || 0;
810
821
  if (appPid) {
811
- process.on('SIGTERM', shutdownAll);
822
+ // ...unless the app quits for an update (scripts/install.sh leaves this marker): then the forwarders stay
823
+ const marker = path.join(w.dataDir(), 'updating');
824
+ const updating = () => { try { return Date.now() - fs.statSync(marker).mtimeMs < 10 * 60 * 1000; } catch { return false; } };
825
+ process.on('SIGTERM', () => (updating() ? restartQuietly() : shutdownAll()));
812
826
  const t = setInterval(() => { try { process.kill(appPid, 0); } catch { shutdownAll(); } }, 1000);
813
827
  t.unref?.();
814
828
  }
@@ -64,15 +64,33 @@ export function startUpdateCheck({ current, enabled = true, firstDelayMs = 15000
64
64
  };
65
65
  }
66
66
 
67
+ /**
68
+ * Where a running update is, from its log: { step, error } ("Downloading Ispyx 1.0.6 · 45%", "Checking the app").
69
+ * The installer (scripts/install.sh) writes "==> <step>" lines, "Downloaded N%" while downloading and
70
+ * "ispyx: <reason>" when it gives up; `ispyx update` on Windows writes npm's output.
71
+ */
72
+ export function readUpdateLog(text) {
73
+ let step = 'Starting', pct = null, error = null;
74
+ for (const raw of String(text).split(/\r?\n/)) {
75
+ const line = raw.trim();
76
+ let m;
77
+ if ((m = /^==> (.+)$/.exec(line))) { step = m[1]; pct = null; }
78
+ else if ((m = /^Downloaded (\d+)%$/.exec(line))) pct = Number(m[1]);
79
+ else if ((m = /^ispyx: (.+)$/.exec(line))) error = m[1];
80
+ }
81
+ return { step: pct === null ? step : `${step} \u00b7 ${pct}%`, error };
82
+ }
83
+
67
84
  /**
68
85
  * Starts the update in a process of its own (the update quits the running ispyx, this process included) and
69
- * returns at once. Its output goes to update.log in the data directory.
86
+ * returns at once. Its output goes to update.log in the data directory; progress() reads it.
70
87
  */
71
88
  export function launchUpdate({ platform = process.platform, dataDir }) {
72
89
  let out = 'ignore';
90
+ const log = path.join(dataDir, 'update.log');
73
91
  try {
74
92
  fs.mkdirSync(dataDir, { recursive: true });
75
- out = fs.openSync(path.join(dataDir, 'update.log'), 'w');
93
+ out = fs.openSync(log, 'w');
76
94
  } catch { /* no log then */ }
77
95
  const child = platform === 'darwin'
78
96
  ? spawn('/bin/sh', ['-c', `curl -fsSL ${MAC_INSTALL_URL} | sh`], { detached: true, stdio: ['ignore', out, out] })
@@ -80,4 +98,5 @@ export function launchUpdate({ platform = process.platform, dataDir }) {
80
98
  child.on('error', () => {});
81
99
  child.unref();
82
100
  if (typeof out === 'number') fs.closeSync(out);
101
+ return { progress: () => { try { return readUpdateLog(fs.readFileSync(log, 'utf8')); } catch { return { step: 'Starting', error: null }; } } };
83
102
  }