Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,19 @@ jobs:
- name: Lint
run: pnpm lint

# Build first: packages that import another workspace package (e.g.
# @tronbrowser/sdk -> @tronbrowser/browser-core) resolve it via its built
# dist, so typecheck/test need the dist present. pnpm builds in topological
# order, so dependencies compile before their dependents.
- name: Build
run: pnpm build

- name: Typecheck
run: pnpm typecheck

- name: Test
run: pnpm test

- name: Build
run: pnpm build

# package + release jobs are stubbed until the Chromium build lands in CI.
package:
runs-on: ubuntu-latest
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/launcher/tron-run-hooks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// ESM resolver hook for `tron run`: maps the bare `@tronbrowser/*` specifiers a
// user script imports to the runtime shipped in the launcher payload, so scripts
// need no node_modules of their own. Registered by tron-run.mjs.
let map = {};

export async function initialize(data) {
map = data ?? {};
}

export async function resolve(specifier, context, next) {
if (Object.prototype.hasOwnProperty.call(map, specifier)) {
return { url: map[specifier], shortCircuit: true };
}
return next(specifier, context);
}
57 changes: 57 additions & 0 deletions apps/desktop/launcher/tron-run.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// `tron run <script>` — execute a user JS/TS automation script that imports
// `@tronbrowser/sdk` (PRD M3.4). Node >= 24 strips TS types natively, so .ts
// runs with no build step. The @tronbrowser/* imports resolve to the runtime
// shipped alongside this file via the registered resolver hook.
import { existsSync } from 'node:fs';
import { register } from 'node:module';
import { dirname, resolve as resolvePath, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

const here = dirname(fileURLToPath(import.meta.url));
const sdkEntry = join(here, 'sdk', 'index.js');
const coreEntry = join(here, 'automate', 'index.js');

const argv = process.argv.slice(2);
let script;
const passthrough = [];
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
if (a === '--') { passthrough.push(...argv.slice(i + 1)); break; }
if (!script && !a.startsWith('-')) { script = a; continue; }
if (a === '--headless') { process.env.TRON_RUN_HEADLESS = '1'; continue; }
if (a === '--profile') { process.env.TRON_RUN_PROFILE = argv[++i]; continue; }
if (a === '--trace') { process.env.TRON_RUN_TRACE = resolvePath(argv[++i]); continue; }
passthrough.push(a);
}

if (!script) {
process.stderr.write('usage: tron run <script.js|.ts> [--headless] [--profile <name>] [--trace <dir>]\n');
process.exit(2);
}

const scriptPath = resolvePath(script);
if (!existsSync(scriptPath)) {
process.stderr.write(`tron run: script not found: ${script}\n`);
process.exit(2);
}
if (!existsSync(sdkEntry)) {
process.stderr.write('This TronBrowser build lacks the SDK runtime. Run: tron upgrade\n');
process.exit(1);
}

register(pathToFileURL(join(here, 'tron-run-hooks.mjs')), import.meta.url, {
data: {
'@tronbrowser/sdk': pathToFileURL(sdkEntry).href,
'@tronbrowser/browser-core': pathToFileURL(coreEntry).href,
},
});

// Present argv to the script as if it were invoked directly.
process.argv = [process.argv[0], scriptPath, ...passthrough];

try {
await import(pathToFileURL(scriptPath).href);
} catch (err) {
process.stderr.write(`tron run: ${err && err.stack ? err.stack : err}\n`);
process.exit(1);
}
24 changes: 15 additions & 9 deletions apps/desktop/scripts/build-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,22 +51,25 @@ fetch_marksyncr() {
[ -n "$MKS_SRC" ] || echo " ! MarkSyncr fetch skipped (non-fatal)"
}

# The Node automation runtime for `tron snapshot|click|fill` (PRD M3.2). The
# @tronbrowser/browser-core source has no runtime deps, so its compiled dist tree
# is self-contained; ship it with a {"type":"module"} marker and the shell
# dispatcher runs it via node. Best-effort like the extension fetches — a build
# host without node/pnpm simply omits it (the CLI then reports "run tron upgrade").
# The Node automation runtime for `tron snapshot|click|fill|extract|...` (M3.2/3)
# and the `@tronbrowser/sdk` used by `tron run` (M3.4). Both packages' source has
# no runtime deps, so their compiled dist trees are self-contained; ship each with
# a {"type":"module"} marker and the shell dispatcher / tron-run.mjs run them via
# node. Best-effort like the extension fetches — a build host without node/pnpm
# simply omits them (the CLI then reports "run tron upgrade").
stage_automation() { # dest dir
local s="$1"
command -v node >/dev/null 2>&1 && command -v pnpm >/dev/null 2>&1 || {
echo " ! automation runtime skipped (needs node + pnpm)"; return; }
if ( cd "$REPO_ROOT" && pnpm --filter @tronbrowser/browser-core build >/dev/null 2>&1 ); then
rm -rf "$s/automate"
if ( cd "$REPO_ROOT" && pnpm --filter @tronbrowser/browser-core --filter @tronbrowser/sdk build >/dev/null 2>&1 ); then
rm -rf "$s/automate" "$s/sdk"
cp -R "$REPO_ROOT/packages/browser-core/dist" "$s/automate"
printf '{\n "type": "module"\n}\n' > "$s/automate/package.json"
echo " + bundled automation runtime (tron snapshot/click/fill)"
cp -R "$REPO_ROOT/packages/sdk/dist" "$s/sdk"
printf '{\n "type": "module"\n}\n' > "$s/sdk/package.json"
echo " + bundled automation runtime + SDK (tron snapshot/extract/run)"
else
echo " ! automation runtime skipped (browser-core build failed)"
echo " ! automation runtime skipped (browser-core/sdk build failed)"
fi
}

Expand All @@ -81,6 +84,9 @@ stage() { # dest dir
# Managed-session engine for `tron browser …` / `tron open` (PRD M3.1). Sits
# next to the shim; the `tron` dispatcher resolves it relative to $CURRENT.
install -m 0755 "$DESKTOP/launcher/tron-session" "$s/tron-session"
# `tron run` launcher + ESM resolver hook (PRD M3.4).
install -m 0644 "$DESKTOP/launcher/tron-run.mjs" "$s/tron-run.mjs"
install -m 0644 "$DESKTOP/launcher/tron-run-hooks.mjs" "$s/tron-run-hooks.mjs"
stage_automation "$s"
# -L dereferences the branding symlinks (icons/logo.svg -> repo-root logo.svg)
# so the package contains real files, not dangling links.
Expand Down
10 changes: 10 additions & 0 deletions apps/web/public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ Usage:
tron extract <mode> Extract text|links|forms|tables|main (JSON)
tron screenshot <p> Save a PNG of the current page (--full-page)
tron headless <url> One-shot: --snapshot | --screenshot <p> | --extract <mode>
tron run <script> Run a JS/TS script using @tronbrowser/sdk (--headless/--trace)
tron upgrade Update to the latest release
tron remove Uninstall TronBrowser (keeps your profile data)
tron version Print the installed version
Expand Down Expand Up @@ -178,6 +179,15 @@ case "${1:-}" in
headless)
# One-shot: launch a headless ephemeral session, navigate, act, tear down.
run_automation "$@" ;;
run)
# Execute a JS/TS automation script that imports @tronbrowser/sdk (PRD M3.4).
shift
[ "$#" -gt 0 ] || { echo "usage: tron run <script.js|.ts> [--headless] [--profile <name>] [--trace <dir>]" >&2; exit 2; }
_ld="$(dirname "$(readlink -f "$CURRENT" 2>/dev/null || echo "$CURRENT")")"
RUNNER="$_ld/tron-run.mjs"
command -v node >/dev/null 2>&1 || { echo "tron run needs Node.js (>=24) on PATH." >&2; exit 1; }
[ -f "$RUNNER" ] || { echo "This TronBrowser build lacks the SDK runtime. Run: tron upgrade" >&2; exit 1; }
exec env TRON_SESSION_BIN="$(session_bin)" node "$RUNNER" "$@" ;;
restart)
# Force-quit any running TronBrowser, then launch fresh. Chromium forwards a
# new launch to an already-running instance (which keeps the OLD extension
Expand Down
63 changes: 63 additions & 0 deletions docs/sdk-and-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# SDK and `tron run` (M3.4)

Write browser automation as scripts with the public SDK, and run them with
`tron run` — no build step, no `node_modules` of your own.

```ts
import { tron } from '@tronbrowser/sdk';

const browser = await tron.launch({ headless: true });
const page = await browser.newPage();

await page.goto('https://example.com/contact');
const snap = await page.snapshot();
const emailRef = snap.elements.find((e) => e.name === 'Email')?.ref;
if (emailRef) await page.fill(emailRef, 'jane@example.com');

console.log(await page.extract('links'));
await page.screenshot({ fullPage: true }).then(/* … */);
await browser.close();
```

```sh
tron run ./agent.ts # TypeScript runs directly (Node ≥24 strips types)
tron run ./agent.js
tron run ./agent.ts --headless # force headless regardless of launch({})
tron run ./agent.ts --profile work # use a named profile
tron run ./agent.ts --trace ./trace # write an action trace bundle
```

## API (`@tronbrowser/sdk`)

- **`tron.launch(options?)` → `Browser`** — `{ headless?, profile? }`. Starts a
managed session in its own isolated temp profile.
- **`Browser`**: `newPage()`, `pages()`, `close()` (stops the session, removes
the temp profile).
- **`Page`**: `goto`, `reload`, `back`, `forward`, `snapshot`/`snapshotText`,
`click(@ref)`, `fill(@ref, value)`, `type`, `extract(target, fields?)`,
`screenshot`, `pdf`, `eval`, `url`, `title`, `close`.
- `analyze` / `step` / `runTask` are declared but land in **M3.5** (AI analyze).

## How `tron run` works

- The shell `tron` dispatcher runs `tron-run.mjs` via `node`, passing
`TRON_SESSION_BIN` so the SDK can launch/close a managed session.
- `tron-run.mjs` registers an ESM resolver hook that maps `@tronbrowser/sdk`
and `@tronbrowser/browser-core` to the runtime shipped in the launcher payload,
then imports your script. TypeScript needs no build — Node ≥24 strips types.
- `--headless` / `--profile` / `--trace` arrive via env and are honored by
`tron.launch()` when the script doesn't specify otherwise.

## Trace bundles (`--trace <dir>`)

A minimal trace (full trace/replay is M3.7): `metadata.json` plus
`actions.jsonl` — the sequence of page actions. **Values are redacted** (a
`fill` records its ref, never the text), matching the PRD's default privacy.

## Scope / limitations

- Requires Node ≥24 (bundled with the desktop app or on PATH).
- `click`/`fill` take `@refs` from a `snapshot`; richer targets (CSS/text/role)
and character-level `type`/`press` come later.
- SDK contracts are unit-tested and exercised end-to-end over the real CDP
transport in `packages/sdk/src/*.test.ts`.
3 changes: 3 additions & 0 deletions packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
"test": "vitest run --passWithNoTests",
"lint": "eslint src"
},
"dependencies": {
"@tronbrowser/browser-core": "workspace:*"
},
"devDependencies": {
"typescript": "^5.6.3",
"vitest": "^2.1.4"
Expand Down
115 changes: 115 additions & 0 deletions packages/sdk/src/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* A managed browser session (PRD M3.4 / §15.1). `tron.launch()` starts a
* `tron-session`, and each Browser owns that session's lifecycle: it hands out
* Page objects (one CDP connection per tab) and tears the session down on close.
*/
import type { CdpConnection, CdpTarget, SessionDescriptor } from '@tronbrowser/browser-core';
import { defaultDeps, type SdkDeps } from './deps.js';
import { Page } from './page.js';
import { Tracer, tracerFromEnv } from './trace.js';

export interface LaunchOptions {
headless?: boolean;
profile?: string;
}

export class Browser {
readonly #deps: SdkDeps;
readonly #dataDir: string;
readonly #descriptor: SessionDescriptor;
readonly #pages: Page[] = [];
readonly #usedTargets = new Set<string>();
readonly #tracer: Tracer | undefined;
readonly #traceDir: string | undefined;
#browserConn: CdpConnection | undefined;
#closed = false;

private constructor(
deps: SdkDeps,
dataDir: string,
descriptor: SessionDescriptor,
trace?: { tracer: Tracer; dir: string },
) {
this.#deps = deps;
this.#dataDir = dataDir;
this.#descriptor = descriptor;
this.#tracer = trace?.tracer;
this.#traceDir = trace?.dir;
}

/** Launch a managed session and return a Browser. */
static async launch(options: LaunchOptions = {}, deps: SdkDeps = defaultDeps): Promise<Browser> {
// CLI flags from `tron run` arrive via env and win when the script is neutral.
const headless = options.headless ?? process.env.TRON_RUN_HEADLESS === '1';
const profile = options.profile ?? process.env.TRON_RUN_PROFILE;
const dataDir = await deps.makeDataDir();
try {
await deps.launchSession(dataDir, {
headless,
...(profile !== undefined ? { profile } : {}),
});
const descriptor = await deps.loadDescriptor(dataDir);
return new Browser(deps, dataDir, descriptor, tracerFromEnv(process.env));
} catch (err) {
await deps.closeSession(dataDir).catch(() => {});
await deps.removeDataDir(dataDir).catch(() => {});
throw err;
}
}

#targets(): Promise<CdpTarget[]> {
return this.#deps.fetchTargets(this.#descriptor.host, this.#descriptor.port);
}

/** Open (or adopt the first free) page target and return a Page for it. */
async newPage(): Promise<Page> {
if (this.#closed) throw new Error('Browser is closed');

let target = (await this.#targets()).find(
(t) => t.type === 'page' && !this.#usedTargets.has(t.id),
);
if (!target) target = await this.#createTarget();
if (!target.webSocketDebuggerUrl) {
throw new Error(`Page target ${target.id} has no webSocketDebuggerUrl`);
}

const conn = await this.#deps.connect(target.webSocketDebuggerUrl);
const page = await Page.attach(conn, target.id, this.#tracer);
this.#usedTargets.add(target.id);
this.#pages.push(page);
return page;
}

async #createTarget(): Promise<CdpTarget> {
if (!this.#descriptor.webSocketDebuggerUrl) {
throw new Error('Cannot open another page: session has no browser WebSocket endpoint');
}
this.#browserConn ??= await this.#deps.connect(this.#descriptor.webSocketDebuggerUrl);
const { targetId } = await this.#browserConn.send<{ targetId: string }>('Target.createTarget', {
url: 'about:blank',
});
const created = (await this.#targets()).find((t) => t.id === targetId);
if (!created) throw new Error('Newly created page target did not appear');
return created;
}

/** Pages opened so far. */
pages(): Page[] {
return [...this.#pages];
}

/** Close every page, stop the managed session, and remove its temp profile. */
async close(): Promise<void> {
if (this.#closed) return;
this.#closed = true;
for (const page of this.#pages) page.close();
this.#browserConn?.close();
if (this.#tracer && this.#traceDir) {
await this.#tracer
.flush(this.#traceDir, { headless: this.#descriptor.headless, profile: this.#descriptor.profileName })
.catch(() => {});
}
await this.#deps.closeSession(this.#dataDir).catch(() => {});
await this.#deps.removeDataDir(this.#dataDir).catch(() => {});
}
}
Loading
Loading