Skip to content

Commit b6b8703

Browse files
fix(browser): switch to Playwright screenshot-based browser with 2x DPI
Native child webview coordinate mapping was unreliable. Now uses headless Chromium via Playwright sidecar with 2x device scale for sharp rendering. Address bar, back/forward/reload all in the browser pane itself. Click and scroll interactions forwarded to Playwright with proper coordinate scaling. Auto-resizes viewport to match pane dimensions.
1 parent d2289f9 commit b6b8703

8 files changed

Lines changed: 293 additions & 234 deletions

File tree

Cargo.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/tauri-app/Cargo.toml

Lines changed: 1 addition & 2 deletions
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 = ["unstable"] }
10+
tauri = { version = "2", features = [] }
1111
tauri-plugin-dialog = "2"
1212
tauri-plugin-shell = "2"
1313
codeforge-session = { path = "../session" }
@@ -21,4 +21,3 @@ uuid = { workspace = true }
2121
chrono = { workspace = true }
2222
anyhow = { workspace = true }
2323
rusqlite = "0.31"
24-
url = "2"

crates/tauri-app/browser-sidecar/browser.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
2424
const { chromium } = await import(join(__dirname, '..', 'frontend', 'node_modules', 'playwright', 'index.mjs'));
2525
import { createInterface } from 'readline';
2626

27-
const VIEWPORT = { width: 900, height: 600 };
27+
const VIEWPORT = { width: 1200, height: 800 };
2828

2929
let browser, context, page;
3030

@@ -44,6 +44,7 @@ async function init() {
4444
});
4545
context = await browser.newContext({
4646
viewport: VIEWPORT,
47+
deviceScaleFactor: 2,
4748
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
4849
});
4950
page = await context.newPage();

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

Lines changed: 139 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,152 @@
1-
import { createSignal, onMount, onCleanup, createEffect } from "solid-js";
1+
import { createSignal, onMount, onCleanup, Show } from "solid-js";
22
import { appStore } from "../../stores/app-store";
33
import * as ipc from "../../ipc";
44

5-
interface BrowserPanelProps {
5+
interface Props {
66
threadId: string;
77
}
88

9-
export function BrowserPanel(props: BrowserPanelProps) {
9+
export function BrowserPanel(props: Props) {
1010
const { store, setStore } = appStore;
11-
const currentUrl = () => store.threadBrowserUrls[props.threadId] || "https://google.com";
12-
const [urlInput, setUrlInput] = createSignal(currentUrl());
13-
const [started, setStarted] = createSignal(false);
14-
let viewportRef: HTMLDivElement | undefined;
15-
16-
// Sync URL input when thread changes
17-
createEffect(() => {
18-
setUrlInput(currentUrl());
19-
});
20-
21-
// Position the native webview to match our viewport div
22-
function updateBounds() {
23-
if (!viewportRef || !started()) return;
24-
const rect = viewportRef.getBoundingClientRect();
25-
if (rect.width < 10 || rect.height < 10) return;
26-
ipc.browserSetBounds(
27-
props.threadId,
28-
Math.round(rect.left),
29-
Math.round(rect.top),
30-
Math.round(rect.width),
31-
Math.round(rect.height),
32-
).catch(() => {});
33-
}
3411

35-
// Keep bounds in sync — ResizeObserver + polling fallback
36-
onMount(() => {
37-
const ro = new ResizeObserver(() => updateBounds());
38-
if (viewportRef) ro.observe(viewportRef);
39-
40-
window.addEventListener("resize", updateBounds);
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;
4117

42-
// Poll every 200ms as fallback for layout changes (sidebar resize, pane drag, etc.)
43-
const interval = setInterval(updateBounds, 200);
18+
// Viewport dimensions for coordinate mapping
19+
const VIEWPORT_W = 1200;
20+
const VIEWPORT_H = 800;
4421

45-
onCleanup(() => {
46-
ro.disconnect();
47-
window.removeEventListener("resize", updateBounds);
48-
clearInterval(interval);
49-
// Hide webview when panel unmounts (thread switch)
50-
ipc.browserHide(props.threadId).catch(() => {});
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+
}
5135
});
52-
});
36+
onCleanup(() => unlisten());
5337

54-
// Open the native webview on first mount — delay to let layout settle
55-
onMount(() => {
56-
setTimeout(() => {
57-
if (!viewportRef) return;
58-
const rect = viewportRef.getBoundingClientRect();
59-
const url = currentUrl();
60-
ipc.browserOpen(
61-
props.threadId,
62-
url,
63-
Math.round(rect.left),
64-
Math.round(rect.top),
65-
Math.round(rect.width),
66-
Math.round(rect.height),
67-
).then(() => {
68-
setStarted(true);
69-
}).catch((e) => {
70-
console.error("Failed to open browser:", e);
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+
}
7148
});
72-
}, 100);
73-
});
74-
75-
// When started, keep bounds in sync
76-
createEffect(() => {
77-
if (started()) {
78-
updateBounds();
49+
ro.observe(viewportRef);
50+
onCleanup(() => ro.disconnect());
7951
}
52+
53+
navigate();
8054
});
8155

8256
function navigate() {
8357
let url = urlInput().trim();
8458
if (!url) return;
8559
if (!url.match(/^https?:\/\//)) url = "https://" + url;
86-
setStore("threadBrowserUrls", props.threadId, url);
8760
setUrlInput(url);
88-
ipc.browserNavigate(props.threadId, url).catch((e) => {
89-
console.error("Navigate failed:", e);
90-
});
61+
setStore("threadBrowserUrls", props.threadId, url);
62+
setLoading(true);
63+
ipc.browserNavigate(props.threadId, url).catch(() => setLoading(false));
64+
}
65+
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(() => {});
78+
}
79+
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(() => {});
88+
}
9189
}
9290

9391
function close() {
9492
setStore("threadBrowserOpen", props.threadId, false);
95-
ipc.browserHide(props.threadId).catch(() => {});
93+
ipc.browserClose(props.threadId).catch(() => {});
9694
}
9795

9896
return (
9997
<div class="bp">
10098
<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>
101108
<input
102109
class="bp-url"
103-
type="text"
104110
value={urlInput()}
105111
onInput={(e) => setUrlInput(e.currentTarget.value)}
106112
onKeyDown={(e) => { if (e.key === "Enter") navigate(); }}
107-
placeholder="Enter URL..."
113+
placeholder="URL..."
108114
/>
109115
<button class="bp-go" onClick={navigate}>Go</button>
110-
<button class="bp-nav" onClick={close} title="Close">
111-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
112-
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
113-
</svg>
116+
<button class="bp-btn" onClick={close} title="Close">
117+
<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>
114118
</button>
115119
</div>
116-
{/* This div reserves space — the native webview is positioned over it */}
117-
<div ref={viewportRef} class="bp-viewport" />
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>
118143
</div>
119144
);
120145
}
121146

122-
if (!document.getElementById("browser-panel-styles")) {
147+
if (!document.getElementById("bp-styles")) {
123148
const s = document.createElement("style");
124-
s.id = "browser-panel-styles";
149+
s.id = "bp-styles";
125150
s.textContent = `
126151
.bp {
127152
flex: 1;
@@ -134,53 +159,62 @@ if (!document.getElementById("browser-panel-styles")) {
134159
.bp-bar {
135160
display: flex;
136161
align-items: center;
137-
gap: 4px;
138-
padding: 6px 8px;
162+
gap: 3px;
163+
padding: 5px 6px;
139164
border-bottom: 1px solid var(--border);
140165
background: var(--bg-surface);
141166
flex-shrink: 0;
142167
}
143-
.bp-nav {
144-
width: 24px;
145-
height: 24px;
168+
.bp-btn {
169+
width: 24px; height: 24px;
146170
border-radius: var(--radius-sm);
147-
display: flex;
148-
align-items: center;
149-
justify-content: center;
171+
display: flex; align-items: center; justify-content: center;
150172
color: var(--text-tertiary);
151173
transition: background 0.1s, color 0.1s;
152174
flex-shrink: 0;
153175
}
154-
.bp-nav:hover { background: var(--bg-accent); color: var(--text-secondary); }
176+
.bp-btn:hover { background: var(--bg-accent); color: var(--text-secondary); }
155177
.bp-url {
156-
flex: 1;
157-
min-width: 0;
158-
height: 26px;
159-
padding: 0 8px;
160-
font-size: 12px;
178+
flex: 1; min-width: 0; height: 24px;
179+
padding: 0 8px; font-size: 12px;
161180
font-family: var(--font-mono);
162181
background: var(--bg-muted);
163182
border: 1px solid var(--border);
164183
border-radius: var(--radius-sm);
165-
color: var(--text);
166-
outline: none;
184+
color: var(--text); outline: none;
167185
}
168186
.bp-url:focus { border-color: var(--primary); }
169187
.bp-go {
170-
height: 26px;
171-
padding: 0 10px;
172-
font-size: 11px;
173-
font-weight: 600;
174-
background: var(--primary);
175-
color: #fff;
188+
height: 24px; padding: 0 10px;
189+
font-size: 11px; font-weight: 600;
190+
background: var(--primary); color: #fff;
176191
border-radius: var(--radius-sm);
177192
flex-shrink: 0;
178-
transition: filter 0.1s;
179193
}
180194
.bp-go:hover { filter: brightness(1.15); }
181195
.bp-viewport {
182-
flex: 1;
183-
min-height: 0;
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;
184218
}
185219
`;
186220
document.head.appendChild(s);

crates/tauri-app/frontend/src/components/tabs/TabBar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ if (!document.getElementById("tab-bar-styles")) {
161161
gap: 2px;
162162
padding: 0 2px 4px;
163163
flex-shrink: 0;
164-
margin-left: 8px;
164+
margin-left: 4px;
165165
}
166166
.tb-action {
167167
width: 24px;

0 commit comments

Comments
 (0)