fraim-hub 2.0.289 → 2.0.291
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/dist/src/ai-hub/cli.js +3 -124
- package/dist/src/ai-hub/desktop-launcher.js +66 -0
- package/dist/src/ai-hub/desktop-main.js +95 -7
- package/dist/src/ai-hub/hub-app-materializer.js +285 -0
- package/dist/src/ai-hub/hub-instance-reconciliation.js +156 -0
- package/dist/src/ai-hub/server.js +103 -15
- package/dist/src/cli/utils/managed-node-runtime.js +269 -0
- package/dist/src/first-run/session-service.js +12 -2
- package/package.json +5 -3
- package/public/ai-hub/script.js +59 -4
- package/public/ai-hub/styles.css +4 -0
package/dist/src/ai-hub/cli.js
CHANGED
|
@@ -46,15 +46,10 @@ const git_utils_1 = require("../core/utils/git-utils");
|
|
|
46
46
|
const path_1 = __importDefault(require("path"));
|
|
47
47
|
const child_process_1 = require("child_process");
|
|
48
48
|
const fs_1 = __importDefault(require("fs"));
|
|
49
|
-
const net_1 = __importDefault(require("net"));
|
|
50
|
-
const http_1 = __importDefault(require("http"));
|
|
51
|
-
const tree_kill_1 = __importDefault(require("tree-kill"));
|
|
52
|
-
const hub_launch_decision_1 = require("./hub-launch-decision");
|
|
53
49
|
const hub_runtime_file_1 = require("./hub-runtime-file");
|
|
54
50
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
55
|
-
const version_utils_1 = require("../cli/utils/version-utils");
|
|
56
51
|
const electron_dist_1 = require("./electron-dist");
|
|
57
|
-
const
|
|
52
|
+
const hub_instance_reconciliation_1 = require("./hub-instance-reconciliation");
|
|
58
53
|
function resolveDesktopEntry() {
|
|
59
54
|
const candidates = [
|
|
60
55
|
path_1.default.resolve(__dirname, 'desktop-main.js'),
|
|
@@ -113,96 +108,6 @@ function openBrowser(url) {
|
|
|
113
108
|
const child = (0, child_process_1.spawn)('xdg-open', [url], { detached: true, stdio: 'ignore' });
|
|
114
109
|
child.unref();
|
|
115
110
|
}
|
|
116
|
-
function killPid(pid) {
|
|
117
|
-
try {
|
|
118
|
-
if (process.platform === 'win32') {
|
|
119
|
-
// /T kills the entire process tree; /F forces termination without prompting.
|
|
120
|
-
(0, child_process_1.execFileSync)('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
121
|
-
}
|
|
122
|
-
else {
|
|
123
|
-
// tree-kill sends SIGTERM to the process group so child Electron helper processes
|
|
124
|
-
// are also terminated, not just the root node process (#921).
|
|
125
|
-
(0, tree_kill_1.default)(pid, 'SIGTERM');
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
129
|
-
/* already gone */
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
// #921: after killing the registered pid, probe ports 43091-43200 for any Hub HTTP
|
|
133
|
-
// response. Any Hub found on those ports that is NOT the already-killed pid is an
|
|
134
|
-
// orphan (e.g. an older version started via bare `npx` that never wrote hub-runtime.json).
|
|
135
|
-
// GET /api/ai-hub/pid identifies the pid; we then kill it and wait for its port to free.
|
|
136
|
-
async function fetchHubPid(port) {
|
|
137
|
-
return new Promise((resolve) => {
|
|
138
|
-
const req = http_1.default.get({ host: '127.0.0.1', port, path: '/api/ai-hub/pid', timeout: 1000 }, (res) => {
|
|
139
|
-
if (res.statusCode !== 200) {
|
|
140
|
-
res.resume();
|
|
141
|
-
return resolve(null);
|
|
142
|
-
}
|
|
143
|
-
let body = '';
|
|
144
|
-
res.on('data', (c) => { body += c; });
|
|
145
|
-
res.on('end', () => { try {
|
|
146
|
-
resolve(JSON.parse(body).pid ?? null);
|
|
147
|
-
}
|
|
148
|
-
catch {
|
|
149
|
-
resolve(null);
|
|
150
|
-
} });
|
|
151
|
-
});
|
|
152
|
-
req.on('error', () => resolve(null));
|
|
153
|
-
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
async function scanAndKillOrphanHubs(excludePid) {
|
|
157
|
-
const HUB_PORT_SCAN_START = 43091;
|
|
158
|
-
const HUB_PORT_SCAN_END = 43200;
|
|
159
|
-
for (let port = HUB_PORT_SCAN_START; port <= HUB_PORT_SCAN_END; port++) {
|
|
160
|
-
const version = await fetchRunningHubVersion(port);
|
|
161
|
-
if (!version)
|
|
162
|
-
continue; // port not a Hub
|
|
163
|
-
const pid = await fetchHubPid(port);
|
|
164
|
-
if (pid === null || pid === excludePid)
|
|
165
|
-
continue; // same process we already killed
|
|
166
|
-
console.log(`Killing orphan Hub (pid ${pid}) on port ${port}`);
|
|
167
|
-
killPid(pid);
|
|
168
|
-
await waitForPortFree(port);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
function isPortFree(port, timeoutMs = 500) {
|
|
172
|
-
return new Promise((resolve) => {
|
|
173
|
-
const sock = net_1.default.connect({ host: '127.0.0.1', port }, () => { sock.destroy(); resolve(false); });
|
|
174
|
-
sock.on('error', () => resolve(true));
|
|
175
|
-
sock.setTimeout(timeoutMs, () => { sock.destroy(); resolve(true); });
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
async function waitForPortFree(port, totalMs = 5000) {
|
|
179
|
-
const start = Date.now();
|
|
180
|
-
while (Date.now() - start < totalMs) {
|
|
181
|
-
if (await isPortFree(port))
|
|
182
|
-
return;
|
|
183
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
function fetchRunningHubVersion(port) {
|
|
187
|
-
return new Promise((resolve) => {
|
|
188
|
-
const req = http_1.default.get({ host: '127.0.0.1', port, path: '/api/ai-hub/version', timeout: 1000 }, (res) => {
|
|
189
|
-
if (res.statusCode !== 200) {
|
|
190
|
-
res.resume();
|
|
191
|
-
return resolve(null);
|
|
192
|
-
}
|
|
193
|
-
let body = '';
|
|
194
|
-
res.on('data', (c) => { body += c; });
|
|
195
|
-
res.on('end', () => { try {
|
|
196
|
-
resolve(JSON.parse(body).version ?? null);
|
|
197
|
-
}
|
|
198
|
-
catch {
|
|
199
|
-
resolve(null);
|
|
200
|
-
} });
|
|
201
|
-
});
|
|
202
|
-
req.on('error', () => resolve(null));
|
|
203
|
-
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
111
|
// #1110: the desktop shell's cold-start cost is work this launcher can neither see nor bound.
|
|
207
112
|
// Before the Hub can answer /api/ai-hub/version it pays for Electron process boot, top-level
|
|
208
113
|
// evaluation of the src/ai-hub/server.ts module graph, cert load plus a blocking certutil
|
|
@@ -278,7 +183,7 @@ async function waitForDesktopHubReady(child, preferredPort, options = {}) {
|
|
|
278
183
|
while (Date.now() - start < timeoutMs) {
|
|
279
184
|
failIfChildDied();
|
|
280
185
|
for (const port of readinessProbePorts(fraimDir, runtimeId, preferredPort)) {
|
|
281
|
-
const version = await fetchRunningHubVersion(port);
|
|
186
|
+
const version = await (0, hub_instance_reconciliation_1.fetchRunningHubVersion)(port);
|
|
282
187
|
if (version) {
|
|
283
188
|
return { port, version };
|
|
284
189
|
}
|
|
@@ -298,32 +203,6 @@ async function waitForDesktopHubReady(child, preferredPort, options = {}) {
|
|
|
298
203
|
+ `${stillRunning}`
|
|
299
204
|
+ ` Set ${exports.DESKTOP_HUB_READY_TIMEOUT_ENV} to a larger value if this machine needs longer.`);
|
|
300
205
|
}
|
|
301
|
-
async function reconcileRunningHub(flags, runtimeId = 'hub') {
|
|
302
|
-
const running = (0, hub_runtime_file_1.readHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)(), runtimeId);
|
|
303
|
-
const confirmedVersion = running ? await fetchRunningHubVersion(running.port) : null;
|
|
304
|
-
const live = !!(running && confirmedVersion && (0, process_liveness_1.isPidAlive)(running.pid));
|
|
305
|
-
const effective = live && running ? { ...running, version: confirmedVersion } : null;
|
|
306
|
-
const decision = (0, hub_launch_decision_1.decideHubLaunch)({
|
|
307
|
-
running: effective,
|
|
308
|
-
pidAlive: live,
|
|
309
|
-
cliVersion: (0, version_utils_1.getFraimVersion)(),
|
|
310
|
-
restart: flags.restart,
|
|
311
|
-
noRestart: flags.keepRunning,
|
|
312
|
-
});
|
|
313
|
-
if (decision.action === 'replace' && effective) {
|
|
314
|
-
console.log(`Replacing running Hub (v${effective.version}, pid ${effective.pid}) - ${decision.reason}`);
|
|
315
|
-
killPid(effective.pid);
|
|
316
|
-
await waitForPortFree(effective.port);
|
|
317
|
-
// #921: after killing the registered pid, scan for orphan Hub processes that were
|
|
318
|
-
// never registered in hub-runtime.json (e.g. older versions started via bare npx).
|
|
319
|
-
if (runtimeId === 'hub') {
|
|
320
|
-
await scanAndKillOrphanHubs(effective.pid);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
else if (decision.action === 'focus-existing' && effective) {
|
|
324
|
-
console.log(`A Hub (v${effective.version}) is already running - focusing it. Use --restart to replace it.`);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
206
|
async function runHub(options) {
|
|
328
207
|
const { AiHubServer, findAvailablePort } = await Promise.resolve().then(() => __importStar(require('./server')));
|
|
329
208
|
const preferredPort = options.port || (0, git_utils_1.getPort)() + 100;
|
|
@@ -332,7 +211,7 @@ async function runHub(options) {
|
|
|
332
211
|
if (options.open) {
|
|
333
212
|
const wantDesktop = !options.browser;
|
|
334
213
|
if (wantDesktop) {
|
|
335
|
-
await reconcileRunningHub({ restart: !!options.restart, keepRunning: !!options.keepRunning }, runtimeId);
|
|
214
|
+
await (0, hub_instance_reconciliation_1.reconcileRunningHub)({ restart: !!options.restart, keepRunning: !!options.keepRunning }, runtimeId);
|
|
336
215
|
}
|
|
337
216
|
const desktopChild = wantDesktop ? await openDesktopWindow(projectPath, preferredPort, runtimeId) : null;
|
|
338
217
|
if (!desktopChild) {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const electron_1 = require("electron");
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const hub_app_materializer_1 = require("./hub-app-materializer");
|
|
9
|
+
// Issue #1415: this is the packaged Electron `main` entry (see packages/fraim-hub/package.json
|
|
10
|
+
// `"main"`). It is deliberately thin and changes rarely: its only job is to resolve which version
|
|
11
|
+
// of the actual Hub application (the code in desktop-main.ts) to run — the latest published to
|
|
12
|
+
// npm, materializing it once per machine if not already cached — and hand off to it. The native
|
|
13
|
+
// installer only ever needs to ship a new build of this file and the Electron shell itself;
|
|
14
|
+
// every ordinary `fraim-hub` release reaches an already-installed user the moment this
|
|
15
|
+
// resolution runs on their next launch, with no new installer download.
|
|
16
|
+
const BUNDLED_ENTRY_PATH = path_1.default.join(__dirname, 'desktop-main.js');
|
|
17
|
+
async function bootstrapFrom(entryPath) {
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
19
|
+
const entry = require(entryPath);
|
|
20
|
+
await entry.bootstrap();
|
|
21
|
+
}
|
|
22
|
+
async function launch() {
|
|
23
|
+
// Issue #1415: deterministic escape hatch for integration testing — skips the real npm
|
|
24
|
+
// resolution (and its dependence on whatever happens to be published at test time) so a test can
|
|
25
|
+
// prove the Electron main-process boot chain itself (resolveHubAppEntry -> require -> bootstrap
|
|
26
|
+
// -> healthy server) without a network round-trip. Not used in any real launch path; mirrors the
|
|
27
|
+
// existing `FRAIM_INSTALLER_LIFECYCLE_TEST` kill-switch pattern already used for testability.
|
|
28
|
+
const resolved = process.env.FRAIM_HUB_SKIP_MATERIALIZE === '1'
|
|
29
|
+
? { entryPath: BUNDLED_ENTRY_PATH, source: 'bundled', version: electron_1.app.getVersion() }
|
|
30
|
+
: await (0, hub_app_materializer_1.resolveHubAppEntry)({
|
|
31
|
+
bundledVersion: electron_1.app.getVersion(),
|
|
32
|
+
bundledEntryPath: BUNDLED_ENTRY_PATH,
|
|
33
|
+
onProgress: (message) => console.log(`[fraim-hub-launcher] ${message}`),
|
|
34
|
+
}).catch((error) => {
|
|
35
|
+
// resolveHubAppEntry is designed to never throw (every failure mode falls back internally),
|
|
36
|
+
// but a launch must survive even a bug in that resolution rather than fail here.
|
|
37
|
+
console.error('[fraim-hub-launcher] resolution failed unexpectedly; using the bundled build:', error);
|
|
38
|
+
return { entryPath: BUNDLED_ENTRY_PATH, source: 'bundled', version: electron_1.app.getVersion() };
|
|
39
|
+
});
|
|
40
|
+
console.log(`[fraim-hub-launcher] running FRAIM Hub ${resolved.version} (${resolved.source})`);
|
|
41
|
+
try {
|
|
42
|
+
await bootstrapFrom(resolved.entryPath);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (resolved.entryPath === BUNDLED_ENTRY_PATH)
|
|
46
|
+
throw error;
|
|
47
|
+
// A materialized copy that fails to load (corrupt cache, an ABI mismatch some future
|
|
48
|
+
// dependency introduces) must not strand the user on a broken launch — fall back to the
|
|
49
|
+
// build that shipped with this installer, which is always known-good for this Electron binary.
|
|
50
|
+
console.error(`[fraim-hub-launcher] materialized FRAIM Hub ${resolved.version} failed to load; falling back to the bundled build:`, error);
|
|
51
|
+
await bootstrapFrom(BUNDLED_ENTRY_PATH);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (process.versions.electron && process.type !== 'renderer') {
|
|
55
|
+
launch().catch((error) => {
|
|
56
|
+
console.error(error instanceof Error ? error.message : error);
|
|
57
|
+
try {
|
|
58
|
+
electron_1.dialog.showErrorBox('FRAIM Hub failed to start', error instanceof Error ? error.message : String(error));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// dialog can be unavailable this early in rare startup failures; the console error above
|
|
62
|
+
// is the fallback record.
|
|
63
|
+
}
|
|
64
|
+
electron_1.app.exit(1);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.launchDesktopShell = launchDesktopShell;
|
|
7
|
+
exports.bootstrap = bootstrap;
|
|
7
8
|
const electron_1 = require("electron");
|
|
8
9
|
const path_1 = __importDefault(require("path"));
|
|
9
10
|
const fs_1 = __importDefault(require("fs"));
|
|
@@ -20,6 +21,8 @@ const server_2 = require("../first-run/server");
|
|
|
20
21
|
const session_service_1 = require("../first-run/session-service");
|
|
21
22
|
const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launcher");
|
|
22
23
|
const desktop_auto_updater_1 = require("./desktop-auto-updater");
|
|
24
|
+
const hub_app_materializer_1 = require("./hub-app-materializer");
|
|
25
|
+
const hub_instance_reconciliation_1 = require("./hub-instance-reconciliation");
|
|
23
26
|
// Keep installed, running, and user-pinned Windows shortcuts grouped under the
|
|
24
27
|
// stable identity declared in packages/fraim-hub/package.json.
|
|
25
28
|
electron_1.app.setAppUserModelId('ai.fraim.hub');
|
|
@@ -73,7 +76,7 @@ function configurePackagedFraimRuntime() {
|
|
|
73
76
|
if (!electron_1.app.isPackaged && process.env.FRAIM_DESKTOP_FIRST_RUN !== '1')
|
|
74
77
|
return;
|
|
75
78
|
process.env.FRAIM_PACKAGED_CLI_EXECUTABLE = process.execPath;
|
|
76
|
-
process.env.FRAIM_PACKAGED_CLI_SCRIPT =
|
|
79
|
+
process.env.FRAIM_PACKAGED_CLI_SCRIPT = (0, hub_app_materializer_1.resolvePackagedCliScriptPath)(__dirname);
|
|
77
80
|
(0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
|
|
78
81
|
}
|
|
79
82
|
function shouldShowEmbeddedFirstRun() {
|
|
@@ -133,11 +136,65 @@ async function configureAutoUpdater() {
|
|
|
133
136
|
});
|
|
134
137
|
return result.action === 'installing';
|
|
135
138
|
}
|
|
136
|
-
|
|
139
|
+
// Issue #1415: shared by every path that needs to know "is a newer FRAIM Hub published than the
|
|
140
|
+
// one currently running" — the automatic second-instance/activate recheck below, and the
|
|
141
|
+
// manually-triggered restart from the web UI's update badge (see restartToLatestNow). Compares
|
|
142
|
+
// `getFraimVersion()` (not `app.getVersion()`), because it resolves from wherever the
|
|
143
|
+
// currently-running code actually lives — the bundled app.asar or a previously materialized copy
|
|
144
|
+
// — while `app.getVersion()` always names the former.
|
|
145
|
+
async function resolveLatestHubAppVersion() {
|
|
146
|
+
const runningVersion = (0, version_utils_1.getFraimVersion)();
|
|
147
|
+
const resolved = await (0, hub_app_materializer_1.resolveHubAppEntry)({
|
|
148
|
+
bundledVersion: runningVersion,
|
|
149
|
+
bundledEntryPath: __filename,
|
|
150
|
+
});
|
|
151
|
+
return { upgradable: resolved.version !== runningVersion, latest: resolved.version };
|
|
152
|
+
}
|
|
153
|
+
// Issue #1415: a second-instance activation (the user re-clicking the icon while the Hub is
|
|
154
|
+
// already running) is the common case once login-item autostart + hide-to-tray keep the Hub
|
|
155
|
+
// resident for days, and it is the one launch-shaped event where the desktop-launcher.ts
|
|
156
|
+
// materializer never gets a chance to run — bootstrap() has already won the single-instance lock
|
|
157
|
+
// by the time it fires. Without this check, an already-running Hub could only ever pick up a new
|
|
158
|
+
// `fraim-hub` release through the slower electron-updater feed below, which is the exact lagging
|
|
159
|
+
// signal #1415 was filed about.
|
|
160
|
+
async function checkForHubAppUpdateOnSecondInstance() {
|
|
161
|
+
const { upgradable, latest } = await resolveLatestHubAppVersion();
|
|
162
|
+
if (!upgradable)
|
|
163
|
+
return false;
|
|
164
|
+
console.log(`[fraim] newer FRAIM Hub ${latest} available (was running ${(0, version_utils_1.getFraimVersion)()}); restarting to apply it`);
|
|
165
|
+
electron_1.app.relaunch();
|
|
166
|
+
electron_1.app.quit();
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
// Issue #1415: the web UI's persistent update badge (#1379) used to just tell the user to quit
|
|
170
|
+
// and relaunch by hand. This is the same "resolve latest, relaunch" as the automatic checks above,
|
|
171
|
+
// just triggered by an explicit click instead of a relaunch-shaped Electron event — wired into
|
|
172
|
+
// AiHubServer as the `restartToLatest` option (see launchDesktopShell) so POST
|
|
173
|
+
// /api/ai-hub/restart-to-latest can call it. The relaunch/quit is deferred a beat so the HTTP
|
|
174
|
+
// response reaches the browser (which uses it to start polling for the new instance) before this
|
|
175
|
+
// process starts tearing down.
|
|
176
|
+
async function restartToLatestNow() {
|
|
177
|
+
const { upgradable, latest } = await resolveLatestHubAppVersion();
|
|
178
|
+
if (!upgradable)
|
|
179
|
+
return { restarting: false, latest };
|
|
180
|
+
console.log(`[fraim] manual restart requested; relaunching to FRAIM Hub ${latest}`);
|
|
181
|
+
setTimeout(() => {
|
|
182
|
+
electron_1.app.relaunch();
|
|
183
|
+
electron_1.app.quit();
|
|
184
|
+
}, 300);
|
|
185
|
+
return { restarting: true, latest };
|
|
186
|
+
}
|
|
187
|
+
// Issue #1415: shared by both re-launch-shaped events on an already-running Hub — Windows/Linux's
|
|
188
|
+
// `second-instance` (a new process lost the single-instance lock) and macOS's `activate` (the
|
|
189
|
+
// Dock icon click that never spawns a new process at all, so `second-instance` never fires there).
|
|
190
|
+
// Before this, neither path re-checked npm-latest, only the older, slower electron-updater feed.
|
|
191
|
+
function checkForRelaunchAttemptUpdate() {
|
|
137
192
|
if (!electron_1.app.isPackaged || process.env.FRAIM_INSTALLER_LIFECYCLE_TEST === '1')
|
|
138
193
|
return;
|
|
139
|
-
void
|
|
140
|
-
|
|
194
|
+
void checkForHubAppUpdateOnSecondInstance()
|
|
195
|
+
.then((restarting) => (restarting ? undefined : configureAutoUpdater().then(() => undefined)))
|
|
196
|
+
.catch((err) => {
|
|
197
|
+
console.warn('[fraim] relaunch-attempt update check failed:', err);
|
|
141
198
|
});
|
|
142
199
|
}
|
|
143
200
|
// ---------------------------------------------------------------------------
|
|
@@ -404,6 +461,7 @@ async function launchDesktopShell(options) {
|
|
|
404
461
|
});
|
|
405
462
|
return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0];
|
|
406
463
|
},
|
|
464
|
+
restartToLatest: restartToLatestNow,
|
|
407
465
|
});
|
|
408
466
|
await server.start(httpPort);
|
|
409
467
|
const resolvedProjectPath = server.getProjectPath();
|
|
@@ -469,6 +527,9 @@ async function launchDesktopShell(options) {
|
|
|
469
527
|
// ---------------------------------------------------------------------------
|
|
470
528
|
// Bootstrap
|
|
471
529
|
// ---------------------------------------------------------------------------
|
|
530
|
+
// Issue #1415: exported so `desktop-launcher.ts` can `require()` this module (whether the copy
|
|
531
|
+
// bundled in this installer or a materialized npm-latest copy) and invoke bootstrap explicitly,
|
|
532
|
+
// instead of relying on the module's own top-level self-execution below.
|
|
472
533
|
async function bootstrap() {
|
|
473
534
|
const options = parseArgs(process.argv.slice(2));
|
|
474
535
|
applyUserDataOverride();
|
|
@@ -483,7 +544,7 @@ async function bootstrap() {
|
|
|
483
544
|
return;
|
|
484
545
|
}
|
|
485
546
|
electron_1.app.on('second-instance', () => {
|
|
486
|
-
void electron_1.app.whenReady().then(
|
|
547
|
+
void electron_1.app.whenReady().then(checkForRelaunchAttemptUpdate);
|
|
487
548
|
if (mainWindow) {
|
|
488
549
|
mainWindow.show();
|
|
489
550
|
mainWindow.focus();
|
|
@@ -492,6 +553,25 @@ async function bootstrap() {
|
|
|
492
553
|
await electron_1.app.whenReady();
|
|
493
554
|
electron_1.app.setName(displayName(options.runtimeId));
|
|
494
555
|
configurePackagedFraimRuntime();
|
|
556
|
+
// Issue #1415 (follow-up): app.requestSingleInstanceLock() above only sees another instance of
|
|
557
|
+
// *this same identity* — it cannot see a Hub already running via a different launch path (e.g.
|
|
558
|
+
// `npx fraim-hub`'s raw electron.exe, confirmed live to end up under a different userData scope
|
|
559
|
+
// than this packaged app). hub-runtime.json is the identity-agnostic record both launch paths
|
|
560
|
+
// already write (#755); reconcile against it — and sweep for unregistered orphans (#921) —
|
|
561
|
+
// before binding our own port, so a direct double-click launch takes over whatever Hub is
|
|
562
|
+
// already running instead of silently opening a second, independent one on a fallback port.
|
|
563
|
+
// `restart: true` because a materialized/bundled launch is by construction already resolved to
|
|
564
|
+
// the current latest — there is nothing to compare, only to replace. Runs after whenReady, like
|
|
565
|
+
// every other network-touching step in this function (checkForRelaunchAttemptUpdate,
|
|
566
|
+
// configureAutoUpdater) — reconcileRunningHub makes real HTTP probes and process kills, and this
|
|
567
|
+
// keeps it on the same footing as those instead of racing Electron's own startup.
|
|
568
|
+
// Gated on the same FRAIM_AI_HUB_FAKE_HOST flag as the single-instance lock above, not on
|
|
569
|
+
// FRAIM_INSTALLER_LIFECYCLE_TEST: the orphan sweep probes real system ports 43091-43200
|
|
570
|
+
// regardless of FRAIM_USER_DIR isolation, so a test double (FAKE_HOST=1) skips it for the same
|
|
571
|
+
// reason it skips the lock — it must never disturb a real Hub already running on this machine.
|
|
572
|
+
if (!skipSingleInstance) {
|
|
573
|
+
await (0, hub_instance_reconciliation_1.reconcileRunningHub)({ restart: true, keepRunning: false }, options.runtimeId);
|
|
574
|
+
}
|
|
495
575
|
// macOS reads the dock icon from the app bundle once packaged, but an
|
|
496
576
|
// unpackaged `npm run hub:desktop` / `npx fraim-hub` run shows Electron's
|
|
497
577
|
// own icon there unless we set it explicitly (#1329).
|
|
@@ -508,7 +588,10 @@ async function bootstrap() {
|
|
|
508
588
|
return;
|
|
509
589
|
}
|
|
510
590
|
electron_1.app.on('activate', () => {
|
|
511
|
-
// macOS: clicking dock icon re-shows the window
|
|
591
|
+
// macOS: clicking dock icon re-shows the window. Unlike Windows/Linux, this never spawns a
|
|
592
|
+
// second process (so 'second-instance' never fires here) — it is macOS's equivalent
|
|
593
|
+
// relaunch-attempt event, so it gets the same npm-latest re-check (#1415).
|
|
594
|
+
checkForRelaunchAttemptUpdate();
|
|
512
595
|
if (mainWindow) {
|
|
513
596
|
mainWindow.show();
|
|
514
597
|
mainWindow.focus();
|
|
@@ -533,7 +616,12 @@ async function bootstrap() {
|
|
|
533
616
|
});
|
|
534
617
|
await launchDesktopShell(options);
|
|
535
618
|
}
|
|
536
|
-
|
|
619
|
+
// Issue #1415: self-execution is now gated on `require.main === module` in addition to the
|
|
620
|
+
// existing Electron-main-process check, so `desktop-launcher.ts` requiring this file (bundled or
|
|
621
|
+
// materialized) does not double-run bootstrap — the launcher calls the exported `bootstrap`
|
|
622
|
+
// explicitly instead. Direct invocation (`npm run hub:desktop`, Electron-launching test suites)
|
|
623
|
+
// is unaffected: this file is still `require.main` in that case.
|
|
624
|
+
if (require.main === module && process.versions.electron && process.type !== 'renderer') {
|
|
537
625
|
bootstrap().catch(async (error) => {
|
|
538
626
|
console.error(error instanceof Error ? error.message : error);
|
|
539
627
|
await stopServerOnce();
|