sliccy 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.
|
@@ -86,6 +86,8 @@ export function buildChromeLaunchArgs(options) {
|
|
|
86
86
|
`--remote-debugging-port=${options.cdpPort}`,
|
|
87
87
|
'--no-first-run',
|
|
88
88
|
'--no-default-browser-check',
|
|
89
|
+
'--disable-crash-reporter',
|
|
90
|
+
'--disable-background-tracing',
|
|
89
91
|
`--user-data-dir=${options.profile.userDataDir}`,
|
|
90
92
|
];
|
|
91
93
|
if (options.profile.extensionPath) {
|
|
@@ -17,6 +17,20 @@ export declare function launchElectronApp(options: {
|
|
|
17
17
|
child: ChildProcess;
|
|
18
18
|
displayName: string;
|
|
19
19
|
}>;
|
|
20
|
+
/**
|
|
21
|
+
* Decode a base64 PNG into raw RGBA pixel data by parsing chunks and inflating.
|
|
22
|
+
* Returns { width, height, pixels } where pixels is a Buffer of RGBA bytes.
|
|
23
|
+
*/
|
|
24
|
+
export declare function decodePngPixels(base64Data: string): {
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
pixels: Buffer;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Compute the average perceived luminance (0–255) from RGBA pixel data,
|
|
31
|
+
* sampling a grid of pixels for performance.
|
|
32
|
+
*/
|
|
33
|
+
export declare function computeAverageLuminance(pixels: Buffer, width: number, height: number, sampleStep?: number): number;
|
|
20
34
|
export declare class ElectronOverlayInjector {
|
|
21
35
|
private readonly cdpPort;
|
|
22
36
|
private readonly bootstrapScript;
|
|
@@ -4,6 +4,7 @@ import { readFile } from 'fs/promises';
|
|
|
4
4
|
import * as http from 'http';
|
|
5
5
|
import * as https from 'https';
|
|
6
6
|
import { promisify } from 'util';
|
|
7
|
+
import { inflateSync } from 'zlib';
|
|
7
8
|
import { WebSocket } from 'ws';
|
|
8
9
|
import { buildElectronAppLaunchSpec, buildElectronOverlayAppUrl, buildElectronOverlayBootstrapScript, buildElectronOverlayEntryUrl, getElectronOverlayEntryDistPath, getElectronServeOrigin, selectBestOverlayTargets, } from './electron-runtime.js';
|
|
9
10
|
const execFile = promisify(nodeExecFile);
|
|
@@ -170,6 +171,175 @@ export async function launchElectronApp(options) {
|
|
|
170
171
|
displayName: launchSpec.displayName,
|
|
171
172
|
};
|
|
172
173
|
}
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Theme detection — screenshot-based luminance analysis
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
/**
|
|
178
|
+
* Decode a base64 PNG into raw RGBA pixel data by parsing chunks and inflating.
|
|
179
|
+
* Returns { width, height, pixels } where pixels is a Buffer of RGBA bytes.
|
|
180
|
+
*/
|
|
181
|
+
export function decodePngPixels(base64Data) {
|
|
182
|
+
const buf = Buffer.from(base64Data, 'base64');
|
|
183
|
+
// Validate PNG signature
|
|
184
|
+
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
185
|
+
if (buf.subarray(0, 8).compare(PNG_SIGNATURE) !== 0) {
|
|
186
|
+
throw new Error('Not a valid PNG');
|
|
187
|
+
}
|
|
188
|
+
let width = 0;
|
|
189
|
+
let height = 0;
|
|
190
|
+
let bitDepth = 0;
|
|
191
|
+
let colorType = 0;
|
|
192
|
+
const idatChunks = [];
|
|
193
|
+
let offset = 8;
|
|
194
|
+
while (offset < buf.length) {
|
|
195
|
+
const chunkLength = buf.readUInt32BE(offset);
|
|
196
|
+
const chunkType = buf.subarray(offset + 4, offset + 8).toString('ascii');
|
|
197
|
+
const chunkData = buf.subarray(offset + 8, offset + 8 + chunkLength);
|
|
198
|
+
if (chunkType === 'IHDR') {
|
|
199
|
+
width = chunkData.readUInt32BE(0);
|
|
200
|
+
height = chunkData.readUInt32BE(4);
|
|
201
|
+
bitDepth = chunkData[8];
|
|
202
|
+
colorType = chunkData[9];
|
|
203
|
+
}
|
|
204
|
+
else if (chunkType === 'IDAT') {
|
|
205
|
+
idatChunks.push(chunkData);
|
|
206
|
+
}
|
|
207
|
+
else if (chunkType === 'IEND') {
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
offset += 12 + chunkLength; // 4 (length) + 4 (type) + data + 4 (CRC)
|
|
211
|
+
}
|
|
212
|
+
if (width === 0 || height === 0)
|
|
213
|
+
throw new Error('Missing IHDR chunk');
|
|
214
|
+
if (bitDepth !== 8)
|
|
215
|
+
throw new Error(`Unsupported bit depth: ${bitDepth}`);
|
|
216
|
+
// Only support RGB (2) and RGBA (6) — CDP screenshots are always RGBA
|
|
217
|
+
const bytesPerPixel = colorType === 6 ? 4 : colorType === 2 ? 3 : 0;
|
|
218
|
+
if (bytesPerPixel === 0)
|
|
219
|
+
throw new Error(`Unsupported color type: ${colorType}`);
|
|
220
|
+
const compressed = Buffer.concat(idatChunks);
|
|
221
|
+
const inflated = inflateSync(compressed);
|
|
222
|
+
// Each row has a 1-byte filter prefix followed by pixel data
|
|
223
|
+
const rowBytes = width * bytesPerPixel;
|
|
224
|
+
const pixels = Buffer.alloc(width * height * 4); // Always output RGBA
|
|
225
|
+
let prevRow = Buffer.alloc(rowBytes);
|
|
226
|
+
for (let y = 0; y < height; y++) {
|
|
227
|
+
const rowStart = y * (1 + rowBytes);
|
|
228
|
+
const filter = inflated[rowStart];
|
|
229
|
+
const row = Buffer.from(inflated.subarray(rowStart + 1, rowStart + 1 + rowBytes));
|
|
230
|
+
// Apply PNG row filters
|
|
231
|
+
for (let i = 0; i < rowBytes; i++) {
|
|
232
|
+
const a = i >= bytesPerPixel ? row[i - bytesPerPixel] : 0;
|
|
233
|
+
const b = prevRow[i];
|
|
234
|
+
const c = i >= bytesPerPixel ? prevRow[i - bytesPerPixel] : 0;
|
|
235
|
+
switch (filter) {
|
|
236
|
+
case 1: // Sub
|
|
237
|
+
row[i] = (row[i] + a) & 0xff;
|
|
238
|
+
break;
|
|
239
|
+
case 2: // Up
|
|
240
|
+
row[i] = (row[i] + b) & 0xff;
|
|
241
|
+
break;
|
|
242
|
+
case 3: // Average
|
|
243
|
+
row[i] = (row[i] + ((a + b) >>> 1)) & 0xff;
|
|
244
|
+
break;
|
|
245
|
+
case 4: { // Paeth
|
|
246
|
+
const p = a + b - c;
|
|
247
|
+
const pa = Math.abs(p - a);
|
|
248
|
+
const pb = Math.abs(p - b);
|
|
249
|
+
const pc = Math.abs(p - c);
|
|
250
|
+
row[i] = (row[i] + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 0xff;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
// case 0: None — no transformation needed
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
for (let x = 0; x < width; x++) {
|
|
257
|
+
const srcIdx = x * bytesPerPixel;
|
|
258
|
+
const dstIdx = (y * width + x) * 4;
|
|
259
|
+
pixels[dstIdx] = row[srcIdx]; // R
|
|
260
|
+
pixels[dstIdx + 1] = row[srcIdx + 1]; // G
|
|
261
|
+
pixels[dstIdx + 2] = row[srcIdx + 2]; // B
|
|
262
|
+
pixels[dstIdx + 3] = bytesPerPixel === 4 ? row[srcIdx + 3] : 255; // A
|
|
263
|
+
}
|
|
264
|
+
prevRow = row;
|
|
265
|
+
}
|
|
266
|
+
return { width, height, pixels };
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Compute the average perceived luminance (0–255) from RGBA pixel data,
|
|
270
|
+
* sampling a grid of pixels for performance.
|
|
271
|
+
*/
|
|
272
|
+
export function computeAverageLuminance(pixels, width, height, sampleStep = 4) {
|
|
273
|
+
let totalLuminance = 0;
|
|
274
|
+
let sampleCount = 0;
|
|
275
|
+
for (let y = 0; y < height; y += sampleStep) {
|
|
276
|
+
for (let x = 0; x < width; x += sampleStep) {
|
|
277
|
+
const idx = (y * width + x) * 4;
|
|
278
|
+
const r = pixels[idx];
|
|
279
|
+
const g = pixels[idx + 1];
|
|
280
|
+
const b = pixels[idx + 2];
|
|
281
|
+
// ITU-R BT.601 perceived luminance
|
|
282
|
+
totalLuminance += 0.299 * r + 0.587 * g + 0.114 * b;
|
|
283
|
+
sampleCount++;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return sampleCount > 0 ? totalLuminance / sampleCount : 128;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Detect whether the target app is using a light or dark theme by taking
|
|
290
|
+
* a CDP screenshot and analyzing the average luminance.
|
|
291
|
+
* Returns 'light' or 'dark'.
|
|
292
|
+
*/
|
|
293
|
+
function detectAppThemeFromScreenshot(ws, send) {
|
|
294
|
+
return new Promise((resolve) => {
|
|
295
|
+
// Take a small JPEG screenshot for speed — we only need luminance
|
|
296
|
+
const screenshotId = send('Page.captureScreenshot', {
|
|
297
|
+
format: 'png',
|
|
298
|
+
quality: 30,
|
|
299
|
+
clip: { x: 0, y: 0, width: 160, height: 120, scale: 0.25 },
|
|
300
|
+
optimizeForSpeed: true,
|
|
301
|
+
});
|
|
302
|
+
const timeout = setTimeout(() => {
|
|
303
|
+
cleanup();
|
|
304
|
+
console.log('[electron-float] Theme detection timed out, defaulting to dark');
|
|
305
|
+
resolve('dark');
|
|
306
|
+
}, 5000);
|
|
307
|
+
const onMessage = (data) => {
|
|
308
|
+
try {
|
|
309
|
+
const msg = JSON.parse(data.toString());
|
|
310
|
+
if (msg.id !== screenshotId)
|
|
311
|
+
return;
|
|
312
|
+
cleanup();
|
|
313
|
+
const base64 = msg.result?.data;
|
|
314
|
+
if (!base64) {
|
|
315
|
+
console.log('[electron-float] Theme detection: no screenshot data, defaulting to dark');
|
|
316
|
+
resolve('dark');
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
const { width, height, pixels } = decodePngPixels(base64);
|
|
321
|
+
const luminance = computeAverageLuminance(pixels, width, height);
|
|
322
|
+
const theme = luminance > 128 ? 'light' : 'dark';
|
|
323
|
+
console.log(`[electron-float] Theme detection: luminance=${luminance.toFixed(1)}, theme=${theme} (${width}x${height})`);
|
|
324
|
+
resolve(theme);
|
|
325
|
+
}
|
|
326
|
+
catch (decodeError) {
|
|
327
|
+
const message = decodeError instanceof Error ? decodeError.message : String(decodeError);
|
|
328
|
+
console.error('[electron-float] Theme detection decode failed:', message);
|
|
329
|
+
resolve('dark');
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
/* ignore non-JSON messages */
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
const cleanup = () => {
|
|
337
|
+
clearTimeout(timeout);
|
|
338
|
+
ws.off('message', onMessage);
|
|
339
|
+
};
|
|
340
|
+
ws.on('message', onMessage);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
173
343
|
async function loadElectronOverlayBundleSource(options) {
|
|
174
344
|
const serveOrigin = getElectronServeOrigin(options.servePort);
|
|
175
345
|
if (options.dev) {
|
|
@@ -323,6 +493,14 @@ export class ElectronOverlayInjector {
|
|
|
323
493
|
let pendingReload = false;
|
|
324
494
|
let pendingCspEscalation = false;
|
|
325
495
|
let fetchProxyActive = false;
|
|
496
|
+
/**
|
|
497
|
+
* Build a script that sets the SLICC theme preference in localStorage
|
|
498
|
+
* to match the target app's detected theme, then runs the bootstrap.
|
|
499
|
+
*/
|
|
500
|
+
const buildThemedBootstrap = (theme) => {
|
|
501
|
+
const themeScript = `try{localStorage.setItem('slicc-theme',${JSON.stringify(theme)})}catch(e){}`;
|
|
502
|
+
return `${themeScript}\n${bootstrapScript}`;
|
|
503
|
+
};
|
|
326
504
|
ws.on('open', () => {
|
|
327
505
|
const isWebContent = target.url.startsWith('https://');
|
|
328
506
|
const alreadyBypassed = cspBypassedTargets.has(target.url);
|
|
@@ -332,39 +510,48 @@ export class ElectronOverlayInjector {
|
|
|
332
510
|
// Set CSP bypass — affects future resource loads on the current page
|
|
333
511
|
send('Page.setBypassCSP', { enabled: true });
|
|
334
512
|
if (alreadyBypassed) {
|
|
335
|
-
// Already reloaded with CSP bypass previously —
|
|
336
|
-
console.log(`[electron-float]
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
513
|
+
// Already reloaded with CSP bypass previously — detect theme and inject
|
|
514
|
+
console.log(`[electron-float] Detecting theme and injecting overlay (CSP already bypassed)...`);
|
|
515
|
+
void detectAppThemeFromScreenshot(ws, send).then((theme) => {
|
|
516
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
517
|
+
return;
|
|
518
|
+
send('Runtime.evaluate', { expression: buildThemedBootstrap(theme), awaitPromise: false });
|
|
519
|
+
});
|
|
346
520
|
return;
|
|
347
521
|
}
|
|
348
|
-
//
|
|
349
|
-
// If CSP blocked it,
|
|
350
|
-
|
|
351
|
-
|
|
522
|
+
// First connection to this target URL: detect theme, then inject overlay.
|
|
523
|
+
// After injection, check if the iframe loaded. If CSP blocked it, fall back to reload+proxy.
|
|
524
|
+
console.log(`[electron-float] Detecting theme before first overlay injection...`);
|
|
525
|
+
void detectAppThemeFromScreenshot(ws, send).then((theme) => {
|
|
352
526
|
if (ws.readyState !== WebSocket.OPEN)
|
|
353
527
|
return;
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
528
|
+
console.log(`[electron-float] Injecting overlay (first attempt, theme=${theme})...`);
|
|
529
|
+
send('Runtime.evaluate', { expression: buildThemedBootstrap(theme), awaitPromise: false });
|
|
530
|
+
if (!isWebContent) {
|
|
531
|
+
// Local content (file://, app protocol) — CSP is not an issue
|
|
358
532
|
return;
|
|
359
533
|
}
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
534
|
+
// After a short delay, probe whether the overlay iframe loaded.
|
|
535
|
+
// If CSP blocked it, reload the page so Page.setBypassCSP takes effect.
|
|
536
|
+
// If that still doesn't work, escalate to the Fetch proxy.
|
|
537
|
+
setTimeout(async () => {
|
|
538
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
539
|
+
return;
|
|
540
|
+
const loaded = await this.probeOverlayIframeLoaded(ws, send);
|
|
541
|
+
if (loaded) {
|
|
542
|
+
console.log(`[electron-float] Overlay iframe loaded successfully — no CSP reload needed`);
|
|
543
|
+
cspBypassedTargets.add(target.url);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
// Phase 2: Page.setBypassCSP was already set — a simple reload should
|
|
547
|
+
// make the browser ignore CSP headers on the fresh navigation.
|
|
548
|
+
console.log(`[electron-float] Overlay iframe blocked by CSP, reloading with bypass: ${target.url}`);
|
|
549
|
+
cspBypassedTargets.add(target.url);
|
|
550
|
+
pendingReload = true;
|
|
551
|
+
pendingCspEscalation = true;
|
|
552
|
+
send('Page.reload', { ignoreCache: true });
|
|
553
|
+
}, 1500);
|
|
554
|
+
});
|
|
368
555
|
});
|
|
369
556
|
// Handle CDP events: lifecycle events and Fetch interception
|
|
370
557
|
ws.on('message', (data) => {
|
|
@@ -373,10 +560,14 @@ export class ElectronOverlayInjector {
|
|
|
373
560
|
// Inject overlay after page load completes (after CSP-bypass reload)
|
|
374
561
|
if (msg.method === 'Page.loadEventFired' && pendingReload) {
|
|
375
562
|
pendingReload = false;
|
|
376
|
-
console.log(`[electron-float] Page loaded after CSP reload, injecting overlay...`);
|
|
563
|
+
console.log(`[electron-float] Page loaded after CSP reload, detecting theme and injecting overlay...`);
|
|
377
564
|
if (ws.readyState !== WebSocket.OPEN)
|
|
378
565
|
return;
|
|
379
|
-
|
|
566
|
+
void detectAppThemeFromScreenshot(ws, send).then((theme) => {
|
|
567
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
568
|
+
return;
|
|
569
|
+
send('Runtime.evaluate', { expression: buildThemedBootstrap(theme), awaitPromise: false });
|
|
570
|
+
});
|
|
380
571
|
// If this was a simple reload (no proxy), check if the iframe loads now.
|
|
381
572
|
// If it still doesn't, escalate to the Fetch proxy as a last resort.
|
|
382
573
|
if (pendingCspEscalation) {
|
|
@@ -2,7 +2,7 @@ import { spawn } from 'child_process';
|
|
|
2
2
|
import { readFile } from 'fs/promises';
|
|
3
3
|
import { resolve } from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
|
-
import { app, BrowserWindow, session } from 'electron';
|
|
5
|
+
import { app, BrowserWindow, nativeTheme, session } from 'electron';
|
|
6
6
|
import { buildElectronOverlayAppUrl, buildElectronOverlayEntryUrl, buildElectronOverlayInjectionCall, buildElectronServerSpawnConfig, getElectronOverlayEntryDistPath, getElectronServeOrigin, parseElectronFloatFlags, } from './electron-runtime.js';
|
|
7
7
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
8
8
|
const PROJECT_ROOT = resolve(__dirname, '..', '..');
|
|
@@ -47,7 +47,10 @@ async function loadOverlayBundleSource() {
|
|
|
47
47
|
}
|
|
48
48
|
async function injectOverlay(window) {
|
|
49
49
|
const bundleSource = await loadOverlayBundleSource();
|
|
50
|
-
|
|
50
|
+
// Detect the app's effective theme and set SLICC's theme to match
|
|
51
|
+
const theme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light';
|
|
52
|
+
const themeScript = `try{localStorage.setItem('slicc-theme',${JSON.stringify(theme)})}catch(e){}`;
|
|
53
|
+
await window.webContents.executeJavaScript(`${themeScript}\n${bundleSource}`, true);
|
|
51
54
|
await window.webContents.executeJavaScript(buildElectronOverlayInjectionCall({ appUrl: OVERLAY_APP_URL }), true);
|
|
52
55
|
}
|
|
53
56
|
function wireOverlayReinjection(window) {
|
package/dist/cli/index.js
CHANGED
|
@@ -464,6 +464,7 @@ async function main() {
|
|
|
464
464
|
launchedBrowserProcess = spawn(chromePath, chromeArgs, {
|
|
465
465
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
466
466
|
detached: false,
|
|
467
|
+
env: { ...process.env, GOOGLE_CRASHPAD_DISABLE: '1' },
|
|
467
468
|
});
|
|
468
469
|
launchedBrowserLabel = chromeProfile.displayName;
|
|
469
470
|
// Parse the actual CDP port from Chrome's stderr before piping output.
|