diff --git a/ROADMAP.md b/ROADMAP.md index a8feadd..6333b13 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -35,7 +35,49 @@ Core engine, CLI, and all major subsystems are stable. Summary of shipped featur ## In Progress -_(Currently empty)_ +### Tauri Desktop App — Mission Control for AI Agents + +**Priority: Urgent** — The desktop app is the primary interface for users to manage, monitor, and assist AI browsing agents. + +**Phase 1 — Semantic Tree Viewer + CAPTCHA Handoff (current)** +- [ ] Semantic tree viewer panel — render ARIA role tree with interactive nodes in Tauri dashboard +- [ ] Per-instance controls — URL bar, navigate, agent status (idle/running/waiting-challenge) +- [ ] CAPTCHA handoff — when agent hits a challenge, popup OS webview (WKWebView/WebKitGTK/WebView2) for user to solve, then sync cookies back to headless browser via CDP `Network.setCookie` +- [ ] Cookie bridge — `tokio-tungstenite` WebSocket client to inject cookies into headless CDP server +- [ ] Agent action log — real-time log of agent actions (navigate, click, type, wait) streamed from CDP events +- [ ] Cross-platform — dashboard is pure HTML/CSS (no OS webview dependency for primary view); CAPTCHA popup uses OS webview only when needed + +**Phase 2 — Multi-Agent Dashboard** +- [ ] Multiple concurrent agent instances — spawn/manage N agents in one window +- [ ] Agent status grid — see all agents at a glance with status indicators (running, idle, stuck, CAPTCHA) +- [ ] Live agent action streaming — watch each agent's actions in real-time via CDP event bus +- [ ] Take-over button — pause agent, let user manually interact, then resume agent +- [ ] Agent conversation panel — show the LLM conversation alongside browser actions + +**Phase 3 — Rendered View (Optional)** +- [ ] Rendered page tab — OS webview shows actual page pixels (WKWebView on macOS, WebKitGTK on Linux, WebView2 on Windows) +- [ ] Split view — semantic tree on left, rendered pixels on right +- [ ] Screenshot capture — use pardus-core screenshot feature (chromiumoxide) for pixel-perfect captures + +**Architecture:** +``` +┌─ Mission Control ──────────────────────────────────────┐ +│ ┌─ Agents ─────┐ ┌─ Semantic Tree ──────────────────┐ │ +│ │ ● Agent 1 │ │ [Document] │ │ +│ │ Shopping │ │ ├── [Nav] "Menu" │ │ +│ │ Running │ │ ├── [Main] │ │ +│ │ │ │ │ ├── [H1] "Welcome" │ │ +│ │ ● Agent 2 │ │ │ ├── [TextBox #3] "Email" │ │ +│ │ Research │ │ │ └── [Button #4] "Submit" │ │ +│ │ ⚠ CAPTCHA │ │ └── [Footer] │ │ +│ └──────────────┘ └───────────────────────────────────┘ │ +│ ┌─ Action Log ────────────────────────────────────────┐ │ +│ │ 12:03:01 Navigate → shop.example.com │ │ +│ │ 12:03:02 Click [#5] "Add to Cart" │ │ +│ │ 12:03:03 ⚠ CAPTCHA detected — Cloudflare │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` --- diff --git a/ai-agent/pardus-browser/src/agent/Agent.ts b/ai-agent/pardus-browser/src/agent/Agent.ts index de7c80b..28148f7 100644 --- a/ai-agent/pardus-browser/src/agent/Agent.ts +++ b/ai-agent/pardus-browser/src/agent/Agent.ts @@ -1,4 +1,4 @@ -import { LLMClient, LLMConfig, Message, getSystemPrompt } from '../llm/index.js'; +import { LLMClient, LLMConfig, Message, getSystemPrompt, compactMessages, truncateToolResult, ContextConfig } from '../llm/index.js'; import { ToolExecutor } from '../tools/executor.js'; import { BrowserManager } from '../core/index.js'; import { BrowserToolName } from '../tools/definitions.js'; @@ -13,13 +13,15 @@ interface AgentOptions { maxRounds?: number; /** Tool execution configuration */ toolConfig?: { - /** Enable parallel execution where safe (default: false) */ + /** Enable parallel execution where safe (default: true) */ parallel?: boolean; /** Continue on tool failure (default: true) */ continueOnError?: boolean; /** Default retry configuration for all tools */ defaultRetryConfig?: ToolExecutionConfig; }; + /** Context window management configuration */ + contextConfig?: Partial; } /** @@ -36,6 +38,7 @@ export class Agent { private browserManager: BrowserManager; private isRunning = false; private toolConfig: AgentOptions['toolConfig']; + private contextConfig: ContextConfig; constructor(browserManager: BrowserManager, options: AgentOptions) { this.browserManager = browserManager; @@ -43,10 +46,17 @@ export class Agent { this.toolExecutor = new ToolExecutor(browserManager); this.maxRounds = options.maxRounds ?? 50; this.toolConfig = { - parallel: false, + parallel: true, continueOnError: true, ...options.toolConfig, }; + this.contextConfig = { + maxTokens: 100_000, + keepRecentMessages: 10, + maxToolResultChars: 6000, + charsPerToken: 4, + ...options.contextConfig, + }; // Initialize with system prompt this.messages.push({ @@ -128,22 +138,21 @@ export class Agent { return errorMessage; } - // Add all tool results to conversation + // Add all tool results to conversation — toolCallId flows from the LLM response for (const result of toolResults) { - // Find the original tool call ID - const toolCall = response.toolCalls.find(t => - t.name === result.name && - JSON.stringify(t.arguments) === JSON.stringify(result.args) - ); - + const content = result.success + ? truncateToolResult(result.content || '', this.contextConfig.maxToolResultChars) + : `Error: ${result.error || 'Unknown error'}\n\nPartial result: ${result.content || 'none'}`; + this.messages.push({ role: 'tool', - tool_call_id: toolCall?.id || 'unknown', - content: result.success - ? (result.content || '') - : `Error: ${result.error || 'Unknown error'}\n\nPartial result: ${result.content || 'none'}`, + tool_call_id: result.toolCallId || 'unknown', + content, }); } + + // Compact conversation history if approaching context limit + this.messages = compactMessages(this.messages, this.contextConfig); } if (rounds >= this.maxRounds) { @@ -169,7 +178,7 @@ export class Agent { if (!this.toolConfig?.parallel) { // Sequential execution const results: ToolExecutionResult[] = []; - + for (const call of toolCalls) { console.log(`[Tool] ${call.name}: ${JSON.stringify(call.arguments)}`); @@ -180,6 +189,7 @@ export class Agent { ); results.push({ + toolCallId: call.id, name: call.name, args: call.arguments, success: result.success, @@ -196,12 +206,12 @@ export class Agent { console.log(`[Tool Error] ${result.error}`); } } - + return results; } else { // Parallel execution with grouping - // Convert to format expected by executeTools const tools = toolCalls.map(call => ({ + toolCallId: call.id, name: call.name as BrowserToolName, args: call.arguments, config: this.toolConfig?.defaultRetryConfig, @@ -220,42 +230,100 @@ export class Agent { console.log(`[Tool Error] ${result.error}`); } } - + return parallelResult.results; } } /** - * Stream a response for interactive CLI - * - * Note: Tool calls still happen after the stream completes + * Stream a response for interactive CLI with full tool call support. + * + * Yields text chunks as they arrive. Tool calls are buffered and + * executed after the stream completes, then the loop continues + * (same as chat() but with streamed text output). */ async *streamChat(userMessage: string): AsyncGenerator { - // For streaming, we currently don't support mid-stream tool calls - // The LLM will respond with text, then we check for tool calls - // This is a simplified version - full implementation would parse tool calls from stream + if (this.isRunning) { + throw new Error('Agent is already processing a message'); + } - this.messages.push({ - role: 'user', - content: userMessage, - }); + this.isRunning = true; - // For simplicity in streaming mode, we don't use tools - // Full implementation would parse tool calls from stream - const stream = this.llm.streamChat(this.messages); - let fullResponse = ''; + try { + this.messages.push({ role: 'user', content: userMessage }); - for await (const chunk of stream) { - fullResponse += chunk; - yield chunk; - } + let rounds = 0; - this.messages.push({ - role: 'assistant', - content: fullResponse, - }); + while (rounds < this.maxRounds) { + rounds++; + + const result = await this.llm.streamChat(this.messages); + + // Yield any text chunks + for (const chunk of result.textChunks) { + yield chunk; + } - return fullResponse; + // No tool calls — done + if (!result.toolCalls || result.toolCalls.length === 0) { + this.messages.push({ + role: 'assistant', + content: result.content ?? '', + }); + return result.content ?? ''; + } + + // Add assistant message with tool calls + this.messages.push({ + role: 'assistant', + content: result.content ?? '', + tool_calls: result.toolCalls.map(call => ({ + id: call.id, + type: 'function' as const, + function: { + name: call.name, + arguments: JSON.stringify(call.arguments), + }, + })), + }); + + // Execute tool calls + const toolResults = await this.executeToolCalls(result.toolCalls); + + const hasFailures = toolResults.some(r => !r.success); + if (hasFailures && !this.toolConfig?.continueOnError) { + const errorMessage = 'Tool execution failed. Aborting conversation.'; + this.messages.push({ role: 'assistant', content: errorMessage }); + yield `\n\n${errorMessage}`; + return errorMessage; + } + + // Add tool results + for (const res of toolResults) { + const content = res.success + ? truncateToolResult(res.content || '', this.contextConfig.maxToolResultChars) + : `Error: ${res.error || 'Unknown error'}\n\nPartial result: ${res.content || 'none'}`; + + this.messages.push({ + role: 'tool', + tool_call_id: res.toolCallId || 'unknown', + content, + }); + } + + // Compact context + this.messages = compactMessages(this.messages, this.contextConfig); + + // The loop continues — the next iteration will stream the LLM's + // response to the tool results (which may include more tool calls). + } + + const limitMsg = 'Maximum number of tool call rounds reached.'; + yield `\n\n${limitMsg}`; + return limitMsg; + } finally { + this.isRunning = false; + } } /** diff --git a/ai-agent/pardus-browser/src/core/BrowserInstance.ts b/ai-agent/pardus-browser/src/core/BrowserInstance.ts index 10d2329..494236c 100644 --- a/ai-agent/pardus-browser/src/core/BrowserInstance.ts +++ b/ai-agent/pardus-browser/src/core/BrowserInstance.ts @@ -39,7 +39,12 @@ export class BrowserInstance extends EventEmitter { private pendingRequests = new Map void; reject: (reason: Error) => void }>(); private requestTimeout = 30000; // 30 second default timeout private navigateTimeout = 60000; // 60 seconds for navigation - + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectBaseDelay = 500; // ms + private isReconnecting = false; + private intentionallyClosed = false; + public readonly id: string; public readonly port: number; public currentUrl?: string; @@ -147,10 +152,88 @@ export class BrowserInstance extends EventEmitter { this.ws.on('close', () => { this.connected = false; this.emit('disconnected'); + this.attemptReconnect(); }); }); } + /** + * Attempt to reconnect the WebSocket with exponential backoff. + * Only reconnects if the browser process is still alive and we weren't + * intentionally closed. + */ + private async attemptReconnect(): Promise { + if (this.intentionallyClosed || this.isReconnecting) return; + + // Don't reconnect if the process is dead + if (!this.process || this.process.exitCode !== null) return; + + this.isReconnecting = true; + + while (this.reconnectAttempts < this.maxReconnectAttempts && !this.intentionallyClosed) { + this.reconnectAttempts++; + const delay = Math.min( + this.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts - 1), + 15000 // max 15s + ); + + console.log(`[Reconnect] Instance ${this.id}: attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`); + + await this.sleep(delay); + + // Check again after sleep — process might have died or we were closed + if (this.intentionallyClosed || !this.process || this.process.exitCode !== null) break; + + try { + await this.connectWebSocket(); + this.reconnectAttempts = 0; + this.isReconnecting = false; + this.emit('reconnected'); + console.log(`[Reconnect] Instance ${this.id}: reconnected`); + return; + } catch { + // Connection failed, retry + } + } + + this.isReconnecting = false; + this.emit('reconnect_failed'); + console.log(`[Reconnect] Instance ${this.id}: all attempts exhausted`); + } + + /** + * Wait for the DOM to settle after a user interaction (click, submit, etc.) + * Polls document.readyState and DOM size until stable, with a minimum wait. + */ + private async waitForDomSettle(minWaitMs = 100, maxWaitMs = 3000, pollIntervalMs = 100): Promise { + await this.sleep(minWaitMs); + + const deadline = Date.now() + maxWaitMs; + let lastNodeCount = -1; + let stableCount = 0; + + while (Date.now() < deadline) { + const check = await this.sendCommand( + 'Runtime.evaluate', + { expression: 'document.readyState + "|" + document.querySelectorAll("*").length', returnByValue: true } + ) as { result?: { value?: string } }; + + const parts = (check.result?.value ?? '').split('|'); + const readyState = parts[0]; + const nodeCount = parseInt(parts[1] ?? '0', 10); + + if (readyState === 'complete' && nodeCount === lastNodeCount) { + stableCount++; + if (stableCount >= 2) return; // DOM stable for 2 consecutive polls + } else { + stableCount = 0; + } + + lastNodeCount = nodeCount; + await this.sleep(pollIntervalMs); + } + } + private sendCommand(method: string, params?: Record, timeout?: number): Promise { return new Promise((resolve, reject) => { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { @@ -266,7 +349,7 @@ export class BrowserInstance extends EventEmitter { }; } - await this.sleep(500); + await this.waitForDomSettle(); const pageInfo = await this.sendCommand('Page.getNavigationHistory', {}) as { currentIndex: number; @@ -366,7 +449,7 @@ export class BrowserInstance extends EventEmitter { }; } - await this.sleep(1000); + await this.waitForDomSettle(200, 5000); const pageInfo = await this.sendCommand('Page.getNavigationHistory', {}) as { currentIndex: number; @@ -407,8 +490,20 @@ export class BrowserInstance extends EventEmitter { }[direction]; await this.sendCommand('Runtime.evaluate', { expression: scrollScript }); - - return { success: true }; + + // Wait briefly for any lazy-loaded content to start loading + await this.sleep(300); + + // Fetch the updated semantic tree + const treeResult = await this.sendCommand( + 'Runtime.evaluate', + { expression: 'document.semanticTree || document.body.innerText' } + ) as { result?: { value?: string } }; + + return { + success: true, + markdown: treeResult.result?.value ?? '', + }; } catch (error) { return { success: false, @@ -722,6 +817,7 @@ export class BrowserInstance extends EventEmitter { } kill(): void { + this.intentionallyClosed = true; if (this.ws) { this.ws.close(); this.ws = null; diff --git a/ai-agent/pardus-browser/src/core/types.ts b/ai-agent/pardus-browser/src/core/types.ts index 73506ac..fa4c320 100644 --- a/ai-agent/pardus-browser/src/core/types.ts +++ b/ai-agent/pardus-browser/src/core/types.ts @@ -59,6 +59,8 @@ export interface BrowserSubmitResult { export interface BrowserScrollResult { success: boolean; + /** Updated semantic tree after scrolling */ + markdown?: string; error?: string; } diff --git a/ai-agent/pardus-browser/src/llm/client.ts b/ai-agent/pardus-browser/src/llm/client.ts index f658d14..4db8d11 100644 --- a/ai-agent/pardus-browser/src/llm/client.ts +++ b/ai-agent/pardus-browser/src/llm/client.ts @@ -85,9 +85,18 @@ export class LLMClient { } /** - * Stream a conversation (for interactive CLI) + * Stream a conversation (for interactive CLI). + * Buffers tool call chunks and returns the full result including tool calls. */ - async *streamChat(messages: Message[]): AsyncGenerator { + async streamChat(messages: Message[]): Promise<{ + content: string | null; + toolCalls?: Array<{ + id: string; + name: string; + arguments: Record; + }>; + textChunks: string[]; + }> { const stream = await this.client.chat.completions.create({ model: this.config.model, messages: messages as OpenAI.Chat.ChatCompletionMessageParam[], @@ -98,12 +107,60 @@ export class LLMClient { stream: true, }); + const textChunks: string[] = []; + let fullContent = ''; + + // Accumulate tool call fragments from stream chunks + const toolCallAccum = new Map(); + for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta?.content; - if (delta) { - yield delta; + const choice = chunk.choices[0]; + if (!choice) continue; + + const delta = choice.delta; + + // Text content + if (delta?.content) { + textChunks.push(delta.content); + fullContent += delta.content; + } + + // Tool call deltas — accumulate by index + if (delta?.tool_calls) { + for (const tc of delta.tool_calls) { + const idx = tc.index ?? 0; + if (!toolCallAccum.has(idx)) { + toolCallAccum.set(idx, { + id: tc.id ?? '', + name: tc.function?.name ?? '', + argsStr: '', + }); + } + const entry = toolCallAccum.get(idx)!; + if (tc.id) entry.id = tc.id; + if (tc.function?.name) entry.name = tc.function.name; + if (tc.function?.arguments) entry.argsStr += tc.function.arguments; + } } } + + // Assemble tool calls + const toolCalls: Array<{ id: string; name: string; arguments: Record }> = []; + for (const [_, tc] of toolCallAccum) { + let parsedArgs: Record = {}; + try { + parsedArgs = JSON.parse(tc.argsStr) as Record; + } catch { + parsedArgs = {}; + } + toolCalls.push({ id: tc.id, name: tc.name, arguments: parsedArgs }); + } + + return { + content: fullContent || null, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + textChunks, + }; } getModel(): string { diff --git a/ai-agent/pardus-browser/src/llm/context.ts b/ai-agent/pardus-browser/src/llm/context.ts new file mode 100644 index 0000000..e7593e7 --- /dev/null +++ b/ai-agent/pardus-browser/src/llm/context.ts @@ -0,0 +1,167 @@ +import { Message } from './client.js'; +import { CORE_PROMPT } from './prompts.js'; + +export interface ContextConfig { + /** Max approximate tokens before compaction triggers (default: 100000) */ + maxTokens: number; + /** Number of recent messages to always keep in full (default: 10) */ + keepRecentMessages: number; + /** Max chars for a tool result before truncation (default: 6000) */ + maxToolResultChars: number; + /** Characters per token for approximation (default: 4) */ + charsPerToken: number; +} + +const DEFAULT_CONTEXT_CONFIG: ContextConfig = { + maxTokens: 100_000, + keepRecentMessages: 10, + maxToolResultChars: 6000, + charsPerToken: 4, +}; + +/** + * Approximate token count for a string. + * Uses character-based heuristic; good enough for budgeting. + */ +function estimateTokens(text: string, charsPerToken: number): number { + return Math.ceil(text.length / charsPerToken); +} + +/** + * Truncate a string to a maximum character length, appending a marker. + */ +function truncate(str: string, maxChars: number): string { + if (str.length <= maxChars) return str; + const half = Math.floor((maxChars - 40) / 2); + return str.slice(0, half) + + '\n\n... [truncated, use browser_get_state for full page content] ...\n\n' + + str.slice(-half); +} + +/** + * Summarize a tool result message for compaction. + * Keeps the essential info (which tool, success/fail) but drops the large content. + */ +function summarizeToolMessage(msg: Message): string { + if (msg.role !== 'tool' || !msg.content) return msg.content ?? ''; + + // Keep the first ~300 chars (usually the header/metadata), drop the full page tree + const content = msg.content; + if (content.length <= 500) return content; + + // Find the "---" separator that typically precedes "## Page Content" + const separatorIdx = content.indexOf('\n---\n'); + if (separatorIdx !== -1) { + // Keep everything before the separator (metadata/stats) plus a note + return content.slice(0, separatorIdx) + + '\n\n[Page content omitted to save context. Use browser_get_state to retrieve it.]'; + } + + // Fallback: keep first 300 chars + return content.slice(0, 300) + + '\n\n[Content truncated for context management.]'; +} + +/** + * Estimate the total token count of a message array. + */ +export function estimateMessageTokens(messages: Message[], charsPerToken: number = 4): number { + let total = 0; + for (const msg of messages) { + // Overhead per message (role, metadata) ~4 tokens + total += 4; + if (msg.content) total += estimateTokens(msg.content, charsPerToken); + if (msg.tool_calls) { + for (const tc of msg.tool_calls) { + total += estimateTokens(tc.function.name + tc.function.arguments, charsPerToken); + total += 4; // overhead per tool call + } + } + } + return total; +} + +/** + * Compact a message array to fit within a token budget. + * + * Strategy: + * 1. Always keep the system prompt (messages[0]) + * 2. Always keep the N most recent messages in full + * 3. For older messages: + * - user/assistant messages: keep but truncate if very long + * - tool messages: summarize (drop page content, keep metadata) + * 4. If still over budget, remove oldest non-system messages + */ +export function compactMessages( + messages: Message[], + config: Partial = {} +): Message[] { + const cfg = { ...DEFAULT_CONTEXT_CONFIG, ...config }; + + if (messages.length <= 1) return messages; + + // Step 1: Truncate large tool results in-place + const truncated = messages.map(msg => { + if (msg.role === 'tool' && msg.content && msg.content.length > cfg.maxToolResultChars) { + return { ...msg, content: truncate(msg.content, cfg.maxToolResultChars) }; + } + return msg; + }); + + // Check if we're within budget after truncation + const currentTokens = estimateMessageTokens(truncated, cfg.charsPerToken); + if (currentTokens <= cfg.maxTokens) { + return truncated; + } + + // Step 2: Need compaction — downgrade system prompt to core + summarize older tool messages + const systemMsg = { ...truncated[0], content: CORE_PROMPT }; + const recentStart = Math.max(1, truncated.length - cfg.keepRecentMessages); + const olderMessages = truncated.slice(1, recentStart); + const recentMessages = truncated.slice(recentStart); + + const summarized = olderMessages.map(msg => { + if (msg.role === 'tool') { + return { ...msg, content: summarizeToolMessage(msg) }; + } + // For very long user/assistant messages, truncate + if (msg.content && msg.content.length > cfg.maxToolResultChars) { + return { ...msg, content: truncate(msg.content, cfg.maxToolResultChars) }; + } + return msg; + }); + + const result = [systemMsg, ...summarized, ...recentMessages]; + + // Step 3: If STILL over budget, drop oldest messages (keep system + recent) + let finalTokens = estimateMessageTokens(result, cfg.charsPerToken); + if (finalTokens > cfg.maxTokens) { + // Drop from the summarized section until we fit + let dropFrom = 1; // start after system message + while (dropFrom < result.length - cfg.keepRecentMessages && finalTokens > cfg.maxTokens) { + const dropped = result.splice(dropFrom, 1)[0]; + finalTokens -= estimateTokens( + (dropped.content ?? '') + (dropped.tool_calls?.map(tc => tc.function.arguments).join('') ?? ''), + cfg.charsPerToken + ); + } + + // Add a note that context was compacted + if (result.length > 1 && result[1].role !== 'system') { + result.splice(1, 0, { + role: 'user', + content: '[System note: Earlier conversation history was compacted to fit the context window.]', + }); + } + } + + return result; +} + +/** + * Truncate a tool result string to the configured max length. + * Use this before returning tool results to the agent. + */ +export function truncateToolResult(content: string, maxChars: number = 6000): string { + return truncate(content, maxChars); +} diff --git a/ai-agent/pardus-browser/src/llm/index.ts b/ai-agent/pardus-browser/src/llm/index.ts index 4efeaf0..9d2e684 100644 --- a/ai-agent/pardus-browser/src/llm/index.ts +++ b/ai-agent/pardus-browser/src/llm/index.ts @@ -1,2 +1,3 @@ export { LLMClient, LLMConfig, Message } from './client.js'; export { SYSTEM_PROMPT, getSystemPrompt } from './prompts.js'; +export { compactMessages, estimateMessageTokens, truncateToolResult, ContextConfig } from './context.js'; diff --git a/ai-agent/pardus-browser/src/llm/prompts.ts b/ai-agent/pardus-browser/src/llm/prompts.ts index 0ec0d01..346189d 100644 --- a/ai-agent/pardus-browser/src/llm/prompts.ts +++ b/ai-agent/pardus-browser/src/llm/prompts.ts @@ -1,106 +1,73 @@ /** - * System prompt for the browsing agent + * System prompts for the browsing agent. + * + * Two tiers: a compact core prompt (~500 tokens) used after compaction, + * and a full prompt (~1200 tokens) used for the first few rounds. */ -export const SYSTEM_PROMPT = `You are a web browsing assistant powered by pardus-browser, a headless browser designed for AI agents. +/** Core prompt — always present, minimal token cost */ +export const CORE_PROMPT = `You are a web browsing assistant powered by pardus-browser, a headless browser for AI agents. + +## Semantic Tree Format +Pages are returned as semantic trees with element IDs in brackets: +- [#N Link] "text" → url — click with browser_click("#N") +- [#N TextBox] label (placeholder: "...") — fill with browser_fill("#N", "value") +- [#N Button] label — click with browser_click("#N") +- Forms: fill all fields then browser_submit() + +## Workflow +1. browser_new() → create instance +2. browser_navigate(url) → load page, get semantic tree +3. Interact: browser_click / browser_fill / browser_submit +4. browser_close() when done + +## Key Rules +- Element IDs change after every navigation — always re-read the tree +- Use browser_wait(condition) for dynamic/SPA pages instead of guessing wait_ms +- Use browser_auto_fill for multi-field forms +- Use browser_get_action_plan when unsure what to do next +- Scroll with browser_scroll(direction) to see more content — scroll returns the updated tree + +Tools (19): browser_new, browser_navigate, browser_click, browser_fill, browser_submit, browser_scroll, browser_close, browser_list, browser_get_state, browser_get_action_plan, browser_auto_fill, browser_wait, browser_get/cookies, browser_set/delete_cookie, browser_get/set/delete/clear_storage.`; + +/** Extended prompt — used for the first few rounds, then compacted */ +export const EXTENDED_PROMPT = ` +## Smart Tools + +### browser_wait — prefer over wait_ms +- **contentLoaded** — no spinners/skeletons + substantial content (best for SPAs) +- **contentStable** — DOM stops changing across polls +- **networkIdle** — longer wait for lazy-loaded images/API data +- **minInteractive** — wait until N interactive elements appear +- **selector** — wait until a CSS selector appears -## How Browser Instances Work - -Each browser instance is an isolated session with its own: -- Cookies and localStorage -- Navigation history (back/forward) -- Page state - -When a user asks you to browse the web: -1. First call browser_new() to create an instance (or ask which existing instance to use) -2. Use browser_navigate() to go to a URL -3. Read the semantic tree to understand the page structure -4. Use browser_click(), browser_fill(), browser_submit() to interact -5. Call browser_close() when done (or keep open for follow-ups) - -## Understanding the Semantic Tree - -The semantic tree shows page structure in Markdown format: - -\`\`\` -[Document] Example Domain - [Heading] Example Domain - [#1 Link] More information → https://iana.org - [#2 TextBox] Search (placeholder: "Type here...") - [#3 Button] Submit -\`\`\` - -Key points: -- **Element IDs** like [#1], [#2] are unique identifiers for interactive elements -- **Links** can be clicked: browser_click("#1") -- **TextBoxes** can be filled: browser_fill("#2", "search query") -- **Buttons/Forms** can submit: browser_submit() or browser_click("#3") - -## Best Practices - -1. **Always check the semantic tree** before interacting - element IDs change after navigation -2. **Use interactive_only: true** for complex pages to reduce noise -3. **Scroll if needed** - use browser_scroll("down") to see more content -4. **Wait for JS** - use wait_ms: 5000 for SPAs (React, Vue, etc.) -5. **Handle forms properly** - fill all required fields before submitting - -## Example Flow - -User: "Find the price of an iPhone 15 on apple.com" +### browser_get_action_plan +Returns page type classification (Login, Search, Form, Listing, etc.), suggested actions with confidence scores, and form/pagination detection. Use when unsure what to do next. -1. browser_new() → Get instance_id: "browser_abc123" -2. browser_navigate({"instance_id": "browser_abc123", "url": "https://apple.com", "wait_ms": 3000}) -3. → See semantic tree, find [#5 Link] iPhone -4. browser_click({"instance_id": "browser_abc123", "element_id": "#5"}) -5. → Page updates, find [#12 Link] iPhone 15 -6. browser_click({"instance_id": "browser_abc123", "element_id": "#12"}) -7. → Extract price from semantic tree -8. browser_close({"instance_id": "browser_abc123"}) +### browser_auto_fill +Fill multiple fields at once with smart matching (by name, label, placeholder, type). Returns matched and unmatched fields. Prefer over individual browser_fill calls for multi-field forms. -## Tips for Success +### Cookie & Storage +- browser_get_cookies / browser_set_cookie / browser_delete_cookie +- browser_get_storage / browser_set_storage / browser_delete_storage / browser_clear_storage -- If a click doesn't navigate, the element might need JavaScript - try with wait_ms +## Tips +- If a click doesn't navigate, try with wait_ms or browser_wait - If you can't find an element, scroll down first -- For search forms: fill the input, then submit (or click the search button) -- For login forms: fill username, fill password, then submit -- Always respect robots.txt and terms of service - -You have access to 19 browser tools: browser_new, browser_navigate, browser_click, browser_fill, browser_submit, browser_scroll, browser_close, browser_list, browser_get_state, browser_get_action_plan, browser_auto_fill, browser_wait, browser_get_cookies, browser_set_cookie, browser_delete_cookie, browser_get_storage, browser_set_storage, browser_delete_storage, browser_clear_storage. - -## Advanced Tools - -### browser_wait -Smart wait conditions that detect when a page is truly ready, instead of guessing with wait_ms: -- **contentLoaded** — waits until no loading spinners/skeletons remain and substantial content is present (best for most SPA pages) -- **contentStable** — waits until the DOM stops changing across polls (progressive-render SPAs) -- **networkIdle** — longer stable wait for lazy-loaded images/API data -- **minInteractive** — waits until N interactive elements appear (useful for dynamically loaded forms/buttons) -- **selector** — waits until a specific CSS selector appears -Use browser_wait({"instance_id": "...", "condition": "contentLoaded"}) after navigating to any SPA or dynamic page instead of wait_ms. - -### browser_get_action_plan -After navigating to a page, use this to get an AI-optimized analysis: -- **Page type classification**: Login, Search, Form, Listing, Content, Navigation -- **Suggested actions** with confidence scores (e.g., "Click Submit (95%): form is complete") -- **Form detection**: Whether the page has forms and pagination -Use this when you are unsure what to do next on a page, or when you want to verify you haven't missed any interactive elements. - -### browser_auto_fill -Efficiently fill multiple form fields at once with smart matching: -- Matches by field name, label text, placeholder, or input type -- Returns which fields were filled and which were unmatched (helpful for required fields you missed) -- Use instead of individual browser_fill() calls when a page has many form fields (e.g., login, registration, checkout) +- For login: fill username, fill password, then submit +- Respect robots.txt and terms of service`; -### Cookie & Storage Tools -- browser_get_cookies / browser_set_cookie / browser_delete_cookie — manage cookies for the current page -- browser_get_storage / browser_set_storage / browser_delete_storage / browser_clear_storage — manage localStorage and sessionStorage`; +/** Full system prompt (core + extended) */ +export const SYSTEM_PROMPT = CORE_PROMPT + EXTENDED_PROMPT; /** - * Get system prompt with optional custom instructions + * Get system prompt with optional custom instructions. + * @param compact If true, return only the core prompt (saves ~700 tokens) */ -export function getSystemPrompt(customInstructions?: string): string { +export function getSystemPrompt(customInstructions?: string, compact?: boolean): string { + const base = compact ? CORE_PROMPT : SYSTEM_PROMPT; if (customInstructions) { - return `${SYSTEM_PROMPT}\n\n## Additional Instructions\n\n${customInstructions}`; + return `${base}\n\n## Additional Instructions\n\n${customInstructions}`; } - return SYSTEM_PROMPT; + return base; } diff --git a/ai-agent/pardus-browser/src/tools/executor.ts b/ai-agent/pardus-browser/src/tools/executor.ts index 3191f5d..1e9dbb8 100644 --- a/ai-agent/pardus-browser/src/tools/executor.ts +++ b/ai-agent/pardus-browser/src/tools/executor.ts @@ -66,9 +66,9 @@ export class ToolExecutor { ): Promise { const startTime = Date.now(); const mergedConfig = { ...DEFAULT_RETRY_CONFIG, ...config }; - + let lastError: Error | undefined; - + for (let attempt = 1; attempt <= mergedConfig.retries + 1; attempt++) { try { const result = await this.executeToolWithTimeout( @@ -76,30 +76,30 @@ export class ToolExecutor { args as ToolCallArgs, mergedConfig.timeout ); - + return result; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); - + const isRetryable = mergedConfig.retryableErrors.some( - errPattern => lastError!.message.includes(errPattern) || + errPattern => lastError!.message.includes(errPattern) || lastError!.constructor.name.includes(errPattern) ); - + if (attempt >= mergedConfig.retries + 1 || !isRetryable) { break; } - + const delay = Math.min( mergedConfig.retryDelay * Math.pow(mergedConfig.retryBackoff, attempt - 1), mergedConfig.maxRetryDelay ); - + console.log(`[Retry] ${name} attempt ${attempt + 1}/${mergedConfig.retries + 1} after ${delay}ms`); await this.sleep(delay); } } - + return { success: false, content: '', @@ -131,6 +131,7 @@ export class ToolExecutor { */ async executeTools( tools: Array<{ + toolCallId: string; name: BrowserToolName; args: Record; config?: ToolExecutionConfig; @@ -145,6 +146,7 @@ export class ToolExecutor { if (!options?.parallel) { for (const tool of tools) { const result = await this.executeToolWithTracking( + tool.toolCallId, tool.name, tool.args, tool.config @@ -162,6 +164,7 @@ export class ToolExecutor { const groupResults = await Promise.all( group.tools.map(tool => this.executeToolWithTracking( + tool.toolCallId!, tool.name as BrowserToolName, tool.args, tool.config @@ -179,6 +182,7 @@ export class ToolExecutor { const retryResults = await Promise.all( group.tools.map(tool => this.executeToolWithTracking( + tool.toolCallId!, tool.name as BrowserToolName, tool.args, { ...tool.config, retries: (tool.config?.retries ?? 0) + 1 } @@ -207,17 +211,18 @@ export class ToolExecutor { * Execute a single tool and track execution details */ private async executeToolWithTracking( + toolCallId: string, name: BrowserToolName, args: Record, config?: ToolExecutionConfig ): Promise { const startTime = Date.now(); const mergedConfig = { ...DEFAULT_RETRY_CONFIG, ...config }; - + let lastError: string | undefined; let lastContent: string | undefined; let attempts = 0; - + for (let attempt = 1; attempt <= mergedConfig.retries + 1; attempt++) { attempts = attempt; try { @@ -226,8 +231,9 @@ export class ToolExecutor { args as ToolCallArgs, mergedConfig.timeout ); - + return { + toolCallId, name, args, success: result.success, @@ -239,25 +245,26 @@ export class ToolExecutor { } catch (error) { lastError = error instanceof Error ? error.message : String(error); lastContent = ''; - + const isRetryable = mergedConfig.retryableErrors.some( errPattern => lastError!.includes(errPattern) ); - + if (attempt >= mergedConfig.retries + 1 || !isRetryable) { break; } - + const delay = Math.min( mergedConfig.retryDelay * Math.pow(mergedConfig.retryBackoff, attempt - 1), mergedConfig.maxRetryDelay ); - + await this.sleep(delay); } } - + return { + toolCallId, name, args, success: false, @@ -564,10 +571,13 @@ export class ToolExecutor { }; } - return { - success: true, - content: `Scrolled ${args.direction}`, - }; + let content = `## Scroll Result\n\n- **Direction**: ${args.direction}\n`; + + if (result.markdown) { + content += `\n---\n\n## Page Content\n\n${result.markdown}`; + } + + return { success: true, content }; } catch (error) { return { success: false, diff --git a/ai-agent/pardus-browser/src/tools/types.ts b/ai-agent/pardus-browser/src/tools/types.ts index 3782bf9..1cf4b9c 100644 --- a/ai-agent/pardus-browser/src/tools/types.ts +++ b/ai-agent/pardus-browser/src/tools/types.ts @@ -20,6 +20,7 @@ export interface ToolExecutionConfig { export interface ParallelToolGroup { /** Tools that can be executed in parallel */ tools: Array<{ + toolCallId?: string; name: string; args: Record; config?: ToolExecutionConfig; @@ -29,6 +30,8 @@ export interface ParallelToolGroup { } export interface ToolExecutionResult { + /** The original LLM tool call ID, used to correlate results back */ + toolCallId: string; name: string; args: Record; success: boolean; @@ -88,7 +91,7 @@ export function canExecuteInParallel( * Each group contains tools that can safely execute in parallel. */ export function groupToolsForParallelExecution( - tools: Array<{ name: string; args: Record; config?: ToolExecutionConfig }> + tools: Array<{ toolCallId?: string; name: string; args: Record; config?: ToolExecutionConfig }> ): ParallelToolGroup[] { const groups: ParallelToolGroup[] = []; let currentGroup: ParallelToolGroup = { tools: [], failureStrategy: 'continue' }; diff --git a/crates/pardus-core/src/js/bootstrap.js b/crates/pardus-core/src/js/bootstrap.js index c0e40cb..c8cd4a0 100644 --- a/crates/pardus-core/src/js/bootstrap.js +++ b/crates/pardus-core/src/js/bootstrap.js @@ -467,36 +467,85 @@ class Element { // ==================== MutationObserver ==================== +// Global registry: observer_id -> MutationObserver instance +const _observerInstances = new Map(); + +function _deliverPendingMutations() { + if (typeof Deno.core.ops.op_has_observers === 'function' && !Deno.core.ops.op_has_observers()) return; + var grouped = Deno.core.ops.op_drain_pending_mutations(); + for (var i = 0; i < grouped.length; i++) { + var obsId = grouped[i][0]; + var records = grouped[i][1]; + var observer = _observerInstances.get(obsId); + if (!observer) continue; + var mappedRecords = []; + for (var j = 0; j < records.length; j++) { + var r = records[j]; + mappedRecords.push({ + type: r.type_, + target: r.target ? new Element(r.target) : null, + addedNodes: (r.added_nodes || []).map(function(id) { return new Element(id); }), + removedNodes: (r.removed_nodes || []).map(function(id) { return new Element(id); }), + attributeName: r.attribute_name || null, + oldValue: r.old_value || null, + }); + } + try { + observer.__callback.call(observer, mappedRecords, observer); + } catch (e) { + // Ignore errors in observer callbacks + } + } +} + class MutationObserver { constructor(callback) { this.__callback = callback; - this.__id = Deno.core.ops.op_register_observer(0, true, true, false); + this.__id = 0; // Assigned on observe() } - observe(target, options = {}) { - if (target && target.__nodeId) { - this.__id = Deno.core.ops.op_register_observer( - target.__nodeId, - options.childList || false, - options.attributes !== false, - options.subtree || false - ); + observe(target, options) { + if (!target || !target.__nodeId) return; + options = options || {}; + + // Disconnect old registration if re-observing + if (this.__id > 0) { + Deno.core.ops.op_disconnect_observer(this.__id); + _observerInstances.delete(this.__id); } + + this.__id = Deno.core.ops.op_register_observer( + target.__nodeId, + !!options.childList, + options.attributes !== false, + !!options.subtree, + !!options.characterData, + !!options.attributeOldValue, + !!options.characterDataOldValue, + options.attributeFilter || [] + ); + _observerInstances.set(this.__id, this); } disconnect() { - Deno.core.ops.op_disconnect_observer(this.__id); + if (this.__id > 0) { + Deno.core.ops.op_disconnect_observer(this.__id); + _observerInstances.delete(this.__id); + this.__id = 0; + } } takeRecords() { - return Deno.core.ops.op_take_mutation_records().map(r => ({ - type: r.type_, - target: r.target ? new Element(r.target) : null, - addedNodes: (r.added_nodes || []).map(id => new Element(id)), - removedNodes: (r.removed_nodes || []).map(id => new Element(id)), - attributeName: r.attribute_name || null, - oldValue: r.old_value || null, - })); + return Deno.core.ops.op_take_mutation_records().map(function(r) { + return { + type: r.type_, + target: r.target ? new Element(r.target) : null, + addedNodes: (r.added_nodes || []).map(function(id) { return new Element(id); }), + removedNodes: (r.removed_nodes || []).map(function(id) { return new Element(id); }), + attributeName: r.attribute_name || null, + oldValue: r.old_value || null, + }; + }); } } @@ -623,6 +672,87 @@ const document = { } }; +// ==================== document.cookie ==================== + +Object.defineProperty(document, 'cookie', { + get: function() { + return Deno.core.ops.op_get_document_cookie(globalThis.__pardusOrigin || ''); + }, + set: function(v) { + Deno.core.ops.op_set_document_cookie(globalThis.__pardusOrigin || '', String(v)); + }, + enumerable: true, + configurable: true, +}); + +// ==================== Storage (localStorage / sessionStorage) ==================== + +function _createStorage(type) { + var _ops = { + get: type === 'local' ? Deno.core.ops.op_local_storage_get : Deno.core.ops.op_session_storage_get, + set: type === 'local' ? Deno.core.ops.op_local_storage_set : Deno.core.ops.op_session_storage_set, + remove: type === 'local' ? Deno.core.ops.op_local_storage_remove : Deno.core.ops.op_session_storage_remove, + clear: type === 'local' ? Deno.core.ops.op_local_storage_clear : Deno.core.ops.op_session_storage_clear, + keys: type === 'local' ? Deno.core.ops.op_local_storage_keys : Deno.core.ops.op_session_storage_keys, + length: type === 'local' ? Deno.core.ops.op_local_storage_length : Deno.core.ops.op_session_storage_length, + }; + + var handler = { + get: function(target, prop) { + var origin = globalThis.__pardusOrigin || ''; + if (prop === 'getItem') return function(key) { return _ops.get(origin, key) || null; }; + if (prop === 'setItem') return function(key, value) { _ops.set(origin, key, String(value)); }; + if (prop === 'removeItem') return function(key) { _ops.remove(origin, key); }; + if (prop === 'clear') return function() { _ops.clear(origin); }; + if (prop === 'key') return function(index) { + var k = _ops.keys(origin); + return index >= 0 && index < k.length ? k[index] : null; + }; + if (prop === 'length') return _ops.length(origin); + // Bracket access: storage['key'] + if (typeof prop === 'string') return _ops.get(origin, prop) || null; + return undefined; + }, + set: function(target, prop, value) { + var origin = globalThis.__pardusOrigin || ''; + if (typeof prop === 'string') { + if (value === null || value === undefined) { + _ops.remove(origin, prop); + } else { + _ops.set(origin, prop, String(value)); + } + } + return true; + }, + has: function(target, prop) { + var origin = globalThis.__pardusOrigin || ''; + return _ops.get(origin, prop) !== null; + }, + deleteProperty: function(target, prop) { + var origin = globalThis.__pardusOrigin || ''; + _ops.remove(origin, prop); + return true; + }, + ownKeys: function() { + var origin = globalThis.__pardusOrigin || ''; + return _ops.keys(origin); + }, + getOwnPropertyDescriptor: function(target, prop) { + var origin = globalThis.__pardusOrigin || ''; + var val = _ops.get(origin, prop); + if (val !== null) { + return { value: val, writable: true, enumerable: true, configurable: true }; + } + return undefined; + } + }; + + return new Proxy({}, handler); +} + +var localStorage = _createStorage('local'); +var sessionStorage = _createStorage('session'); + // ==================== Fetch polyfill ==================== async function fetch(input, init) { @@ -660,6 +790,8 @@ async function fetch(input, init) { const window = { document, fetch, + localStorage: localStorage, + sessionStorage: sessionStorage, addEventListener: document.addEventListener.bind(document), removeEventListener: document.removeEventListener.bind(document), location: new Proxy({ @@ -932,6 +1064,8 @@ class EventSource { globalThis.window = window; globalThis.document = document; globalThis.fetch = fetch; +globalThis.localStorage = localStorage; +globalThis.sessionStorage = sessionStorage; globalThis.Element = Element; globalThis.TextNode = TextNode; globalThis.DocumentFragment = DocumentFragment; diff --git a/crates/pardus-core/src/js/bootstrap_readonly.js b/crates/pardus-core/src/js/bootstrap_readonly.js index ce98afb..6d02434 100644 --- a/crates/pardus-core/src/js/bootstrap_readonly.js +++ b/crates/pardus-core/src/js/bootstrap_readonly.js @@ -199,11 +199,28 @@ class MutationObserver { takeRecords() { return []; } } +// ==================== Storage (Read-Only) ==================== + +var _noopStorage = new Proxy({}, { + get: function(_, prop) { + if (prop === 'getItem') return function() { return null; }; + if (prop === 'setItem') return function() {}; + if (prop === 'removeItem') return function() {}; + if (prop === 'clear') return function() {}; + if (prop === 'key') return function() { return null; }; + if (prop === 'length') return 0; + return null; + }, + set: function() { return true; } +}); + // ==================== Window (Read-Only) ==================== const window = { document, fetch() { return Promise.reject(new Error("fetch is disabled in read-only mode")); }, + localStorage: _noopStorage, + sessionStorage: _noopStorage, addEventListener: document.addEventListener.bind(document), removeEventListener: document.removeEventListener.bind(document), location: new Proxy({ @@ -231,6 +248,8 @@ const window = { globalThis.window = window; globalThis.document = document; +globalThis.localStorage = _noopStorage; +globalThis.sessionStorage = _noopStorage; globalThis.Element = Element; globalThis.TextNode = Element; globalThis.DocumentFragment = Element; diff --git a/crates/pardus-core/src/js/extension.rs b/crates/pardus-core/src/js/extension.rs index ed0c7f1..bdb8e00 100644 --- a/crates/pardus-core/src/js/extension.rs +++ b/crates/pardus-core/src/js/extension.rs @@ -77,5 +77,22 @@ deno_core::extension!( op_sse_close, op_sse_ready_state, op_sse_url, + // Cookies + op_get_document_cookie, + op_set_document_cookie, + // localStorage + op_local_storage_get, + op_local_storage_set, + op_local_storage_remove, + op_local_storage_clear, + op_local_storage_keys, + op_local_storage_length, + // sessionStorage + op_session_storage_get, + op_session_storage_set, + op_session_storage_remove, + op_session_storage_clear, + op_session_storage_keys, + op_session_storage_length, ], ); diff --git a/crates/pardus-core/src/js/ops.rs b/crates/pardus-core/src/js/ops.rs index 79d1fdf..edc6469 100644 --- a/crates/pardus-core/src/js/ops.rs +++ b/crates/pardus-core/src/js/ops.rs @@ -3,9 +3,12 @@ //! These ops provide the bridge between JavaScript and our Rust DOM implementation. use super::dom::DomDocument; +use super::runtime::SessionStorageMap; +use crate::session::SessionStore; use deno_core::*; use std::cell::RefCell; use std::rc::Rc; +use std::sync::Arc; // ==================== Document Methods ==================== @@ -395,3 +398,158 @@ pub fn op_redo(state: &mut OpState) -> bool { let dom = state.borrow::>>().clone(); dom.borrow_mut().redo() } + +// ==================== Cookie Ops ==================== + +#[op2] +#[string] +pub fn op_get_document_cookie(state: &mut OpState, #[string] origin: &str) -> String { + let session = match state.try_borrow::>() { + Some(s) => s.clone(), + None => return String::new(), + }; + let url = match url::Url::parse(origin) { + Ok(u) => u, + Err(_) => return String::new(), + }; + match session.cookies(&url) { + Some(hv) => hv.to_str().unwrap_or("").to_string(), + None => String::new(), + } +} + +#[op2(fast)] +pub fn op_set_document_cookie(state: &mut OpState, #[string] origin: &str, #[string] cookie_str: &str) { + let session = match state.try_borrow::>() { + Some(s) => s.clone(), + None => return, + }; + let url = match url::Url::parse(origin) { + Ok(u) => u, + Err(_) => return, + }; + if cookie_str.trim().is_empty() { + return; + } + if let Ok(header) = rquest::header::HeaderValue::from_str(cookie_str) { + let mut iter = std::iter::once(&header); + session.set_cookies(&mut iter, &url); + } +} + +// ==================== localStorage Ops ==================== + +#[op2] +#[string] +pub fn op_local_storage_get( + state: &mut OpState, + #[string] origin: &str, + #[string] key: &str, +) -> Option { + let session = state.try_borrow::>()?; + session.local_storage_get(origin, key) +} + +#[op2(fast)] +pub fn op_local_storage_set( + state: &mut OpState, + #[string] origin: &str, + #[string] key: &str, + #[string] value: &str, +) { + if let Some(session) = state.try_borrow::>() { + session.local_storage_set(origin, key, value); + } +} + +#[op2(fast)] +pub fn op_local_storage_remove(state: &mut OpState, #[string] origin: &str, #[string] key: &str) { + if let Some(session) = state.try_borrow::>() { + session.local_storage_remove(origin, key); + } +} + +#[op2(fast)] +pub fn op_local_storage_clear(state: &mut OpState, #[string] origin: &str) { + if let Some(session) = state.try_borrow::>() { + session.local_storage_clear(origin); + } +} + +#[op2] +#[serde] +pub fn op_local_storage_keys(state: &mut OpState, #[string] origin: &str) -> Vec { + match state.try_borrow::>() { + Some(session) => session.local_storage_keys(origin), + None => Vec::new(), + } +} + +#[op2(fast)] +pub fn op_local_storage_length(state: &mut OpState, #[string] origin: &str) -> u32 { + match state.try_borrow::>() { + Some(session) => session.local_storage_keys(origin).len() as u32, + None => 0, + } +} + +// ==================== sessionStorage Ops ==================== + +#[op2] +#[string] +pub fn op_session_storage_get( + state: &mut OpState, + #[string] origin: &str, + #[string] key: &str, +) -> Option { + let storage = state.borrow::(); + storage.get(origin).and_then(|m| m.get(key).cloned()) +} + +#[op2(fast)] +pub fn op_session_storage_set( + state: &mut OpState, + #[string] origin: &str, + #[string] key: &str, + #[string] value: &str, +) { + let storage = state.borrow_mut::(); + storage + .entry(origin.to_string()) + .or_default() + .insert(key.to_string(), value.to_string()); +} + +#[op2(fast)] +pub fn op_session_storage_remove( + state: &mut OpState, + #[string] origin: &str, + #[string] key: &str, +) { + let storage = state.borrow_mut::(); + if let Some(m) = storage.get_mut(origin) { + m.remove(key); + } +} + +#[op2(fast)] +pub fn op_session_storage_clear(state: &mut OpState, #[string] origin: &str) { + let storage = state.borrow_mut::(); + storage.remove(origin); +} + +#[op2] +#[serde] +pub fn op_session_storage_keys(state: &mut OpState, #[string] origin: &str) -> Vec { + let storage = state.borrow::(); + storage + .get(origin) + .map(|m| m.keys().cloned().collect()) + .unwrap_or_default() +} + +#[op2(fast)] +pub fn op_session_storage_length(state: &mut OpState, #[string] origin: &str) -> u32 { + let storage = state.borrow::(); + storage.get(origin).map(|m| m.len() as u32).unwrap_or(0) +} diff --git a/crates/pardus-core/src/js/runtime.rs b/crates/pardus-core/src/js/runtime.rs index e69a25b..e48dcca 100644 --- a/crates/pardus-core/src/js/runtime.rs +++ b/crates/pardus-core/src/js/runtime.rs @@ -4,6 +4,7 @@ //! Provides a minimal `document` and `window` shim via ops that interact with the DOM. use std::cell::RefCell; +use std::collections::HashMap; use std::rc::Rc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -19,6 +20,10 @@ use super::dom::DomDocument; use super::extension::pardus_dom; use super::snapshot::get_bootstrap_snapshot; use crate::sandbox::{JsSandboxMode, SandboxPolicy}; +use crate::session::SessionStore; + +/// Per-execution in-memory sessionStorage (not persisted to disk). +pub type SessionStorageMap = HashMap>; // ==================== Configuration ==================== @@ -297,6 +302,7 @@ fn create_runtime( base_url: &Url, sandbox: &SandboxPolicy, user_agent: &str, + session: Option>, ) -> anyhow::Result { let snapshot = get_bootstrap_snapshot(&sandbox.js_mode); let mut runtime = JsRuntime::new(RuntimeOptions { @@ -319,6 +325,14 @@ fn create_runtime( blocked: sandbox.block_js_fetch, }); + // Store session store for cookie/localStorage ops + if let Some(session_store) = session { + runtime.op_state().borrow_mut().put(session_store); + } + + // Store per-execution in-memory sessionStorage + runtime.op_state().borrow_mut().put(SessionStorageMap::new()); + // Set up window.location and user agent from base_url. // Use individual property assignments (not `window.location = {...}`) // to preserve the Proxy setter from bootstrap.js that detects @@ -335,6 +349,7 @@ fn create_runtime( window.location.search = "{}"; window.location.hash = "{}"; globalThis.__pardusUserAgent = "{}"; + globalThis.__pardusOrigin = "{}"; var _docEl = document.documentElement; if (_docEl) _docEl.removeAttribute("data-pardus-navigation-href"); "#, @@ -347,6 +362,7 @@ fn create_runtime( base_url.query().unwrap_or(""), base_url.fragment().unwrap_or(""), ua_escaped, + base_url.origin().ascii_serialization(), ); runtime.execute_script("location.js", location_js)?; @@ -361,6 +377,7 @@ fn create_runtime_snapshot( base_url: &Url, sandbox: &SandboxPolicy, user_agent: &str, + session: Option>, ) -> anyhow::Result<(JsRuntime, bool)> { let snapshot = get_bootstrap_snapshot(&sandbox.js_mode); @@ -379,6 +396,14 @@ fn create_runtime_snapshot( // Store sandbox policy in op state so ops can check restrictions runtime.op_state().borrow_mut().put(sandbox.clone()); + // Store session store for cookie/localStorage ops + if let Some(session_store) = session { + runtime.op_state().borrow_mut().put(session_store); + } + + // Store per-execution in-memory sessionStorage + runtime.op_state().borrow_mut().put(SessionStorageMap::new()); + // Set up window.location and user agent from base_url. // Use individual property assignments (not `window.location = {...}`) // to preserve the Proxy setter from bootstrap.js that detects @@ -395,6 +420,7 @@ fn create_runtime_snapshot( window.location.search = "{}"; window.location.hash = "{}"; globalThis.__pardusUserAgent = "{}"; + globalThis.__pardusOrigin = "{}"; var _docEl = document.documentElement; if (_docEl) _docEl.removeAttribute("data-pardus-navigation-href"); "#, @@ -407,6 +433,7 @@ fn create_runtime_snapshot( base_url.query().unwrap_or(""), base_url.fragment().unwrap_or(""), ua_escaped, + base_url.origin().ascii_serialization(), ); runtime.execute_script("location.js", location_js)?; @@ -446,6 +473,7 @@ fn execute_scripts_with_timeout( timeout_ms: u64, sandbox: SandboxPolicy, user_agent: String, + session: Option>, ) -> Option { let lock = Arc::new(Mutex::new(ThreadResult { dom_html: None, @@ -486,7 +514,7 @@ fn execute_scripts_with_timeout( let dom = Rc::new(RefCell::new(doc)); // Create runtime (pass sandbox policy, use snapshot if available) - let (mut runtime, bootstrapped) = match create_runtime_snapshot(dom.clone(), &base, &sandbox, &user_agent) { + let (mut runtime, bootstrapped) = match create_runtime_snapshot(dom.clone(), &base, &sandbox, &user_agent, session) { Ok(r) => r, Err(e) => { *lock.lock() = ThreadResult { @@ -703,6 +731,7 @@ pub async fn execute_js( wait_ms: u32, sandbox: Option<&SandboxPolicy>, user_agent: &str, + session: Option>, ) -> anyhow::Result { let sandbox = sandbox.cloned().unwrap_or_default(); @@ -764,6 +793,7 @@ pub async fn execute_js( timeout, sandbox, user_agent.to_string(), + session, ); match result { @@ -953,14 +983,14 @@ export function hello() {} #[tokio::test] async fn test_execute_js_no_scripts() { let html = "

Hello

"; - let result = execute_js(html, "https://example.com", 100, None, "test-ua").await.unwrap(); + let result = execute_js(html, "https://example.com", 100, None, "test-ua", None).await.unwrap(); assert_eq!(result, html); } #[tokio::test] async fn test_execute_js_invalid_url() { let html = "

Hello

"; - let result = execute_js(html, "not-a-url", 100, None, "test-ua").await.unwrap(); + let result = execute_js(html, "not-a-url", 100, None, "test-ua", None).await.unwrap(); assert_eq!(result, html); } @@ -971,7 +1001,7 @@ export function hello() {} "#; - let result = execute_js(html, "https://example.com", 100, None, "test-ua").await.unwrap(); + let result = execute_js(html, "https://example.com", 100, None, "test-ua", None).await.unwrap(); assert!(result.contains("")); } @@ -1030,7 +1060,7 @@ export function hello() {} "#; - let result = execute_js(html, "https://example.com", 100, None, "test-ua").await.unwrap(); + let result = execute_js(html, "https://example.com", 100, None, "test-ua", None).await.unwrap(); assert!(result.contains("Safe")); } } diff --git a/crates/pardus-core/src/page.rs b/crates/pardus-core/src/page.rs index b3ceea1..feb25ad 100644 --- a/crates/pardus-core/src/page.rs +++ b/crates/pardus-core/src/page.rs @@ -467,7 +467,7 @@ impl Page { let sandbox = &app.config.read().sandbox; let user_agent = app.config.read().user_agent.clone(); let final_body = - crate::js::execute_js(&html_str, &base_url, wait_ms, Some(sandbox), &user_agent).await?; + crate::js::execute_js(&html_str, &base_url, wait_ms, Some(sandbox), &user_agent, Some(app.cookie_jar.clone())).await?; if let Some(nav_href) = Self::parse_js_navigation_href(&final_body) { let resolved = Url::parse(&page.url) diff --git a/crates/pardus-tauri/package.json b/crates/pardus-tauri/package.json index cd96e46..9cf86bc 100644 --- a/crates/pardus-tauri/package.json +++ b/crates/pardus-tauri/package.json @@ -4,14 +4,15 @@ "version": "0.1.0", "type": "module", "scripts": { - "build": "tsc && cp src/index.html dist/index.html", - "dev": "tsc --watch", + "build": "esbuild src/main.ts --bundle --outfile=dist/bundle.js --format=esm --target=es2022 --platform=browser && cp src/index.html dist/index.html", + "dev": "esbuild src/main.ts --bundle --outfile=dist/bundle.js --format=esm --target=es2022 --platform=browser --watch", "typecheck": "tsc --noEmit" }, "dependencies": { "@tauri-apps/api": "^2.0.0" }, "devDependencies": { + "esbuild": "^0.28.0", "typescript": "^5.5.0" } } diff --git a/crates/pardus-tauri/src-tauri/Cargo.toml b/crates/pardus-tauri/src-tauri/Cargo.toml index d639dbf..1082fb8 100644 --- a/crates/pardus-tauri/src-tauri/Cargo.toml +++ b/crates/pardus-tauri/src-tauri/Cargo.toml @@ -23,6 +23,9 @@ anyhow = { workspace = true } async-trait = { workspace = true } url = { workspace = true } +tokio-tungstenite = "0.26" +futures-util = { workspace = true } + pardus-core = { path = "../../pardus-core" } pardus-challenge = { path = "../../pardus-challenge" } pardus-cdp = { path = "../../pardus-cdp" } diff --git a/crates/pardus-tauri/src-tauri/src/browser_window.rs b/crates/pardus-tauri/src-tauri/src/browser_window.rs new file mode 100644 index 0000000..236dd4d --- /dev/null +++ b/crates/pardus-tauri/src-tauri/src/browser_window.rs @@ -0,0 +1,174 @@ +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; + +/// JavaScript injected into every browser window page to add a navigation toolbar. +pub const BROWSER_TOOLBAR_JS: &str = r#" +(function() { + if (window.__pardusToolbar) return; + window.__pardusToolbar = true; + + var INSTANCE_ID = '__INSTANCE_ID__'; + + function emit(name, data) { + try { window.__TAURI__.event.emit(name, data); } catch(e) { + try { window.__TAURI_INTERNALS__.postMessage({ cmd: 'event', event: name, payload: JSON.stringify(data) }); } catch(e2) {} + } + } + + function createToolbar() { + var bar = document.createElement('div'); + bar.id = 'pardus-toolbar'; + bar.style.cssText = 'position:fixed;top:0;left:0;right:0;height:40px;z-index:2147483647;' + + 'background:#161b22;border-bottom:1px solid #30363d;display:flex;align-items:center;' + + 'padding:0 8px;gap:6px;font-family:-apple-system,BlinkMacSystemFont,sans-serif;'; + + var btnStyle = 'background:#30363d;border:none;color:#e6edf3;border-radius:4px;padding:4px 10px;' + + 'font-size:12px;cursor:pointer;height:28px;line-height:20px;'; + + // Back button + var back = document.createElement('button'); + back.textContent = '\u2190'; + back.style.cssText = btnStyle; + back.onclick = function() { window.history.back(); }; + bar.appendChild(back); + + // Forward button + var fwd = document.createElement('button'); + fwd.textContent = '\u2192'; + fwd.style.cssText = btnStyle; + fwd.onclick = function() { window.history.forward(); }; + bar.appendChild(fwd); + + // Refresh button + var ref = document.createElement('button'); + ref.textContent = '\u21BB'; + ref.style.cssText = btnStyle; + ref.onclick = function() { window.location.reload(); }; + bar.appendChild(ref); + + // URL input + var input = document.createElement('input'); + input.type = 'text'; + input.value = window.location.href; + input.style.cssText = 'flex:1;height:28px;background:#0d1117;border:1px solid #30363d;' + + 'border-radius:4px;color:#e6edf3;padding:0 8px;font-size:12px;' + + 'font-family:\'SF Mono\',\'Cascadia Code\',monospace;outline:none;'; + input.onfocus = function() { input.select(); }; + input.onkeydown = function(e) { + if (e.key === 'Enter') { + var url = input.value.trim(); + if (url && !url.match(/^https?:\/\//)) url = 'https://' + url; + emit('browser-navigate', { instance_id: INSTANCE_ID, url: url }); + } + }; + bar.appendChild(input); + + // Update input on URL change + var origPush = history.pushState; + history.pushState = function() { + origPush.apply(this, arguments); + input.value = window.location.href; + emit('browser-url-changed', { instance_id: INSTANCE_ID, url: window.location.href }); + }; + var origReplace = history.replaceState; + history.replaceState = function() { + origReplace.apply(this, arguments); + input.value = window.location.href; + emit('browser-url-changed', { instance_id: INSTANCE_ID, url: window.location.href }); + }; + window.addEventListener('popstate', function() { + input.value = window.location.href; + emit('browser-url-changed', { instance_id: INSTANCE_ID, url: window.location.href }); + }); + + document.documentElement.appendChild(bar); + document.body.style.paddingTop = '40px'; + + emit('browser-url-changed', { instance_id: INSTANCE_ID, url: window.location.href }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', createToolbar); + } else { + createToolbar(); + } +})(); +"#; + +/// JavaScript injected when a CAPTCHA challenge is detected — adds an urgent banner. +pub const CHALLENGE_BANNER_JS: &str = r#" +(function() { + if (window.__pardusChallengeBanner) return; + window.__pardusChallengeBanner = true; + + var banner = document.createElement('div'); + banner.id = 'pardus-challenge-banner'; + banner.style.cssText = 'position:fixed;top:40px;left:0;right:0;z-index:2147483646;' + + 'background:linear-gradient(135deg,#ff6b35,#f7931e);color:#fff;' + + 'padding:10px 20px;font-family:system-ui,sans-serif;font-size:14px;font-weight:600;' + + 'display:flex;align-items:center;justify-content:space-between;' + + 'box-shadow:0 4px 12px rgba(0,0,0,0.3);'; + banner.innerHTML = '\u26A0\uFE0F CAPTCHA DETECTED \u2014 Solve the challenge to let the agent continue' + + ''; + document.documentElement.appendChild(banner); + document.body.style.paddingTop = '80px'; +})(); +"#; + +/// Open a browser window for the given instance. +pub fn open_browser_window( + app_handle: &AppHandle, + instance_id: &str, + url: &str, +) -> Result { + let label = format!("browser-{}", instance_id); + + // Close existing window if any + if let Some(existing) = app_handle.get_webview_window(&label) { + let _ = existing.close(); + } + + let parsed_url: url::Url = url.parse().map_err(|e: url::ParseError| e.to_string())?; + + let toolbar_js = BROWSER_TOOLBAR_JS.replace("__INSTANCE_ID__", instance_id); + + let _window = WebviewWindowBuilder::new( + app_handle, + &label, + WebviewUrl::External(parsed_url), + ) + .title("Pardus Browser") + .inner_size(1200.0, 800.0) + .resizable(true) + .initialization_script(&toolbar_js) + .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15") + .build() + .map_err(|e| e.to_string())?; + + Ok(label) +} + +/// Close a browser window for the given instance. +pub fn close_browser_window( + app_handle: &AppHandle, + instance_id: &str, +) -> Result<(), String> { + let label = format!("browser-{}", instance_id); + if let Some(window) = app_handle.get_webview_window(&label) { + window.close().map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// Inject the challenge banner into a browser window. +pub fn inject_challenge_banner( + app_handle: &AppHandle, + instance_id: &str, +) -> Result<(), String> { + let label = format!("browser-{}", instance_id); + if let Some(window) = app_handle.get_webview_window(&label) { + window.eval(CHALLENGE_BANNER_JS).map_err(|e| e.to_string())?; + } + Ok(()) +} diff --git a/crates/pardus-tauri/src-tauri/src/challenge.rs b/crates/pardus-tauri/src-tauri/src/challenge.rs index 13e214c..c01cdf8 100644 --- a/crates/pardus-tauri/src-tauri/src/challenge.rs +++ b/crates/pardus-tauri/src-tauri/src/challenge.rs @@ -8,6 +8,9 @@ use serde_json; use pardus_challenge::resolver::{ChallengeResolver, Resolution}; use pardus_challenge::detector::ChallengeInfo; +use crate::browser_window; +use crate::cookie_bridge; + const CHALLENGE_MONITOR_JS: &str = r#" (function() { if (window.__pardusChallengeActive) return; @@ -88,8 +91,9 @@ const CHALLENGE_MONITOR_JS: &str = r#" "#; struct PendingChallenge { - url: String, + _url: String, window_label: String, + instance_id: Option, tx: oneshot::Sender, } @@ -106,36 +110,71 @@ impl TauriChallengeResolver { } } + /// Open a challenge window — reuses existing browser window if one exists for the instance. async fn open_challenge_window( &self, info: &ChallengeInfo, + instance_id: Option<&str>, tx: oneshot::Sender, ) -> Result<(), String> { - let sanitized: String = info.url.chars().take(40).map(|c| { - if c.is_alphanumeric() { c } else { '-' } - }).collect(); - let label = format!("challenge-{}", sanitized); - - let parsed_url: url::Url = info.url.parse().map_err(|e: url::ParseError| e.to_string())?; - - let kind_str = info.kinds.iter().map(|k| k.to_string()).collect::>().join(", "); - let title = format!("Solve: {}", kind_str); - - WebviewWindowBuilder::new( - &self.app_handle, - &label, - WebviewUrl::External(parsed_url), - ) - .title(&title) - .inner_size(500.0, 680.0) - .resizable(true) - .initialization_script(CHALLENGE_MONITOR_JS) - .build() - .map_err(|e| e.to_string())?; + let label = if let Some(inst_id) = instance_id { + // Try to reuse the existing browser window for this instance + let browser_label = format!("browser-{}", inst_id); + if let Some(window) = self.app_handle.get_webview_window(&browser_label) { + // Navigate existing browser window to the challenge URL + let _parsed: url::Url = info.url.parse().map_err(|e: url::ParseError| e.to_string())?; + // Close and reopen with challenge URL + let _ = window.close(); + let new_label = browser_window::open_browser_window( + &self.app_handle, inst_id, &info.url.to_string(), + )?; + // Inject challenge banner + browser_window::inject_challenge_banner(&self.app_handle, inst_id)?; + new_label + } else { + // No existing window — create a new browser window + let new_label = browser_window::open_browser_window( + &self.app_handle, inst_id, &info.url.to_string(), + )?; + browser_window::inject_challenge_banner(&self.app_handle, inst_id)?; + new_label + } + } else { + // Fallback: create a standalone challenge window + let sanitized: String = info.url.chars().take(40).map(|c| { + if c.is_alphanumeric() { c } else { '-' } + }).collect(); + let label = format!("challenge-{}", sanitized); + + let parsed_url: url::Url = info.url.parse().map_err(|e: url::ParseError| e.to_string())?; + + let kind_str = info.kinds.iter().map(|k| k.to_string()).collect::>().join(", "); + let title = format!("Solve: {}", kind_str); + + WebviewWindowBuilder::new( + &self.app_handle, + &label, + WebviewUrl::External(parsed_url), + ) + .title(&title) + .inner_size(500.0, 680.0) + .resizable(true) + .initialization_script(CHALLENGE_MONITOR_JS) + .build() + .map_err(|e| e.to_string())?; + + label + }; + + // Also inject the challenge monitor script into the browser window + if let Some(window) = self.app_handle.get_webview_window(&label) { + let _ = window.eval(CHALLENGE_MONITOR_JS); + } let pending = PendingChallenge { - url: info.url.clone(), + _url: info.url.clone(), window_label: label, + instance_id: instance_id.map(|s| s.to_string()), tx, }; self.pending.lock().await.insert(info.url.clone(), pending); @@ -146,10 +185,23 @@ impl TauriChallengeResolver { pub async fn handle_cookies(&self, challenge_url: String, cookies: String) { let mut pending = self.pending.lock().await; if let Some(challenge) = pending.remove(&challenge_url) { + // Close the challenge window if let Some(window) = self.app_handle.get_webview_window(&challenge.window_label) { let _ = window.close(); } + // Extract port before any async work (drop MutexGuard first) + let port = challenge.instance_id.as_ref().and_then(|inst_id| { + let instances = self.app_handle.state::(); + let lock = instances.instances.lock().unwrap(); + lock.get(inst_id).map(|i| i.port) + }); + + // Send cookies to the headless browser + if let Some(port) = port { + let _ = cookie_bridge::send_cookies_to_headless(port, &cookies, &challenge_url).await; + } + let resolution = Resolution::ModifyHeaders { headers: HashMap::new(), cookies: Some(cookies), @@ -187,7 +239,15 @@ impl ChallengeResolver for TauriChallengeResolver { let _ = self.app_handle.emit("challenge-detected", &info); - if let Err(e) = self.open_challenge_window(&info, tx).await { + // Try to find an instance associated with this URL + let instance_id = { + let instances = self.app_handle.state::(); + let lock = instances.instances.lock().unwrap(); + // Return the first instance — in practice you'd match by URL + lock.keys().next().cloned() + }; + + if let Err(e) = self.open_challenge_window(&info, instance_id.as_deref(), tx).await { tracing::error!(url = %info.url, error = %e, "failed to open challenge window"); return Resolution::Blocked(e); } diff --git a/crates/pardus-tauri/src-tauri/src/commands.rs b/crates/pardus-tauri/src-tauri/src/commands.rs index 2fc43a6..dd65647 100644 --- a/crates/pardus-tauri/src-tauri/src/commands.rs +++ b/crates/pardus-tauri/src-tauri/src/commands.rs @@ -1,4 +1,4 @@ -use tauri::{AppHandle, WebviewUrl, WebviewWindowBuilder}; +use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; use serde::Serialize; use crate::AppState; @@ -13,6 +13,9 @@ pub struct InstanceInfo { pub port: u16, pub ws_url: String, pub running: bool, + pub browser_window_open: bool, + pub current_url: Option, + pub agent_status: String, } #[tauri::command] @@ -27,6 +30,9 @@ pub async fn list_instances( port: inst.port, ws_url: inst.ws_url.clone(), running: true, + browser_window_open: inst.browser_window_label.is_some(), + current_url: inst.current_url.clone(), + agent_status: inst.agent_status.clone(), }) .collect(); Ok(list) @@ -59,6 +65,9 @@ pub async fn spawn_instance( port, ws_url: ws_url.clone(), running: true, + browser_window_open: false, + current_url: None, + agent_status: "idle".to_string(), }; let managed = crate::instance::ManagedInstance { @@ -66,6 +75,9 @@ pub async fn spawn_instance( port, process: child, ws_url, + browser_window_label: None, + current_url: None, + agent_status: "idle".to_string(), }; state.instances.lock().unwrap().insert(id.clone(), managed); @@ -74,11 +86,18 @@ pub async fn spawn_instance( #[tauri::command] pub async fn kill_instance( + app: AppHandle, state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { let mut instances = state.instances.lock().unwrap(); if let Some(mut inst) = instances.remove(&id) { + // Close browser window if open + if let Some(label) = &inst.browser_window_label { + if let Some(window) = app.get_webview_window(label) { + let _ = window.close(); + } + } let _ = inst.process.kill(); Ok(()) } else { @@ -88,10 +107,17 @@ pub async fn kill_instance( #[tauri::command] pub async fn kill_all_instances( + app: AppHandle, state: tauri::State<'_, AppState>, ) -> Result<(), String> { let mut instances = state.instances.lock().unwrap(); for (_, mut inst) in instances.drain() { + // Close browser window if open + if let Some(label) = &inst.browser_window_label { + if let Some(window) = app.get_webview_window(label) { + let _ = window.close(); + } + } let _ = inst.process.kill(); } Ok(()) @@ -167,3 +193,88 @@ pub async fn cancel_challenge( resolver.handle_failed(challenge_url, "cancelled by user".to_string()).await; Ok(()) } + +// --------------------------------------------------------------------------- +// Browser window commands +// --------------------------------------------------------------------------- + +/// Open a visual browser window for an instance. +#[tauri::command] +pub async fn open_browser_window( + app: AppHandle, + state: tauri::State<'_, AppState>, + instance_id: String, + url: Option, +) -> Result<(), String> { + // Verify the instance exists + { + let instances = state.instances.lock().unwrap(); + instances + .get(&instance_id) + .ok_or_else(|| format!("instance '{}' not found", instance_id))?; + } + + let target_url = url.unwrap_or_else(|| "https://example.com".to_string()); + let label = crate::browser_window::open_browser_window(&app, &instance_id, &target_url)?; + + let mut instances = state.instances.lock().unwrap(); + if let Some(inst) = instances.get_mut(&instance_id) { + inst.browser_window_label = Some(label); + inst.current_url = Some(target_url); + } + + Ok(()) +} + +/// Navigate the browser window for an instance. +#[tauri::command] +pub async fn navigate_browser_window( + app: AppHandle, + state: tauri::State<'_, AppState>, + instance_id: String, + url: String, +) -> Result<(), String> { + let label = { + let instances = state.instances.lock().unwrap(); + instances + .get(&instance_id) + .and_then(|i| i.browser_window_label.clone()) + .ok_or_else(|| format!("no browser window for instance '{}'", instance_id))? + }; + + let parsed_url: url::Url = url.parse().map_err(|e: url::ParseError| e.to_string())?; + + if let Some(window) = app.get_webview_window(&label) { + // Navigate by closing and reopening (Tauri 2 webview navigation) + let _ = window.close(); + } + + let new_label = crate::browser_window::open_browser_window(&app, &instance_id, url.as_str())?; + + let mut instances = state.instances.lock().unwrap(); + if let Some(inst) = instances.get_mut(&instance_id) { + inst.browser_window_label = Some(new_label); + inst.current_url = Some(url); + } + + let _ = parsed_url; + Ok(()) +} + +/// Close the browser window for an instance. +#[tauri::command] +pub async fn close_browser_window( + app: AppHandle, + state: tauri::State<'_, AppState>, + instance_id: String, +) -> Result<(), String> { + crate::browser_window::close_browser_window(&app, &instance_id)?; + + let mut instances = state.instances.lock().unwrap(); + if let Some(inst) = instances.get_mut(&instance_id) { + inst.browser_window_label = None; + inst.current_url = None; + } + + Ok(()) +} diff --git a/crates/pardus-tauri/src-tauri/src/cookie_bridge.rs b/crates/pardus-tauri/src-tauri/src/cookie_bridge.rs new file mode 100644 index 0000000..acec4f0 --- /dev/null +++ b/crates/pardus-tauri/src-tauri/src/cookie_bridge.rs @@ -0,0 +1,99 @@ +use futures_util::{SinkExt, StreamExt}; +use tokio_tungstenite::tungstenite::Message; + +/// Send cookies extracted from the visual webview to the headless CDP server. +/// +/// Connects to the CDP WebSocket endpoint and sends `Network.setCookie` commands +/// for each cookie in the string. The CDP server writes to the shared cookie jar +/// on the `Arc`, so the agent's existing session will see the new cookies. +pub async fn send_cookies_to_headless( + port: u16, + cookie_string: &str, + url: &str, +) -> Result { + let ws_url = format!("ws://127.0.0.1:{}", port); + + let (mut ws_stream, _) = tokio_tungstenite::connect_async(&ws_url) + .await + .map_err(|e| format!("failed to connect to CDP at {}: {}", ws_url, e))?; + + // Extract domain from URL for cookie scoping + let domain = url::Url::parse(url) + .ok() + .and_then(|u| u.host_str().map(|h| h.to_string())) + .unwrap_or_else(|| "example.com".to_string()); + + let cookies = parse_cookie_string(cookie_string); + let count = cookies.len(); + + for (i, (name, value)) in cookies.iter().enumerate() { + let msg = serde_json::json!({ + "id": i + 1, + "method": "Network.setCookie", + "params": { + "name": name, + "value": value, + "domain": domain, + "path": "/" + } + }); + + ws_stream + .send(Message::Text(msg.to_string().into())) + .await + .map_err(|e| format!("failed to send setCookie: {}", e))?; + + // Read response (discard) + if let Some(Ok(_response)) = ws_stream.next().await { + // Response received, cookie set + } + } + + let _ = ws_stream.close(None).await; + Ok(count) +} + +/// Parse a cookie string like "name1=value1; name2=value2" into pairs. +fn parse_cookie_string(cookie_string: &str) -> Vec<(String, String)> { + cookie_string + .split(';') + .filter_map(|pair| { + let pair = pair.trim(); + if pair.is_empty() { + return None; + } + let mut parts = pair.splitn(2, '='); + let name = parts.next()?.trim().to_string(); + let value = parts.next()?.trim().to_string(); + if name.is_empty() { + return None; + } + Some((name, value)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_cookie_string() { + let cookies = parse_cookie_string("cf_clearance=abc123; _ga=GA1.2.123"); + assert_eq!(cookies.len(), 2); + assert_eq!(cookies[0], ("cf_clearance".to_string(), "abc123".to_string())); + assert_eq!(cookies[1], ("_ga".to_string(), "GA1.2.123".to_string())); + } + + #[test] + fn test_parse_empty() { + assert!(parse_cookie_string("").is_empty()); + } + + #[test] + fn test_parse_single() { + let cookies = parse_cookie_string("token=xyz"); + assert_eq!(cookies.len(), 1); + assert_eq!(cookies[0], ("token".to_string(), "xyz".to_string())); + } +} diff --git a/crates/pardus-tauri/src-tauri/src/instance.rs b/crates/pardus-tauri/src-tauri/src/instance.rs index 5d84442..9c54eac 100644 --- a/crates/pardus-tauri/src-tauri/src/instance.rs +++ b/crates/pardus-tauri/src-tauri/src/instance.rs @@ -5,6 +5,9 @@ pub struct ManagedInstance { pub port: u16, pub process: Child, pub ws_url: String, + pub browser_window_label: Option, + pub current_url: Option, + pub agent_status: String, } impl std::fmt::Debug for ManagedInstance { @@ -13,6 +16,9 @@ impl std::fmt::Debug for ManagedInstance { .field("id", &self.id) .field("port", &self.port) .field("ws_url", &self.ws_url) + .field("browser_window_label", &self.browser_window_label) + .field("current_url", &self.current_url) + .field("agent_status", &self.agent_status) .finish() } } @@ -28,7 +34,17 @@ pub fn find_free_port(base: u16) -> u16 { } pub fn spawn_browser_process(port: u16) -> anyhow::Result { - let child = std::process::Command::new("pardus-browser") + // Try absolute path first (same dir as tauri binary), fall back to PATH + let bin_path = std::env::current_exe()? + .parent() + .map(|p| p.join("pardus-browser")) + .filter(|p| p.exists()); + + let bin = bin_path + .as_deref() + .unwrap_or(std::path::Path::new("pardus-browser")); + + let child = std::process::Command::new(bin) .arg("serve") .arg("--port") .arg(port.to_string()) diff --git a/crates/pardus-tauri/src-tauri/src/lib.rs b/crates/pardus-tauri/src-tauri/src/lib.rs index 07334a4..79d1515 100644 --- a/crates/pardus-tauri/src-tauri/src/lib.rs +++ b/crates/pardus-tauri/src-tauri/src/lib.rs @@ -1,5 +1,7 @@ +mod browser_window; mod challenge; mod commands; +mod cookie_bridge; mod instance; use std::collections::HashMap; @@ -30,6 +32,9 @@ pub fn run() { commands::open_challenge_window, commands::submit_challenge_resolution, commands::cancel_challenge, + commands::open_browser_window, + commands::navigate_browser_window, + commands::close_browser_window, ]) .setup(|app| { tracing_subscriber::fmt() @@ -73,6 +78,48 @@ pub fn run() { } }); + // Listen for browser-navigate events from browser window toolbars + let nav_handle = app.handle().clone(); + app.listen("browser-navigate", move |event| { + let payload = event.payload(); + if let Ok(data) = serde_json::from_str::(payload) { + let instance_id = data["instance_id"].as_str().unwrap_or("").to_string(); + let url = data["url"].as_str().unwrap_or("").to_string(); + let h = nav_handle.clone(); + tauri::async_runtime::spawn(async move { + let label = format!("browser-{}", instance_id); + // Close and reopen with new URL + if let Some(window) = h.get_webview_window(&label) { + let _ = window.close(); + } + if let Ok(_new_label) = browser_window::open_browser_window(&h, &instance_id, &url) { + // Update instance state + let state = h.state::(); + let mut instances = state.instances.lock().unwrap(); + if let Some(inst) = instances.get_mut(&instance_id) { + inst.current_url = Some(url); + } + } + }); + } + }); + + // Listen for browser-url-changed events to track current URL + let url_handle = app.handle().clone(); + app.listen("browser-url-changed", move |event| { + let payload = event.payload(); + if let Ok(data) = serde_json::from_str::(payload) { + let instance_id = data["instance_id"].as_str().unwrap_or("").to_string(); + let url = data["url"].as_str().unwrap_or("").to_string(); + let h = url_handle.clone(); + let state = h.state::(); + let mut instances = state.instances.lock().unwrap(); + if let Some(inst) = instances.get_mut(&instance_id) { + inst.current_url = Some(url.to_string()); + } + } + }); + Ok(()) }) .run(tauri::generate_context!()) diff --git a/crates/pardus-tauri/src-tauri/tauri.conf.json b/crates/pardus-tauri/src-tauri/tauri.conf.json index 30ee247..8e86057 100644 --- a/crates/pardus-tauri/src-tauri/tauri.conf.json +++ b/crates/pardus-tauri/src-tauri/tauri.conf.json @@ -4,9 +4,7 @@ "version": "0.1.0", "identifier": "ai.pardus.browser", "build": { - "beforeDevCommand": "npm run dev", "beforeBuildCommand": "npm run build", - "devUrl": "http://localhost:1420", "frontendDist": "../dist" }, "app": { diff --git a/crates/pardus-tauri/src/api.ts b/crates/pardus-tauri/src/api.ts index 71ef5df..37ef489 100644 --- a/crates/pardus-tauri/src/api.ts +++ b/crates/pardus-tauri/src/api.ts @@ -1,6 +1,10 @@ import { invoke } from "@tauri-apps/api/core"; import type { InstanceInfo } from "./types"; +// --------------------------------------------------------------------------- +// Instance management +// --------------------------------------------------------------------------- + export async function listInstances(): Promise { return invoke("list_instances"); } @@ -17,6 +21,32 @@ export async function killAllInstances(): Promise { return invoke("kill_all_instances"); } +// --------------------------------------------------------------------------- +// Browser window +// --------------------------------------------------------------------------- + +export async function openBrowserWindow( + instanceId: string, + url?: string +): Promise { + return invoke("open_browser_window", { instanceId, url }); +} + +export async function navigateBrowserWindow( + instanceId: string, + url: string +): Promise { + return invoke("navigate_browser_window", { instanceId, url }); +} + +export async function closeBrowserWindow(instanceId: string): Promise { + return invoke("close_browser_window", { instanceId }); +} + +// --------------------------------------------------------------------------- +// Challenge +// --------------------------------------------------------------------------- + export async function openChallengeWindow( url: string, title?: string diff --git a/crates/pardus-tauri/src/challenge.ts b/crates/pardus-tauri/src/challenge.ts index 20f6a18..eb75b99 100644 --- a/crates/pardus-tauri/src/challenge.ts +++ b/crates/pardus-tauri/src/challenge.ts @@ -1,4 +1,4 @@ -import type { ChallengeInfo, LogEntry } from "./types"; +import type { LogEntry } from "./types"; import * as api from "./api"; import { log } from "./events"; @@ -25,7 +25,7 @@ export class ChallengeManager { this.onLog = onLog; } - handleDetected(info: ChallengeInfo): void { + handleDetected(info: { url: string; kinds: string[]; risk_score: number }): void { if (this.activeChallenges.has(info.url)) { return; } @@ -40,17 +40,21 @@ export class ChallengeManager { this.onLog(log("warn", `Challenge detected: ${info.kinds.join(", ")} (score: ${info.risk_score}) — ${info.url}`)); - this.openChallengeWindow(info.url, info.kinds); + // The Rust backend automatically opens a browser window with the challenge banner. + // No need to open a separate challenge window here. this.render(); } - private async openChallengeWindow(url: string, kinds: string[]): Promise { - try { - const label = await api.openChallengeWindow(url, `Solve: ${kinds.join(", ")}`); - this.onLog(log("info", `Challenge window opened: ${label}`)); - } catch (e) { - this.onLog(log("error", `Failed to open challenge window: ${e}`)); - } + handleSolved(info: { url: string }): void { + this.activeChallenges.delete(info.url); + this.onLog(log("info", `Challenge resolved: ${info.url}`)); + this.render(); + } + + handleFailed(url: string, reason: string): void { + this.activeChallenges.delete(url); + this.onLog(log("error", `Challenge failed: ${reason} — ${url}`)); + this.render(); } async submitCookies(url: string, cookies: string): Promise { @@ -96,7 +100,8 @@ export class ChallengeManager {
${url}
- + Solve the CAPTCHA in the browser window — cookies will sync automatically +
`; diff --git a/crates/pardus-tauri/src/events.ts b/crates/pardus-tauri/src/events.ts index 2bc8b04..21ecc07 100644 --- a/crates/pardus-tauri/src/events.ts +++ b/crates/pardus-tauri/src/events.ts @@ -1,23 +1,40 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -import type { ChallengeInfo, LogEntry } from "./types"; +import type { LogEntry } from "./types"; type LogCallback = (entry: LogEntry) => void; -export function onChallengeDetected(callback: (info: ChallengeInfo) => void): Promise { - return listen("challenge-detected", (event) => { - callback(event.payload); +export function onChallengeDetected( + callback: (info: { url: string; kinds: string[]; risk_score: number }) => void +): Promise { + return listen("challenge-detected", (event) => { + callback(event.payload as any); }); } -export function onChallengeSolved(callback: (info: ChallengeInfo) => void): Promise { - return listen("challenge-solved", (event) => { - callback(event.payload); +export function onChallengeSolved( + callback: (info: { url: string }) => void +): Promise { + return listen("challenge-solved", (event) => { + callback(event.payload as any); }); } -export function onChallengeFailed(callback: (url: string, reason: string) => void): Promise { - return listen<{ challenge_url: string; reason: string }>("challenge-failed", (event) => { - callback(event.payload.challenge_url, event.payload.reason); +export function onChallengeFailed( + callback: (url: string, reason: string) => void +): Promise { + return listen<{ challenge_url: string; reason: string }>( + "challenge-failed", + (event) => { + callback(event.payload.challenge_url, event.payload.reason); + } + ); +} + +export function onBrowserUrlChanged( + callback: (data: { instance_id: string; url: string }) => void +): Promise { + return listen("browser-url-changed", (event) => { + callback(event.payload as any); }); } diff --git a/crates/pardus-tauri/src/index.html b/crates/pardus-tauri/src/index.html index 08fb5f5..a1821f6 100644 --- a/crates/pardus-tauri/src/index.html +++ b/crates/pardus-tauri/src/index.html @@ -51,11 +51,21 @@ .btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 4px; background: var(--border); color: var(--text); } .btn-sm:hover { background: #444c56; } table { width: 100%; border-collapse: collapse; } - th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); font-size: 13px; } + th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; } th { color: var(--text-muted); font-size: 11px; font-weight: 500; } .mono { font-family: 'SF Mono', 'Cascadia Code', monospace; font-size: 12px; } - .status { font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 500; } + .status { font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 500; display: inline-block; } + .status.idle { background: rgba(139,148,158,0.12); color: var(--text-muted); } .status.running { background: rgba(63,185,80,0.12); color: var(--success); } + .status.challenge { background: rgba(248,81,73,0.15); color: var(--danger); animation: pulse 1.5s infinite; } + @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } + .url-row { display: flex; gap: 4px; align-items: center; } + .url-input { + flex: 1; height: 26px; background: var(--bg); border: 1px solid var(--border); + border-radius: 4px; color: var(--text); padding: 0 6px; font-size: 12px; + font-family: 'SF Mono', 'Cascadia Code', monospace; outline: none; min-width: 120px; + } + .url-input:focus { border-color: var(--accent); } .log { background: var(--bg); border: 1px solid var(--border); @@ -71,6 +81,8 @@ .log-entry.warn { color: var(--warn); } .log-entry.error { color: var(--danger); } #challenge-panel { display: none; } + #challenge-panel.active { display: block; border-color: var(--warn); animation: glow 2s infinite; } + @keyframes glow { 0%, 100% { box-shadow: 0 0 0 0 rgba(210,153,34,0); } 50% { box-shadow: 0 0 12px 2px rgba(210,153,34,0.3); } } .challenge-item { background: var(--bg); border: 1px solid var(--border); @@ -83,14 +95,15 @@ .challenge-score { font-size: 11px; color: var(--text-muted); } .challenge-elapsed { font-size: 11px; color: var(--text-muted); margin-left: auto; } .challenge-url { font-family: 'SF Mono', 'Cascadia Code', monospace; font-size: 11px; color: var(--text-muted); margin-bottom: 8px; word-break: break-all; } - .challenge-actions { display: flex; gap: 6px; } + .challenge-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } + .challenge-hint { font-size: 11px; color: var(--text-muted); font-style: italic; }

Pardus Browser

-

AI agent browser launcher with CAPTCHA human-in-the-loop

- +

AI agent browser with visual control and CAPTCHA handoff

+
@@ -98,12 +111,12 @@

Pardus Browser

Instances

- +
IDPortWebSocket URLStatusActions
IDPortWebSocketStatusNavigateActions

- No instances running + No instances running — click "Spawn + Open Browser" to start

@@ -117,6 +130,6 @@

Log

- + diff --git a/crates/pardus-tauri/src/instances.ts b/crates/pardus-tauri/src/instances.ts index df52307..4c7b791 100644 --- a/crates/pardus-tauri/src/instances.ts +++ b/crates/pardus-tauri/src/instances.ts @@ -6,7 +6,6 @@ export class InstanceManager { private tableBody: HTMLElement; private noInstances: HTMLElement; private onLog: (entry: LogEntry) => void; - private challengeListenerCleanup: (() => void) | null = null; constructor( tableBody: HTMLElement, @@ -33,6 +32,8 @@ export class InstanceManager { this.instances.push(inst); this.render(); this.onLog({ level: "info", message: `Instance ${inst.id} spawned on port ${inst.port}`, timestamp: ts() }); + // Auto-open browser window + await this.openBrowser(inst.id); } catch (e) { this.onLog({ level: "error", message: `Spawn failed: ${e}`, timestamp: ts() }); } @@ -60,6 +61,35 @@ export class InstanceManager { } } + async openBrowser(id: string): Promise { + try { + await api.openBrowserWindow(id); + this.onLog({ level: "info", message: `Browser view opened for ${id}`, timestamp: ts() }); + await this.refresh(); + } catch (e) { + this.onLog({ level: "error", message: `Open browser failed: ${e}`, timestamp: ts() }); + } + } + + async closeBrowser(id: string): Promise { + try { + await api.closeBrowserWindow(id); + this.onLog({ level: "info", message: `Browser view closed for ${id}`, timestamp: ts() }); + await this.refresh(); + } catch (e) { + this.onLog({ level: "error", message: `Close browser failed: ${e}`, timestamp: ts() }); + } + } + + async navigateInBrowser(id: string, url: string): Promise { + try { + await api.navigateBrowserWindow(id, url); + this.onLog({ level: "info", message: `Navigating ${id} to ${url}`, timestamp: ts() }); + } catch (e) { + this.onLog({ level: "error", message: `Navigate failed: ${e}`, timestamp: ts() }); + } + } + private render(): void { this.tableBody.innerHTML = ""; @@ -71,27 +101,54 @@ export class InstanceManager { for (const inst of this.instances) { const tr = document.createElement("tr"); + + const statusClass = inst.agent_status === "waiting-challenge" ? "challenge" : inst.agent_status; + const browserBtn = inst.browser_window_open + ? `` + : ``; + tr.innerHTML = ` ${inst.id} ${inst.port} ${inst.ws_url} - running + ${inst.agent_status} - +
+ + +
+ + + ${browserBtn} + `; + tr.querySelector(`[data-kill="${inst.id}"]`)?.addEventListener("click", () => this.kill(inst.id)); tr.querySelector(`[data-copy="${inst.ws_url}"]`)?.addEventListener("click", () => { navigator.clipboard.writeText(inst.ws_url); }); + tr.querySelector(`[data-open-browser="${inst.id}"]`)?.addEventListener("click", () => this.openBrowser(inst.id)); + tr.querySelector(`[data-close-browser="${inst.id}"]`)?.addEventListener("click", () => this.closeBrowser(inst.id)); + tr.querySelector(`[data-navigate="${inst.id}"]`)?.addEventListener("click", () => { + const input = tr.querySelector(`[data-url-input="${inst.id}"]`) as HTMLInputElement; + if (input?.value) this.navigateInBrowser(inst.id, input.value); + }); + + const urlInput = tr.querySelector(`[data-url-input="${inst.id}"]`); + urlInput?.addEventListener("keydown", (e) => { + if ((e as KeyboardEvent).key === "Enter") { + const val = (e.target as HTMLInputElement).value; + if (val) this.navigateInBrowser(inst.id, val); + } + }); + this.tableBody.appendChild(tr); } } - destroy(): void { - this.challengeListenerCleanup?.(); - } + destroy(): void {} } function ts(): string { diff --git a/crates/pardus-tauri/src/main.ts b/crates/pardus-tauri/src/main.ts index c886057..a880435 100644 --- a/crates/pardus-tauri/src/main.ts +++ b/crates/pardus-tauri/src/main.ts @@ -1,9 +1,15 @@ import { InstanceManager } from "./instances"; import { ChallengeManager } from "./challenge"; -import { onChallengeDetected, createLogger, log } from "./events"; +import { + onChallengeDetected, + onChallengeSolved, + onChallengeFailed, + createLogger, + log, +} from "./events"; function init(): void { - const tableBody = document.getElementById("instance-table")!; + const tableBody = document.getElementById("instance-table") as HTMLTableSectionElement; const noInstances = document.getElementById("no-instances")!; const logContainer = document.getElementById("log-entries")!; const challengePanel = document.getElementById("challenge-panel")!; @@ -12,18 +18,23 @@ function init(): void { const logger = createLogger(logContainer); const instanceManager = new InstanceManager(tableBody, noInstances, logger); - const challengeManager = new ChallengeManager(challengePanel, challengeBody, logger); document.getElementById("btn-spawn")?.addEventListener("click", () => instanceManager.spawn()); document.getElementById("btn-kill-all")?.addEventListener("click", () => instanceManager.killAll()); + // Challenge events from the Rust backend onChallengeDetected((info) => { challengeManager.handleDetected(info); }); + onChallengeSolved((info) => { + challengeManager.handleSolved(info); + }); + onChallengeFailed((url, reason) => { + challengeManager.handleFailed(url, reason); + }); instanceManager.refresh(); - logger(log("info", "Pardus Browser app initialized")); } diff --git a/crates/pardus-tauri/src/types.ts b/crates/pardus-tauri/src/types.ts index 435c7ed..8d55f67 100644 --- a/crates/pardus-tauri/src/types.ts +++ b/crates/pardus-tauri/src/types.ts @@ -3,6 +3,9 @@ export interface InstanceInfo { port: number; ws_url: string; running: boolean; + browser_window_open: boolean; + current_url: string | null; + agent_status: "idle" | "running" | "waiting-challenge"; } export interface ChallengeInfo {