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
|
|
301
|
-
//
|
|
302
|
-
//
|
|
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 "
|
|
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
|
-
: [
|
|
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
|
-
|
|
102
|
-
return false;
|
|
125
|
+
return { ok: false, error: String(err.message) };
|
|
103
126
|
}
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
//
|
|
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
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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).
|
package/dist/host/src/tray.js
CHANGED
|
@@ -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 "
|
|
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] : [
|
|
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
|
-
|
|
228
|
-
return false;
|
|
236
|
+
return { ok: false, error: String(err.message) };
|
|
229
237
|
}
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
285
|
-
type: "ERROR",
|
|
286
|
-
error: "No .stikfix-token in " + tokenRoot + ". Start the host first."
|
|
287
|
-
});
|
|
288
|
-
process.exit(1);
|
|
336
|
+
token = "";
|
|
289
337
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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);
|