stikfix 1.5.0 → 1.6.1
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 +27 -7
- package/dist/host/src/bootstrap/register.js +116 -17
- package/dist/host/src/config.js +23 -7
- package/dist/host/src/doctor.js +459 -0
- package/dist/host/src/exe-main.js +254 -0
- package/dist/host/src/index.js +156 -118
- package/dist/host/src/native-host.js +140 -41
- package/dist/host/stikfix-init.cjs +61 -28
- package/dist/host/stikfix-native.cjs +81 -29
- package/dist/lib/pin-position.js +69 -0
- package/package.json +8 -1
|
@@ -76,25 +76,120 @@ 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
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
//
|
|
106
|
+
const hostExe = typeof cfg.hostExe === 'string' && cfg.hostExe.length > 0 ? cfg.hostExe : undefined;
|
|
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).
|
|
112
|
+
const spawnArgs = hostExe
|
|
113
|
+
? ['serve', '--root', resolvedRoot]
|
|
114
|
+
: [resolveHostEntry(cfg), '--root', resolvedRoot];
|
|
84
115
|
try {
|
|
85
|
-
const child = spawnFn(
|
|
116
|
+
const child = spawnFn(spawnCmd, spawnArgs, {
|
|
86
117
|
detached: true,
|
|
87
118
|
stdio: 'ignore',
|
|
88
119
|
windowsHide: true,
|
|
89
120
|
});
|
|
90
121
|
child.unref();
|
|
122
|
+
return { ok: true };
|
|
91
123
|
}
|
|
92
124
|
catch (err) {
|
|
93
|
-
|
|
94
|
-
return false;
|
|
125
|
+
return { ok: false, error: String(err.message) };
|
|
95
126
|
}
|
|
96
|
-
|
|
97
|
-
|
|
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);
|
|
98
193
|
}
|
|
99
194
|
// ---------------------------------------------------------------------------
|
|
100
195
|
// PICK_FOLDER handler — open the OS dialog, validate, respond, exit (Pitfall 8)
|
|
@@ -188,38 +283,23 @@ export function main() {
|
|
|
188
283
|
notesDir = join(resolvedRoot, 'notes');
|
|
189
284
|
}
|
|
190
285
|
// Token is required ONLY for GET_TOKEN — read it lazily here.
|
|
191
|
-
let token;
|
|
286
|
+
let token = '';
|
|
192
287
|
try {
|
|
193
288
|
token = readFileSync(join(tokenRoot, '.stikfix-token'), 'utf8').trim();
|
|
194
289
|
}
|
|
195
290
|
catch {
|
|
196
|
-
|
|
197
|
-
type: 'ERROR',
|
|
198
|
-
error: 'No .stikfix-token in ' + tokenRoot + '. Start the host first.',
|
|
199
|
-
});
|
|
200
|
-
process.exit(1);
|
|
201
|
-
}
|
|
202
|
-
// Port is optional — read if present; SW falls back to port scan (A5 fallback)
|
|
203
|
-
let port;
|
|
204
|
-
try {
|
|
205
|
-
const raw = readFileSync(join(tokenRoot, '.stikfix-port'), 'utf8').trim();
|
|
206
|
-
const parsed = parseInt(raw, 10);
|
|
207
|
-
if (!isNaN(parsed)) {
|
|
208
|
-
port = parsed;
|
|
209
|
-
}
|
|
291
|
+
token = '';
|
|
210
292
|
}
|
|
211
|
-
|
|
212
|
-
//
|
|
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;
|
|
213
297
|
}
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
name,
|
|
220
|
-
notesDir,
|
|
221
|
-
});
|
|
222
|
-
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;
|
|
223
303
|
}
|
|
224
304
|
if (m.type === 'START_HOST') {
|
|
225
305
|
// Spawn a DETACHED HTTP host for the requested root, then exit (one-shot).
|
|
@@ -239,12 +319,31 @@ export function main() {
|
|
|
239
319
|
process.exit(0);
|
|
240
320
|
});
|
|
241
321
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
322
|
+
/**
|
|
323
|
+
* Async-friendly entry alias for the SEA dispatcher (exe-main.ts). The native
|
|
324
|
+
* message loop is event-driven, so this resolves immediately after wiring up the
|
|
325
|
+
* stdin listener; the process stays alive until stdin closes (Chrome closes the
|
|
326
|
+
* pipe) or a handler calls process.exit. Delegates to main() so the direct
|
|
327
|
+
* require.main path below is unchanged.
|
|
328
|
+
*/
|
|
329
|
+
export async function runNativeHost() {
|
|
330
|
+
main();
|
|
331
|
+
}
|
|
332
|
+
function bundledInExe() {
|
|
333
|
+
try {
|
|
334
|
+
return typeof __STIKFIX_BUNDLED__ !== 'undefined' && __STIKFIX_BUNDLED__ === true;
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// Run only when invoked as the entry point (standalone stikfix-native.cjs),
|
|
341
|
+
// never on import. In that esbuild CJS bundle `require`/`module` are CJS scope
|
|
342
|
+
// locals and `require.main === module` is true when run directly. The node:test
|
|
343
|
+
// compile path emits ESM where these are undefined at runtime — the `typeof`
|
|
344
|
+
// guards keep main() from running on import there.
|
|
345
|
+
if (!bundledInExe() &&
|
|
346
|
+
typeof require !== 'undefined' &&
|
|
248
347
|
typeof module !== 'undefined' &&
|
|
249
348
|
require.main === module) {
|
|
250
349
|
main();
|
|
@@ -30,6 +30,8 @@ var REG_CHROME_KEY = `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${NA
|
|
|
30
30
|
var REG_EDGE_KEY = `HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`;
|
|
31
31
|
var REG_FIREFOX_KEY = `HKCU\\Software\\Mozilla\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`;
|
|
32
32
|
var DEFAULT_GECKO_ID = "stikfix@stikfix.com";
|
|
33
|
+
var CONFIG_DIR = (home) => (0, import_node_path2.join)(home, ".config", "stikfix");
|
|
34
|
+
var CONFIG_PATH = (home) => (0, import_node_path2.join)(CONFIG_DIR(home), "config.json");
|
|
33
35
|
var LAUNCHER_BATCH_FILENAME = "stikfix-host.bat";
|
|
34
36
|
var LAUNCHER_VBS_FILENAME = "stikfix-host.vbs";
|
|
35
37
|
var LAUNCHER_LNK_FILENAME = "Stikfix Host.lnk";
|
|
@@ -176,14 +178,17 @@ function createLauncherFiles(opts) {
|
|
|
176
178
|
const hostEntry = opts.hostEntryPath;
|
|
177
179
|
const rootArg = opts.root;
|
|
178
180
|
const portArg = opts.port !== void 0 ? ` --port ${opts.port}` : "";
|
|
181
|
+
const useExe = typeof opts.hostExe === "string" && opts.hostExe.length > 0;
|
|
182
|
+
const hostExe = opts.hostExe ?? "";
|
|
179
183
|
if (plat === "win32") {
|
|
184
|
+
const batchLaunch = useExe ? `"${hostExe}" serve --root "${rootArg}"${portArg}` : `"${nodeCmd}" "${hostEntry}" --root "${rootArg}"${portArg}`;
|
|
180
185
|
const batchContent = [
|
|
181
186
|
"@echo off",
|
|
182
187
|
`rem Stikfix HTTP host launcher \u2014 double-click to start the backend`,
|
|
183
188
|
`rem Generated by: npx stikfix init`,
|
|
184
189
|
`rem Root: ${rootArg}`,
|
|
185
190
|
``,
|
|
186
|
-
|
|
191
|
+
batchLaunch,
|
|
187
192
|
`if %ERRORLEVEL% NEQ 0 pause`
|
|
188
193
|
].join("\r\n");
|
|
189
194
|
const batchPath = paths.launcher;
|
|
@@ -191,17 +196,20 @@ function createLauncherFiles(opts) {
|
|
|
191
196
|
written.push(batchPath);
|
|
192
197
|
const vq = (s) => s.replace(/"/g, '""');
|
|
193
198
|
const vbsHostEntry = vq(hostEntry);
|
|
199
|
+
const vbsHostExe = vq(hostExe);
|
|
194
200
|
const vbsRoot = vq(rootArg);
|
|
195
201
|
const vbsPortArg = opts.port !== void 0 ? ` --port ${opts.port}` : "";
|
|
202
|
+
const vbsRunLine = useExe ? `sh.Run """" & hostExe & """ serve --root """ & root & """${vbsPortArg}", 0, False` : `sh.Run "cmd /c node """ & hostEntry & """ --root """ & root & """${vbsPortArg}", 0, False`;
|
|
196
203
|
const vbsContent = [
|
|
197
204
|
"Option Explicit",
|
|
198
|
-
"Dim sh, fso, hostEntry, root, portFile, msg, port, f",
|
|
205
|
+
"Dim sh, fso, hostEntry, hostExe, root, portFile, msg, port, f",
|
|
199
206
|
'Set sh = CreateObject("WScript.Shell")',
|
|
200
207
|
'Set fso = CreateObject("Scripting.FileSystemObject")',
|
|
201
208
|
`hostEntry = "${vbsHostEntry}"`,
|
|
209
|
+
`hostExe = "${vbsHostExe}"`,
|
|
202
210
|
`root = "${vbsRoot}"`,
|
|
203
211
|
"' Launch the host hidden (window style 0), do not wait",
|
|
204
|
-
|
|
212
|
+
vbsRunLine,
|
|
205
213
|
"' Give it a moment to bind and write the port file",
|
|
206
214
|
"WScript.Sleep 1800",
|
|
207
215
|
'msg = "Stikfix host is running." & vbCrLf & vbCrLf & "You can start dropping notes."',
|
|
@@ -218,21 +226,22 @@ function createLauncherFiles(opts) {
|
|
|
218
226
|
(0, import_node_fs.writeFileSync)(vbsPath, vbsContent, { encoding: "utf8" });
|
|
219
227
|
written.push(vbsPath);
|
|
220
228
|
if (paths.shortcut) {
|
|
221
|
-
const
|
|
229
|
+
const guessedLnkPath = paths.shortcut;
|
|
222
230
|
const iconPath = opts.iconPath ?? "";
|
|
223
|
-
const
|
|
231
|
+
const safeLnkFileName = LAUNCHER_LNK_FILENAME.replace(/'/g, "''");
|
|
224
232
|
const safeIconPath = iconPath.replace(/'/g, "''");
|
|
225
233
|
const wscriptPath = (0, import_node_path2.join)(process.env["SystemRoot"] ?? "C:\\Windows", "System32", "wscript.exe");
|
|
226
234
|
const safeWscript = wscriptPath.replace(/'/g, "''");
|
|
227
235
|
const safeVbsPath = vbsPath.replace(/'/g, "''");
|
|
228
|
-
const psScript = `$ws = New-Object -ComObject WScript.Shell;$s = $ws.CreateShortcut(
|
|
236
|
+
const psScript = `$desktop = [Environment]::GetFolderPath('Desktop');$lnkPath = Join-Path $desktop '${safeLnkFileName}';$ws = New-Object -ComObject WScript.Shell;$s = $ws.CreateShortcut($lnkPath);$s.TargetPath = '${safeWscript}';$s.Arguments = '"${safeVbsPath}"';$s.Description = 'Start the Stikfix HTTP backend host';` + (safeIconPath ? `$s.IconLocation = '${safeIconPath},0';` : "") + `$s.Save();Write-Output $lnkPath`;
|
|
229
237
|
try {
|
|
230
|
-
(0, import_node_child_process.execFileSync)(
|
|
238
|
+
const stdout = (0, import_node_child_process.execFileSync)(
|
|
231
239
|
"powershell.exe",
|
|
232
240
|
["-NoProfile", "-NonInteractive", "-Command", psScript],
|
|
233
|
-
{ timeout: 15e3,
|
|
241
|
+
{ timeout: 15e3, encoding: "utf8" }
|
|
234
242
|
);
|
|
235
|
-
|
|
243
|
+
const resolvedLnkPath = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).pop() ?? guessedLnkPath;
|
|
244
|
+
written.push(resolvedLnkPath);
|
|
236
245
|
} catch (err) {
|
|
237
246
|
warnings.push(
|
|
238
247
|
`Desktop shortcut creation failed (non-fatal): ${String(err.message)}. The batch file at ${batchPath} can be used directly.`
|
|
@@ -245,7 +254,7 @@ function createLauncherFiles(opts) {
|
|
|
245
254
|
`# Stikfix HTTP host launcher \u2014 double-click in Finder or drag to Dock`,
|
|
246
255
|
`# Generated by: npx stikfix init`,
|
|
247
256
|
``,
|
|
248
|
-
`exec "${nodeCmd}" "${hostEntry}" --root "${rootArg}"${portArg}`
|
|
257
|
+
useExe ? `exec "${hostExe}" serve --root "${rootArg}"${portArg}` : `exec "${nodeCmd}" "${hostEntry}" --root "${rootArg}"${portArg}`
|
|
249
258
|
].join("\n");
|
|
250
259
|
const commandPath = paths.launcher;
|
|
251
260
|
(0, import_node_fs.writeFileSync)(commandPath, commandContent, { encoding: "utf8", mode: 493 });
|
|
@@ -256,7 +265,7 @@ function createLauncherFiles(opts) {
|
|
|
256
265
|
`# Stikfix HTTP host launcher`,
|
|
257
266
|
`# Generated by: npx stikfix init`,
|
|
258
267
|
``,
|
|
259
|
-
`exec "${nodeCmd}" "${hostEntry}" --root "${rootArg}"${portArg}`
|
|
268
|
+
useExe ? `exec "${hostExe}" serve --root "${rootArg}"${portArg}` : `exec "${nodeCmd}" "${hostEntry}" --root "${rootArg}"${portArg}`
|
|
260
269
|
].join("\n");
|
|
261
270
|
const shPath = paths.launcher;
|
|
262
271
|
(0, import_node_fs.writeFileSync)(shPath, shContent, { encoding: "utf8" });
|
|
@@ -321,8 +330,8 @@ function registerNativeHost(opts) {
|
|
|
321
330
|
const home = opts.home ?? (0, import_node_os.homedir)();
|
|
322
331
|
const browser = opts.browser ?? "chrome";
|
|
323
332
|
const manifestPath = nativeManifestPath(plat, home, browser);
|
|
324
|
-
const
|
|
325
|
-
const manifest = buildManifest(opts.extensionId,
|
|
333
|
+
const manifestTarget = opts.directPath ? (0, import_node_path2.resolve)(opts.hostBinPath) : writeNativeWrapper(opts.hostBinPath, plat, home, browser);
|
|
334
|
+
const manifest = buildManifest(opts.extensionId, manifestTarget, browser);
|
|
326
335
|
writeManifest(manifest, manifestPath);
|
|
327
336
|
if (plat === "win32") {
|
|
328
337
|
const execReg = opts.execReg ?? ((args) => {
|
|
@@ -373,6 +382,34 @@ function unregisterNativeHost(opts = {}) {
|
|
|
373
382
|
}
|
|
374
383
|
}
|
|
375
384
|
}
|
|
385
|
+
function teardownHost(opts = {}) {
|
|
386
|
+
const plat = opts.plat ?? process.platform;
|
|
387
|
+
const home = opts.home ?? (0, import_node_os.homedir)();
|
|
388
|
+
const browser = opts.browser ?? "chrome";
|
|
389
|
+
const result = {
|
|
390
|
+
manifestRemoved: true,
|
|
391
|
+
configPath: CONFIG_PATH(home),
|
|
392
|
+
configRemoved: false
|
|
393
|
+
};
|
|
394
|
+
try {
|
|
395
|
+
unregisterNativeHost({ plat, home, browser });
|
|
396
|
+
} catch (err) {
|
|
397
|
+
result.manifestRemoved = false;
|
|
398
|
+
result.manifestError = err instanceof Error ? err.message : String(err);
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
unregisterStartup({ plat, home });
|
|
402
|
+
} catch (err) {
|
|
403
|
+
result.startupError = err instanceof Error ? err.message : String(err);
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
(0, import_node_fs.rmSync)(result.configPath, { force: true });
|
|
407
|
+
result.configRemoved = true;
|
|
408
|
+
} catch {
|
|
409
|
+
result.configRemoved = false;
|
|
410
|
+
}
|
|
411
|
+
return result;
|
|
412
|
+
}
|
|
376
413
|
|
|
377
414
|
// host/src/extension-id.ts
|
|
378
415
|
var import_node_crypto2 = require("node:crypto");
|
|
@@ -430,8 +467,8 @@ function resolveStartupChoice(forceOn, forceOff, isTTY) {
|
|
|
430
467
|
}
|
|
431
468
|
return false;
|
|
432
469
|
}
|
|
433
|
-
var
|
|
434
|
-
var
|
|
470
|
+
var CONFIG_DIR2 = (0, import_node_path3.join)((0, import_node_os2.homedir)(), ".config", "stikfix");
|
|
471
|
+
var CONFIG_PATH2 = (0, import_node_path3.join)(CONFIG_DIR2, "config.json");
|
|
435
472
|
if (subcommand === "init") {
|
|
436
473
|
const rawRoot = values["root"];
|
|
437
474
|
const rawExtId = values["extension-id"];
|
|
@@ -449,9 +486,9 @@ if (subcommand === "init") {
|
|
|
449
486
|
const name = (0, import_node_path3.basename)(root);
|
|
450
487
|
const notesDir = (0, import_node_path3.join)(root, "notes");
|
|
451
488
|
const hostEntryPath = (0, import_node_path3.resolve)((0, import_node_path3.join)(__dirname, "src", "index.js"));
|
|
452
|
-
(0, import_node_fs2.mkdirSync)(
|
|
489
|
+
(0, import_node_fs2.mkdirSync)(CONFIG_DIR2, { recursive: true });
|
|
453
490
|
const config = { root, name, notesDir, hostEntry: hostEntryPath, nodePath: process.execPath };
|
|
454
|
-
(0, import_node_fs2.writeFileSync)(
|
|
491
|
+
(0, import_node_fs2.writeFileSync)(CONFIG_PATH2, JSON.stringify(config, null, 2), { encoding: "utf8", mode: 384 });
|
|
455
492
|
const hostBinPath = (0, import_node_path3.resolve)((0, import_node_path3.join)(__dirname, "stikfix-native.cjs"));
|
|
456
493
|
try {
|
|
457
494
|
registerNativeHost({ extensionId, hostBinPath, browser });
|
|
@@ -540,9 +577,9 @@ if (subcommand === "init") {
|
|
|
540
577
|
console.log("");
|
|
541
578
|
console.log(" 2. Start the backend \u2014 double-click the desktop launcher:");
|
|
542
579
|
if (process.platform === "win32") {
|
|
543
|
-
const lnkPath =
|
|
580
|
+
const lnkPath = launcherResult.written.find((p) => p.endsWith(".lnk")) ?? "";
|
|
544
581
|
const batchPath = launcherResult.written.find((p) => p.endsWith(".bat")) ?? "";
|
|
545
|
-
if ((0, import_node_fs2.existsSync)(lnkPath)) {
|
|
582
|
+
if (lnkPath && (0, import_node_fs2.existsSync)(lnkPath)) {
|
|
546
583
|
console.log(' Desktop shortcut: "Stikfix Host" (icon on your Desktop)');
|
|
547
584
|
} else if (batchPath) {
|
|
548
585
|
console.log(" Batch file: " + batchPath);
|
|
@@ -567,17 +604,13 @@ if (subcommand === "init") {
|
|
|
567
604
|
console.log(" npx --yes stikfix@latest init --root " + root);
|
|
568
605
|
} else if (subcommand === "uninstall") {
|
|
569
606
|
const browser = resolveBrowser(values["browser"]);
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
console.error("stikfix uninstall: error removing native-host manifest:", String(err));
|
|
607
|
+
const teardown = teardownHost({ browser });
|
|
608
|
+
if (teardown.manifestError) {
|
|
609
|
+
console.error("stikfix uninstall: error removing native-host manifest:", teardown.manifestError);
|
|
574
610
|
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
} catch (err) {
|
|
578
|
-
console.error("stikfix uninstall: error removing startup entry:", String(err));
|
|
611
|
+
if (teardown.startupError) {
|
|
612
|
+
console.error("stikfix uninstall: error removing startup entry:", teardown.startupError);
|
|
579
613
|
}
|
|
580
|
-
(0, import_node_fs2.rmSync)(CONFIG_PATH, { force: true });
|
|
581
614
|
console.log("stikfix: native host unregistered.");
|
|
582
615
|
console.log(" manifest removed");
|
|
583
616
|
console.log(" launcher files removed");
|
|
@@ -23,6 +23,7 @@ __export(native_host_exports, {
|
|
|
23
23
|
handlePickFolder: () => handlePickFolder,
|
|
24
24
|
handleStartHost: () => handleStartHost,
|
|
25
25
|
main: () => main,
|
|
26
|
+
runNativeHost: () => runNativeHost,
|
|
26
27
|
validateChosenFolder: () => validateChosenFolder
|
|
27
28
|
});
|
|
28
29
|
module.exports = __toCommonJS(native_host_exports);
|
|
@@ -210,21 +211,76 @@ function handleStartHost(cfg, root, spawnFn = import_node_child_process2.spawn,
|
|
|
210
211
|
emit({ type: "ERROR", error: `START_HOST root does not exist: ${resolvedRoot}` });
|
|
211
212
|
return false;
|
|
212
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) {
|
|
213
223
|
const nodePath = typeof cfg.nodePath === "string" && cfg.nodePath.length > 0 ? cfg.nodePath : process.execPath;
|
|
214
|
-
const
|
|
224
|
+
const hostExe = typeof cfg.hostExe === "string" && cfg.hostExe.length > 0 ? cfg.hostExe : void 0;
|
|
225
|
+
const spawnCmd = hostExe ?? nodePath;
|
|
226
|
+
const spawnArgs = hostExe ? ["serve", "--root", resolvedRoot] : [resolveHostEntry(cfg), "--root", resolvedRoot];
|
|
215
227
|
try {
|
|
216
|
-
const child = spawnFn(
|
|
228
|
+
const child = spawnFn(spawnCmd, spawnArgs, {
|
|
217
229
|
detached: true,
|
|
218
230
|
stdio: "ignore",
|
|
219
231
|
windowsHide: true
|
|
220
232
|
});
|
|
221
233
|
child.unref();
|
|
234
|
+
return { ok: true };
|
|
222
235
|
} catch (err) {
|
|
223
|
-
|
|
224
|
-
return false;
|
|
236
|
+
return { ok: false, error: String(err.message) };
|
|
225
237
|
}
|
|
226
|
-
|
|
227
|
-
|
|
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);
|
|
228
284
|
}
|
|
229
285
|
async function handlePickFolder(origin, pickFn = pickFolder, plat = process.platform, out) {
|
|
230
286
|
let chosen;
|
|
@@ -273,33 +329,18 @@ function main() {
|
|
|
273
329
|
name = (0, import_node_path2.basename)(resolvedRoot);
|
|
274
330
|
notesDir = (0, import_node_path2.join)(resolvedRoot, "notes");
|
|
275
331
|
}
|
|
276
|
-
let token;
|
|
332
|
+
let token = "";
|
|
277
333
|
try {
|
|
278
334
|
token = (0, import_node_fs2.readFileSync)((0, import_node_path2.join)(tokenRoot, ".stikfix-token"), "utf8").trim();
|
|
279
335
|
} catch {
|
|
280
|
-
|
|
281
|
-
type: "ERROR",
|
|
282
|
-
error: "No .stikfix-token in " + tokenRoot + ". Start the host first."
|
|
283
|
-
});
|
|
284
|
-
process.exit(1);
|
|
336
|
+
token = "";
|
|
285
337
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const parsed = parseInt(raw, 10);
|
|
290
|
-
if (!isNaN(parsed)) {
|
|
291
|
-
port = parsed;
|
|
292
|
-
}
|
|
293
|
-
} catch {
|
|
338
|
+
if (token.length > 0) {
|
|
339
|
+
sendTokenFrame(tokenRoot, token, name, notesDir);
|
|
340
|
+
return;
|
|
294
341
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
token,
|
|
298
|
-
port,
|
|
299
|
-
name,
|
|
300
|
-
notesDir
|
|
301
|
-
});
|
|
302
|
-
process.exit(0);
|
|
342
|
+
autoStartAndReturnToken(cfg, tokenRoot, name, notesDir);
|
|
343
|
+
return;
|
|
303
344
|
}
|
|
304
345
|
if (m.type === "START_HOST") {
|
|
305
346
|
const ok = handleStartHost(cfg, m.root);
|
|
@@ -315,7 +356,17 @@ function main() {
|
|
|
315
356
|
process.exit(0);
|
|
316
357
|
});
|
|
317
358
|
}
|
|
318
|
-
|
|
359
|
+
async function runNativeHost() {
|
|
360
|
+
main();
|
|
361
|
+
}
|
|
362
|
+
function bundledInExe() {
|
|
363
|
+
try {
|
|
364
|
+
return typeof __STIKFIX_BUNDLED__ !== "undefined" && __STIKFIX_BUNDLED__ === true;
|
|
365
|
+
} catch {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (!bundledInExe() && typeof require !== "undefined" && typeof module !== "undefined" && require.main === module) {
|
|
319
370
|
main();
|
|
320
371
|
}
|
|
321
372
|
// Annotate the CommonJS export names for ESM import in node:
|
|
@@ -323,5 +374,6 @@ if (typeof require !== "undefined" && typeof module !== "undefined" && require.m
|
|
|
323
374
|
handlePickFolder,
|
|
324
375
|
handleStartHost,
|
|
325
376
|
main,
|
|
377
|
+
runNativeHost,
|
|
326
378
|
validateChosenFolder
|
|
327
379
|
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL-path matching and pin position computation for stikfix persistent pins.
|
|
3
|
+
*
|
|
4
|
+
* matchesUrlPath + computePinPosition — pure, node:test-safe (no DOM/chrome at module level).
|
|
5
|
+
* Scroll offsets and anchor rects are PASSED AS PARAMETERS by the caller (pin.ts in Plan 04),
|
|
6
|
+
* which supplies window.scrollX/scrollY and el.getBoundingClientRect() at render time.
|
|
7
|
+
*
|
|
8
|
+
* INVARIANT: No top-level browser API access — no window/document/chrome references.
|
|
9
|
+
* This module imports cleanly under node:test.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Compare two URLs by pathname only, ignoring query string and fragment (D-02).
|
|
13
|
+
* Returns false (not throws) on any malformed URL input.
|
|
14
|
+
*
|
|
15
|
+
* @param noteUrl The URL stored in the note's YAML frontmatter
|
|
16
|
+
* @param pageUrl The current page URL from chrome.tabs.get
|
|
17
|
+
*/
|
|
18
|
+
export function matchesUrlPath(noteUrl, pageUrl) {
|
|
19
|
+
try {
|
|
20
|
+
const notePath = new URL(noteUrl).pathname;
|
|
21
|
+
const pagePath = new URL(pageUrl).pathname;
|
|
22
|
+
return notePath === pagePath;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Compute the CSS viewport position for an on-page pin — pure, DOM-free.
|
|
30
|
+
*
|
|
31
|
+
* Branch logic:
|
|
32
|
+
* - orphaned || anchorRect === null: orphaned-fallback at last-known page-absolute
|
|
33
|
+
* rect minus scroll offsets → { left: storedRect.x - scrollX, top: storedRect.y - scrollY, orphaned: true }
|
|
34
|
+
* - !orphaned && anchorRect !== null: use anchorRect x/y directly (already in
|
|
35
|
+
* fixed/viewport coords from getBoundingClientRect or stored viewport coords
|
|
36
|
+
* for free notes) → { left: anchorRect.x, top: anchorRect.y, orphaned: false }
|
|
37
|
+
*
|
|
38
|
+
* The caller (pin.ts) passes:
|
|
39
|
+
* - anchorRect: el.getBoundingClientRect() for element pins, stored viewport
|
|
40
|
+
* coords {x,y,width:0,height:0} for free pins, or null when selector misses.
|
|
41
|
+
* - storedRect: page-absolute rect from frontmatter (for orphaned-fallback).
|
|
42
|
+
* - scrollX/scrollY: window.scrollX / window.scrollY at call time.
|
|
43
|
+
* - orphaned: true when the selector re-query returned null.
|
|
44
|
+
*
|
|
45
|
+
* NEVER reads window.scrollX or window.scrollY internally — caller supplies them.
|
|
46
|
+
*
|
|
47
|
+
* @param anchorRect Live fixed-coord rect from getBoundingClientRect, or null
|
|
48
|
+
* @param storedRect Page-absolute rect from frontmatter (orphaned fallback), or null
|
|
49
|
+
* @param scrollX window.scrollX at call time (passed by caller)
|
|
50
|
+
* @param scrollY window.scrollY at call time (passed by caller)
|
|
51
|
+
* @param orphaned true when selector matched nothing on the current page
|
|
52
|
+
*/
|
|
53
|
+
export function computePinPosition(anchorRect, storedRect, scrollX, scrollY, orphaned) {
|
|
54
|
+
if (orphaned || anchorRect === null) {
|
|
55
|
+
// Orphaned fallback: last-known page-absolute rect → convert to viewport via scroll
|
|
56
|
+
return {
|
|
57
|
+
left: (storedRect?.x ?? 0) - scrollX,
|
|
58
|
+
top: (storedRect?.y ?? 0) - scrollY,
|
|
59
|
+
orphaned: true,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// Element-anchored (live getBoundingClientRect in fixed/viewport coords)
|
|
63
|
+
// OR free-floating (stored viewport coords already in anchorRect x/y)
|
|
64
|
+
return {
|
|
65
|
+
left: anchorRect.x,
|
|
66
|
+
top: anchorRect.y,
|
|
67
|
+
orphaned: false,
|
|
68
|
+
};
|
|
69
|
+
}
|