sliccy 1.4.0 → 1.5.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/README.md +13 -3
- package/dist/cli/electron-controller.d.ts +6 -0
- package/dist/cli/electron-controller.js +229 -7
- package/dist/cli/electron-runtime.d.ts +26 -0
- package/dist/cli/electron-runtime.js +225 -1
- package/dist/cli/index.js +64 -11
- package/dist/cli/runtime-flags.d.ts +2 -0
- package/dist/cli/runtime-flags.js +1 -0
- package/dist/ui/assets/{___vite-browser-external_commonjs-proxy-Dfz2nQpt.js → ___vite-browser-external_commonjs-proxy-Dmy7YA9d.js} +1 -1
- package/dist/ui/assets/{bsh-watchdog-Bg5KAOQZ.js → bsh-watchdog-Dor44V0O.js} +1 -1
- package/dist/ui/assets/{index-CbuY8SZ0.js → index-8BrOrSsx.js} +1 -1
- package/dist/ui/assets/{index-B4khf8fB.js → index-BLgzA5P5.js} +1 -1
- package/dist/ui/assets/{index-Bk00i2d6.js → index-BMX9qDja.js} +1 -1
- package/dist/ui/assets/{index-DLbRRCUW.js → index-C1tZMpET.js} +2259 -1602
- package/dist/ui/assets/{index-DXoH0djJ.js → index-CCawsuAn.js} +1 -1
- package/dist/ui/assets/{index-BhkUx2Bt.js → index-CE-zqWqs.js} +1 -1
- package/dist/ui/assets/{index-C9dx-Iqm.js → index-CfkLdZHp.js} +1 -1
- package/dist/ui/assets/{index-CM00ez50.js → index-Cwh84rC2.js} +1 -1
- package/dist/ui/assets/index-D2mLc9tI.css +1 -0
- package/dist/ui/assets/{index-BZBTT_8U.js → index-DtWhmY9J.js} +1 -1
- package/dist/ui/assets/{offscreen-client-BXEKsuSZ.js → offscreen-client-BsZ13Pkt.js} +1 -1
- package/dist/ui/assets/{sql-wasm-D2FbW-kH.js → sql-wasm-CBrEnfnW.js} +1 -1
- package/dist/ui/electron-overlay-entry.js +91 -312
- package/dist/ui/index.html +2 -1969
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-

|
|
2
|
+
|
|
3
|
+
You are looking at a macOS desktop, with four windows running:
|
|
4
|
+
|
|
5
|
+
1. Google Chrome, running SLICC as a web application. It shows a Welcome page, a hidden tab with meeting preparation notes that were created by the agent, and a terminal, showing that the operating system is of the unlikely `Mozilla/5.0` kind. What?
|
|
6
|
+
2. Slack, the desktop app. Err. Slack the Electron app. It has on overlay injected, showing the ice cream logo asking to join a tray. If you do this, Slack can be remote-controlled by your agent. What the?
|
|
7
|
+
3. Sliccstart, the desktop app. It's an actual macOS app, but one that controls browsers, and browsers that prentend to be native apps alike. What the ice cream?
|
|
8
|
+
4. An image of an antropomorphized ice cream cone made out of felt and googly eyes. It's sticking out its tongue, half in astonishment, half in anticipation. What the ice cream truck?
|
|
9
|
+
|
|
10
|
+
If this scares, confuses, or excites you, keep reading.
|
|
2
11
|
|
|
3
12
|
# slicc — Self-Licking Ice Cream Cone
|
|
4
13
|
|
|
@@ -8,13 +17,14 @@
|
|
|
8
17
|
|
|
9
18
|
SLICC runs in a browser and controls the browser it runs in. It combines a shell, files, browser automation, and multi-agent delegation so you can do real work from one workspace — coding, web automation, authenticated app tasks, and the weird in-between jobs that do not fit neatly inside a chat panel. SLICC can orchestrate multiple browsers, and even some apps through telepathy, making it a powerful hub for your digital work.
|
|
10
19
|
|
|
11
|
-
-
|
|
20
|
+
- Head over to [releases](https://github.com/ai-ecoverse/slicc/releases) and grab the latest `.dmg` file. No Windows or Linux UI yet
|
|
21
|
+
- Or launch it from the CLI today (we also have a Chrome extension)
|
|
12
22
|
- Connect other browser windows or Electron apps
|
|
13
23
|
- Install skills that teach it how to perform challenging tasks
|
|
14
24
|
- Give it practical tools models already know how to use
|
|
15
25
|
- Delegate parallel work so tasks get done faster
|
|
16
26
|
|
|
17
|
-
> Status: active working prototype. The
|
|
27
|
+
> Status: active working prototype. The macOS app is the easiest way in today; and we have submitted the extension to Chrome Web Store.
|
|
18
28
|
|
|
19
29
|
## Why SLICC is different
|
|
20
30
|
|
|
@@ -21,6 +21,7 @@ export declare class ElectronOverlayInjector {
|
|
|
21
21
|
private readonly cdpPort;
|
|
22
22
|
private readonly bootstrapScript;
|
|
23
23
|
private readonly connections;
|
|
24
|
+
private readonly cspBypassedTargets;
|
|
24
25
|
private syncTimer;
|
|
25
26
|
private syncing;
|
|
26
27
|
private constructor();
|
|
@@ -33,6 +34,11 @@ export declare class ElectronOverlayInjector {
|
|
|
33
34
|
start(): Promise<void>;
|
|
34
35
|
stop(): void;
|
|
35
36
|
private syncTargets;
|
|
37
|
+
/**
|
|
38
|
+
* Check if the overlay iframe loaded successfully by evaluating a probe script.
|
|
39
|
+
* Returns true if the iframe element exists and has started loading content.
|
|
40
|
+
*/
|
|
41
|
+
private probeOverlayIframeLoaded;
|
|
36
42
|
private connectToTarget;
|
|
37
43
|
}
|
|
38
44
|
export {};
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { execFile as nodeExecFile, spawn } from 'child_process';
|
|
2
2
|
import { existsSync } from 'fs';
|
|
3
3
|
import { readFile } from 'fs/promises';
|
|
4
|
+
import * as http from 'http';
|
|
5
|
+
import * as https from 'https';
|
|
4
6
|
import { promisify } from 'util';
|
|
5
7
|
import { WebSocket } from 'ws';
|
|
6
|
-
import { buildElectronAppLaunchSpec, buildElectronOverlayAppUrl, buildElectronOverlayBootstrapScript, buildElectronOverlayEntryUrl, getElectronOverlayEntryDistPath, getElectronServeOrigin,
|
|
8
|
+
import { buildElectronAppLaunchSpec, buildElectronOverlayAppUrl, buildElectronOverlayBootstrapScript, buildElectronOverlayEntryUrl, getElectronOverlayEntryDistPath, getElectronServeOrigin, selectBestOverlayTargets, } from './electron-runtime.js';
|
|
7
9
|
const execFile = promisify(nodeExecFile);
|
|
8
10
|
const ELECTRON_OVERLAY_SYNC_INTERVAL_MS = 1500;
|
|
9
11
|
function commandLineExecutableMatchesPattern(commandLine, pattern) {
|
|
@@ -183,6 +185,7 @@ export class ElectronOverlayInjector {
|
|
|
183
185
|
cdpPort;
|
|
184
186
|
bootstrapScript;
|
|
185
187
|
connections = new Map();
|
|
188
|
+
cspBypassedTargets = new Set();
|
|
186
189
|
syncTimer = null;
|
|
187
190
|
syncing = false;
|
|
188
191
|
constructor(cdpPort, bootstrapScript) {
|
|
@@ -228,7 +231,14 @@ export class ElectronOverlayInjector {
|
|
|
228
231
|
throw new Error(`CDP target listing failed with ${response.status} ${response.statusText}`);
|
|
229
232
|
}
|
|
230
233
|
const targets = (await response.json());
|
|
231
|
-
const
|
|
234
|
+
const pageCount = targets.filter(t => t.type === 'page').length;
|
|
235
|
+
const injectableTargets = selectBestOverlayTargets(targets);
|
|
236
|
+
if (injectableTargets.length < pageCount) {
|
|
237
|
+
console.log(`[electron-float] Selected ${injectableTargets.length}/${pageCount} page targets for overlay injection`);
|
|
238
|
+
for (const t of injectableTargets) {
|
|
239
|
+
console.log(`[electron-float] → ${t.title || '(untitled)'} @ ${t.url.substring(0, 80)}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
232
242
|
const liveConnectionIds = new Set(injectableTargets.map((target) => target.webSocketDebuggerUrl));
|
|
233
243
|
for (const [targetId, connection] of this.connections.entries()) {
|
|
234
244
|
if (liveConnectionIds.has(targetId))
|
|
@@ -256,20 +266,232 @@ export class ElectronOverlayInjector {
|
|
|
256
266
|
this.syncing = false;
|
|
257
267
|
}
|
|
258
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* Check if the overlay iframe loaded successfully by evaluating a probe script.
|
|
271
|
+
* Returns true if the iframe element exists and has started loading content.
|
|
272
|
+
*/
|
|
273
|
+
async probeOverlayIframeLoaded(ws, send) {
|
|
274
|
+
return new Promise((resolve) => {
|
|
275
|
+
const probeId = send('Runtime.evaluate', {
|
|
276
|
+
expression: `(function() {
|
|
277
|
+
var host = document.getElementById('slicc-electron-overlay-root');
|
|
278
|
+
if (!host || !host.shadowRoot) return 'no-host';
|
|
279
|
+
var sidebar = host.shadowRoot.querySelector('slicc-electron-sidebar');
|
|
280
|
+
if (!sidebar || !sidebar.shadowRoot) return 'no-sidebar';
|
|
281
|
+
var iframe = sidebar.shadowRoot.querySelector('iframe');
|
|
282
|
+
if (!iframe) return 'no-iframe';
|
|
283
|
+
if (!iframe.src) return 'no-src';
|
|
284
|
+
return 'ok';
|
|
285
|
+
})()`,
|
|
286
|
+
awaitPromise: false,
|
|
287
|
+
returnByValue: true,
|
|
288
|
+
});
|
|
289
|
+
const timeout = setTimeout(() => {
|
|
290
|
+
cleanup();
|
|
291
|
+
resolve(false);
|
|
292
|
+
}, 3000);
|
|
293
|
+
const onMessage = (data) => {
|
|
294
|
+
try {
|
|
295
|
+
const msg = JSON.parse(data.toString());
|
|
296
|
+
if (msg.id === probeId) {
|
|
297
|
+
cleanup();
|
|
298
|
+
const value = msg.result?.result?.value;
|
|
299
|
+
resolve(value === 'ok');
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
catch { /* ignore */ }
|
|
303
|
+
};
|
|
304
|
+
const cleanup = () => {
|
|
305
|
+
clearTimeout(timeout);
|
|
306
|
+
ws.off('message', onMessage);
|
|
307
|
+
};
|
|
308
|
+
ws.on('message', onMessage);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
259
311
|
connectToTarget(target) {
|
|
260
312
|
const targetId = target.webSocketDebuggerUrl;
|
|
261
313
|
const ws = new WebSocket(targetId);
|
|
262
314
|
this.connections.set(targetId, ws);
|
|
263
315
|
let messageId = 1;
|
|
264
316
|
const send = (method, params) => {
|
|
265
|
-
|
|
317
|
+
const id = messageId++;
|
|
318
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
319
|
+
return id;
|
|
266
320
|
};
|
|
321
|
+
const cspBypassedTargets = this.cspBypassedTargets;
|
|
322
|
+
const bootstrapScript = this.bootstrapScript;
|
|
323
|
+
let pendingReload = false;
|
|
324
|
+
let pendingCspEscalation = false;
|
|
325
|
+
let fetchProxyActive = false;
|
|
267
326
|
ws.on('open', () => {
|
|
268
|
-
|
|
327
|
+
const isWebContent = target.url.startsWith('https://');
|
|
328
|
+
const alreadyBypassed = cspBypassedTargets.has(target.url);
|
|
329
|
+
console.log(`[electron-float] Connected to target, web=${isWebContent}, bypassed=${alreadyBypassed}, url=${target.url}`);
|
|
269
330
|
send('Runtime.enable');
|
|
270
|
-
send('Page.
|
|
271
|
-
|
|
272
|
-
|
|
331
|
+
send('Page.enable');
|
|
332
|
+
// Set CSP bypass — affects future resource loads on the current page
|
|
333
|
+
send('Page.setBypassCSP', { enabled: true });
|
|
334
|
+
if (alreadyBypassed) {
|
|
335
|
+
// Already reloaded with CSP bypass previously — just inject
|
|
336
|
+
console.log(`[electron-float] Injecting overlay (CSP already bypassed)...`);
|
|
337
|
+
send('Runtime.evaluate', { expression: bootstrapScript, awaitPromise: false });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
// First connection to this target URL: inject overlay immediately, then
|
|
341
|
+
// check if the iframe loaded. If CSP blocked it, fall back to reload+proxy.
|
|
342
|
+
console.log(`[electron-float] Injecting overlay (first attempt)...`);
|
|
343
|
+
send('Runtime.evaluate', { expression: bootstrapScript, awaitPromise: false });
|
|
344
|
+
if (!isWebContent) {
|
|
345
|
+
// Local content (file://, app protocol) — CSP is not an issue
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// After a short delay, probe whether the overlay iframe loaded.
|
|
349
|
+
// If CSP blocked it, reload the page so Page.setBypassCSP takes effect.
|
|
350
|
+
// If that still doesn't work, escalate to the Fetch proxy.
|
|
351
|
+
setTimeout(async () => {
|
|
352
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
353
|
+
return;
|
|
354
|
+
const loaded = await this.probeOverlayIframeLoaded(ws, send);
|
|
355
|
+
if (loaded) {
|
|
356
|
+
console.log(`[electron-float] Overlay iframe loaded successfully — no CSP reload needed`);
|
|
357
|
+
cspBypassedTargets.add(target.url);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
// Phase 2: Page.setBypassCSP was already set — a simple reload should
|
|
361
|
+
// make the browser ignore CSP headers on the fresh navigation.
|
|
362
|
+
console.log(`[electron-float] Overlay iframe blocked by CSP, reloading with bypass: ${target.url}`);
|
|
363
|
+
cspBypassedTargets.add(target.url);
|
|
364
|
+
pendingReload = true;
|
|
365
|
+
pendingCspEscalation = true;
|
|
366
|
+
send('Page.reload', { ignoreCache: true });
|
|
367
|
+
}, 1500);
|
|
368
|
+
});
|
|
369
|
+
// Handle CDP events: lifecycle events and Fetch interception
|
|
370
|
+
ws.on('message', (data) => {
|
|
371
|
+
try {
|
|
372
|
+
const msg = JSON.parse(data.toString());
|
|
373
|
+
// Inject overlay after page load completes (after CSP-bypass reload)
|
|
374
|
+
if (msg.method === 'Page.loadEventFired' && pendingReload) {
|
|
375
|
+
pendingReload = false;
|
|
376
|
+
console.log(`[electron-float] Page loaded after CSP reload, injecting overlay...`);
|
|
377
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
378
|
+
return;
|
|
379
|
+
send('Runtime.evaluate', { expression: bootstrapScript, awaitPromise: false });
|
|
380
|
+
// If this was a simple reload (no proxy), check if the iframe loads now.
|
|
381
|
+
// If it still doesn't, escalate to the Fetch proxy as a last resort.
|
|
382
|
+
if (pendingCspEscalation) {
|
|
383
|
+
pendingCspEscalation = false;
|
|
384
|
+
setTimeout(async () => {
|
|
385
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
386
|
+
return;
|
|
387
|
+
const loaded = await this.probeOverlayIframeLoaded(ws, send);
|
|
388
|
+
if (loaded) {
|
|
389
|
+
console.log(`[electron-float] Overlay iframe loaded after CSP reload — no proxy needed`);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
console.log(`[electron-float] CSP reload insufficient, escalating to Fetch proxy: ${target.url}`);
|
|
393
|
+
fetchProxyActive = true;
|
|
394
|
+
const urlOrigin = new URL(target.url).origin;
|
|
395
|
+
send('Fetch.enable', {
|
|
396
|
+
patterns: [{ urlPattern: `${urlOrigin}/*`, requestStage: 'Request' }],
|
|
397
|
+
});
|
|
398
|
+
pendingReload = true;
|
|
399
|
+
send('Page.reload', { ignoreCache: true });
|
|
400
|
+
}, 1500);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (msg.method === 'Fetch.requestPaused' && fetchProxyActive) {
|
|
404
|
+
const requestId = msg.params?.requestId;
|
|
405
|
+
if (!requestId) {
|
|
406
|
+
console.warn('[electron-float] Fetch.requestPaused without requestId, skipping');
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const url = msg.params?.request?.url || '';
|
|
410
|
+
const method = msg.params?.request?.method || 'GET';
|
|
411
|
+
const requestHeaders = msg.params?.request?.headers || {};
|
|
412
|
+
const postData = msg.params?.request?.postData;
|
|
413
|
+
// Only proxy HTML document requests (Accept header contains text/html)
|
|
414
|
+
const acceptHeader = requestHeaders['Accept'] || requestHeaders['accept'] || '';
|
|
415
|
+
if (!acceptHeader.includes('text/html')) {
|
|
416
|
+
send('Fetch.continueRequest', { requestId });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
console.log(`[electron-float] Proxying request to strip CSP: ${url.substring(0, 60)}`);
|
|
420
|
+
// Make the request ourselves using Node.js http/https
|
|
421
|
+
const parsedUrl = new URL(url);
|
|
422
|
+
const transport = parsedUrl.protocol === 'https:' ? https : http;
|
|
423
|
+
const options = {
|
|
424
|
+
hostname: parsedUrl.hostname,
|
|
425
|
+
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
|
|
426
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
427
|
+
method: method,
|
|
428
|
+
headers: requestHeaders,
|
|
429
|
+
};
|
|
430
|
+
const proxyReq = transport.request(options, (proxyRes) => {
|
|
431
|
+
const bodyChunks = [];
|
|
432
|
+
proxyRes.on('data', (chunk) => bodyChunks.push(chunk));
|
|
433
|
+
proxyRes.on('end', () => {
|
|
434
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
435
|
+
return;
|
|
436
|
+
const fullBody = Buffer.concat(bodyChunks);
|
|
437
|
+
// Build response headers, stripping CSP and hop-by-hop headers
|
|
438
|
+
// that are invalid in Fetch.fulfillRequest responses
|
|
439
|
+
const HOP_BY_HOP = new Set([
|
|
440
|
+
'content-security-policy',
|
|
441
|
+
'content-security-policy-report-only',
|
|
442
|
+
'transfer-encoding',
|
|
443
|
+
'connection',
|
|
444
|
+
'keep-alive',
|
|
445
|
+
]);
|
|
446
|
+
const responseHeaders = [];
|
|
447
|
+
let strippedCSP = false;
|
|
448
|
+
for (const [name, value] of Object.entries(proxyRes.headers)) {
|
|
449
|
+
const lower = name.toLowerCase();
|
|
450
|
+
if (lower.includes('content-security-policy')) {
|
|
451
|
+
strippedCSP = true;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (HOP_BY_HOP.has(lower))
|
|
455
|
+
continue;
|
|
456
|
+
// Update content-length to match actual body size
|
|
457
|
+
if (lower === 'content-length') {
|
|
458
|
+
responseHeaders.push({ name, value: String(fullBody.length) });
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (Array.isArray(value)) {
|
|
462
|
+
value.forEach(v => responseHeaders.push({ name, value: v }));
|
|
463
|
+
}
|
|
464
|
+
else if (value) {
|
|
465
|
+
responseHeaders.push({ name, value });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (strippedCSP) {
|
|
469
|
+
console.log(`[electron-float] Stripped CSP from: ${url.substring(0, 60)}`);
|
|
470
|
+
}
|
|
471
|
+
send('Fetch.fulfillRequest', {
|
|
472
|
+
requestId,
|
|
473
|
+
responseCode: proxyRes.statusCode || 200,
|
|
474
|
+
responseHeaders,
|
|
475
|
+
body: fullBody.toString('base64'),
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
proxyReq.on('error', (err) => {
|
|
480
|
+
console.error(`[electron-float] Proxy request failed for ${url.substring(0, 60)}:`, err.message);
|
|
481
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
482
|
+
send('Fetch.failRequest', { requestId, errorReason: 'Failed' });
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
// Forward request body if present (for POST/PUT requests)
|
|
486
|
+
if (postData) {
|
|
487
|
+
proxyReq.write(postData);
|
|
488
|
+
}
|
|
489
|
+
proxyReq.end();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
// Ignore parse errors for non-JSON messages
|
|
494
|
+
}
|
|
273
495
|
});
|
|
274
496
|
ws.on('close', () => {
|
|
275
497
|
if (this.connections.get(targetId) === ws) {
|
|
@@ -17,6 +17,7 @@ export interface ElectronAppLaunchSpec {
|
|
|
17
17
|
}
|
|
18
18
|
export interface ElectronInspectableTarget {
|
|
19
19
|
type: string;
|
|
20
|
+
title?: string;
|
|
20
21
|
url: string;
|
|
21
22
|
webSocketDebuggerUrl?: string;
|
|
22
23
|
}
|
|
@@ -26,6 +27,25 @@ export declare const DEFAULT_ELECTRON_CDP_PORT = 9223;
|
|
|
26
27
|
export declare const DEFAULT_ELECTRON_TARGET_URL = "about:blank";
|
|
27
28
|
export declare const DEFAULT_ELECTRON_OVERLAY_TAB = "chat";
|
|
28
29
|
export declare const ELECTRON_OVERLAY_APP_PATH = "/electron";
|
|
30
|
+
export declare const PORT_HASH_RANGE = 40;
|
|
31
|
+
/**
|
|
32
|
+
* Simple string hash function that returns a value between 0 and max-1.
|
|
33
|
+
* Exported for testing.
|
|
34
|
+
*/
|
|
35
|
+
export declare function hashString(str: string, max: number): number;
|
|
36
|
+
/**
|
|
37
|
+
* Get a port for an Electron app based on its path.
|
|
38
|
+
* Uses hash-based offset from base port, with fallback to next available port
|
|
39
|
+
* starting from the preferred port (to stay in the app's "slot" range).
|
|
40
|
+
*/
|
|
41
|
+
export declare function getElectronAppPort(appPath: string, basePort: number): Promise<number>;
|
|
42
|
+
/**
|
|
43
|
+
* Get both CDP and serve ports for an Electron app.
|
|
44
|
+
*/
|
|
45
|
+
export declare function getElectronAppPorts(appPath: string): Promise<{
|
|
46
|
+
cdpPort: number;
|
|
47
|
+
servePort: number;
|
|
48
|
+
}>;
|
|
29
49
|
export declare function getElectronAppDisplayName(appPath: string): string;
|
|
30
50
|
export declare function resolveElectronAppExecutablePath(appPath: string, platform?: NodeJS.Platform): string;
|
|
31
51
|
export declare function buildElectronAppProcessMatchPatterns(appPath: string, platform?: NodeJS.Platform): string[];
|
|
@@ -56,3 +76,9 @@ export declare function buildElectronOverlayBootstrapScript(options: {
|
|
|
56
76
|
activeTab?: string;
|
|
57
77
|
}): string;
|
|
58
78
|
export declare function shouldInjectElectronOverlayTarget(target: ElectronInspectableTarget): boolean;
|
|
79
|
+
/**
|
|
80
|
+
* From a list of injectable targets, select the best target per origin.
|
|
81
|
+
* Multi-window apps (like Teams) expose several page targets for the same origin;
|
|
82
|
+
* we only want to inject the overlay into the primary content window.
|
|
83
|
+
*/
|
|
84
|
+
export declare function selectBestOverlayTargets(targets: ElectronInspectableTarget[]): ElectronInspectableTarget[];
|
|
@@ -1,10 +1,97 @@
|
|
|
1
1
|
import { basename, join, resolve } from 'path';
|
|
2
|
+
import { accessSync, constants, readdirSync, statSync } from 'fs';
|
|
2
3
|
export const DEFAULT_ELECTRON_SERVE_PORT = 5710;
|
|
3
4
|
export const DEFAULT_ELECTRON_SERVE_HOST = 'localhost';
|
|
4
5
|
export const DEFAULT_ELECTRON_CDP_PORT = 9223;
|
|
5
6
|
export const DEFAULT_ELECTRON_TARGET_URL = 'about:blank';
|
|
6
7
|
export const DEFAULT_ELECTRON_OVERLAY_TAB = 'chat';
|
|
7
8
|
export const ELECTRON_OVERLAY_APP_PATH = '/electron';
|
|
9
|
+
// Port allocation constants
|
|
10
|
+
export const PORT_HASH_RANGE = 40;
|
|
11
|
+
/**
|
|
12
|
+
* Simple string hash function that returns a value between 0 and max-1.
|
|
13
|
+
* Exported for testing.
|
|
14
|
+
*/
|
|
15
|
+
export function hashString(str, max) {
|
|
16
|
+
let hash = 0;
|
|
17
|
+
for (let i = 0; i < str.length; i++) {
|
|
18
|
+
const char = str.charCodeAt(i);
|
|
19
|
+
hash = ((hash << 5) - hash) + char;
|
|
20
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
21
|
+
}
|
|
22
|
+
return Math.abs(hash) % max;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Try to listen on a specific port and host, returning the assigned port.
|
|
26
|
+
*/
|
|
27
|
+
async function tryListenOnPort(port, host) {
|
|
28
|
+
const { createServer } = await import('net');
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const server = createServer();
|
|
31
|
+
server.on('error', reject);
|
|
32
|
+
server.listen(port, host, () => {
|
|
33
|
+
const addr = server.address();
|
|
34
|
+
const assignedPort = addr && typeof addr === 'object' ? addr.port : port;
|
|
35
|
+
server.close(() => resolve(assignedPort));
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Check if a port is available on both IPv4 (127.0.0.1) and IPv6 (::1).
|
|
41
|
+
* On macOS, `localhost` resolves to `::1`, so we need to check both.
|
|
42
|
+
*/
|
|
43
|
+
async function isPortAvailable(port) {
|
|
44
|
+
try {
|
|
45
|
+
await tryListenOnPort(port, '127.0.0.1');
|
|
46
|
+
try {
|
|
47
|
+
await tryListenOnPort(port, '::1');
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
// ::1 may not be available on some systems — only fail on EADDRINUSE
|
|
51
|
+
if (err.code === 'EADDRINUSE') {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Find an available port starting from the given port.
|
|
63
|
+
*/
|
|
64
|
+
async function findAvailablePort(startPort, maxAttempts = 100) {
|
|
65
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
66
|
+
const port = startPort + i;
|
|
67
|
+
if (await isPortAvailable(port)) {
|
|
68
|
+
return port;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`Could not find available port starting from ${startPort}`);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Get a port for an Electron app based on its path.
|
|
75
|
+
* Uses hash-based offset from base port, with fallback to next available port
|
|
76
|
+
* starting from the preferred port (to stay in the app's "slot" range).
|
|
77
|
+
*/
|
|
78
|
+
export async function getElectronAppPort(appPath, basePort) {
|
|
79
|
+
const offset = hashString(appPath, PORT_HASH_RANGE);
|
|
80
|
+
const preferredPort = basePort + offset;
|
|
81
|
+
if (await isPortAvailable(preferredPort)) {
|
|
82
|
+
return preferredPort;
|
|
83
|
+
}
|
|
84
|
+
// Fallback: find next available port starting from preferred (stay in slot range)
|
|
85
|
+
return findAvailablePort(preferredPort + 1);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Get both CDP and serve ports for an Electron app.
|
|
89
|
+
*/
|
|
90
|
+
export async function getElectronAppPorts(appPath) {
|
|
91
|
+
const cdpPort = await getElectronAppPort(appPath, DEFAULT_ELECTRON_CDP_PORT);
|
|
92
|
+
const servePort = await getElectronAppPort(appPath, DEFAULT_ELECTRON_SERVE_PORT);
|
|
93
|
+
return { cdpPort, servePort };
|
|
94
|
+
}
|
|
8
95
|
export function getElectronAppDisplayName(appPath) {
|
|
9
96
|
const trimmedPath = appPath.replace(/[\\/]+$/, '');
|
|
10
97
|
const fileName = basename(trimmedPath);
|
|
@@ -16,7 +103,80 @@ export function getElectronAppDisplayName(appPath) {
|
|
|
16
103
|
export function resolveElectronAppExecutablePath(appPath, platform = process.platform) {
|
|
17
104
|
const resolvedAppPath = resolve(appPath);
|
|
18
105
|
if (platform === 'darwin' && resolvedAppPath.toLowerCase().endsWith('.app')) {
|
|
19
|
-
|
|
106
|
+
const macOSDir = join(resolvedAppPath, 'Contents', 'MacOS');
|
|
107
|
+
// First try the expected name (app name without .app)
|
|
108
|
+
const expectedName = getElectronAppDisplayName(resolvedAppPath);
|
|
109
|
+
const expectedPath = join(macOSDir, expectedName);
|
|
110
|
+
try {
|
|
111
|
+
const stat = statSync(expectedPath);
|
|
112
|
+
if (stat.isFile()) {
|
|
113
|
+
return expectedPath;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Expected path doesn't exist, scan the directory
|
|
118
|
+
}
|
|
119
|
+
// Scan MacOS directory for the main executable
|
|
120
|
+
// Many Electron apps use "Electron" as the executable name
|
|
121
|
+
// Prefer known main executable names, filter out helper processes
|
|
122
|
+
const helperPatterns = [
|
|
123
|
+
/helper/i,
|
|
124
|
+
/crash/i,
|
|
125
|
+
/gpu/i,
|
|
126
|
+
/renderer/i,
|
|
127
|
+
/plugin/i,
|
|
128
|
+
/utility/i,
|
|
129
|
+
];
|
|
130
|
+
try {
|
|
131
|
+
const entries = readdirSync(macOSDir);
|
|
132
|
+
// Helper to check if a file is executable
|
|
133
|
+
const isExecutable = (path) => {
|
|
134
|
+
try {
|
|
135
|
+
accessSync(path, constants.X_OK);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
// First pass: look for "Electron" executable (common in Electron apps)
|
|
143
|
+
if (entries.includes('Electron')) {
|
|
144
|
+
const electronPath = join(macOSDir, 'Electron');
|
|
145
|
+
try {
|
|
146
|
+
const stat = statSync(electronPath);
|
|
147
|
+
if (stat.isFile() && isExecutable(electronPath)) {
|
|
148
|
+
return electronPath;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// Continue to next fallback
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Second pass: find first non-helper executable with execute permission
|
|
156
|
+
for (const entry of entries) {
|
|
157
|
+
// Skip hidden files, scripts, and helper executables
|
|
158
|
+
if (entry.startsWith('.') || entry.endsWith('.sh'))
|
|
159
|
+
continue;
|
|
160
|
+
if (helperPatterns.some((p) => p.test(entry)))
|
|
161
|
+
continue;
|
|
162
|
+
const entryPath = join(macOSDir, entry);
|
|
163
|
+
try {
|
|
164
|
+
const stat = statSync(entryPath);
|
|
165
|
+
if (stat.isFile() && isExecutable(entryPath)) {
|
|
166
|
+
return entryPath;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Can't read directory, fall back to expected path
|
|
176
|
+
}
|
|
177
|
+
// Fall back to expected path even if it doesn't exist
|
|
178
|
+
// (error will be caught later)
|
|
179
|
+
return expectedPath;
|
|
20
180
|
}
|
|
21
181
|
return resolvedAppPath;
|
|
22
182
|
}
|
|
@@ -135,3 +295,67 @@ export function shouldInjectElectronOverlayTarget(target) {
|
|
|
135
295
|
return false;
|
|
136
296
|
return true;
|
|
137
297
|
}
|
|
298
|
+
/**
|
|
299
|
+
* Extract the origin from a URL, or return the URL as-is for non-standard schemes.
|
|
300
|
+
*/
|
|
301
|
+
function safeOrigin(url) {
|
|
302
|
+
try {
|
|
303
|
+
return new URL(url).origin;
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
return url;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Score a target for "primary window" ranking within a group of same-origin pages.
|
|
311
|
+
* Higher score = more likely the main content window.
|
|
312
|
+
*/
|
|
313
|
+
function scoreOverlayTarget(target) {
|
|
314
|
+
let score = 0;
|
|
315
|
+
const title = target.title ?? '';
|
|
316
|
+
const url = target.url;
|
|
317
|
+
// Longer, more descriptive titles indicate content windows (e.g.
|
|
318
|
+
// "Calendar | Adobe | Microsoft Teams" vs generic "Microsoft Teams")
|
|
319
|
+
score += Math.min(title.length, 120);
|
|
320
|
+
// Penalize URLs with hash fragments that suggest hidden/auxiliary windows
|
|
321
|
+
// (e.g. Teams uses #deepLink=default&isMinimized=false for background windows)
|
|
322
|
+
if (url.includes('isMinimized=') || url.includes('deepLink=')) {
|
|
323
|
+
score -= 200;
|
|
324
|
+
}
|
|
325
|
+
// Prefer clean URLs without large hash fragments (shell pages often have them)
|
|
326
|
+
const hashLength = url.includes('#') ? url.length - url.indexOf('#') : 0;
|
|
327
|
+
score -= Math.min(hashLength, 100);
|
|
328
|
+
return score;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* From a list of injectable targets, select the best target per origin.
|
|
332
|
+
* Multi-window apps (like Teams) expose several page targets for the same origin;
|
|
333
|
+
* we only want to inject the overlay into the primary content window.
|
|
334
|
+
*/
|
|
335
|
+
export function selectBestOverlayTargets(targets) {
|
|
336
|
+
const injectable = targets.filter(shouldInjectElectronOverlayTarget);
|
|
337
|
+
// Group by origin
|
|
338
|
+
const byOrigin = new Map();
|
|
339
|
+
for (const target of injectable) {
|
|
340
|
+
const origin = safeOrigin(target.url);
|
|
341
|
+
const group = byOrigin.get(origin);
|
|
342
|
+
if (group) {
|
|
343
|
+
group.push(target);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
byOrigin.set(origin, [target]);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Pick the best target from each origin group
|
|
350
|
+
const result = [];
|
|
351
|
+
for (const group of byOrigin.values()) {
|
|
352
|
+
if (group.length === 1) {
|
|
353
|
+
result.push(group[0]);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
// Sort by score descending and pick the winner
|
|
357
|
+
group.sort((a, b) => scoreOverlayTarget(b) - scoreOverlayTarget(a));
|
|
358
|
+
result.push(group[0]);
|
|
359
|
+
}
|
|
360
|
+
return result;
|
|
361
|
+
}
|