diff --git a/package-lock.json b/package-lock.json index aca1e2d..74ac701 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "roxy", - "version": "0.0.81", + "version": "0.0.84", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "roxy", - "version": "0.0.81", + "version": "0.0.84", "license": "MIT", "dependencies": { "@ai-sdk/anthropic": "^2.0.85", diff --git a/package.json b/package.json index 6e76f6b..4de962b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roxy", - "version": "0.0.83", + "version": "0.0.84", "description": "Roxy — an open-source AI coding agent for engineers.", "main": "./out/main/index.js", "author": "Roxy (https://github.com/roxy-gg/roxy)", @@ -36,10 +36,11 @@ "icons:providers": "node script/copy-provider-icons.mjs", "smoke:shared": "esbuild test/shared.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/shared.cjs && node test/.out/shared.cjs", "smoke:app": "esbuild test/smoke.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/smoke.cjs && electron test/.out/smoke.cjs", - "smoke": "npm run smoke:shared && npm run smoke:store && npm run smoke:app", + "smoke": "npm run smoke:shared && npm run smoke:store && npm run smoke:cookies && npm run smoke:app", "smoke:cliproxy": "esbuild test/cliproxy.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/cliproxy.cjs && electron test/.out/cliproxy.cjs", "worktree:setup": "npm ci --prefer-offline --no-audit --no-fund && electron-builder install-app-deps", - "smoke:store": "node test/store-guard.mjs" + "smoke:store": "node test/store-guard.mjs", + "smoke:cookies": "esbuild test/cookies.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/cookies.cjs && electron test/.out/cookies.cjs" }, "dependencies": { "@ai-sdk/anthropic": "^2.0.85", diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index c9d0fe4..2498c61 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -4,6 +4,7 @@ import type { SessionConfigPatch } from '../../shared/session-config' import type { ClipboardAction } from '../../shared/context-menu' import { clipboardHasContent, runClipboardAction } from '../services/context-menu' import type { + CookieRow, CreateChatInput, CreateLoopInput, CreateWorktreeInput, @@ -28,6 +29,7 @@ import * as repo from '../db/repo' import * as copilot from '../services/copilot' import * as cliproxy from '../services/cliproxy' import * as browser from '../services/browser' +import * as cookies from '../services/cookies' import { listModels } from '../services/models' import { pickDefaultModel } from '../../shared/models' import { CLIPROXY_PROVIDER_IDS, accountsFor, isCliProxyProvider } from '../../shared/cliproxy' @@ -87,7 +89,7 @@ const llmControllers = new Map() * * `llmControllers` alone was not enough for Stop to be reliable. The renderer * only learns a requestId once the turn is actually starting, and real work - * happens before that — most of all compaction, which is a full model call on a + * happens before that — most of all compaction, which is a full model call on a * long history and used to run with a hardcoded never-aborted signal. Stop * during that window found no requestId and silently did nothing, which is a * large part of why the button felt stuck. @@ -285,7 +287,7 @@ export function registerIpc(): void { // Fire-and-forget: deletion must never block on git, so a failure here is // logged and the session goes anyway (`git:prune-worktrees` sweeps up // whatever is left behind). It re-kills the session's processes internally - // and awaits them — the ordering that keeps removal working on Windows. + // and awaits them — the ordering that keeps removal working on Windows. void removeWorktreeForChat(id).then( (r) => { if (!r.ok && r.error) console.warn('[worktree] remove on delete failed:', r.error) @@ -409,7 +411,7 @@ export function registerIpc(): void { state: getUpdateState() })) ipcMain.handle(CHANNELS.systemOpenExternal, async (_e, url: string) => { - // Only allow web URLs — never file:, javascript:, or other schemes. + // Only allow web URLs — never file:, javascript:, or other schemes. try { const parsed = new URL(url) if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { @@ -653,7 +655,7 @@ export function registerIpc(): void { // If this session is shared to a phone, relay the turn there too so the phone // streams a desktop-typed reply live (the mirror of a phone turn on the PC). // The current prompt is the last user message; announce it so the phone shows - // the bubble it never echoed. `null` when nothing's shared → zero overhead. + // the bubble it never echoed. `null` when nothing's shared → zero overhead. const lastUser = [...input.messages].reverse().find((m) => m.role === 'user') const relay = remote.relayLocalTurnStart(input.sessionId, lastUser?.content) try { @@ -677,7 +679,7 @@ export function registerIpc(): void { llmControllers.get(requestId)?.abort() }) // Stop, as the UI means it: end everything this session has in flight, - // whatever stage it's at. Also cancels the session's delegates — stopping a + // whatever stage it's at. Also cancels the session's delegates — stopping a // turn while it waits on a subagent has to stop the subagent, or the work // carries on invisibly after the transcript says it stopped. ipcMain.handle(CHANNELS.llmAbortSession, (_e, sessionId: string) => { @@ -765,6 +767,20 @@ export function registerIpc(): void { browser.moveTab(id, toIndex, keyOf(e)) ) + // ---- cookies (the built-in Cookie-Editor) ---- + // One jar, shared by every session: the browser's persisted partition is + // global, so these are deliberately NOT keyed off the sender. Both surfaces + // -- the browser window's Cookies panel and Settings -> Browser -- call the + // same handlers and see the same cookies. + ipcMain.handle(CHANNELS.cookiesList, (_e, url?: string) => cookies.list(url)) + ipcMain.handle(CHANNELS.cookiesSet, (_e, row: Partial) => cookies.set(row)) + ipcMain.handle(CHANNELS.cookiesRemove, (_e, row: CookieRow) => cookies.remove(row)) + ipcMain.handle(CHANNELS.cookiesClear, (_e, host?: string) => cookies.clear(host)) + ipcMain.handle(CHANNELS.cookiesImport, (_e, text: string) => cookies.importJson(text)) + ipcMain.handle(CHANNELS.browserChromeHeight, (e, height: number) => + browser.setChromeHeight(height, keyOf(e)) + ) + // ---- services (a session's background processes) ---- // Every handler resolves the ROOT session first: a subagent's dev server is // registered under its parent, and the parent's panel is where it belongs. @@ -836,9 +852,9 @@ export function registerIpc(): void { ipcMain.handle( CHANNELS.gitCreateWorktree, async (_e, input: CreateWorktreeInput): Promise => { - if (!(await git.isGitAvailable())) return { ok: false, error: 'Git isn’t installed.' } + if (!(await git.isGitAvailable())) return { ok: false, error: 'Git isn’t installed.' } const root = await git.repoRoot(input.cwd) - if (!root) return { ok: false, error: 'This folder isn’t a git repository.' } + if (!root) return { ok: false, error: 'This folder isn’t a git repository.' } const r = input.mode === 'new' ? await git.createWorktree({ @@ -851,7 +867,7 @@ export function registerIpc(): void { ) ipcMain.handle(CHANNELS.gitRemoveWorktree, async (_e, worktreePath: string, force?: boolean) => { - if (!(await git.isGitAvailable())) return { ok: false, error: 'Git isn’t installed.' } + if (!(await git.isGitAvailable())) return { ok: false, error: 'Git isn’t installed.' } return git.removeWorktree(worktreePath, { force: force ?? false }) }) diff --git a/src/main/services/browser.ts b/src/main/services/browser.ts index 06a760f..9887b21 100644 --- a/src/main/services/browser.ts +++ b/src/main/services/browser.ts @@ -1,5 +1,5 @@ /** - * The Roxy browser — a real, persistent Electron BrowserWindow the agent can + * The Roxy browser — a real, persistent Electron BrowserWindow the agent can * drive. It uses a `persist:` session partition so cookies/logins survive * restarts (sign in once, automate forever), and it taps Electron's native * APIs to screenshot the page, read its HTML, and collect console messages. @@ -22,17 +22,17 @@ import { CHANNELS } from '../../shared/ipc' import type { BrowserState, BrowserTab } from '../../shared/api' import { attachNativeContextMenu } from './context-menu' -/** Persisted session → cookies, localStorage and logins survive app restarts. */ -const PARTITION = 'persist:roxy-browser' +/** Persisted session → cookies, localStorage and logins survive app restarts. */ +export const PARTITION = 'persist:roxy-browser' const MAX_CONSOLE = 500 /** Height of the chrome overlaid at the top: tab strip + URL-bar toolbar. */ const CHROME_H = 80 -/** Where a blank/new tab lands — a homepage, like a normal browser. */ +/** Where a blank/new tab lands — a homepage, like a normal browser. */ const HOME_URL = 'https://www.google.com' /** The shared window used by the manual "Open browser" button and keyless callers. */ const DEFAULT_KEY = '__default__' -/** App icon path, injected from main (so this file has no `?asset` import — the +/** App icon path, injected from main (so this file has no `?asset` import — the * smoke harness bundles it with esbuild, which doesn't understand `?asset`). */ let appIconPath: string | undefined export function setAppIcon(p: string): void { @@ -48,14 +48,14 @@ export interface ConsoleEntry { ts: number } -/** One open tab — a persistent-session page view. The active tab fills the +/** One open tab — a persistent-session page view. The active tab fills the * window; the rest sit at zero size (still alive, so their pages are kept). */ interface Tab { id: string view: BrowserView } -/** One isolated browser — a window + its tabs + console, owned by a session key. */ +/** One isolated browser — a window + its tabs + console, owned by a session key. */ interface Session { key: string /** A human label (usually the project folder) shown in the window title. */ @@ -65,6 +65,14 @@ interface Session { activeTabId: string | null consoleLog: ConsoleEntry[] tabSeq: number + /** + * Chrome height in px when a chrome panel (the cookie editor) is open. + * BrowserViews always paint ABOVE the window's own webContents, so a panel + * rendered in the chrome would be hidden behind the page. Growing the chrome + * instead pushes the page view down out of sight -- the panel is genuinely + * on top, with no z-index fight to lose. + */ + chromeH?: number } /** Every live browser, keyed by session (chat) id. */ @@ -80,7 +88,7 @@ function getSession(key: string): Session { return s } -/** The session for `key` WITHOUT creating one — for read/nav ops that must not spawn a window. */ +/** The session for `key` WITHOUT creating one — for read/nav ops that must not spawn a window. */ function peek(key: string): Session | undefined { return sessions.get(key) } @@ -91,7 +99,7 @@ function activeView(s: Session): BrowserView | null { return t && !t.view.webContents.isDestroyed() ? t.view : null } -/** The active page's contents for `key` — what every browser_* tool drives. */ +/** The active page's contents for `key` — what every browser_* tool drives. */ function pageContents(key: string): Electron.WebContents { const s = peek(key) const v = s ? activeView(s) : null @@ -99,9 +107,9 @@ function pageContents(key: string): Electron.WebContents { return v.webContents } -/** The window title for a session — names the project so windows are tellable apart. */ +/** The window title for a session — names the project so windows are tellable apart. */ function windowTitle(s: Session): string { - return s.label ? `Roxy Browser — ${s.label}` : 'Roxy Browser' + return s.label ? `Roxy Browser — ${s.label}` : 'Roxy Browser' } function ensureWindow(s: Session): BrowserWindow { @@ -121,7 +129,7 @@ function ensureWindow(s: Session): BrowserWindow { backgroundColor: '#0a0a0a', autoHideMenuBar: true, ...(isMac || !appIconPath ? {} : { icon: appIconPath }), - // Hide the native OS title bar — our React chrome (tab strip + URL bar) IS + // Hide the native OS title bar — our React chrome (tab strip + URL bar) IS // the title bar. Keep native window controls, themed to match (no second // light bar stacked on top of the chrome). titleBarStyle: 'hidden', @@ -137,11 +145,11 @@ function ensureWindow(s: Session): BrowserWindow { s.win = win // The chrome (tab strip + URL bar) is a real React app rendered into the - // WINDOW's OWN webContents — not a BrowserView — so its title-bar strip is + // WINDOW's OWN webContents — not a BrowserView — so its title-bar strip is // draggable via `-webkit-app-region` (which doesn't work inside a BrowserView) // and the native control overlay lands on it cleanly. Page tabs sit in // BrowserViews on top, below the chrome. Best-effort load (the test harness - // has no bundled browser.html — the pages still work). + // has no bundled browser.html — the pages still work). win.webContents.once('did-finish-load', () => { pushState(s) pushTabs(s) @@ -171,16 +179,32 @@ function ensureWindow(s: Session): BrowserWindow { function layout(s: Session): void { if (!s.win || s.win.isDestroyed()) return const { width, height } = s.win.getContentBounds() + const top = s.chromeH ?? CHROME_H for (const t of s.tabs) { if (t.view.webContents.isDestroyed()) continue t.view.setBounds( t.id === s.activeTabId - ? { x: 0, y: CHROME_H, width, height: Math.max(0, height - CHROME_H) } + ? { x: 0, y: top, width, height: Math.max(0, height - top) } : { x: 0, y: 0, width: 0, height: 0 } ) } } +/** + * Reserve height px of window for the chrome, so a chrome-rendered panel can + * cover the page. Pass 0/undefined to restore the normal toolbar height. + * + * Clamped to the window, because a panel taller than the window would push the + * page view to a negative height and Chromium would reject the bounds. + */ +export function setChromeHeight(height: number, key: string = DEFAULT_KEY): void { + const s = peek(key) + if (!s || !s.win || s.win.isDestroyed()) return + const max = s.win.getContentBounds().height + s.chromeH = height > CHROME_H ? Math.min(height, max) : undefined + layout(s) +} + /** Create a tab (optionally at a URL) and make it active. Assumes a window. */ function createTab(s: Session, rawUrl?: string): string { if (!s.win || s.win.isDestroyed()) return '' @@ -262,7 +286,7 @@ function tabsOf(s: Session): BrowserTab[] { }) } -/** The open tabs for `key` — also used by the browser_tabs tool. */ +/** The open tabs for `key` — also used by the browser_tabs tool. */ export function listTabs(key: string = DEFAULT_KEY): BrowserTab[] { const s = peek(key) return s ? tabsOf(s) : [] @@ -307,7 +331,7 @@ export async function open( const wc = pageContents(key) const url = normalizeUrl(rawUrl) // Show the window so you can watch the agent browse, but DON'T steal focus - // (showInactive) — the agent driving the browser shouldn't yank you out of + // (showInactive) — the agent driving the browser shouldn't yank you out of // whatever you're typing. The Settings "Open browser" button (openWindow) // still focuses it for manual sign-in. if (s.win?.isMinimized()) s.win.restore() @@ -378,7 +402,7 @@ export function close(key: string = DEFAULT_KEY): void { const s = sessions.get(key) if (!s) return if (s.win && !s.win.isDestroyed()) - s.win.close() // fires 'closed' → sessions.delete + s.win.close() // fires 'closed' → sessions.delete else sessions.delete(key) } diff --git a/src/main/services/cookies.ts b/src/main/services/cookies.ts new file mode 100644 index 0000000..f6fdff2 --- /dev/null +++ b/src/main/services/cookies.ts @@ -0,0 +1,230 @@ +/** + * Cookie editing for the Roxy browser — the "Cookie-Editor extension", built in. + * + * The Roxy browser runs every tab on one persisted session partition, so its + * cookie jar is a single Electron `Session.cookies` store. That store already + * exposes everything the Cookie-Editor extension wraps (get/set/remove), which + * is why this ships as a native panel instead of real Chrome extension support: + * `chrome.cookies` IS `session.cookies` here, minus a CRX loader we'd otherwise + * have to carry. + * + * INTEROP is the point. Import/export speak the exact JSON shape Cookie-Editor + * and EditThisCookie use, so a blob copied out of Chrome pastes straight in + * here (and back). That format differs from Electron's in three ways we bridge: + * - session cookies must OMIT `expirationDate` entirely rather than send 0, + * which would instead expire the cookie on the spot; + * - `hostOnly` is derived, not settable — Electron infers it from whether you + * pass `domain` at all, so a host-only cookie is written by dropping it; + * - `sameSite` arrives in several casings ("no_restriction", "None", "Lax") + * depending on which tool exported it, so it gets normalized. + */ +import { session } from 'electron' +import { PARTITION } from './browser' +import type { CookieImportResult, CookieRow } from '../../shared/api' + +/** The Roxy browser's cookie jar (the same partition every tab renders on). */ +function jar(): Electron.Cookies { + return session.fromPartition(PARTITION).cookies +} + +/** + * Normalize the many spellings of SameSite into Electron's four values. + * Chrome's extension API and Electron both use snake_case, but tools exporting + * from DevTools emit the HTTP header casing ("None"/"Lax"/"Strict") — and an + * unrecognized value must fall back to `unspecified` rather than throw, or one + * bad row would reject an entire paste. + */ +function normalizeSameSite(raw: unknown): CookieRow['sameSite'] { + const v = String(raw ?? '') + .trim() + .toLowerCase() + if (v === 'no_restriction' || v === 'none') return 'no_restriction' + if (v === 'lax') return 'lax' + if (v === 'strict') return 'strict' + return 'unspecified' +} + +/** + * The URL to address a cookie by. Electron's cookie store is keyed by URL, not + * by domain, so every set/remove needs one synthesized from the cookie itself. + * The scheme must track `secure`: Chromium refuses to store a Secure cookie + * against an http:// URL. + */ +function urlFor(c: { domain?: string; path?: string; secure?: boolean }): string { + const host = (c.domain ?? '').replace(/^\./, '') + const scheme = c.secure ? 'https' : 'http' + return `${scheme}://${host}${c.path || '/'}` +} + +/** Convert one Electron cookie into the interchange shape. */ +function toRow(c: Electron.Cookie): CookieRow { + const row: CookieRow = { + name: c.name, + value: c.value, + domain: c.domain ?? '', + path: c.path ?? '/', + secure: Boolean(c.secure), + httpOnly: Boolean(c.httpOnly), + hostOnly: Boolean(c.hostOnly), + session: Boolean(c.session), + sameSite: normalizeSameSite(c.sameSite), + storeId: '0' + } + if (!c.session && typeof c.expirationDate === 'number') row.expirationDate = c.expirationDate + return row +} + +/** The hostname of a URL, or '' when it isn't parseable (about:blank, empty). */ +function hostOf(url: string): string { + try { + return new URL(url).hostname + } catch { + return '' + } +} + +/** + * The domains whose cookies are in play for `host`: the host itself and each + * parent down to a two-label name — so `app.stage.example.com` also pulls + * `.example.com`, where the session cookie you're actually debugging usually + * lives. + * + * Electron's `domain` filter matches a domain and its SUBdomains, never its + * parents, so querying the host alone silently omits exactly those cookies. + * Bare hostnames (`localhost`) and IP literals have no parents to walk. + */ +function domainChain(host: string): string[] { + if (!host) return [] + if (/^[\d.]+$/.test(host) || host.includes(':')) return [host] + const parts = host.split('.') + if (parts.length < 2) return [host] + const out: string[] = [] + for (let i = 0; i <= parts.length - 2; i++) out.push(parts.slice(i).join('.')) + return out +} + +/** + * Every cookie relevant to `url` (its whole domain chain), or the entire jar + * when no URL is given. Deduped, because a host and its parent both match a + * cookie sitting in the middle of the chain. + */ +export async function list(url?: string): Promise { + const host = url ? hostOf(url) : '' + const queries: Electron.CookiesGetFilter[] = host + ? domainChain(host).map((domain) => ({ domain })) + : [{}] + const found = new Map() + for (const q of queries) { + for (const c of await jar().get(q)) { + const row = toRow(c) + found.set(`${row.domain}|${row.path}|${row.name}`, row) + } + } + return [...found.values()].sort( + (a, b) => a.domain.localeCompare(b.domain) || a.name.localeCompare(b.name) + ) +} + +/** + * Create or overwrite one cookie. Returns an error string rather than throwing: + * a bulk import must be able to report "3 of 40 rejected" instead of dying on + * the first cookie Chromium dislikes (bad domain, `__Host-` prefix rules, ...). + */ +export async function set(row: Partial): Promise { + const name = String(row.name ?? '').trim() + if (!name) return 'a cookie in the list has no name' + const domain = String(row.domain ?? '').trim() + if (!domain) return `"${name}": no domain` + + const details: Electron.CookiesSetDetails = { + url: urlFor({ domain, path: row.path, secure: row.secure }), + name, + value: String(row.value ?? ''), + path: row.path || '/', + secure: Boolean(row.secure), + httpOnly: Boolean(row.httpOnly), + sameSite: normalizeSameSite(row.sameSite) + } + // A host-only cookie is expressed by OMITTING domain (Electron then binds it + // to the URL's host); sending a domain always yields a subdomain cookie. + if (!row.hostOnly) details.domain = domain + // Session cookies must omit expirationDate entirely — sending 0 would expire + // the cookie immediately instead of making it session-scoped. + if (!row.session && typeof row.expirationDate === 'number') { + details.expirationDate = row.expirationDate + } + + try { + await jar().set(details) + return null + } catch (e) { + return `"${name}": ${e instanceof Error ? e.message : String(e)}` + } +} + +/** Delete one cookie, addressed the same way it was stored. */ +export async function remove( + row: Pick +): Promise { + await jar().remove(urlFor(row), row.name) +} + +/** + * Delete every cookie, or every cookie in one host's domain chain. Removal goes + * through the per-cookie API rather than `clearStorageData` so a domain-scoped + * wipe is possible at all, and so localStorage logins survive a cookie reset. + */ +export async function clear(host?: string): Promise { + const queries: Electron.CookiesGetFilter[] = host + ? domainChain(host).map((domain) => ({ domain })) + : [{}] + const seen = new Set() + let removed = 0 + for (const q of queries) { + for (const c of await jar().get(q)) { + const id = `${c.domain}|${c.path}|${c.name}` + if (seen.has(id)) continue + seen.add(id) + try { + await jar().remove(urlFor(c), c.name) + removed++ + } catch { + // Already gone, or unaddressable — nothing useful to do per-cookie. + } + } + } + return removed +} + +/** + * Import a Cookie-Editor / EditThisCookie JSON blob. Accepts either a bare + * array (what those tools emit) or a `{ cookies: [...] }` wrapper, because both + * are in the wild. Bad rows are collected and reported, never fatal — only + * malformed JSON throws. + */ +export async function importJson(text: string): Promise { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch (e) { + throw new Error(`Not valid JSON: ${e instanceof Error ? e.message : String(e)}`) + } + const wrapped = (parsed as { cookies?: unknown } | null)?.cookies + const rows = Array.isArray(parsed) ? parsed : Array.isArray(wrapped) ? wrapped : null + if (!rows) throw new Error('Expected an array of cookies (or { "cookies": [...] }).') + + const errors: string[] = [] + let imported = 0 + for (const raw of rows) { + if (!raw || typeof raw !== 'object') { + errors.push('skipped an entry that was not an object') + continue + } + const err = await set(raw as Partial) + if (err) errors.push(err) + else imported++ + } + // Keep the error list short — a 500-cookie paste that wholly fails shouldn't + // push 500 near-identical strings through IPC into a toast. + return { imported, failed: errors.length, errors: errors.slice(0, 8) } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 5f8b0ac..e78fe61 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -211,6 +211,7 @@ const roxy: RoxyApi = { closeTab: (id) => ipcRenderer.invoke(CHANNELS.browserCloseTab, id), activateTab: (id) => ipcRenderer.invoke(CHANNELS.browserActivateTab, id), moveTab: (id, toIndex) => ipcRenderer.invoke(CHANNELS.browserMoveTab, id, toIndex), + setChromeHeight: (height) => ipcRenderer.invoke(CHANNELS.browserChromeHeight, height), onState: (callback) => { const handler = (_event: Electron.IpcRendererEvent, state: BrowserState): void => callback(state) @@ -224,6 +225,13 @@ const roxy: RoxyApi = { return () => ipcRenderer.removeListener(CHANNELS.browserTabs, handler) } }, + cookies: { + list: (url) => ipcRenderer.invoke(CHANNELS.cookiesList, url), + set: (row) => ipcRenderer.invoke(CHANNELS.cookiesSet, row), + remove: (row) => ipcRenderer.invoke(CHANNELS.cookiesRemove, row), + clear: (host) => ipcRenderer.invoke(CHANNELS.cookiesClear, host), + importJson: (text) => ipcRenderer.invoke(CHANNELS.cookiesImport, text) + }, services: { list: (sessionId) => ipcRenderer.invoke(CHANNELS.servicesList, sessionId), output: (sessionId, id) => ipcRenderer.invoke(CHANNELS.servicesOutput, sessionId, id), diff --git a/src/renderer/src/browser/BrowserChrome.tsx b/src/renderer/src/browser/BrowserChrome.tsx index 70ca0db..c40aba5 100644 --- a/src/renderer/src/browser/BrowserChrome.tsx +++ b/src/renderer/src/browser/BrowserChrome.tsx @@ -1,8 +1,9 @@ -import { useEffect, useState } from 'react' -import { ArrowLeft, ArrowRight, Globe, Plus, RotateCw, Search, X } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { ArrowLeft, ArrowRight, Cookie, Globe, Plus, RotateCw, Search, X } from 'lucide-react' import type { BrowserState, BrowserTab } from '@shared/api' import { api } from '../lib/api' import { cn } from '../lib/cn' +import { CookiePanel } from '../components/CookiePanel' const BLANK: BrowserState = { url: '', @@ -13,7 +14,7 @@ const BLANK: BrowserState = { } /** - * The Roxy browser's chrome — a real React tab strip + URL bar (themed to match + * The Roxy browser's chrome — a real React tab strip + URL bar (themed to match * the app), rendered into the browser window's top BrowserView. It talks to the * main process purely through `window.roxy.browser.*`; the agent still drives * the active tab from main, and this just reflects/controls it. @@ -24,7 +25,34 @@ export function BrowserChrome(): JSX.Element { const [draft, setDraft] = useState('') const [editing, setEditing] = useState(false) const [dragId, setDragId] = useState(null) + const [cookiesOpen, setCookiesOpen] = useState(false) + // The host the cookie panel scopes to -- the active tab's, like the + // Cookie-Editor popup. Undefined on a blank tab, which shows the whole jar. + const host = useMemo(() => { + try { + return new URL(nav.url).hostname || undefined + } catch { + return undefined + } + }, [nav.url]) + + // A BrowserView always paints ABOVE the window's own webContents, so the + // panel can't simply overlay the page: main has to shrink the page view out + // of the way first. Growing the reserved chrome height does exactly that, + // and 0 hands the space back. + useEffect(() => { + void api.browser.setChromeHeight(cookiesOpen ? window.innerHeight : 0) + }, [cookiesOpen]) + + // Reserved height is absolute pixels, so a window resize while the panel is + // open would leave the page peeking out below it. + useEffect(() => { + if (!cookiesOpen) return + const onResize = (): void => void api.browser.setChromeHeight(window.innerHeight) + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, [cookiesOpen]) useEffect(() => { const offState = api.browser.onState(setNav) const offTabs = api.browser.onTabs(setTabs) @@ -53,7 +81,7 @@ export function BrowserChrome(): JSX.Element { return (
- {/* Tab strip — doubles as the draggable title bar; native controls overlay it. */} + {/* Tab strip — doubles as the draggable title bar; native controls overlay it. */}
{tabs.map((t) => (
+ setCookiesOpen((v) => !v)} title="Cookies" active={cookiesOpen}> + +
+ + {/* Cookie editor. Rendered in the chrome (not over the page) because a + BrowserView can't be painted over -- main shrinks the page view to + make room, so this genuinely sits on top. */} + {cookiesOpen && ( + setCookiesOpen(false)} title="Close cookies"> + + + } + /> + )}
) } @@ -163,12 +209,15 @@ function NavButton({ children, onClick, disabled, - title + title, + active }: { children: React.ReactNode onClick: () => void disabled?: boolean title: string + /** Held-down look, for buttons that toggle a panel open. */ + active?: boolean }): JSX.Element { return ( diff --git a/src/renderer/src/components/CookiePanel.tsx b/src/renderer/src/components/CookiePanel.tsx new file mode 100644 index 0000000..4268425 --- /dev/null +++ b/src/renderer/src/components/CookiePanel.tsx @@ -0,0 +1,508 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Check, ClipboardPaste, Copy, Plus, RotateCw, Search, Trash2, X } from 'lucide-react' +import type { CookieRow } from '@shared/api' +import { api } from '../lib/api' +import { cn } from '../lib/cn' + +/** A blank cookie scoped to the host you're looking at — what "Add" starts from. */ +function blankCookie(host: string): CookieRow { + return { + name: '', + value: '', + domain: host, + path: '/', + secure: true, + httpOnly: false, + hostOnly: false, + session: true, + sameSite: 'lax', + storeId: '0' + } +} + +/** Stable identity for a cookie — the tuple the jar itself keys on. */ +function idOf(c: CookieRow): string { + return `${c.domain}|${c.path}|${c.name}` +} + +/** "in 3 days" / "expired" / "session" — the only part of a cookie people scan for. */ +function expiryLabel(c: CookieRow): string { + if (c.session || !c.expirationDate) return 'Session' + const ms = c.expirationDate * 1000 - Date.now() + if (ms <= 0) return 'Expired' + const days = Math.round(ms / 86_400_000) + if (days >= 1) return `${days}d` + const hours = Math.round(ms / 3_600_000) + return hours >= 1 ? `${hours}h` : `${Math.max(1, Math.round(ms / 60_000))}m` +} + +/** + * The cookie editor — Cookie-Editor's job, native to the Roxy browser. + * + * Used from two places: the browser window's own chrome (scoped to the active + * tab's host, like the extension's popup) and Settings → Browser (`scope` + * omitted, so it shows the whole jar). Both drive the same partition, because + * there is only one. + */ +export function CookiePanel({ + /** Host to scope to; omit for the entire jar. */ + host, + /** Rendered top-right, next to the refresh control (the panel's close button). */ + action, + className +}: { + host?: string + action?: React.ReactNode + className?: string +}): JSX.Element { + const [rows, setRows] = useState([]) + const [query, setQuery] = useState('') + const [openId, setOpenId] = useState(null) + const [busy, setBusy] = useState(false) + const [note, setNote] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null) + const [importing, setImporting] = useState(false) + const [importText, setImportText] = useState('') + const [copied, setCopied] = useState(false) + + const refresh = useCallback(async (): Promise => { + setBusy(true) + try { + setRows(await api.cookies.list(host ? `https://${host}/` : undefined)) + } finally { + setBusy(false) + } + }, [host]) + + useEffect(() => { + void refresh() + }, [refresh]) + + // Notices are transient: they report the result of an action, and a stale + // "Imported 12" sitting above a list you've since edited is just noise. + useEffect(() => { + if (!note) return + const t = setTimeout(() => setNote(null), 4000) + return () => clearTimeout(t) + }, [note]) + + const shown = useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return rows + return rows.filter( + (c) => + c.name.toLowerCase().includes(q) || + c.domain.toLowerCase().includes(q) || + c.value.toLowerCase().includes(q) + ) + }, [rows, query]) + + const save = async (row: CookieRow, original?: CookieRow): Promise => { + // Renaming or re-scoping writes a NEW cookie rather than moving one, so the + // old one has to go explicitly or you'd silently end up with both. + if (original && idOf(original) !== idOf(row) && original.name) + await api.cookies.remove(original) + const err = await api.cookies.set(row) + if (err) setNote({ kind: 'err', text: err }) + else { + setNote({ kind: 'ok', text: `Saved ${row.name}` }) + setOpenId(null) + } + await refresh() + } + + const del = async (row: CookieRow): Promise => { + await api.cookies.remove(row) + if (openId === idOf(row)) setOpenId(null) + await refresh() + } + + const copyAll = async (): Promise => { + // Cookie-Editor's export is a bare array, pretty-printed — match it exactly + // so what lands on the clipboard pastes into that extension unchanged. + await navigator.clipboard.writeText(JSON.stringify(shown, null, 2)) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + + const runImport = async (): Promise => { + try { + const r = await api.cookies.importJson(importText) + setNote( + r.failed + ? { + kind: 'err', + text: `Imported ${r.imported}, ${r.failed} failed: ${r.errors[0] ?? ''}` + } + : { kind: 'ok', text: `Imported ${r.imported} cookie${r.imported === 1 ? '' : 's'}.` } + ) + if (r.imported) { + setImportText('') + setImporting(false) + } + } catch (e) { + setNote({ kind: 'err', text: e instanceof Error ? e.message : String(e) }) + } + await refresh() + } + + const clearAll = async (): Promise => { + const n = await api.cookies.clear(host) + setNote({ kind: 'ok', text: `Deleted ${n} cookie${n === 1 ? '' : 's'}.` }) + await refresh() + } + + return ( +
+ {/* Toolbar */} +
+
+ + setQuery(e.target.value)} + placeholder={host ? `Cookies for ${host}` : 'Search all cookies'} + spellCheck={false} + className="h-7 w-full rounded-md border border-border bg-surface-2 pl-7 pr-2 text-xs outline-none placeholder:text-text-subtle focus:border-accent" + /> +
+ void refresh()} title="Refresh" busy={busy}> + + + { + const c = blankCookie(host ?? '') + setRows((r) => [c, ...r]) + setOpenId(idOf(c)) + }} + title="Add cookie" + > + + + void copyAll()} title="Copy as Cookie-Editor JSON"> + {copied ? ( + + ) : ( + + )} + + setImporting((v) => !v)} title="Import JSON" active={importing}> + + + void clearAll()} + title={host ? `Delete all for ${host}` : 'Delete all'} + danger + > + + + {action} +
+ + {importing && ( +
+