stikfix 1.2.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/LICENSE +21 -0
- package/README.md +187 -0
- package/dist/host/src/bind.js +69 -0
- package/dist/host/src/bootstrap/register.js +556 -0
- package/dist/host/src/config.js +147 -0
- package/dist/host/src/extension-id.js +49 -0
- package/dist/host/src/folder-picker.js +142 -0
- package/dist/host/src/index.js +110 -0
- package/dist/host/src/native-host.js +143 -0
- package/dist/host/src/native-msg.js +100 -0
- package/dist/host/src/probe.js +62 -0
- package/dist/host/src/read-note.js +175 -0
- package/dist/host/src/security.js +76 -0
- package/dist/host/src/serial.js +32 -0
- package/dist/host/src/server.js +451 -0
- package/dist/host/src/types.js +5 -0
- package/dist/host/src/validate-folder.js +68 -0
- package/dist/host/src/write-note.js +158 -0
- package/dist/host/stikfix-init.cjs +500 -0
- package/dist/host/stikfix-native.cjs +257 -0
- package/package.json +66 -0
- package/public/icon/128.png +0 -0
- package/public/icon/16.png +0 -0
- package/public/icon/256.png +0 -0
- package/public/icon/32.png +0 -0
- package/public/icon/48.png +0 -0
- package/public/icon/stikfix.ico +0 -0
- package/public/icon/stikfix.svg +47 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native-messaging manifest writer, per-OS path resolver, Windows registry
|
|
3
|
+
* registration, uninstall enumerator, and desktop launcher creator for stikfix.
|
|
4
|
+
*
|
|
5
|
+
* Node builtins only — no WXT, no Chrome imports.
|
|
6
|
+
*
|
|
7
|
+
* Security:
|
|
8
|
+
* - T-09-02: buildManifest resolves an ABSOLUTE path (Pitfall 4)
|
|
9
|
+
* - T-09-03: Windows registry writes use HKCU (Pitfall 5), execFileSync (never exec)
|
|
10
|
+
* - T-09-05: enumerateArtifacts lists every init-created artifact (ONB-05)
|
|
11
|
+
* - Launcher shortcut: execFile(powershell, [argArray]) — NEVER exec or shell interpolation
|
|
12
|
+
*
|
|
13
|
+
* Analog: host/src/config.ts (mkdirSync/writeFileSync/existsSync/rmSync patterns)
|
|
14
|
+
*/
|
|
15
|
+
import { mkdirSync, writeFileSync, rmSync, chmodSync } from 'node:fs';
|
|
16
|
+
import { resolve, join, dirname } from 'node:path';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
19
|
+
import { isInsideDir } from '../security.js';
|
|
20
|
+
// The native host name — must match the manifest JSON `name` field and the
|
|
21
|
+
// value passed to chrome.runtime.sendNativeMessage in background.ts.
|
|
22
|
+
const NATIVE_HOST_NAME = 'com.stikfix.host';
|
|
23
|
+
const MANIFEST_FILENAME = `${NATIVE_HOST_NAME}.json`;
|
|
24
|
+
// On Windows the Firefox manifest shares the stikfix data dir with the Chrome
|
|
25
|
+
// one, so it needs a distinct filename to avoid clobbering the Chrome manifest.
|
|
26
|
+
// (On macOS/Linux Firefox uses a separate Mozilla dir, so this filename only
|
|
27
|
+
// differs the win32 on-disk JSON; the registry value points straight at it.)
|
|
28
|
+
const FIREFOX_MANIFEST_FILENAME = `${NATIVE_HOST_NAME}.firefox.json`;
|
|
29
|
+
// Native-messaging launcher wrapper file names. On Windows, Chrome launches the
|
|
30
|
+
// native host via CreateProcess, which cannot execute a .cjs directly — so the
|
|
31
|
+
// manifest must point at a wrapper that runs `node <abs cjs>`.
|
|
32
|
+
const NATIVE_WRAPPER_WIN = `${NATIVE_HOST_NAME}.bat`;
|
|
33
|
+
const NATIVE_WRAPPER_NIX = `${NATIVE_HOST_NAME}.sh`;
|
|
34
|
+
// Firefox wrapper file names — MUST be distinct from the Chrome wrapper. On
|
|
35
|
+
// Windows both browsers share the stikfix data dir, so a shared wrapper file
|
|
36
|
+
// would be deleted by a Firefox uninstall and break a co-installed Chrome whose
|
|
37
|
+
// manifest still references that absolute path (cross-browser regression). The
|
|
38
|
+
// suffix mirrors FIREFOX_MANIFEST_FILENAME so manifest+wrapper stay paired.
|
|
39
|
+
const NATIVE_WRAPPER_WIN_FIREFOX = `${NATIVE_HOST_NAME}.firefox.bat`;
|
|
40
|
+
const NATIVE_WRAPPER_NIX_FIREFOX = `${NATIVE_HOST_NAME}.firefox.sh`;
|
|
41
|
+
// Chrome/Edge registry key prefixes (Windows HKCU — Pitfall 5: never HKLM)
|
|
42
|
+
const REG_CHROME_KEY = `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`;
|
|
43
|
+
const REG_EDGE_KEY = `HKCU\\Software\\Microsoft\\Edge\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`;
|
|
44
|
+
// Firefox registry key prefix (Windows HKCU — same HKCU rule as Chrome/Edge)
|
|
45
|
+
const REG_FIREFOX_KEY = `HKCU\\Software\\Mozilla\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`;
|
|
46
|
+
// The default add-on id used by the Firefox path. Must match
|
|
47
|
+
// browser_specific_settings.gecko.id in wxt.config.ts and the
|
|
48
|
+
// allowed_extensions entry in the Firefox native-messaging manifest.
|
|
49
|
+
export const DEFAULT_GECKO_ID = 'stikfix@stikfix.com';
|
|
50
|
+
// Config file location (read by native host at startup)
|
|
51
|
+
const CONFIG_DIR = (home) => join(home, '.config', 'stikfix');
|
|
52
|
+
const CONFIG_PATH = (home) => join(CONFIG_DIR(home), 'config.json');
|
|
53
|
+
// Launcher file names used across functions
|
|
54
|
+
const LAUNCHER_BATCH_FILENAME = 'stikfix-host.bat';
|
|
55
|
+
const LAUNCHER_VBS_FILENAME = 'stikfix-host.vbs';
|
|
56
|
+
const LAUNCHER_LNK_FILENAME = 'Stikfix Host.lnk';
|
|
57
|
+
const LAUNCHER_COMMAND_FILENAME = 'stikfix-host.command';
|
|
58
|
+
const LAUNCHER_DESKTOP_FILENAME = 'stikfix-host.desktop';
|
|
59
|
+
const LAUNCHER_SH_FILENAME = 'stikfix-host.sh';
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// nativeManifestPath — per-OS path resolver
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
/**
|
|
64
|
+
* Return the absolute path where the native-messaging manifest JSON should
|
|
65
|
+
* be written for the given platform and home directory.
|
|
66
|
+
*
|
|
67
|
+
* Paths verified via Chrome + Edge native-messaging docs (RESEARCH Pattern 4):
|
|
68
|
+
* darwin: ~/Library/App Support/Google/Chrome/NativeMessagingHosts/
|
|
69
|
+
* linux: ~/.config/google-chrome/NativeMessagingHosts/
|
|
70
|
+
* win32: ~/.local/share/stikfix/ (manifest only; registry key written separately)
|
|
71
|
+
*/
|
|
72
|
+
export function nativeManifestPath(plat = process.platform, home = homedir(), browser = 'chrome') {
|
|
73
|
+
if (browser === 'firefox') {
|
|
74
|
+
switch (plat) {
|
|
75
|
+
case 'darwin':
|
|
76
|
+
return join(home, 'Library', 'Application Support', 'Mozilla', 'NativeMessagingHosts', MANIFEST_FILENAME);
|
|
77
|
+
case 'linux':
|
|
78
|
+
return join(home, '.mozilla', 'native-messaging-hosts', MANIFEST_FILENAME);
|
|
79
|
+
case 'win32':
|
|
80
|
+
// Windows: the manifest JSON lives on disk in the stikfix data dir
|
|
81
|
+
// (same place as the Chrome one would, but a distinct filename via the
|
|
82
|
+
// firefox suffix so both can coexist); the registry key points at it.
|
|
83
|
+
return join(home, '.local', 'share', 'stikfix', FIREFOX_MANIFEST_FILENAME);
|
|
84
|
+
default:
|
|
85
|
+
throw new Error(`Unsupported platform: ${plat}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
switch (plat) {
|
|
89
|
+
case 'darwin':
|
|
90
|
+
return join(home, 'Library', 'Application Support', 'Google', 'Chrome', 'NativeMessagingHosts', MANIFEST_FILENAME);
|
|
91
|
+
case 'linux':
|
|
92
|
+
return join(home, '.config', 'google-chrome', 'NativeMessagingHosts', MANIFEST_FILENAME);
|
|
93
|
+
case 'win32':
|
|
94
|
+
return join(home, '.local', 'share', 'stikfix', MANIFEST_FILENAME);
|
|
95
|
+
default:
|
|
96
|
+
throw new Error(`Unsupported platform: ${plat}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// nativeWrapperPath / writeNativeWrapper — native-messaging launcher wrapper
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
/**
|
|
103
|
+
* Return the path of the native-messaging launcher wrapper, in the SAME
|
|
104
|
+
* directory as the manifest. On win32 a .bat, otherwise a .sh.
|
|
105
|
+
*/
|
|
106
|
+
export function nativeWrapperPath(plat = process.platform, home = homedir(), browser = 'chrome') {
|
|
107
|
+
const dir = dirname(nativeManifestPath(plat, home, browser));
|
|
108
|
+
const winName = browser === 'firefox' ? NATIVE_WRAPPER_WIN_FIREFOX : NATIVE_WRAPPER_WIN;
|
|
109
|
+
const nixName = browser === 'firefox' ? NATIVE_WRAPPER_NIX_FIREFOX : NATIVE_WRAPPER_NIX;
|
|
110
|
+
return join(dir, plat === 'win32' ? winName : nixName);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Write the per-OS launcher wrapper that runs `node <abs cjs>`, returning its
|
|
114
|
+
* path. Chrome's CreateProcess (Windows) cannot execute a .cjs directly; the
|
|
115
|
+
* manifest must point at this wrapper instead of the raw .cjs.
|
|
116
|
+
*
|
|
117
|
+
* Security: `abs` is a developer-controlled absolute path (no user input);
|
|
118
|
+
* this is a file write, not exec — no injection vector.
|
|
119
|
+
*/
|
|
120
|
+
export function writeNativeWrapper(hostBinPath, plat = process.platform, home = homedir(), browser = 'chrome') {
|
|
121
|
+
const wrapperPath = nativeWrapperPath(plat, home, browser);
|
|
122
|
+
mkdirSync(dirname(wrapperPath), { recursive: true });
|
|
123
|
+
const abs = resolve(hostBinPath);
|
|
124
|
+
if (plat === 'win32') {
|
|
125
|
+
const content = `@echo off\r\n"node" "${abs}" %*\r\n`;
|
|
126
|
+
writeFileSync(wrapperPath, content, { encoding: 'utf8' });
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
const content = `#!/bin/sh\nexec node "${abs}" "$@"\n`;
|
|
130
|
+
writeFileSync(wrapperPath, content, { encoding: 'utf8', mode: 0o755 });
|
|
131
|
+
}
|
|
132
|
+
return wrapperPath;
|
|
133
|
+
}
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// launcherDir — where launcher files are stored (alongside the manifest on win32)
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
/**
|
|
138
|
+
* Return the directory where launcher files (batch, .command, .desktop) are written.
|
|
139
|
+
* On win32: ~/.local/share/stikfix/ (same dir as the manifest)
|
|
140
|
+
* On darwin/linux: ~/.config/stikfix/
|
|
141
|
+
*/
|
|
142
|
+
export function launcherDir(plat = process.platform, home = homedir()) {
|
|
143
|
+
if (plat === 'win32') {
|
|
144
|
+
return join(home, '.local', 'share', 'stikfix');
|
|
145
|
+
}
|
|
146
|
+
return join(home, '.config', 'stikfix');
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Return the expected paths of all launcher files for the given platform.
|
|
150
|
+
* These paths are used by both createLauncherFiles and enumerateArtifacts.
|
|
151
|
+
*/
|
|
152
|
+
export function getLauncherPaths(plat = process.platform, home = homedir()) {
|
|
153
|
+
const dir = launcherDir(plat, home);
|
|
154
|
+
if (plat === 'win32') {
|
|
155
|
+
return {
|
|
156
|
+
launcher: join(dir, LAUNCHER_BATCH_FILENAME),
|
|
157
|
+
shortcut: join(home, 'Desktop', LAUNCHER_LNK_FILENAME),
|
|
158
|
+
desktopEntry: null,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (plat === 'darwin') {
|
|
162
|
+
return {
|
|
163
|
+
launcher: join(dir, LAUNCHER_COMMAND_FILENAME),
|
|
164
|
+
shortcut: null,
|
|
165
|
+
desktopEntry: null,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
// linux
|
|
169
|
+
return {
|
|
170
|
+
launcher: join(dir, LAUNCHER_SH_FILENAME),
|
|
171
|
+
shortcut: null,
|
|
172
|
+
desktopEntry: join(home, '.local', 'share', 'applications', LAUNCHER_DESKTOP_FILENAME),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// buildManifest — manifest object builder
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
/** Regex: exactly 32 lowercase a-p characters (Chrome extension ID alphabet) */
|
|
179
|
+
const EXT_ID_RE = /^[a-p]{32}$/;
|
|
180
|
+
/**
|
|
181
|
+
* Regex: a Firefox/gecko add-on id. Firefox accepts either an email-style id
|
|
182
|
+
* (`name@domain`) or a UUID in braces; stikfix ships the email-style id
|
|
183
|
+
* `stikfix@stikfix.com`. We validate the `local@domain` shape (no spaces,
|
|
184
|
+
* a single @, a dot-bearing domain) rather than the Chrome a-p alphabet.
|
|
185
|
+
*/
|
|
186
|
+
const GECKO_ID_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
187
|
+
/**
|
|
188
|
+
* Build a native-messaging manifest object for the given extension identity
|
|
189
|
+
* and host binary path.
|
|
190
|
+
*
|
|
191
|
+
* - hostBinPath is resolved to an ABSOLUTE path (Pitfall 4).
|
|
192
|
+
* - browser 'chrome' (default): `extensionId` must be exactly 32 lowercase a-p
|
|
193
|
+
* chars; emits `allowed_origins: ["chrome-extension://<id>/"]`.
|
|
194
|
+
* - browser 'firefox': `extensionId` is the gecko add-on id (e.g.
|
|
195
|
+
* `stikfix@stikfix.com`); emits `allowed_extensions: [<gecko-id>]`.
|
|
196
|
+
* Firefox rejects a manifest carrying `allowed_origins`, so the two shapes
|
|
197
|
+
* are mutually exclusive.
|
|
198
|
+
*/
|
|
199
|
+
export function buildManifest(extensionId, hostBinPath, browser = 'chrome') {
|
|
200
|
+
const absPath = resolve(hostBinPath);
|
|
201
|
+
if (browser === 'firefox') {
|
|
202
|
+
if (!GECKO_ID_RE.test(extensionId)) {
|
|
203
|
+
throw new Error(`Invalid Firefox add-on id "${extensionId}": must be a gecko id like "name@domain.tld".`);
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
name: NATIVE_HOST_NAME,
|
|
207
|
+
description: 'stikfix native messaging host',
|
|
208
|
+
path: absPath,
|
|
209
|
+
type: 'stdio',
|
|
210
|
+
allowed_extensions: [extensionId],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (!EXT_ID_RE.test(extensionId)) {
|
|
214
|
+
throw new Error(`Invalid extension ID "${extensionId}": must be exactly 32 lowercase a-p characters.`);
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
name: NATIVE_HOST_NAME,
|
|
218
|
+
description: 'stikfix native messaging host',
|
|
219
|
+
path: absPath,
|
|
220
|
+
type: 'stdio',
|
|
221
|
+
allowed_origins: [`chrome-extension://${extensionId}/`],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// writeManifest — write manifest JSON to disk
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
/**
|
|
228
|
+
* Write the manifest object to `manifestPath`, creating parent directories
|
|
229
|
+
* if needed. Mode 0o644 (non-credential — readable by Chrome process).
|
|
230
|
+
*
|
|
231
|
+
* Analog: ensureNotesDir (mkdirSync recursive) + writeTokenFile pattern.
|
|
232
|
+
*/
|
|
233
|
+
export function writeManifest(manifest, manifestPath) {
|
|
234
|
+
mkdirSync(dirname(manifestPath), { recursive: true });
|
|
235
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), {
|
|
236
|
+
encoding: 'utf8',
|
|
237
|
+
mode: 0o644,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Create a double-click launcher that starts the HTTP host backend,
|
|
242
|
+
* so the user never has to open a terminal.
|
|
243
|
+
*
|
|
244
|
+
* Windows: writes stikfix-host.bat in the stikfix data dir, then creates
|
|
245
|
+
* a Desktop shortcut (.lnk) pointing at that batch file. If the .lnk creation
|
|
246
|
+
* fails, the batch file alone is the acceptable fallback.
|
|
247
|
+
*
|
|
248
|
+
* macOS: writes an executable stikfix-host.command (bash script).
|
|
249
|
+
*
|
|
250
|
+
* Linux: writes stikfix-host.sh + a .desktop entry.
|
|
251
|
+
*
|
|
252
|
+
* Security: the ONLY subprocess allowed is the Windows .lnk creation via
|
|
253
|
+
* execFile(powershell, [argArray]) — mirroring folder-picker.ts safety exactly.
|
|
254
|
+
* No user-controlled values are interpolated into the PowerShell script string;
|
|
255
|
+
* all paths are developer-controlled absolute paths validated before use.
|
|
256
|
+
*
|
|
257
|
+
* Node builtins only (fs, child_process.execFile).
|
|
258
|
+
*/
|
|
259
|
+
export function createLauncherFiles(opts) {
|
|
260
|
+
const plat = opts.plat ?? process.platform;
|
|
261
|
+
const home = opts.home ?? homedir();
|
|
262
|
+
const written = [];
|
|
263
|
+
const warnings = [];
|
|
264
|
+
const paths = getLauncherPaths(plat, home);
|
|
265
|
+
const launcherDirPath = launcherDir(plat, home);
|
|
266
|
+
mkdirSync(launcherDirPath, { recursive: true });
|
|
267
|
+
const nodeCmd = 'node';
|
|
268
|
+
const hostEntry = opts.hostEntryPath;
|
|
269
|
+
const rootArg = opts.root;
|
|
270
|
+
const portArg = opts.port !== undefined ? ` --port ${opts.port}` : '';
|
|
271
|
+
if (plat === 'win32') {
|
|
272
|
+
// Write batch file — plain text, no exec required
|
|
273
|
+
const batchContent = [
|
|
274
|
+
'@echo off',
|
|
275
|
+
`rem Stikfix HTTP host launcher — double-click to start the backend`,
|
|
276
|
+
`rem Generated by: npx stikfix init`,
|
|
277
|
+
`rem Root: ${rootArg}`,
|
|
278
|
+
``,
|
|
279
|
+
`"${nodeCmd}" "${hostEntry}" --root "${rootArg}"${portArg}`,
|
|
280
|
+
`if %ERRORLEVEL% NEQ 0 pause`,
|
|
281
|
+
].join('\r\n');
|
|
282
|
+
const batchPath = paths.launcher;
|
|
283
|
+
writeFileSync(batchPath, batchContent, { encoding: 'utf8' });
|
|
284
|
+
written.push(batchPath);
|
|
285
|
+
// Write a VBScript that launches the host HIDDEN (no console window) and
|
|
286
|
+
// shows an auto-dismissing native Windows dialog confirming it's running.
|
|
287
|
+
// VBS string-literal escaping: a literal " is doubled ("").
|
|
288
|
+
const vq = (s) => s.replace(/"/g, '""');
|
|
289
|
+
const vbsHostEntry = vq(hostEntry);
|
|
290
|
+
const vbsRoot = vq(rootArg);
|
|
291
|
+
const vbsPortArg = opts.port !== undefined ? ` --port ${opts.port}` : '';
|
|
292
|
+
const vbsContent = [
|
|
293
|
+
'Option Explicit',
|
|
294
|
+
'Dim sh, fso, hostEntry, root, portFile, msg, port, f',
|
|
295
|
+
'Set sh = CreateObject("WScript.Shell")',
|
|
296
|
+
'Set fso = CreateObject("Scripting.FileSystemObject")',
|
|
297
|
+
`hostEntry = "${vbsHostEntry}"`,
|
|
298
|
+
`root = "${vbsRoot}"`,
|
|
299
|
+
"' Launch the host hidden (window style 0), do not wait",
|
|
300
|
+
`sh.Run "cmd /c node """ & hostEntry & """ --root """ & root & """${vbsPortArg}", 0, False`,
|
|
301
|
+
"' Give it a moment to bind and write the port file",
|
|
302
|
+
'WScript.Sleep 1800',
|
|
303
|
+
'msg = "Stikfix host is running." & vbCrLf & vbCrLf & "You can start dropping notes."',
|
|
304
|
+
'portFile = root & "\\.stikfix-port"',
|
|
305
|
+
'If fso.FileExists(portFile) Then',
|
|
306
|
+
' Set f = fso.OpenTextFile(portFile, 1)',
|
|
307
|
+
' port = Trim(f.ReadAll)',
|
|
308
|
+
' f.Close',
|
|
309
|
+
' If Len(port) > 0 Then msg = "Stikfix host is running on port " & port & "." & vbCrLf & vbCrLf & "You can start dropping notes."',
|
|
310
|
+
'End If',
|
|
311
|
+
'sh.Popup msg, 5, "Stikfix", 64',
|
|
312
|
+
].join('\r\n');
|
|
313
|
+
const vbsPath = join(launcherDir(plat, home), LAUNCHER_VBS_FILENAME);
|
|
314
|
+
writeFileSync(vbsPath, vbsContent, { encoding: 'utf8' });
|
|
315
|
+
written.push(vbsPath);
|
|
316
|
+
// Create Desktop shortcut (.lnk) via PowerShell WScript.Shell.
|
|
317
|
+
// The shortcut launches the VBS via wscript.exe so the console stays HIDDEN.
|
|
318
|
+
// Security: ALL paths are developer-controlled absolute strings — no user input.
|
|
319
|
+
// execFile (not exec) — no shell spawned, no injection vector.
|
|
320
|
+
// If this fails for any reason, fall through to the warning (batch file is the fallback).
|
|
321
|
+
if (paths.shortcut) {
|
|
322
|
+
const lnkPath = paths.shortcut;
|
|
323
|
+
const iconPath = opts.iconPath ?? '';
|
|
324
|
+
// Build PowerShell script as a single string — paths passed as PS variables
|
|
325
|
+
// set from the -Command string. No shell metacharacter interpolation from
|
|
326
|
+
// external data; all values are constants determined at registration time.
|
|
327
|
+
// PowerShell single-quote escaping: replace ' with '' in path strings.
|
|
328
|
+
const safeLnkPath = lnkPath.replace(/'/g, "''");
|
|
329
|
+
const safeIconPath = iconPath.replace(/'/g, "''");
|
|
330
|
+
const wscriptPath = join(process.env['SystemRoot'] ?? 'C:\\Windows', 'System32', 'wscript.exe');
|
|
331
|
+
const safeWscript = wscriptPath.replace(/'/g, "''");
|
|
332
|
+
const safeVbsPath = vbsPath.replace(/'/g, "''");
|
|
333
|
+
const psScript = `$ws = New-Object -ComObject WScript.Shell;` +
|
|
334
|
+
`$s = $ws.CreateShortcut('${safeLnkPath}');` +
|
|
335
|
+
`$s.TargetPath = '${safeWscript}';` +
|
|
336
|
+
`$s.Arguments = '"${safeVbsPath}"';` +
|
|
337
|
+
`$s.Description = 'Start the Stikfix HTTP backend host';` +
|
|
338
|
+
(safeIconPath ? `$s.IconLocation = '${safeIconPath},0';` : '') +
|
|
339
|
+
`$s.Save()`;
|
|
340
|
+
// execFileSync — NEVER exec; arg array is static with respect to user input.
|
|
341
|
+
// Synchronous so init reports the shortcut accurately (the async variant
|
|
342
|
+
// raced existsSync in the CLI and printed a false "skipped" message).
|
|
343
|
+
try {
|
|
344
|
+
execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', psScript], { timeout: 15_000, stdio: 'ignore' });
|
|
345
|
+
// Only record the shortcut once it actually exists on disk.
|
|
346
|
+
written.push(lnkPath);
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
// Non-fatal: batch file is the acceptable fallback
|
|
350
|
+
warnings.push(`Desktop shortcut creation failed (non-fatal): ${String(err.message)}. ` +
|
|
351
|
+
`The batch file at ${batchPath} can be used directly.`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
else if (plat === 'darwin') {
|
|
356
|
+
// macOS: executable .command file (double-click in Finder to run in Terminal)
|
|
357
|
+
const commandContent = [
|
|
358
|
+
'#!/bin/bash',
|
|
359
|
+
`# Stikfix HTTP host launcher — double-click in Finder or drag to Dock`,
|
|
360
|
+
`# Generated by: npx stikfix init`,
|
|
361
|
+
``,
|
|
362
|
+
`exec "${nodeCmd}" "${hostEntry}" --root "${rootArg}"${portArg}`,
|
|
363
|
+
].join('\n');
|
|
364
|
+
const commandPath = paths.launcher;
|
|
365
|
+
writeFileSync(commandPath, commandContent, { encoding: 'utf8', mode: 0o755 });
|
|
366
|
+
written.push(commandPath);
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
// Linux: executable shell script + .desktop entry
|
|
370
|
+
const shContent = [
|
|
371
|
+
'#!/bin/sh',
|
|
372
|
+
`# Stikfix HTTP host launcher`,
|
|
373
|
+
`# Generated by: npx stikfix init`,
|
|
374
|
+
``,
|
|
375
|
+
`exec "${nodeCmd}" "${hostEntry}" --root "${rootArg}"${portArg}`,
|
|
376
|
+
].join('\n');
|
|
377
|
+
const shPath = paths.launcher;
|
|
378
|
+
writeFileSync(shPath, shContent, { encoding: 'utf8' });
|
|
379
|
+
try {
|
|
380
|
+
chmodSync(shPath, 0o755);
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
warnings.push(`Could not chmod 755 ${shPath} — run: chmod +x "${shPath}"`);
|
|
384
|
+
}
|
|
385
|
+
written.push(shPath);
|
|
386
|
+
// .desktop entry
|
|
387
|
+
if (paths.desktopEntry) {
|
|
388
|
+
const desktopDir = dirname(paths.desktopEntry);
|
|
389
|
+
mkdirSync(desktopDir, { recursive: true });
|
|
390
|
+
const iconLine = opts.iconPath ? `Icon=${opts.iconPath}` : 'Icon=utilities-terminal';
|
|
391
|
+
const desktopContent = [
|
|
392
|
+
'[Desktop Entry]',
|
|
393
|
+
'Version=1.0',
|
|
394
|
+
'Type=Application',
|
|
395
|
+
'Name=Stikfix Host',
|
|
396
|
+
'Comment=Start the Stikfix HTTP backend host',
|
|
397
|
+
`Exec=${shPath}`,
|
|
398
|
+
iconLine,
|
|
399
|
+
'Terminal=true',
|
|
400
|
+
'Categories=Development;',
|
|
401
|
+
].join('\n');
|
|
402
|
+
writeFileSync(paths.desktopEntry, desktopContent, { encoding: 'utf8', mode: 0o644 });
|
|
403
|
+
written.push(paths.desktopEntry);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return { written, warnings };
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Write the native-messaging manifest and, on Windows, register the matching
|
|
410
|
+
* HKCU registry key(s):
|
|
411
|
+
* - chrome → Google\Chrome + Microsoft\Edge keys, `allowed_origins`
|
|
412
|
+
* - firefox → Mozilla key, `allowed_extensions`
|
|
413
|
+
*
|
|
414
|
+
* execFileSync is used (NEVER exec, NEVER shell) — T-09-03.
|
|
415
|
+
*/
|
|
416
|
+
export function registerNativeHost(opts) {
|
|
417
|
+
const plat = opts.plat ?? process.platform;
|
|
418
|
+
const home = opts.home ?? homedir();
|
|
419
|
+
const browser = opts.browser ?? 'chrome';
|
|
420
|
+
const manifestPath = nativeManifestPath(plat, home, browser);
|
|
421
|
+
// Chrome's CreateProcess (and Firefox on Windows) cannot run a .cjs directly —
|
|
422
|
+
// point the manifest at a per-OS wrapper that runs `node <abs cjs>` instead.
|
|
423
|
+
const wrapperPath = writeNativeWrapper(opts.hostBinPath, plat, home, browser);
|
|
424
|
+
const manifest = buildManifest(opts.extensionId, wrapperPath, browser);
|
|
425
|
+
writeManifest(manifest, manifestPath);
|
|
426
|
+
if (plat === 'win32') {
|
|
427
|
+
const execReg = opts.execReg ?? ((args) => {
|
|
428
|
+
execFileSync('reg', args);
|
|
429
|
+
});
|
|
430
|
+
if (browser === 'firefox') {
|
|
431
|
+
// Register for Firefox (HKCU — Pitfall 5, never HKLM)
|
|
432
|
+
execReg(['ADD', REG_FIREFOX_KEY, '/ve', '/t', 'REG_SZ', '/d', manifestPath, '/f']);
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
// Register for Chrome (HKCU — Pitfall 5, never HKLM)
|
|
436
|
+
execReg(['ADD', REG_CHROME_KEY, '/ve', '/t', 'REG_SZ', '/d', manifestPath, '/f']);
|
|
437
|
+
// Register for Edge (drop-in, D-05)
|
|
438
|
+
execReg(['ADD', REG_EDGE_KEY, '/ve', '/t', 'REG_SZ', '/d', manifestPath, '/f']);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Remove the native-messaging manifest, launcher files, Desktop shortcut,
|
|
444
|
+
* and on Windows delete the matching HKCU registry key(s):
|
|
445
|
+
* - chrome → Google\Chrome + Microsoft\Edge keys
|
|
446
|
+
* - firefox → Mozilla key
|
|
447
|
+
*
|
|
448
|
+
* Idempotent: does not throw if any artifact is already absent.
|
|
449
|
+
* execFileSync is used (NEVER exec) with the /f flag to tolerate absent keys.
|
|
450
|
+
*/
|
|
451
|
+
export function unregisterNativeHost(opts = {}) {
|
|
452
|
+
const plat = opts.plat ?? process.platform;
|
|
453
|
+
const home = opts.home ?? homedir();
|
|
454
|
+
const browser = opts.browser ?? 'chrome';
|
|
455
|
+
const manifestPath = opts.manifestPath ?? nativeManifestPath(plat, home, browser);
|
|
456
|
+
rmSync(manifestPath, { force: true });
|
|
457
|
+
rmSync(nativeWrapperPath(plat, home, browser), { force: true });
|
|
458
|
+
// Remove launcher files
|
|
459
|
+
const defaultPaths = getLauncherPaths(plat, home);
|
|
460
|
+
const lPaths = {
|
|
461
|
+
launcher: opts.launcherPaths?.launcher ?? defaultPaths.launcher,
|
|
462
|
+
shortcut: opts.launcherPaths?.shortcut !== undefined
|
|
463
|
+
? opts.launcherPaths.shortcut
|
|
464
|
+
: defaultPaths.shortcut,
|
|
465
|
+
desktopEntry: opts.launcherPaths?.desktopEntry !== undefined
|
|
466
|
+
? opts.launcherPaths.desktopEntry
|
|
467
|
+
: defaultPaths.desktopEntry,
|
|
468
|
+
};
|
|
469
|
+
rmSync(lPaths.launcher, { force: true });
|
|
470
|
+
if (lPaths.shortcut)
|
|
471
|
+
rmSync(lPaths.shortcut, { force: true });
|
|
472
|
+
if (lPaths.desktopEntry)
|
|
473
|
+
rmSync(lPaths.desktopEntry, { force: true });
|
|
474
|
+
// Remove the hidden-launch VBS (win32 only — written by createLauncherFiles)
|
|
475
|
+
if (plat === 'win32') {
|
|
476
|
+
rmSync(join(launcherDir(plat, home), LAUNCHER_VBS_FILENAME), { force: true });
|
|
477
|
+
}
|
|
478
|
+
if (plat === 'win32') {
|
|
479
|
+
// /f flag suppresses "are you sure?" prompt; tolerates absent key (non-zero exit ignored)
|
|
480
|
+
if (browser === 'firefox') {
|
|
481
|
+
try {
|
|
482
|
+
execFileSync('reg', ['DELETE', REG_FIREFOX_KEY, '/f']);
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
// Key may not exist — ignore
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
try {
|
|
490
|
+
execFileSync('reg', ['DELETE', REG_CHROME_KEY, '/f']);
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
// Key may not exist — ignore
|
|
494
|
+
}
|
|
495
|
+
try {
|
|
496
|
+
execFileSync('reg', ['DELETE', REG_EDGE_KEY, '/f']);
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
// Key may not exist — ignore
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Return the complete list of filesystem paths and registry keys that
|
|
506
|
+
* `registerNativeHost` + `createLauncherFiles` create, so `uninstall` can
|
|
507
|
+
* remove everything and leave no orphaned artifacts (ONB-05 / T-09-05).
|
|
508
|
+
*
|
|
509
|
+
* paths includes:
|
|
510
|
+
* - native-messaging manifest JSON
|
|
511
|
+
* - stikfix config file (~/.config/stikfix/config.json)
|
|
512
|
+
* - <root>/.stikfix-port (written by HTTP host on startup)
|
|
513
|
+
* - launcher batch/command/sh file
|
|
514
|
+
* - Desktop shortcut (.lnk on win32) or .desktop entry (linux)
|
|
515
|
+
*
|
|
516
|
+
* registryKeys (win32 only):
|
|
517
|
+
* - HKCU Chrome NativeMessagingHosts key
|
|
518
|
+
* - HKCU Edge NativeMessagingHosts key
|
|
519
|
+
*/
|
|
520
|
+
export function enumerateArtifacts(opts = {}) {
|
|
521
|
+
const plat = opts.plat ?? process.platform;
|
|
522
|
+
const home = opts.home ?? homedir();
|
|
523
|
+
const root = opts.root;
|
|
524
|
+
const browser = opts.browser ?? 'chrome';
|
|
525
|
+
const paths = [
|
|
526
|
+
nativeManifestPath(plat, home, browser),
|
|
527
|
+
nativeWrapperPath(plat, home, browser),
|
|
528
|
+
CONFIG_PATH(home),
|
|
529
|
+
];
|
|
530
|
+
// .stikfix-port is written by the HTTP host alongside .stikfix-token
|
|
531
|
+
if (root) {
|
|
532
|
+
paths.push(join(root, '.stikfix-port'));
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
// Include a generic indicator when root is unknown
|
|
536
|
+
paths.push('.stikfix-port');
|
|
537
|
+
}
|
|
538
|
+
// Launcher files created by createLauncherFiles
|
|
539
|
+
const lPaths = getLauncherPaths(plat, home);
|
|
540
|
+
paths.push(lPaths.launcher);
|
|
541
|
+
if (lPaths.shortcut)
|
|
542
|
+
paths.push(lPaths.shortcut);
|
|
543
|
+
if (lPaths.desktopEntry)
|
|
544
|
+
paths.push(lPaths.desktopEntry);
|
|
545
|
+
if (plat === 'win32') {
|
|
546
|
+
paths.push(join(launcherDir(plat, home), LAUNCHER_VBS_FILENAME));
|
|
547
|
+
}
|
|
548
|
+
const registryKeys = plat === 'win32'
|
|
549
|
+
? browser === 'firefox'
|
|
550
|
+
? [REG_FIREFOX_KEY]
|
|
551
|
+
: [REG_CHROME_KEY, REG_EDGE_KEY]
|
|
552
|
+
: [];
|
|
553
|
+
return { paths, registryKeys };
|
|
554
|
+
}
|
|
555
|
+
// Re-export isInsideDir for consumers that need path validation
|
|
556
|
+
export { isInsideDir };
|