stikfix 1.6.0 → 1.6.2

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.
@@ -297,11 +297,19 @@ export function createLauncherFiles(opts) {
297
297
  const vbsHostExe = vq(hostExe);
298
298
  const vbsRoot = vq(rootArg);
299
299
  const vbsPortArg = opts.port !== undefined ? ` --port ${opts.port}` : '';
300
- // The hidden-launch line: exe mode runs `"<exe>" serve --root ...`, node
301
- // mode runs `node "<hostEntry>" --root ...`. Both go through `cmd /c` so a
302
- // relative `node` on PATH resolves and the window stays hidden (style 0).
300
+ // The hidden-launch line (window style 0).
301
+ //
302
+ // exe mode runs the ABSOLUTE exe path DIRECTLY via sh.Run NOT through
303
+ // `cmd /c`. Reason: `cmd /c "<quoted exe>" ... "<quoted root>"` starts and
304
+ // ends with a quote, so cmd.exe strips the outer pair (documented in
305
+ // `cmd /?`) and mangles the spaced "C:\Program Files\..." path → the host
306
+ // never starts. WScript.Shell.Run parses the quoted exe path correctly, so
307
+ // no cmd wrapper (and no PATH lookup) is needed for an absolute exe.
308
+ //
309
+ // node mode keeps `cmd /c` so a relative `node` on PATH resolves; that line
310
+ // begins with `node` (not a quote), so cmd's outer-quote strip never fires.
303
311
  const vbsRunLine = useExe
304
- ? `sh.Run "cmd /c """ & hostExe & """ serve --root """ & root & """${vbsPortArg}", 0, False`
312
+ ? `sh.Run """" & hostExe & """ serve --root """ & root & """${vbsPortArg}", 0, False`
305
313
  : `sh.Run "cmd /c node """ & hostEntry & """ --root """ & root & """${vbsPortArg}", 0, False`;
306
314
  const vbsContent = [
307
315
  'Option Explicit',
@@ -76,19 +76,42 @@ export function handleStartHost(cfg, root, spawnFn = spawn, out) {
76
76
  emit({ type: 'ERROR', error: `START_HOST root does not exist: ${resolvedRoot}` });
77
77
  return false;
78
78
  }
79
+ const started = spawnDetachedHost(cfg, resolvedRoot, spawnFn);
80
+ if (!started.ok) {
81
+ emit({ type: 'ERROR', error: `Failed to start host: ${started.error}` });
82
+ return false;
83
+ }
84
+ emit({ type: 'HOST_STARTING', root: resolvedRoot });
85
+ return true;
86
+ }
87
+ /**
88
+ * Spawn the HTTP host as a DETACHED process for `resolvedRoot`, WITHOUT writing
89
+ * any native-messaging frame — the caller owns the single wire response for this
90
+ * one-shot process. Returns {ok:true} on a successful spawn, {ok:false,error} on
91
+ * spawn failure.
92
+ *
93
+ * Shared by handleStartHost (which then emits HOST_STARTING) and the GET_TOKEN
94
+ * auto-start fallback (which then polls for the freshly-written token and emits
95
+ * TOKEN). Reuses the exact command resolution as before:
96
+ * - standalone exe install → `<hostExe> serve --root <root>`
97
+ * - npx/node install → `node <hostEntry> --root <root>`
98
+ *
99
+ * CRITICAL (T-09-07): the native host MUST NOT bind/listen itself — it only
100
+ * spawns a SEPARATE, detached process. NEVER writes to stdout (native-frame
101
+ * integrity, Pitfall 2). Detached + stdio:'ignore' + unref() so the child
102
+ * outlives this one-shot native process.
103
+ */
104
+ function spawnDetachedHost(cfg, resolvedRoot, spawnFn = spawn) {
79
105
  const nodePath = typeof cfg.nodePath === 'string' && cfg.nodePath.length > 0 ? cfg.nodePath : process.execPath;
80
- const hostEntry = resolveHostEntry(cfg);
81
- // Standalone-exe install path: when config points at a single-executable host,
82
- // spawn `<exe> serve --root <root>` (the exe self-detects the subcommand).
83
- // Otherwise keep the exact prior behavior: `node <hostEntry> --root <root>`.
84
106
  const hostExe = typeof cfg.hostExe === 'string' && cfg.hostExe.length > 0 ? cfg.hostExe : undefined;
85
107
  const spawnCmd = hostExe ?? nodePath;
108
+ // resolveHostEntry() is only evaluated when actually needed (non-exe/node
109
+ // install path) — calling it unconditionally in exe-mode would hit the
110
+ // __dirname fallback for no reason (and __dirname is undefined in the
111
+ // tsc/ESM test build; production's esbuild CJS bundle is unaffected).
86
112
  const spawnArgs = hostExe
87
113
  ? ['serve', '--root', resolvedRoot]
88
- : [hostEntry, '--root', resolvedRoot];
89
- // CRITICAL (T-09-07): the native host MUST NOT bind/listen itself. It only
90
- // spawns a SEPARATE, detached process that is the HTTP host. Detached +
91
- // stdio:'ignore' + unref() so the child outlives this one-shot native process.
114
+ : [resolveHostEntry(cfg), '--root', resolvedRoot];
92
115
  try {
93
116
  const child = spawnFn(spawnCmd, spawnArgs, {
94
117
  detached: true,
@@ -96,13 +119,77 @@ export function handleStartHost(cfg, root, spawnFn = spawn, out) {
96
119
  windowsHide: true,
97
120
  });
98
121
  child.unref();
122
+ return { ok: true };
99
123
  }
100
124
  catch (err) {
101
- emit({ type: 'ERROR', error: `Failed to start host: ${String(err.message)}` });
102
- return false;
125
+ return { ok: false, error: String(err.message) };
103
126
  }
104
- emit({ type: 'HOST_STARTING', root: resolvedRoot });
105
- return true;
127
+ }
128
+ /**
129
+ * Read the optional `.stikfix-port` for `tokenRoot`, then send the final TOKEN
130
+ * frame and exit(0). One-shot terminal step for a successful GET_TOKEN — the
131
+ * only stdout write on this path.
132
+ */
133
+ function sendTokenFrame(tokenRoot, token, name, notesDir) {
134
+ // Port is optional — read if present; SW falls back to port scan (A5 fallback).
135
+ let port;
136
+ try {
137
+ const raw = readFileSync(join(tokenRoot, '.stikfix-port'), 'utf8').trim();
138
+ const parsed = parseInt(raw, 10);
139
+ if (!isNaN(parsed)) {
140
+ port = parsed;
141
+ }
142
+ }
143
+ catch {
144
+ // .stikfix-port absent is OK — SW re-probes (A5)
145
+ }
146
+ sendNativeMessage({ type: 'TOKEN', token, port, name, notesDir });
147
+ process.exit(0);
148
+ }
149
+ /**
150
+ * GET_TOKEN self-heal: the `.stikfix-token` is missing because the HTTP host has
151
+ * never started (fresh install) or was stopped. Rather than fail the pairing and
152
+ * make the user hunt for a "start the host" step, auto-start the host (detached)
153
+ * and poll briefly for the token file it writes, then return TOKEN. This makes
154
+ * auto-pair work on the very first popup open with no manual step — the core
155
+ * reliability promise (a dropped note is a regression).
156
+ *
157
+ * NEVER writes to stdout until the single terminal frame (TOKEN or ERROR).
158
+ */
159
+ function autoStartAndReturnToken(cfg, tokenRoot, name, notesDir) {
160
+ const tokenPath = join(tokenRoot, '.stikfix-token');
161
+ const started = spawnDetachedHost(cfg, tokenRoot);
162
+ if (!started.ok) {
163
+ sendNativeMessage({
164
+ type: 'ERROR',
165
+ error: 'No .stikfix-token in ' + tokenRoot + ' and the host could not be started: ' + started.error,
166
+ });
167
+ process.exit(1);
168
+ }
169
+ // Poll ~5s (25 × 200ms) for the freshly-started host to bind + write the token.
170
+ const deadline = Date.now() + 5000;
171
+ const tick = () => {
172
+ let tok = '';
173
+ try {
174
+ tok = readFileSync(tokenPath, 'utf8').trim();
175
+ }
176
+ catch {
177
+ tok = '';
178
+ }
179
+ if (tok.length > 0) {
180
+ sendTokenFrame(tokenRoot, tok, name, notesDir);
181
+ return;
182
+ }
183
+ if (Date.now() >= deadline) {
184
+ sendNativeMessage({
185
+ type: 'ERROR',
186
+ error: 'Started the Stikfix host but it did not become ready in time (' + tokenRoot + ').',
187
+ });
188
+ process.exit(1);
189
+ }
190
+ setTimeout(tick, 200);
191
+ };
192
+ setTimeout(tick, 200);
106
193
  }
107
194
  // ---------------------------------------------------------------------------
108
195
  // PICK_FOLDER handler — open the OS dialog, validate, respond, exit (Pitfall 8)
@@ -196,38 +283,23 @@ export function main() {
196
283
  notesDir = join(resolvedRoot, 'notes');
197
284
  }
198
285
  // Token is required ONLY for GET_TOKEN — read it lazily here.
199
- let token;
286
+ let token = '';
200
287
  try {
201
288
  token = readFileSync(join(tokenRoot, '.stikfix-token'), 'utf8').trim();
202
289
  }
203
290
  catch {
204
- sendNativeMessage({
205
- type: 'ERROR',
206
- error: 'No .stikfix-token in ' + tokenRoot + '. Start the host first.',
207
- });
208
- process.exit(1);
209
- }
210
- // Port is optional — read if present; SW falls back to port scan (A5 fallback)
211
- let port;
212
- try {
213
- const raw = readFileSync(join(tokenRoot, '.stikfix-port'), 'utf8').trim();
214
- const parsed = parseInt(raw, 10);
215
- if (!isNaN(parsed)) {
216
- port = parsed;
217
- }
291
+ token = '';
218
292
  }
219
- catch {
220
- // .stikfix-port absent is OK SW re-probes (A5)
293
+ if (token.length > 0) {
294
+ // Happy path: host already running, token on disk → send it and exit.
295
+ sendTokenFrame(tokenRoot, token, name, notesDir);
296
+ return;
221
297
  }
222
- // Send token + port (if known) + host identity, then exit (one-shot)
223
- sendNativeMessage({
224
- type: 'TOKEN',
225
- token,
226
- port,
227
- name,
228
- notesDir,
229
- });
230
- process.exit(0);
298
+ // Token missing the host isn't running yet (fresh install / stopped).
299
+ // Self-heal: start the host and poll for the token, then send TOKEN. This
300
+ // keeps the terminal-frame contract (one TOKEN or one ERROR) intact.
301
+ autoStartAndReturnToken(cfg, tokenRoot, name, notesDir);
302
+ return;
231
303
  }
232
304
  if (m.type === 'START_HOST') {
233
305
  // Spawn a DETACHED HTTP host for the requested root, then exit (one-shot).
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { spawn } from 'node:child_process';
22
22
  import { writeFileSync, mkdirSync } from 'node:fs';
23
- import { join } from 'node:path';
23
+ import { dirname, join } from 'node:path';
24
24
  import { homedir } from 'node:os';
25
25
  /**
26
26
  * Directory + path where the embedded tray script is written on each start.
@@ -48,6 +48,7 @@ export function trayScriptPath() {
48
48
  */
49
49
  export const TRAY_PS1 = String.raw `param(
50
50
  [int]$Port,
51
+ [string]$IconPath,
51
52
  [string]$Root,
52
53
  [string]$Name,
53
54
  [string]$NotesDir,
@@ -67,6 +68,7 @@ try {
67
68
  # --- Resolve a custom app icon, else fall back to a stock icon -------------
68
69
  function Get-BaseIcon {
69
70
  $candidates = @(
71
+ $IconPath,
70
72
  (Join-Path $PSScriptRoot 'stikfix.ico'),
71
73
  (Join-Path $Root '.output\chrome-mv3\icon\stikfix.ico'),
72
74
  (Join-Path $Root 'public\icon\stikfix.ico')
@@ -193,6 +195,8 @@ export function startTray(opts) {
193
195
  const ps1Path = trayScriptPath();
194
196
  mkdirSync(join(homedir(), '.local', 'share', 'stikfix'), { recursive: true });
195
197
  writeFileSync(ps1Path, TRAY_PS1, { encoding: 'utf8' });
198
+ // Installed host ships stikfix.ico next to the exe ({app}\stikfix.ico). For npx/dev this points at node's dir and simply won't resolve — Get-BaseIcon falls back to a stock icon.
199
+ const iconPath = join(dirname(process.execPath), 'stikfix.ico');
196
200
  const child = spawn('powershell.exe', [
197
201
  '-NoProfile',
198
202
  '-ExecutionPolicy',
@@ -203,6 +207,8 @@ export function startTray(opts) {
203
207
  ps1Path,
204
208
  '-Port',
205
209
  String(opts.port),
210
+ '-IconPath',
211
+ iconPath,
206
212
  '-Root',
207
213
  opts.root,
208
214
  '-Name',
@@ -199,7 +199,7 @@ function createLauncherFiles(opts) {
199
199
  const vbsHostExe = vq(hostExe);
200
200
  const vbsRoot = vq(rootArg);
201
201
  const vbsPortArg = opts.port !== void 0 ? ` --port ${opts.port}` : "";
202
- const vbsRunLine = useExe ? `sh.Run "cmd /c """ & hostExe & """ serve --root """ & root & """${vbsPortArg}", 0, False` : `sh.Run "cmd /c node """ & hostEntry & """ --root """ & root & """${vbsPortArg}", 0, False`;
202
+ const vbsRunLine = useExe ? `sh.Run """" & hostExe & """ serve --root """ & root & """${vbsPortArg}", 0, False` : `sh.Run "cmd /c node """ & hostEntry & """ --root """ & root & """${vbsPortArg}", 0, False`;
203
203
  const vbsContent = [
204
204
  "Option Explicit",
205
205
  "Dim sh, fso, hostEntry, hostExe, root, portFile, msg, port, f",
@@ -211,11 +211,19 @@ function handleStartHost(cfg, root, spawnFn = import_node_child_process2.spawn,
211
211
  emit({ type: "ERROR", error: `START_HOST root does not exist: ${resolvedRoot}` });
212
212
  return false;
213
213
  }
214
+ const started = spawnDetachedHost(cfg, resolvedRoot, spawnFn);
215
+ if (!started.ok) {
216
+ emit({ type: "ERROR", error: `Failed to start host: ${started.error}` });
217
+ return false;
218
+ }
219
+ emit({ type: "HOST_STARTING", root: resolvedRoot });
220
+ return true;
221
+ }
222
+ function spawnDetachedHost(cfg, resolvedRoot, spawnFn = import_node_child_process2.spawn) {
214
223
  const nodePath = typeof cfg.nodePath === "string" && cfg.nodePath.length > 0 ? cfg.nodePath : process.execPath;
215
- const hostEntry = resolveHostEntry(cfg);
216
224
  const hostExe = typeof cfg.hostExe === "string" && cfg.hostExe.length > 0 ? cfg.hostExe : void 0;
217
225
  const spawnCmd = hostExe ?? nodePath;
218
- const spawnArgs = hostExe ? ["serve", "--root", resolvedRoot] : [hostEntry, "--root", resolvedRoot];
226
+ const spawnArgs = hostExe ? ["serve", "--root", resolvedRoot] : [resolveHostEntry(cfg), "--root", resolvedRoot];
219
227
  try {
220
228
  const child = spawnFn(spawnCmd, spawnArgs, {
221
229
  detached: true,
@@ -223,12 +231,56 @@ function handleStartHost(cfg, root, spawnFn = import_node_child_process2.spawn,
223
231
  windowsHide: true
224
232
  });
225
233
  child.unref();
234
+ return { ok: true };
226
235
  } catch (err) {
227
- emit({ type: "ERROR", error: `Failed to start host: ${String(err.message)}` });
228
- return false;
236
+ return { ok: false, error: String(err.message) };
229
237
  }
230
- emit({ type: "HOST_STARTING", root: resolvedRoot });
231
- return true;
238
+ }
239
+ function sendTokenFrame(tokenRoot, token, name, notesDir) {
240
+ let port;
241
+ try {
242
+ const raw = (0, import_node_fs2.readFileSync)((0, import_node_path2.join)(tokenRoot, ".stikfix-port"), "utf8").trim();
243
+ const parsed = parseInt(raw, 10);
244
+ if (!isNaN(parsed)) {
245
+ port = parsed;
246
+ }
247
+ } catch {
248
+ }
249
+ sendNativeMessage({ type: "TOKEN", token, port, name, notesDir });
250
+ process.exit(0);
251
+ }
252
+ function autoStartAndReturnToken(cfg, tokenRoot, name, notesDir) {
253
+ const tokenPath = (0, import_node_path2.join)(tokenRoot, ".stikfix-token");
254
+ const started = spawnDetachedHost(cfg, tokenRoot);
255
+ if (!started.ok) {
256
+ sendNativeMessage({
257
+ type: "ERROR",
258
+ error: "No .stikfix-token in " + tokenRoot + " and the host could not be started: " + started.error
259
+ });
260
+ process.exit(1);
261
+ }
262
+ const deadline = Date.now() + 5e3;
263
+ const tick = () => {
264
+ let tok = "";
265
+ try {
266
+ tok = (0, import_node_fs2.readFileSync)(tokenPath, "utf8").trim();
267
+ } catch {
268
+ tok = "";
269
+ }
270
+ if (tok.length > 0) {
271
+ sendTokenFrame(tokenRoot, tok, name, notesDir);
272
+ return;
273
+ }
274
+ if (Date.now() >= deadline) {
275
+ sendNativeMessage({
276
+ type: "ERROR",
277
+ error: "Started the Stikfix host but it did not become ready in time (" + tokenRoot + ")."
278
+ });
279
+ process.exit(1);
280
+ }
281
+ setTimeout(tick, 200);
282
+ };
283
+ setTimeout(tick, 200);
232
284
  }
233
285
  async function handlePickFolder(origin, pickFn = pickFolder, plat = process.platform, out) {
234
286
  let chosen;
@@ -277,33 +329,18 @@ function main() {
277
329
  name = (0, import_node_path2.basename)(resolvedRoot);
278
330
  notesDir = (0, import_node_path2.join)(resolvedRoot, "notes");
279
331
  }
280
- let token;
332
+ let token = "";
281
333
  try {
282
334
  token = (0, import_node_fs2.readFileSync)((0, import_node_path2.join)(tokenRoot, ".stikfix-token"), "utf8").trim();
283
335
  } catch {
284
- sendNativeMessage({
285
- type: "ERROR",
286
- error: "No .stikfix-token in " + tokenRoot + ". Start the host first."
287
- });
288
- process.exit(1);
336
+ token = "";
289
337
  }
290
- let port;
291
- try {
292
- const raw = (0, import_node_fs2.readFileSync)((0, import_node_path2.join)(tokenRoot, ".stikfix-port"), "utf8").trim();
293
- const parsed = parseInt(raw, 10);
294
- if (!isNaN(parsed)) {
295
- port = parsed;
296
- }
297
- } catch {
338
+ if (token.length > 0) {
339
+ sendTokenFrame(tokenRoot, token, name, notesDir);
340
+ return;
298
341
  }
299
- sendNativeMessage({
300
- type: "TOKEN",
301
- token,
302
- port,
303
- name,
304
- notesDir
305
- });
306
- process.exit(0);
342
+ autoStartAndReturnToken(cfg, tokenRoot, name, notesDir);
343
+ return;
307
344
  }
308
345
  if (m.type === "START_HOST") {
309
346
  const ok = handleStartHost(cfg, m.root);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stikfix",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "description": "Pin sticky notes on any page — your AI reads them.",
5
5
  "license": "MIT",
6
6
  "author": "Omer Nesher <omernesher@gmail.com>",