@browserbox/browserbox
Version:
BrowserBox CLI - Secure, cross-platform RBI. See dosaygo.com
1,302 lines (1,209 loc) • 55 kB
JavaScript
#!/usr/bin/env node
// Kernel Browser - a terminal client for the modern world wide web
// Project Information
// Initiated by: DOSAYGO Corporation and @o0101
// Incept date: February 2025
// Assisted by: @grok and ChatGPT
// Copyright (C) DOSAYGO and @o0101 2025
// License: Commercial / paid license - contact sales@dosaygo.com
// Project Codenames during development: Jaguar, BabyJaguar, CyberJaguar - BrowserBox TUI Browser Application
// Setup
// imports
// built in
import util from 'util';
import { appendFileSync } from 'fs';
import { exec } from 'child_process';
// 3rd-party
import TK from 'terminal-kit';
export const { terminal } = TK;
// internal
import Layout from './layout.js';
import TerminalBrowser from './terminal-browser.js';
import { ConnectionManager } from './connection-manager.js';
import { InputManager } from './input-manager.js';
import { ggLog, sleep, logBBMessage, logClicks, debugLog, DEBUG } from './log.js';
// global scroll state
const vScroll = {
X: 0,
Y: 0,
atMarginTop: true,
atMarginBottom: true,
atMarginLeft: true,
atMarginRight: true,
};
// Constants and state
const clickCounter = { value: 0 };
const IGNORE_VSCROLL = false;
const USE_SYNTHETIC_FOCUS = true;
const GLOBAL_IGNORE_CLICKABLE_FOR_INPUT = true;
const IGNORE_CLICKABLE_FOR_TYPES = new Set([
'input'
]);
const markClicks = false;
const DEBOUNCE_DELAY = 300;
const args = process.argv.slice(2);
const mySource = 'krnlclient' + Math.random().toString(36);
export const renderedBoxes = [];
const stateBySession = new Map(); // Map<sessionId, state>
export const sessions = new Map();
export const renderedBoxesBySession = new Map(); // Map<sessionId, renderedBoxes>
// Update browserState
export const browserState = {
currentSessionId: null,
targets: null,
send: null,
};
let connection;
let cleanup;
let targets;
let send;
let browser;
let messageId = Math.round(Math.round(Math.random() * 1000 + 1) * 1e6);
let sessionId;
let browserbox;
let loginLink;
// arrows
const debouncedRefresh = debounce(() => {
const {send, currentSessionId: sessionId} = browserState;
if (sessionId) {
refreshTerminal({ send, sessionId });
}
}, DEBOUNCE_DELAY);
// arg processing
if (args.length === 1) {
loginLink = args[0];
try {
const urlObj = new URL(loginLink);
if (!urlObj.pathname.startsWith('/login') || !urlObj.searchParams.has('token')) {
throw new Error('Invalid login link format. Expected: /login?token=<token>');
}
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
} else {
console.error('Usage: node script.mjs <login-link>');
console.error('Example: node script.mjs "https://example.com/login?token=abc123"');
process.exit(1);
}
// remote browser connection constants
const urlObj = new URL(loginLink);
const hostname = urlObj.hostname;
const token = urlObj.searchParams.get('token');
const baseUrl = `${urlObj.protocol}//${urlObj.host}`;
const port = parseInt(urlObj.port, 10) || (urlObj.protocol === 'https:' ? 443 : 80);
const proxyPort = port + 1;
const proxyBaseUrl = `${urlObj.protocol}//${urlObj.hostname}:${proxyPort}`;
const loginUrl = `${baseUrl}/login?token=${token}`;
const apiUrl = `${baseUrl}/api/v11/tabs`;
if (DEBUG) {
const ox = process.exit.bind(process);
process.exit = (...stuff) => {
const e = new Error();
console.error('Exiting', e, ...stuff);
ox(stuff[0]);
};
}
startKernel();
// main render
function renderBoxes(layoutState) {
const { visibleBoxes, termWidth, termHeight, viewportX, viewportY, clickableElements } = layoutState;
const newBoxes = [];
const sessionId = browserState.currentSessionId; // Use current sessionId
vScroll.atMarginRight = true;
vScroll.atMarginBottom = true;
vScroll.atMarginLeft = true;
vScroll.atMarginTop = true;
logClicks(`renderBoxes: clickableElements.length=${clickableElements.length}`);
for (const box of visibleBoxes) {
DEBUG && debugLog(`Processing box: text="${box.text}", type="${box.type}", isClickable=${box.isClickable}, backendNodeId=${box.backendNodeId}`);
const { text, boundingBox, isClickable, termX, termY, ancestorType, backendNodeId, layoutIndex, nodeIndex, type, subType } = box;
const renderX = termX - (IGNORE_VSCROLL ? 0 : vScroll.X);
const renderY = termY + 1 - (IGNORE_VSCROLL ? 0 : vScroll.Y);
if (renderX > termWidth) {
vScroll.atMarginRight = false;
}
if (renderY > termHeight + 4) {
vScroll.atMarginBottom = false;
}
if (renderX > termWidth || renderY > termHeight + 4) continue;
const displayAvailable = Math.max(0, termWidth - renderX + 1);
let termWidthForBox = text.length;
const isDeterminedInput = type === 'input' && boundingBox?.width && layoutState.viewportWidth;
if (isDeterminedInput) {
const scaleFactor = termWidth / layoutState.viewportWidth;
termWidthForBox = Math.round(boundingBox.width * scaleFactor);
termWidthForBox = Math.max(10, Math.min(termWidthForBox, displayAvailable));
logClicks(`Input sizing for backendNodeId: ${backendNodeId}, boundingBox.width: ${boundingBox.width}, viewportWidth: ${layoutState.viewportWidth}, scaleFactor: ${scaleFactor}, termWidth: ${termWidthForBox}`);
}
const actualDisplayWidth = Math.min(termWidth, renderX + termWidthForBox) - Math.max(0, renderX);
const clipStart = Math.max(0, 0 - renderX);
let displayText = text.substring(clipStart, clipStart + actualDisplayWidth);
if (!isDeterminedInput) {
termWidthForBox = displayText.length;
}
const renderedBox = {
text,
type,
subType,
boundingBox,
isClickable,
termX: renderX,
termY: renderY,
termWidth: termWidthForBox,
termHeight: 1,
viewportX,
viewportY,
viewportWidth: layoutState.viewportWidth,
viewportHeight: layoutState.viewportHeight,
backendNodeId,
layoutIndex,
nodeIndex,
};
if ( renderX < 0 ) {
vScroll.atMarginLeft = false;
}
if ( renderY < 5 ) {
vScroll.atMarginTop = false;
}
if ( (renderX + renderedBox.termWidth - 1) < 0 || (renderY + renderedBox.termHeight - 1) < 5 ) continue;
debugLog('render_box', null, {
backendNodeId,
type,
isClickable,
text: text.slice(0, 50),
termX,
termY
});
const isFocused = browser.focusManager.getFocusedElement() === (type === 'input' ? `input:${backendNodeId}` : `clickable:${backendNodeId}`);
if (type === 'input') {
switch(subType) {
case "checkbox": {
logClicks(`Drawing checkbox for backendNodeId: ${backendNodeId}`);
const currentBackendNodeId = backendNodeId;
let value = '';
let checked = false;
let name = '';
send('DOM.resolveNode', { backendNodeId: currentBackendNodeId }, sessionId).then(async resolveResult => {
if (!resolveResult?.object?.objectId) throw new Error('Node no longer exists');
const objectId = resolveResult.object.objectId;
const propsResult = await send(
'Runtime.callFunctionOn',
{
objectId,
functionDeclaration: 'function() { return { checked: this.checked, value: this.value, name: this.name }; }',
arguments: [],
returnByValue: true,
},
sessionId
);
if (propsResult?.result?.value) {
checked = propsResult.result.value.checked;
value = propsResult.result.value.value || '';
name = propsResult.result.value.name || '';
logClicks(`Fetched live checkbox props for backendNodeId: ${currentBackendNodeId}: checked=${checked}, value="${value}", name="${name}"`);
}
const checkboxWidth = 3; // Fixed width for [ ]
debugLog(`Checkbox props: backendNodeId=${currentBackendNodeId}, value=${value}, checked=${checked}, name=${name}, termWidthForBox=${checkboxWidth}`);
const onChange = InputManager.createInputChangeHandler({ send, sessionId, backendNodeId: currentBackendNodeId, isCheckbox: true });
const checkboxField = drawCheckboxForNode({
renderX,
renderY,
termWidthForBox: checkboxWidth,
backendNodeId: currentBackendNodeId,
value,
checked,
name,
onChange
});
renderedBox.termWidth = checkboxField.width;
renderedBox.termHeight = 1;
if (GLOBAL_IGNORE_CLICKABLE_FOR_INPUT || isClickable) {
const clickable = clickableElements.find(el => el.backendNodeId === currentBackendNodeId);
if (clickable) {
clickable.termX = renderX;
clickable.termY = renderY;
clickable.termWidth = checkboxField.width;
clickable.termHeight = 1;
logClicks(`Updated clickable checkbox: backendNodeId=${currentBackendNodeId}, termX=${renderX}, termY=${renderY}`);
} else {
debugLog(`No clickable found for checkbox: backendNodeId=${currentBackendNodeId}`, renderedBox);
logClicks(`No clickable found for checkbox: backendNodeId=${currentBackendNodeId}`);
clickableElements.push({
text: value,
boundingBox,
backendNodeId: currentBackendNodeId,
termX: renderX,
termY: renderY,
termWidth: checkboxField.width,
termHeight: 1,
});
}
}
}).catch(error => {
logClicks(`Failed to fetch live checkbox props for backendNodeId ${currentBackendNodeId}: ${error.message}`);
});
}; break;
case "radio": {
logClicks(`Drawing radio for backendNodeId: ${backendNodeId}`);
const currentBackendNodeId = backendNodeId;
let value = '';
let checked = false;
let name = '';
send('DOM.resolveNode', { backendNodeId: currentBackendNodeId }, sessionId).then(async resolveResult => {
if (!resolveResult?.object?.objectId) throw new Error('Node no longer exists');
const objectId = resolveResult.object.objectId;
const propsResult = await send(
'Runtime.callFunctionOn',
{
objectId,
functionDeclaration: 'function() { return { checked: this.checked, value: this.value, name: this.name }; }',
arguments: [],
returnByValue: true,
},
sessionId
);
if (propsResult?.result?.value) {
checked = propsResult.result.value.checked;
value = propsResult.result.value.value || '';
name = propsResult.result.value.name || '';
logClicks(`Fetched live radio props for backendNodeId: ${currentBackendNodeId}: checked=${checked}, value="${value}", name="${name}"`);
}
const radioWidth = 3; // Fixed width for [ ]
debugLog(`Checkbox props: backendNodeId=${currentBackendNodeId}, value=${value}, checked=${checked}, name=${name}, termWidthForBox=${radioWidth}`);
const onChange = InputManager.createInputChangeHandler({ send, sessionId, backendNodeId: currentBackendNodeId, isRadio: true });
const radioField = drawRadioForNode({
renderX,
renderY,
termWidthForBox: radioWidth,
backendNodeId: currentBackendNodeId,
value,
checked,
name,
onChange
});
renderedBox.termWidth = radioField.width;
renderedBox.termHeight = 1;
if (GLOBAL_IGNORE_CLICKABLE_FOR_INPUT || isClickable) {
const clickable = clickableElements.find(el => el.backendNodeId === currentBackendNodeId);
if (clickable) {
clickable.termX = renderX;
clickable.termY = renderY;
clickable.termWidth = radioField.width;
clickable.termHeight = 1;
logClicks(`Updated clickable radio: backendNodeId=${currentBackendNodeId}, termX=${renderX}, termY=${renderY}`);
} else {
debugLog(`No clickable found for radio: backendNodeId=${currentBackendNodeId}`, renderedBox);
logClicks(`No clickable found for radio: backendNodeId=${currentBackendNodeId}`);
clickableElements.push({
text: value,
boundingBox,
backendNodeId: currentBackendNodeId,
termX: renderX,
termY: renderY,
termWidth: radioField.width,
termHeight: 1,
});
}
}
}).catch(error => {
logClicks(`Failed to fetch live radio props for backendNodeId ${currentBackendNodeId}: ${error.message}`);
});
}; break;
case "select": {
logClicks(`Drawing select field for backendNodeId: ${backendNodeId}`);
const currentBackendNodeId = backendNodeId;
const onChange = InputManager.createInputChangeHandler({ send, sessionId, backendNodeId: currentBackendNodeId });
send('DOM.resolveNode', { backendNodeId: currentBackendNodeId }, sessionId).then(async resolveResult => {
if (!resolveResult?.object?.objectId) throw new Error('Node no longer exists');
const objectId = resolveResult.object.objectId;
const propsResult = await send(
'Runtime.callFunctionOn',
{
objectId,
functionDeclaration: `function() {
const options = Array.from(this.options).map(opt => ({
value: opt.value,
label: opt.text,
selected: opt.selected
}));
return { options, value: this.value };
}`,
arguments: [],
returnByValue: true,
},
sessionId
);
let selectOptions = [];
let selectedValue = '';
if (propsResult?.result?.value) {
selectOptions = propsResult.result.value.options || [];
selectedValue = propsResult.result.value.value || '';
logClicks(`Fetched live select props for backendNodeId: ${currentBackendNodeId}: options=${JSON.stringify(selectOptions)}, value="${selectedValue}"`);
}
// Calculate termWidth based on longest option label, capped at 42
const longestLabelLength = selectOptions.length > 0
? Math.max(...selectOptions.map(opt => opt.label.length))
: 20; // Fallback to default if no options
const selectWidth = Math.min(42, Math.max(10, longestLabelLength + 2)); // +2 for [ ]
const selectField = drawSelectForNode({
renderX,
renderY,
termWidthForBox: selectWidth,
backendNodeId: currentBackendNodeId,
options: selectOptions,
initialValue: selectedValue,
onChange,
});
renderedBox.termWidth = selectField.width;
renderedBox.termHeight = 1;
if (GLOBAL_IGNORE_CLICKABLE_FOR_INPUT || isClickable) {
const clickable = clickableElements.find(el => el.backendNodeId === currentBackendNodeId);
if (clickable) {
clickable.termX = renderX;
clickable.termY = renderY;
clickable.termWidth = selectField.width;
clickable.termHeight = 1;
logClicks(`Updated clickable select: backendNodeId=${currentBackendNodeId}, termX=${renderX}, termY=${renderY}`);
} else {
debugLog(`No clickable found for select: backendNodeId=${currentBackendNodeId}`, renderedBox);
logClicks(`No clickable found for select: backendNodeId=${currentBackendNodeId}`);
clickableElements.push({
text: selectedValue,
boundingBox,
backendNodeId: currentBackendNodeId,
termX: renderX,
termY: renderY,
termWidth: selectField.width,
termHeight: 1,
});
}
}
}).catch(error => {
logClicks(`Failed to fetch live select props for backendNodeId ${currentBackendNodeId}: ${error.message}`);
// Fallback rendering
const selectWidth = 20; // Default width
const selectField = drawSelectForNode({
renderX,
renderY,
termWidthForBox: selectWidth,
backendNodeId: currentBackendNodeId,
options: [],
initialValue: '',
onChange,
});
renderedBox.termWidth = selectField.width;
renderedBox.termHeight = 1;
});
}; break;
default: {
logClicks(`Drawing input field for backendNodeId: ${backendNodeId}`);
const currentBackendNodeId = backendNodeId;
const fallbackValue = text.startsWith('[INPUT') ? '' : text;
const onChange = InputManager.createInputChangeHandler({ send, sessionId, backendNodeId: currentBackendNodeId });
send('DOM.resolveNode', { backendNodeId: currentBackendNodeId }, sessionId).then(async resolveResult => {
if (!resolveResult?.object?.objectId) throw new Error('Node no longer exists');
const objectId = resolveResult.object.objectId;
const valueResult = await send(
'Runtime.callFunctionOn',
{
objectId,
functionDeclaration: 'function() { return this.value; }',
arguments: [],
returnByValue: true,
},
sessionId
);
let liveValue = fallbackValue;
if (valueResult?.result?.value !== undefined) {
liveValue = '' + valueResult.result.value;
logClicks(`Fetched live value for backendNodeId: ${currentBackendNodeId}: "${liveValue}"`);
}
const inputField = drawInputFieldForNode({
renderX,
renderY,
termWidthForBox,
backendNodeId: currentBackendNodeId,
initialValue: liveValue,
onChange,
});
renderedBox.termWidth = inputField.width;
if (GLOBAL_IGNORE_CLICKABLE_FOR_INPUT || isClickable) {
const clickable = clickableElements.find(el => el.backendNodeId === currentBackendNodeId);
if (clickable) {
clickable.termX = renderX;
clickable.termY = renderY;
clickable.termWidth = inputField.width;
clickable.termHeight = 1;
logClicks(`Updated clickable input: backendNodeId=${currentBackendNodeId}, termX=${renderX}, termY=${renderY}`);
} else {
debugLog(`No clickable found for input: backendNodeId=${currentBackendNodeId}`, renderedBox);
logClicks(`No clickable found for input: backendNodeId=${currentBackendNodeId}`);
clickableElements.push({
text,
boundingBox,
backendNodeId: currentBackendNodeId,
termX: renderX,
termY: renderY,
termWidth: inputField.width,
termHeight: 1,
});
}
}
}).catch(error => {
logClicks(`Failed to fetch live value for backendNodeId ${currentBackendNodeId}: ${error.message}`);
const inputField = drawInputFieldForNode({
renderX,
renderY,
termWidthForBox,
backendNodeId: currentBackendNodeId,
initialValue: fallbackValue,
onChange,
});
renderedBox.termWidth = inputField.width;
if (isClickable) {
const clickable = clickableElements.find(el => el.backendNodeId === currentBackendNodeId);
if (clickable) {
clickable.termX = renderX;
clickable.termY = renderY;
clickable.termWidth = inputField.width;
clickable.termHeight = 1;
logClicks(`Updated clickable input (error path): backendNodeId=${currentBackendNodeId}`);
} else {
logClicks(`No clickable found for input (error path): backendNodeId=${currentBackendNodeId}`);
clickableElements.push({
text,
boundingBox,
backendNodeId: currentBackendNodeId,
termX: renderX,
termY: renderY,
termWidth: inputField.width,
termHeight: 1,
});
}
}
});
}; break;
}
} else {
terminal.moveTo(renderX, renderY);
if (isFocused && isClickable) {
terminal.bgCyan();
if (type === 'button' || ancestorType === 'button') {
terminal.green(displayText);
} else if (type === 'media' && isClickable) {
terminal.gray.underline(displayText);
} else if (ancestorType === 'hyperlink') {
terminal.black.underline(displayText);
} else if (ancestorType === 'other_clickable') {
terminal.defaultColor.bold(displayText);
} else {
terminal.defaultColor(displayText);
}
} else {
terminal.defaultColor().bgDefaultColor();
if (type === 'button') {
terminal.bgGreen.black(displayText);
} else if (type === 'media') {
if (isClickable) {
terminal.gray.underline(displayText);
} else {
terminal.brightBlack(displayText);
}
} else {
switch (ancestorType) {
case 'hyperlink':
terminal.cyan.underline(displayText);
break;
case 'button':
terminal.bgGreen.black(displayText);
break;
case 'other_clickable':
terminal.bold(displayText);
break;
default:
terminal(displayText);
}
}
}
if (isClickable) {
const clickable = clickableElements.find(el => el.backendNodeId === backendNodeId);
if (clickable) {
clickable.termX = renderX;
clickable.termY = renderY;
clickable.termWidth = displayText.length;
clickable.termHeight = 1;
logClicks(`Updated clickable: backendNodeId=${backendNodeId}, termX=${renderX}, termY=${renderY}`);
} else {
logClicks(`No clickable found for: backendNodeId=${backendNodeId}`);
clickableElements.push({
text,
boundingBox,
backendNodeId,
termX: renderX,
termY: renderY,
termWidth: displayText.length,
termHeight: 1,
});
}
}
}
newBoxes.push(renderedBox);
}
const boxes = renderedBoxesBySession.get(sessionId) || [];
boxes.length = 0;
boxes.push(...newBoxes);
renderedBoxesBySession.set(sessionId, boxes);
logClicks(`renderBoxes: Stored ${newBoxes.length} boxes, clickableElements.length=${clickableElements.length}`);
debugLog('render_boxes_complete', null, {
boxCount: newBoxes.length,
backendNodeIds: newBoxes.map(b => b.backendNodeId).filter(id => id !== undefined)
});
}
// main logic
async function startKernel() {
try {
terminal.cyan('Starting browser connection...\n');
const handler = message => {
switch(message.method) {
case 'Target.targetInfoChanged': {
const { targetInfo } = message.params;
browser.emit('targetInfoChanged', targetInfo);
debouncedRefresh();
}; break;
case 'Page.frameNavigated': {
ggLog(message);
vScroll.X = 0;
vScroll.Y = 0;
vScroll.atMarginRight = true;
vScroll.atMarginBottom = true;
vScroll.atMarginLeft = true;
vScroll.atMarginTop = true;
}
}
};
connection = await connectToBrowser(handler);
browserState.send = connection.send;
browserState.targets = connection.targets;
send = connection.send;
targets = connection.targets;
browserbox = connection.browserbox;
browser = new TerminalBrowser(
{
tabWidth: Math.max(15, Math.ceil(terminal.width / 4)),
initialTabs: targets,
},
getTabState,
getBrowserState
);
await send('Target.setDiscoverTargets', { discover: true });
await send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
if (targets.length === 0) {
await send('Target.createTarget', { url: 'about:blank' });
targets = await connection.connectionManager.fetchTargets();
}
for (const target of targets) {
const targetId = target.targetId;
if (!sessions.has(targetId)) {
const { sessionId: newSessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
sessions.set(targetId, newSessionId);
initializeState(newSessionId);
debugLog(`Initialized state for target ${targetId}, session ${newSessionId}`);
}
}
browser.on('tabSelected', async (tab) => {
const index = tab ? browser.getTabs().findIndex(t => t.targetId === tab.targetId) : 0;
browser.activeTarget = targets[index];
const { targetId } = browser.activeTarget;
browser.setAddress(tab.url);
browserbox.send(browserboxMessage(
{
command: {
name: 'Target.activateTarget',
params: { targetId },
},
},
{
command: {
isZombieLordCommand: true,
name: 'Connection.activateTarget',
params: {
targetId,
source: mySource,
},
},
}
));
// we can save vScroll state per tab later
vScroll.X = 0;
vScroll.Y = 0;
vScroll.atMarginRight = true;
vScroll.atMarginBottom = true;
vScroll.atMarginLeft = true;
vScroll.atMarginTop = true;
await selectTabAndRender();
});
browser.on('newTabRequested', async (tab) => {
DEBUG && terminal.cyan(`Creating new remote tab: ${tab.title}\n`);
try {
await send('Target.createTarget', { url: tab.url || 'about:blank' });
await sleep(500);
targets = await connection.connectionManager.fetchTargets();
browser.targets = targets;
browser.render();
await selectTabAndRender();
} catch (error) {
DEBUG && terminal.red(`Failed to create new tab: ${error.message}\n`);
}
});
browser.on('tabClosed', async (index) => {
let targetId;
try {
targetId = targets[index].targetId;
await send('Target.closeTarget', { targetId });
targets = await connection.connectionManager.fetchTargets();
browser.targets = targets;
browser.tabs = targets;
DEBUG && terminal.cyan(`Closing remote target: ${targetId}\n`);
browser.activeTarget = targets[browser.selectedTabIndex] || null;
await selectTabAndRender();
} catch (error) {
debugLog(JSON.stringify({ targets, index }, null, 2));
DEBUG && terminal.red(`Failed to close target ${targetId}: ${error.message}\n`);
DEBUG && terminal.processExit(1);
}
});
browser.on('navigate', async (url) => {
const normalizedUrl = normalizeUrl(url);
DEBUG && terminal.cyan(`Navigating to: ${normalizedUrl}\n`);
send('Page.navigate', { url: normalizedUrl }, sessionId);
await refreshTerminal({ send, sessionId });
});
browser.on('back', async () => {
const navigated = await goBack(sessionId);
if (navigated) {
await refreshTerminal({ send, sessionId });
}
});
browser.on('forward', async () => {
const navigated = await goForward(sessionId);
if (navigated) {
await refreshTerminal({ send, sessionId });
}
});
browser.on('renderContent', () => {
const state = getTabState(getBrowserState().currentSessionId);
if (state.layoutState) {
DEBUG && console.log('Rendering content');
renderLayout({ layoutState: state.layoutState });
}
});
browser.on('click', async ({ x, y }) => {
const state = getTabState(getBrowserState().currentSessionId);
if (!state.isInitialized) return;
await handleClick({
termX: x,
termY: y,
clickableElements: state.clickableElements,
layoutToNode: state.layoutToNode,
nodeToParent: state.nodeToParent,
nodes: state.nodes,
});
});
browser.on('scroll', async ({ direction, axis, resetVScroll = false }) => {
const state = getTabState(getBrowserState().currentSessionId);
try {
if ( axis == 'vertical' ) {
if ( resetVScroll ) {
vScroll.Y = 0;
vScroll.atMarginLeft = true;
vScroll.atMarginTop = true;
}
if ( IGNORE_VSCROLL || resetVScroll || vScroll.atMarginBottom && direction > 0 || vScroll.atMarginTop && direction < 0 ) {
const lineHeight = Math.round(state.viewportHeight / terminal.height);
const deltaY = direction * lineHeight;
await send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 0, y: 0, deltaX: 0, deltaY }, sessionId);
} else {
vScroll.Y += direction;
}
} else if ( axis == 'horizontal' ) {
if ( resetVScroll ) {
vScroll.X = 0;
vScroll.atMarginRight = true;
vScroll.atMarginBottom = true;
}
if ( IGNORE_VSCROLL || resetVScroll || vScroll.atMarginRight && direction > 0 || vScroll.atMarginLeft && direction < 0 ) {
const columnWidth = Math.round(state.viewportWidth / terminal.height);
const deltaX = direction * columnWidth;
await send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 0, y: 0, deltaX, deltaY: 0 }, sessionId);
} else {
vScroll.X += direction;
}
}
debouncedRefresh();
} catch (e) {
console.error(e);
}
});
browser.on('tell-browserbox', stuff => {
if ( ! Array.isArray(stuff) ) {
stuff = [stuff];
}
browserbox.send(browserboxMessage(...stuff));
});
const handleBBMessage = data => {
let message;
try {
message = JSON.parse(data.toString('utf8'));
} catch(e) {
DEBUG && console.warn(e);
}
if (!message) return;
if (message.meta && browser ) {
logBBMessage(message);
for( const meta of message.meta ) {
try {
if (meta.modal && browser) {
const { sessionId, type, defaultPrompt } = meta.modal;
let { message: modalMessage } = meta.modal;
switch (type) {
case 'alert':
browser.showAlert(sessionId, modalMessage);
break;
case 'beforeunload':
modalMessage = 'Are you sure you wish to leave this page?';
/* eslint-disable no-fallthrough */
case 'confirm':
/* eslint-enable no-fallthrough */
browser.showConfirm(sessionId, modalMessage);
break;
case 'prompt':
browser.showPrompt(sessionId, modalMessage, defaultPrompt);
break;
}
} else if (meta.authRequired && browser) {
const { authChallenge: { scheme, realm }, requestId } = meta.authRequired;
// Assume sessionId is available in message or derivable; adjust as needed
const sessionId = 'default-session';
browser.showHTTPAuth(sessionId, scheme, realm, requestId);
} else if (meta.closeModal && browser) {
const { sessionId, modalType } = meta.closeModal;
browser.closeModal(sessionId, modalType);
}
} catch (error) {
if (DEBUG) console.warn(error, meta);
terminal.red(`BrowserBox message error: ${error.message} at ${error.stack} on meta: ${JSON.stringify(meta)}\n`);
}
}
}
};
browserbox.on('message', handleBBMessage);
await sleep(3000);
await selectTabAndRender();
process.title = 'KRNL-RENDER';
process.on('SIGINT', async () => {
if (connection.connectionManager) connection.connectionManager.cleanup();
browser.destroy();
terminal.clear();
terminal.grabInput(false);
terminal.green('Exiting...\n');
process.exit(0);
});
function browserboxMessage(...data) {
logBBMessage(data);
for( const val of data ) {
if ( ! val.command ) throw new Error(`Must be a command`);
}
return JSON.stringify({
messageId: messageId++,
zombie: {
events: data
},
});
}
} catch (error) {
debugLog(JSON.stringify({ error, stack: error.stack }, null, 2));
if (connection?.connectionManager) connection.connectionManager.cleanup();
console.error(error);
if (DEBUG) console.warn(error);
terminal.red(`Main error: ${error.message}\n`);
terminal.grabInput(false);
browser?.destroy();
process.exit(1);
}
}
export function getBrowserState() {
return browserState;
}
export function getTabState(sessionId) {
if (!sessionId) {
console.warn('No sessionId provided, returning empty tab state');
return {
sessionId: null,
send: browserState.send || (() => Promise.resolve({})),
clickCounter,
clickableElements: [],
isListening: true,
scrollDelta: 50,
currentScrollY: 0,
layoutToNode: null,
nodeToParent: null,
nodes: null,
isInitialized: false,
strings: [],
viewportHeight: 0,
viewportWidth: 0,
};
}
let state = stateBySession.get(sessionId);
if (!state) {
state = initializeState(sessionId);
stateBySession.set(sessionId, state);
}
return state;
}
function initializeState(sessionId) {
const state = {
get sessionId() {
return sessionId;
},
get send() {
return browserState.send;
},
clickCounter,
clickableElements: [],
isListening: true,
scrollDelta: 50,
currentScrollY: 0,
layoutToNode: null,
nodeToParent: null,
nodes: null,
isInitialized: false,
strings: [],
viewportHeight: 0,
viewportWidth: 0,
};
stateBySession.set(sessionId, state);
send("Page.enable", {}, sessionId);
return state;
}
// Update refreshTerminal
export async function refreshTerminal({ send, sessionId }) {
try {
const { snapshot, viewportWidth, viewportHeight, viewportX, viewportY } = await pollForSnapshot({ send, sessionId });
if (!snapshot) return;
const state = getTabState(sessionId); // Use getTabState
state.currentScrollY = viewportY;
const layoutState = await Layout.prepareLayoutState({
snapshot,
viewportWidth,
viewportHeight,
viewportX,
viewportY,
getTerminalSize,
});
terminal.clear();
terminal.bgDefaultColor();
terminal.defaultColor();
terminal.styleReset();
browser.render();
if (layoutState) {
state.layoutState = layoutState;
state.clickableElements = layoutState.clickableElements;
state.layoutToNode = layoutState.layoutToNode;
state.nodeToParent = layoutState.nodeToParent;
state.nodes = layoutState.nodes;
state.strings = snapshot.strings;
renderLayout({ layoutState, sessionId }); // Pass sessionId
state.isInitialized = true;
DEBUG && terminal.cyan(`Found ${layoutState.visibleBoxes.length} visible text boxes.\n`);
} else {
DEBUG && terminal.yellow('No text boxes found after polling.\n');
statusLine('No text boxes found');
renderLayout({ layoutState, sessionId });
state.isInitialized = true;
}
} catch (error) {
if (DEBUG) console.warn(error);
DEBUG && terminal.red(`Error printing text layout: ${error.message}\n`);
}
}
async function selectTabAndRender() {
if (cleanup) cleanup();
DEBUG && debugLog(util.inspect({ browser, targets }));
const selectedTarget = targets.find(t => t.targetId == browser.selectedTabId) || targets[0];
const targetId = selectedTarget.targetId;
browser.activeTarget = selectedTarget;
DEBUG && terminal.cyan(`Attaching to target ${targetId}...\n`);
if (!sessions.has(targetId)) {
const { sessionId: newSessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
sessionId = newSessionId;
sessions.set(targetId, sessionId);
initializeState(sessionId);
} else {
sessionId = sessions.get(targetId);
}
browserState.currentSessionId = sessionId;
DEBUG && terminal.green(`Attached with session ${sessionId}\n`);
await refreshTerminal({ send, sessionId });
const state = getTabState(sessionId);
if (state.isInitialized) {
try {
browser.focusManager.restoreFocus(targetId);
logClicks(`Restored focus for target ${targetId}`);
} catch (error) {
logClicks(`Failed to restore focus for target ${targetId}: ${error.message}`);
}
} else {
logClicks(`Skipping focus restoration for target ${targetId}: state not initialized`);
}
const stop = await printTextLayoutToTerminal();
cleanup = stop;
}
// Helpers
// Data processing helpers
function debounce(func, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), delay);
};
}
async function pollForSnapshot({ send, sessionId, maxAttempts = 4, interval = 1000 }) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const { snapshot, viewportWidth, viewportHeight, viewportX, viewportY } = await fetchSnapshot({ send, sessionId });
if (snapshot) {
const state = getTabState(sessionId); // Use getTabState
state.viewportHeight = viewportHeight;
state.viewportWidth = viewportWidth;
const { textLayoutBoxes } = Layout.extractTextLayoutBoxes({ snapshot });
if (textLayoutBoxes.length > 0) {
return { snapshot, viewportWidth, viewportHeight, viewportX, viewportY };
}
DEBUG && terminal.yellow(`Attempt ${attempt}: Found 0 text boxes, retrying in ${interval}ms...\n`);
}
await sleep(interval);
}
DEBUG && terminal.yellow(`Max attempts reached, proceeding with last snapshot.\n`);
return await fetchSnapshot({ send, sessionId });
}
// Browser UI
function statusLine(...stuff) {
DEBUG && console.error(...stuff);
}
function normalizeUrl(input) {
const trimmedInput = input.trim();
const urlPattern = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/.*)?$/i;
if (urlPattern.test(trimmedInput)) {
if (!/^https?:\/\//i.test(trimmedInput)) {
return `https://${trimmedInput}`;
}
return trimmedInput;
}
const query = encodeURIComponent(trimmedInput);
return `https://duckduckgo.com/?q=${query}`;
}
// Layout calculation and Render helpers
async function getTerminalSize() {
const size = { columns: terminal.width, rows: terminal.height };
if (!size.columns || !size.rows) {
return { columns: 80, rows: 24 };
}
return size;
}
function renderLayout({ layoutState }) {
if (!layoutState) return;
renderBoxes({ ...layoutState });
}
async function fetchSnapshot({ send, sessionId }) {
try {
DEBUG && terminal.cyan('Enabling DOM and snapshot domains...\n');
await Promise.all([
send('DOM.enable', {}, sessionId),
send('DOMSnapshot.enable', {}, sessionId),
]);
DEBUG && terminal.cyan('Capturing snapshot...\n');
const snapshot = await send(
'DOMSnapshot.captureSnapshot',
{
computedStyles: ['display', 'visibility', 'overflow', 'position', 'width', 'height', 'transform', 'opacity'],
includePaintOrder: true,
},
sessionId
);
if (!snapshot?.documents?.length) {
debugLog('snapshot_failed', sessionId, { reason: 'no_documents' }, (new Error).stack);
return;
}
DEBUG && appendFileSync('snapshot.log', JSON.stringify({ snapshot }, null, 2));
const layoutMetrics = await send('Page.getLayoutMetrics', {}, sessionId);
const viewport = layoutMetrics.visualViewport;
const document = snapshot.documents[0];
// Log backendNodeIds for clickable nodes
const clickableNodeIds = document.nodes.isClickable?.index
.map(idx => document.nodes.backendNodeId[idx])
.filter(id => id !== undefined);
debugLog('snapshot_nodes', sessionId, {
clickableNodeIds,
totalNodes: document.nodes.backendNodeId.length
}, (new Error).stack);
return {
snapshot,
viewportWidth: viewport.clientWidth,
viewportHeight: viewport.clientHeight,
viewportX: document.scrollOffsetX,
viewportY: document.scrollOffsetY,
};
} catch (e) {
DEBUG && console.warn(e);
debugLog('snapshot_failed', sessionId, { reason: e.message }, (new Error).stack);
return {};
}
}
function drawInputFieldForNode({ renderX, renderY, termWidthForBox, backendNodeId, initialValue, onChange }) {
const inputField = browser.drawInputField({
x: renderX,
y: renderY,
width: termWidthForBox,
key: backendNodeId,
initialValue,
onChange,
});
return inputField;
}
function drawCheckboxForNode({ renderX, renderY, termWidthForBox, backendNodeId, value, checked, name, onChange }) {
const checkbox = browser.drawCheckbox({
x: renderX,
y: renderY,
width: termWidthForBox,
key: backendNodeId,
value,
checked,
name,
onChange,
});
return checkbox;
}
function drawRadioForNode({ renderX, renderY, termWidthForBox, backendNodeId, value, checked, name, onChange }) {
const radio = browser.drawRadio({
x: renderX,
y: renderY,
width: termWidthForBox,
key: backendNodeId,
value,
checked,
name,
onChange,
});
return radio;
}
function drawSelectForNode({ renderX, renderY, termWidthForBox, backendNodeId, options, initialValue, onChange }) {
const selectField = browser.drawSelect({
x: renderX,
y: renderY,
width: termWidthForBox,
key: backendNodeId,
options,
initialValue,
onChange,
});
return selectField;
}
async function getNavigationHistory(sessionId) {
const { currentIndex, entries } = await send('Page.getNavigationHistory', {}, sessionId);
return { currentIndex, entries };
}
async function navigateHistory(sessionId, direction) {
const { currentIndex, entries } = await getNavigationHistory(sessionId);
const newIndex = currentIndex + direction;
if (newIndex >= 0 && newIndex < entries.length) {
await send('Page.navigateToHistoryEntry', { entryId: entries[newIndex].id }, sessionId);
return true;
}
statusLine(`No ${direction < 0 ? 'previous' : 'next'} page in history`);
return false;
}
async function goBack(sessionId) {
return navigateHistory(sessionId, -1);
}
async function goForward(sessionId) {
return navigateHistory(sessionId, 1);
}
export function getClickedBox({ termX, termY, ignoreIsClickable = false }) {
let clickedBox = null;
const sessionBoxes = renderedBoxesBySession.get(browserState.currentSessionId) || [];
logClicks(`getClickedBox: termX=${termX}, termY=${termY}, sessionBoxes.length=${sessionBoxes.length}`);
for (let i = sessionBoxes.length - 1; i >= 0; i--) {
const box = sessionBoxes[i];
const inX = termX >= box.termX && termX < box.termX + box.termWidth;
const inY = termY == box.termY;
if (inX && inY && ((ignoreIsClickable && IGNORE_CLICKABLE_FOR_TYPES.has(box.type)) || box.isClickable)) {
clickedBox = box;
break;
}
}
if (!clickedBox) {
logClicks(`No clickable box at (${termX}, ${termY})`);
} else {
logClicks(`Found clickable box: type=${clickedBox.type}, backendNodeId=${clickedBox.backendNodeId}`);
}
return clickedBox;
}
export async function handleClick({ termX, termY, layoutToNode, nodeToParent, nodes }) {
const clickedBox = getClickedBox({ termX, termY, ignoreIsClickable: true });
if (!clickedBox) {
logClicks(`handleClick: No clickedBox found at (${termX}, ${termY})`);
return;
}
logClicks(`handleClick: Processing type=${clickedBox.type}, backendNodeId=${clickedBox.backendNodeId}`);
if (clickedBox.type === 'input') {
await focusInput({ clickedBox, browser, send, sessionId, termX });
return;
} else {
logClicks('Simulating click on non-input element');
terminal.moveTo(clickedBox.termX, clickedBox.termY);
terminal.yellow(clickedBox.text);
await sleep(300);
const relativeX = (termX - clickedBox.termX) / clickedBox.termWidth;
const relativeY = (termY - clickedBox.termY) / clickedBox.termHeight;
const guiX = clickedBox.boundingBox.x + relativeX * clickedBox.boundingBox.width;
const guiY = clickedBox.boundingBox.y + relativeY * clickedBox.boundingBox.height;
const clickX = guiX + clickedBox.viewportX;
const clickY = guiY + clickedBox.viewportY;
const clickId = clickCounter.value++;
const nodeTag = clickedBox.ancestorType || 'unknown';
const nodeText = clickedBox.text;
const clickEvent = { type: 'click' };
try {
DEBUG && logClicks(`${new Date().toISOString()} - Click ${clickId}: T coords (${termX}, ${termY}), Node (tag: ${nodeTag}, text: "${nodeText}"), G coords (${clickX}, ${clickY}), Event: ${JSON.stringify(clickEvent)}\n`);
} catch (error) {
console.error(`Failed to write to clicks log: ${error.message}`);
}
let backendNodeId = clickedBox.backendNodeId;
let currentNodeIndex = layoutToNode.get(clickedBox.layoutIndex);
let clickableNodeIndex = currentNodeIndex;
while (currentNodeIndex !== -1) {
if (nodes.isClickable && nodes.isClickable.index.includes(currentNodeIndex)) {
clickableNodeIndex = currentNodeIndex;
break;
}
currentNodeIndex = nodeToParent.get(currentNodeIndex);
}
backendNodeId = nodes.backendNodeId[clickableNodeIndex];
let objectId;
try {
const resolveResult = await send('DOM.resolveNode', { backendNodeId }, sessionId);
objectId = resolveResult.object.objectId;
DEBUG && debugLog(`Resolved backendNodeId ${backendNodeId} to objectId ${objectId}`);
} catch (error) {
DEBUG && debugLog(`Failed to resolve backendNodeId ${backendNodeId}: ${error.message}`);
return;
}
try {
const clickResult = await send(
'Runtime.callFunctionOn',
{
objectId,
functionDeclaration: 'function() { this.click(); }',
arguments: [],
returnByValue: tr