Skip to content

Commit dafa98d

Browse files
gaoyu06claude
andcommitted
feat: built-in browser, browser_open wiring, element references, screen capture
Built-in browser (right dock): - Renders as a borderless child window pinned over a right-dock "browser" panel, tracking its bounds and hiding on inactive tab / open modal. This replaces the unstable multi-webview (add_child) path, which does not composite on current macOS. Presents a desktop Safari UA so sites that sniff the agent (e.g. baidu) load correctly. - Element picker: an injected devtools-style inspector; picked elements are sent back and inserted into the composer. CLI integration: - Desktop sets JUCODE_DESKTOP so the engine exposes the browser_open tool; its tool events drive the panel's navigation. - Stops bundling the CLI engine sidecar — resolves the system-installed `jucode` (PATH / well-known dirs), dev-checkout fallback. Drops externalBin, prepare-engine, and the CI/release sidecar steps. Composer as a rich editor: - Textarea replaced with a contenteditable surface. Picked page elements become atomic reference chips that serialize to [网页元素#N:label] tokens (source of truth stays a plain string) and expand in place on send, so ordering is controlled inline. Screen capture: - Region screenshot and screen recording (macOS screencapture); videos are keyframe-extracted via ffmpeg and attached as images + a text description. Note: also includes the concurrent, previously-uncommitted voice-input work present in the working tree (audio.ts, Settings/settings voice UI, Info.plist, and voice code in Composer.svelte), which is intermixed in shared files and could not be separated without interactive staging. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a28afb3 commit dafa98d

26 files changed

Lines changed: 1763 additions & 138 deletions

.github/workflows/ci.yml

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,5 @@ jobs:
4747
# generate_context!() embeds the frontend, so build it before compiling Rust.
4848
- run: pnpm install --frozen-lockfile
4949
- run: pnpm build
50-
# tauri-build validates externalBin exists; the real sidecar needs the
51-
# JuCode-CLI checkout, so stub it for the compile/test (it isn't executed).
52-
- name: Stub engine sidecar
53-
run: |
54-
mkdir -p src-tauri/binaries
55-
printf '#!/bin/sh\nexit 0\n' > src-tauri/binaries/jucode-x86_64-unknown-linux-gnu
56-
chmod +x src-tauri/binaries/jucode-x86_64-unknown-linux-gnu
5750
- run: cargo test --manifest-path src-tauri/Cargo.toml
5851
- run: cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings

.github/workflows/release.yml

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,8 @@ jobs:
2121
- os: ubuntu-22.04 # Linux x86_64
2222
- os: windows-latest # Windows x86_64
2323
runs-on: ${{ matrix.os }}
24-
env:
25-
# The engine sidecar is built from the JuCode-CLI checkout below.
26-
JUCODE_CLI_DIR: ${{ github.workspace }}/_engine
2724
steps:
2825
- uses: actions/checkout@v4
29-
- name: Checkout engine (JuCode-CLI)
30-
uses: actions/checkout@v4
31-
with:
32-
repository: JuCode-Team/JuCode-CLI
33-
path: _engine
3426

3527
- name: Install Linux dependencies
3628
if: matrix.os == 'ubuntu-22.04'
@@ -48,15 +40,13 @@ jobs:
4840
- uses: dtolnay/rust-toolchain@stable
4941
- uses: Swatinem/rust-cache@v2
5042
with:
51-
workspaces: |
52-
src-tauri
53-
_engine
43+
workspaces: src-tauri
5444

5545
- run: pnpm install --frozen-lockfile
5646

57-
# tauri-action runs beforeBuildCommand (pnpm bundle:engine && pnpm build),
58-
# which builds the engine sidecar from JUCODE_CLI_DIR, then bundles the app
59-
# and uploads the installers to a release named after the tag.
47+
# tauri-action runs beforeBuildCommand (pnpm build), bundles the app and
48+
# uploads the installers to a release named after the tag. The engine is
49+
# no longer bundled — the app drives the system-installed `jucode` CLI.
6050
- uses: tauri-apps/tauri-action@v0
6151
env:
6252
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
"preview": "vite preview",
1111
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
1212
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
13-
"bundle:engine": "node scripts/prepare-engine.mjs",
1413
"tauri": "tauri",
1514
"test": "vitest run",
1615
"test:watch": "vitest"

scripts/prepare-engine.mjs

Lines changed: 0 additions & 45 deletions
This file was deleted.

src-tauri/Cargo.lock

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

src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ tauri-build = { version = "2", features = [] }
2222

2323
[dependencies]
2424
tauri = { version = "2", features = ["protocol-asset"] }
25+
base64 = "0.22"
2526
tauri-plugin-opener = "2"
2627
tauri-plugin-dialog = "2"
2728
tauri-plugin-notification = "2"

src-tauri/Info.plist

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>NSMicrophoneUsageDescription</key>
6+
<string>JuCode 使用麦克风进行语音输入转写。JuCode uses the microphone for voice-input transcription.</string>
7+
</dict>
8+
</plist>

src-tauri/src/browser.rs

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
//! Embedded browser. Rendered as a borderless **child window** floating exactly
2+
//! over the right-dock browser panel, rather than a child *webview* overlaid on
3+
//! the main window: the multi-webview (`add_child`) path does not composite
4+
//! reliably on newer macOS, whereas a real child WebviewWindow always renders.
5+
//!
6+
//! The frontend's BrowserPanel placeholder reports its client-rect bounds; we
7+
//! convert those to screen coordinates (main window inner position + logical
8+
//! offset × scale) and keep the child window pinned there. As an OS child window
9+
//! it moves with the parent automatically; we only reposition on layout changes
10+
//! (dock/sidebar/window resize) and hide it when a modal is open or the tab is
11+
//! inactive.
12+
//!
13+
//! The page talks back to the host (nav state, picked elements) by navigating to
14+
//! `jucode-ipc:` URLs, which the on_navigation handler intercepts and re-emits as
15+
//! `browser-event`.
16+
17+
use base64::Engine as _;
18+
use tauri::webview::PageLoadEvent;
19+
use tauri::{
20+
AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, Url, WebviewUrl, WebviewWindow,
21+
WebviewWindowBuilder,
22+
};
23+
24+
pub const BROWSER_LABEL: &str = "embedded-browser";
25+
26+
const INIT_SCRIPT: &str = include_str!("browser_init.js");
27+
28+
/// A mainstream Safari UA. The default WKWebView/wry user agent trips up sites
29+
/// that sniff for a "real" browser (e.g. baidu.com serves a broken/blocked page
30+
/// to unrecognized agents), so present as desktop Safari.
31+
const BROWSER_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
32+
AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15";
33+
34+
fn emit_browser(app: &AppHandle, payload: serde_json::Value) {
35+
let _ = app.emit("browser-event", payload);
36+
}
37+
38+
/// Accepts bare hosts ("example.com") by defaulting to https, and rejects
39+
/// anything that isn't http(s) so the panel can't be pointed at file: etc.
40+
fn normalize_url(url: &str) -> Result<Url, String> {
41+
let candidate = if url.contains("://") {
42+
url.to_string()
43+
} else {
44+
format!("https://{}", url.trim())
45+
};
46+
let parsed = Url::parse(&candidate).map_err(|e| format!("invalid url: {e}"))?;
47+
match parsed.scheme() {
48+
"http" | "https" => Ok(parsed),
49+
other => Err(format!("unsupported scheme: {other}")),
50+
}
51+
}
52+
53+
fn get_browser(app: &AppHandle) -> Result<WebviewWindow, String> {
54+
app.get_webview_window(BROWSER_LABEL)
55+
.ok_or_else(|| "browser not open".to_string())
56+
}
57+
58+
/// Positions the child window over the panel. `(x, y, width, height)` are
59+
/// logical, in the main webview's client space; we translate to physical screen
60+
/// coordinates. A non-positive size hides the window (inactive tab / modal open).
61+
fn place(app: &AppHandle, child: &WebviewWindow, x: f64, y: f64, width: f64, height: f64) {
62+
if width < 1.0 || height < 1.0 {
63+
let _ = child.hide();
64+
return;
65+
}
66+
if let Some(main) = app.get_webview_window("main") {
67+
if let (Ok(origin), Ok(scale)) = (main.inner_position(), main.scale_factor()) {
68+
let px = origin.x as f64 + x * scale;
69+
let py = origin.y as f64 + y * scale;
70+
let _ = child.set_position(PhysicalPosition::new(px.round() as i32, py.round() as i32));
71+
let _ = child.set_size(PhysicalSize::new(
72+
(width * scale).round() as u32,
73+
(height * scale).round() as u32,
74+
));
75+
let _ = child.show();
76+
}
77+
}
78+
}
79+
80+
/// Creates the child browser window at the given panel bounds (or navigates the
81+
/// existing one) and loads `url`.
82+
#[tauri::command]
83+
pub fn browser_open(
84+
app: AppHandle,
85+
url: String,
86+
x: f64,
87+
y: f64,
88+
width: f64,
89+
height: f64,
90+
) -> Result<(), String> {
91+
let target = normalize_url(&url)?;
92+
if let Some(existing) = app.get_webview_window(BROWSER_LABEL) {
93+
place(&app, &existing, x, y, width, height);
94+
return existing.navigate(target).map_err(|e| e.to_string());
95+
}
96+
97+
let main = app
98+
.get_webview_window("main")
99+
.ok_or_else(|| "main window not found".to_string())?;
100+
101+
let nav_handle = app.clone();
102+
let load_handle = app.clone();
103+
let window = WebviewWindowBuilder::new(&app, BROWSER_LABEL, WebviewUrl::External(target))
104+
.parent(&main)
105+
.map_err(|e| e.to_string())?
106+
.user_agent(BROWSER_UA)
107+
.title("")
108+
.decorations(false)
109+
.shadow(false)
110+
.skip_taskbar(true)
111+
.resizable(false)
112+
.focused(false)
113+
.visible(false)
114+
.inner_size(width.max(1.0), height.max(1.0))
115+
.initialization_script(INIT_SCRIPT)
116+
.on_navigation(move |url| {
117+
if url.scheme() == "jucode-ipc" {
118+
if let Some(fragment) = url.fragment() {
119+
if let Ok(bytes) =
120+
base64::engine::general_purpose::STANDARD.decode(fragment.as_bytes())
121+
{
122+
if let Ok(value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
123+
emit_browser(&nav_handle, value);
124+
}
125+
}
126+
}
127+
return false;
128+
}
129+
true
130+
})
131+
.on_page_load(move |_webview, payload| {
132+
let kind = match payload.event() {
133+
PageLoadEvent::Started => "nav-start",
134+
PageLoadEvent::Finished => "nav",
135+
};
136+
emit_browser(
137+
&load_handle,
138+
serde_json::json!({ "kind": kind, "url": payload.url().to_string() }),
139+
);
140+
})
141+
.build()
142+
.map_err(|e| e.to_string())?;
143+
144+
place(&app, &window, x, y, width, height);
145+
Ok(())
146+
}
147+
148+
#[tauri::command]
149+
pub fn browser_navigate(app: AppHandle, url: String) -> Result<(), String> {
150+
let target = normalize_url(&url)?;
151+
get_browser(&app)?.navigate(target).map_err(|e| e.to_string())
152+
}
153+
154+
#[tauri::command]
155+
pub fn browser_back(app: AppHandle) -> Result<(), String> {
156+
get_browser(&app)?
157+
.eval("history.back()")
158+
.map_err(|e| e.to_string())
159+
}
160+
161+
#[tauri::command]
162+
pub fn browser_forward(app: AppHandle) -> Result<(), String> {
163+
get_browser(&app)?
164+
.eval("history.forward()")
165+
.map_err(|e| e.to_string())
166+
}
167+
168+
#[tauri::command]
169+
pub fn browser_reload(app: AppHandle) -> Result<(), String> {
170+
get_browser(&app)?
171+
.eval("location.reload()")
172+
.map_err(|e| e.to_string())
173+
}
174+
175+
/// Moves/resizes the child window to track the panel placeholder. A non-positive
176+
/// size hides it (inactive tab, open modal).
177+
#[tauri::command]
178+
pub fn browser_set_bounds(
179+
app: AppHandle,
180+
x: f64,
181+
y: f64,
182+
width: f64,
183+
height: f64,
184+
) -> Result<(), String> {
185+
let child = get_browser(&app)?;
186+
place(&app, &child, x, y, width, height);
187+
Ok(())
188+
}
189+
190+
#[tauri::command]
191+
pub fn browser_close(app: AppHandle) -> Result<(), String> {
192+
if let Some(child) = app.get_webview_window(BROWSER_LABEL) {
193+
child.close().map_err(|e| e.to_string())?;
194+
}
195+
Ok(())
196+
}
197+
198+
/// Toggles the devtools-style element picker inside the page.
199+
#[tauri::command]
200+
pub fn browser_pick(app: AppHandle, enable: bool) -> Result<(), String> {
201+
let js = if enable {
202+
"window.__jucodePicker && window.__jucodePicker.enable()"
203+
} else {
204+
"window.__jucodePicker && window.__jucodePicker.disable()"
205+
};
206+
get_browser(&app)?.eval(js).map_err(|e| e.to_string())
207+
}
208+
209+
#[cfg(test)]
210+
mod tests {
211+
use super::normalize_url;
212+
213+
#[test]
214+
fn bare_host_gets_https() {
215+
assert_eq!(normalize_url("example.com").unwrap().scheme(), "https");
216+
}
217+
218+
#[test]
219+
fn http_kept() {
220+
assert_eq!(
221+
normalize_url("http://localhost:5173/x").unwrap().as_str(),
222+
"http://localhost:5173/x"
223+
);
224+
}
225+
226+
#[test]
227+
fn file_scheme_rejected() {
228+
assert!(normalize_url("file:///etc/passwd").is_err());
229+
}
230+
}

src-tauri/src/browser_init.js

4.64 KB
Binary file not shown.

0 commit comments

Comments
 (0)