Skip to content

Commit f7b935a

Browse files
fix(browser): use Tauri JS Webview API for native browser pane
Creates child webview directly from frontend via @tauri-apps/api/webview. Coordinates are relative to window content area (no title bar offset). Webview persists across tab switches (moved offscreen when hidden). Address bar stays in the DOM above the native webview container.
1 parent b6b8703 commit f7b935a

5 files changed

Lines changed: 120 additions & 326 deletions

File tree

crates/tauri-app/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ edition.workspace = true
77
tauri-build = { version = "2", features = [] }
88

99
[dependencies]
10-
tauri = { version = "2", features = [] }
10+
tauri = { version = "2", features = ["unstable"] }
1111
tauri-plugin-dialog = "2"
1212
tauri-plugin-shell = "2"
1313
codeforge-session = { path = "../session" }

crates/tauri-app/frontend/src/components/browser/BrowserPanel.tsx

Lines changed: 116 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -1,110 +1,144 @@
1-
import { createSignal, onMount, onCleanup, Show } from "solid-js";
1+
import { createSignal, onMount, onCleanup } from "solid-js";
22
import { appStore } from "../../stores/app-store";
3-
import * as ipc from "../../ipc";
3+
import { Window } from "@tauri-apps/api/window";
4+
import { Webview } from "@tauri-apps/api/webview";
45

56
interface Props {
67
threadId: string;
78
}
89

10+
// Track webview instances across component lifecycle
11+
const webviewInstances: Record<string, Webview> = {};
12+
913
export function BrowserPanel(props: Props) {
1014
const { store, setStore } = appStore;
15+
const [urlInput, setUrlInput] = createSignal(
16+
store.threadBrowserUrls[props.threadId] || "https://google.com"
17+
);
18+
const [ready, setReady] = createSignal(false);
19+
let containerRef: HTMLDivElement | undefined;
1120

12-
const [urlInput, setUrlInput] = createSignal(store.threadBrowserUrls[props.threadId] || "https://google.com");
13-
const [screenshot, setScreenshot] = createSignal<string | null>(null);
14-
const [loading, setLoading] = createSignal(false);
15-
let imgRef: HTMLImageElement | undefined;
16-
let viewportRef: HTMLDivElement | undefined;
17-
18-
// Viewport dimensions for coordinate mapping
19-
const VIEWPORT_W = 1200;
20-
const VIEWPORT_H = 800;
21-
22-
onMount(async () => {
23-
const unlisten = await ipc.listenBrowserEvent((payload) => {
24-
if (payload.thread_id !== props.threadId) return;
25-
const t = payload.type;
26-
if (t === "screenshot" && payload.data) {
27-
setScreenshot(`data:image/png;base64,${payload.data}`);
28-
setLoading(false);
29-
} else if (t === "navigated" && payload.url) {
30-
setUrlInput(payload.url);
31-
setStore("threadBrowserUrls", props.threadId, payload.url);
32-
} else if (t === "ready") {
33-
navigate();
34-
}
35-
});
36-
onCleanup(() => unlisten());
37-
38-
// Auto-resize the Playwright viewport to match the pane width
39-
if (viewportRef) {
40-
const ro = new ResizeObserver((entries) => {
41-
for (const e of entries) {
42-
const w = Math.round(e.contentRect.width * 2); // 2x for retina
43-
const h = Math.round(e.contentRect.height * 2);
44-
if (w > 100 && h > 100) {
45-
ipc.browserResize(props.threadId, w, h).catch(() => {});
46-
}
47-
}
48-
});
49-
ro.observe(viewportRef);
50-
onCleanup(() => ro.disconnect());
21+
const label = () => `browser-${props.threadId.replace(/[^a-zA-Z0-9-]/g, "")}`;
22+
23+
function getContainerBounds() {
24+
if (!containerRef) return null;
25+
const rect = containerRef.getBoundingClientRect();
26+
if (rect.width < 10 || rect.height < 10) return null;
27+
return { x: Math.round(rect.left), y: Math.round(rect.top), w: Math.round(rect.width), h: Math.round(rect.height) };
28+
}
29+
30+
async function updateBounds() {
31+
const wv = webviewInstances[props.threadId];
32+
if (!wv) return;
33+
const b = getContainerBounds();
34+
if (!b) return;
35+
try {
36+
await wv.setPosition(new (await import("@tauri-apps/api/dpi")).LogicalPosition(b.x, b.y));
37+
await wv.setSize(new (await import("@tauri-apps/api/dpi")).LogicalSize(b.w, b.h));
38+
} catch {}
39+
}
40+
41+
async function createWebview() {
42+
const existing = webviewInstances[props.threadId];
43+
if (existing) {
44+
// Already exists — just reposition and show
45+
setReady(true);
46+
await updateBounds();
47+
return;
5148
}
5249

53-
navigate();
54-
});
50+
const b = getContainerBounds();
51+
if (!b) return;
5552

56-
function navigate() {
53+
try {
54+
const appWindow = await Window.getByLabel("main");
55+
if (!appWindow) return;
56+
57+
const url = urlInput().trim() || "https://google.com";
58+
const wv = new Webview(appWindow, label(), {
59+
url,
60+
x: b.x,
61+
y: b.y,
62+
width: b.w,
63+
height: b.h,
64+
});
65+
66+
// Wait for it to be created
67+
await wv.once("tauri://created", () => {});
68+
webviewInstances[props.threadId] = wv;
69+
setReady(true);
70+
} catch (e) {
71+
console.error("Failed to create webview:", e);
72+
}
73+
}
74+
75+
async function navigate() {
5776
let url = urlInput().trim();
5877
if (!url) return;
5978
if (!url.match(/^https?:\/\//)) url = "https://" + url;
6079
setUrlInput(url);
6180
setStore("threadBrowserUrls", props.threadId, url);
62-
setLoading(true);
63-
ipc.browserNavigate(props.threadId, url).catch(() => setLoading(false));
64-
}
6581

66-
function handleClick(e: MouseEvent) {
67-
if (!imgRef) return;
68-
const rect = imgRef.getBoundingClientRect();
69-
const x = ((e.clientX - rect.left) / rect.width) * VIEWPORT_W;
70-
const y = ((e.clientY - rect.top) / rect.height) * VIEWPORT_H;
71-
setLoading(true);
72-
ipc.browserClick(props.threadId, x, y).catch(() => setLoading(false));
73-
}
74-
75-
function handleScroll(e: WheelEvent) {
76-
e.preventDefault();
77-
ipc.browserScroll(props.threadId, e.deltaY * 2).catch(() => {});
82+
const wv = webviewInstances[props.threadId];
83+
if (wv) {
84+
try {
85+
// Navigate by evaluating JS in the webview
86+
await wv.eval(`window.location.href = ${JSON.stringify(url)}`);
87+
} catch {
88+
// If eval fails, destroy and recreate
89+
await destroyWebview();
90+
await createWebview();
91+
}
92+
}
7893
}
7994

80-
function handleKeyDown(e: KeyboardEvent) {
81-
// Only forward when the viewport area is focused
82-
if (e.target !== viewportRef && e.target !== imgRef) return;
83-
e.preventDefault();
84-
if (e.key.length === 1) {
85-
ipc.browserTypeText(props.threadId, e.key).catch(() => {});
86-
} else {
87-
ipc.browserKeypress(props.threadId, e.key).catch(() => {});
95+
async function destroyWebview() {
96+
const wv = webviewInstances[props.threadId];
97+
if (wv) {
98+
try { await wv.close(); } catch {}
99+
delete webviewInstances[props.threadId];
88100
}
101+
setReady(false);
89102
}
90103

91104
function close() {
92105
setStore("threadBrowserOpen", props.threadId, false);
93-
ipc.browserClose(props.threadId).catch(() => {});
106+
// Move offscreen instead of destroying (so it persists when switching tabs)
107+
const wv = webviewInstances[props.threadId];
108+
if (wv) {
109+
import("@tauri-apps/api/dpi").then(({ LogicalPosition }) => {
110+
wv.setPosition(new LogicalPosition(-9999, -9999)).catch(() => {});
111+
});
112+
}
94113
}
95114

115+
onMount(() => {
116+
// Wait for layout to settle then create the webview
117+
setTimeout(() => createWebview(), 150);
118+
119+
// Track bounds changes
120+
const ro = new ResizeObserver(() => updateBounds());
121+
if (containerRef) ro.observe(containerRef);
122+
window.addEventListener("resize", updateBounds);
123+
const interval = setInterval(updateBounds, 300);
124+
125+
onCleanup(() => {
126+
ro.disconnect();
127+
window.removeEventListener("resize", updateBounds);
128+
clearInterval(interval);
129+
// Move offscreen when unmounting (tab switch)
130+
const wv = webviewInstances[props.threadId];
131+
if (wv) {
132+
import("@tauri-apps/api/dpi").then(({ LogicalPosition }) => {
133+
wv.setPosition(new LogicalPosition(-9999, -9999)).catch(() => {});
134+
});
135+
}
136+
});
137+
});
138+
96139
return (
97140
<div class="bp">
98141
<div class="bp-bar">
99-
<button class="bp-btn" onClick={() => ipc.browserBack(props.threadId)} title="Back">
100-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="15 18 9 12 15 6"/></svg>
101-
</button>
102-
<button class="bp-btn" onClick={() => ipc.browserForward(props.threadId)} title="Forward">
103-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>
104-
</button>
105-
<button class="bp-btn" onClick={() => { setLoading(true); ipc.browserReload(props.threadId); }} title="Reload">
106-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
107-
</button>
108142
<input
109143
class="bp-url"
110144
value={urlInput()}
@@ -117,29 +151,7 @@ export function BrowserPanel(props: Props) {
117151
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
118152
</button>
119153
</div>
120-
<div
121-
ref={viewportRef}
122-
class="bp-viewport"
123-
tabIndex={0}
124-
onKeyDown={handleKeyDown}
125-
>
126-
<Show when={screenshot()}>
127-
<img
128-
ref={imgRef}
129-
class="bp-img"
130-
src={screenshot()!}
131-
onClick={handleClick}
132-
onWheel={handleScroll}
133-
draggable={false}
134-
/>
135-
</Show>
136-
<Show when={loading() && !screenshot()}>
137-
<div class="bp-status">Loading...</div>
138-
</Show>
139-
<Show when={!loading() && !screenshot()}>
140-
<div class="bp-status">Enter a URL and press Go</div>
141-
</Show>
142-
</div>
154+
<div ref={containerRef} class="bp-container" />
143155
</div>
144156
);
145157
}
@@ -152,7 +164,6 @@ if (!document.getElementById("bp-styles")) {
152164
flex: 1;
153165
display: flex;
154166
flex-direction: column;
155-
background: var(--bg-card);
156167
min-height: 0;
157168
overflow: hidden;
158169
}
@@ -164,6 +175,7 @@ if (!document.getElementById("bp-styles")) {
164175
border-bottom: 1px solid var(--border);
165176
background: var(--bg-surface);
166177
flex-shrink: 0;
178+
z-index: 10;
167179
}
168180
.bp-btn {
169181
width: 24px; height: 24px;
@@ -192,29 +204,9 @@ if (!document.getElementById("bp-styles")) {
192204
flex-shrink: 0;
193205
}
194206
.bp-go:hover { filter: brightness(1.15); }
195-
.bp-viewport {
196-
flex: 1; min-height: 0;
197-
overflow: hidden;
198-
background: #0a0a0a;
199-
display: flex;
200-
align-items: flex-start;
201-
justify-content: center;
202-
outline: none;
203-
}
204-
.bp-img {
205-
width: 100%;
206-
height: 100%;
207-
object-fit: contain;
208-
object-position: top left;
209-
cursor: pointer;
210-
image-rendering: auto;
211-
}
212-
.bp-status {
213-
color: var(--text-tertiary);
214-
font-size: 13px;
215-
padding: 32px;
216-
text-align: center;
217-
align-self: center;
207+
.bp-container {
208+
flex: 1;
209+
min-height: 0;
218210
}
219211
`;
220212
document.head.appendChild(s);

crates/tauri-app/frontend/src/ipc.ts

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -192,38 +192,6 @@ export const getFileDiff = (cwd: string, filePath: string) =>
192192
export const getFileContent = (cwd: string, filePath: string, version: string) =>
193193
invoke<string>("get_file_content", { cwd, filePath, version });
194194

195-
// Browser (Playwright-backed, screenshot-based)
196-
export const browserNavigate = (threadId: string, url: string) =>
197-
invoke("browser_navigate", { threadId, url });
198-
export const browserClick = (threadId: string, x: number, y: number) =>
199-
invoke("browser_click", { threadId, x, y });
200-
export const browserScroll = (threadId: string, deltaY: number) =>
201-
invoke("browser_scroll", { threadId, deltaY });
202-
export const browserTypeText = (threadId: string, text: string) =>
203-
invoke("browser_type_text", { threadId, text });
204-
export const browserKeypress = (threadId: string, key: string) =>
205-
invoke("browser_keypress", { threadId, key });
206-
export const browserBack = (threadId: string) =>
207-
invoke("browser_back", { threadId });
208-
export const browserForward = (threadId: string) =>
209-
invoke("browser_forward", { threadId });
210-
export const browserReload = (threadId: string) =>
211-
invoke("browser_reload", { threadId });
212-
export const browserResize = (threadId: string, width: number, height: number) =>
213-
invoke("browser_resize", { threadId, width, height });
214-
export const browserClose = (threadId: string) =>
215-
invoke("browser_close", { threadId });
216-
217-
export interface BrowserEventPayload {
218-
thread_id: string;
219-
type: string;
220-
data?: string;
221-
url?: string;
222-
}
223-
224-
export const listenBrowserEvent = (callback: (payload: BrowserEventPayload) => void) =>
225-
listen<BrowserEventPayload>("browser-event", (e) => callback(e.payload));
226-
227195
// Naming
228196
export const autoNameThread = (threadId: string, messagesSummary: string, provider: string) =>
229197
invoke<string>("auto_name_thread", { threadId, messagesSummary, provider });

0 commit comments

Comments
 (0)