wry 在 macOS 上
主线位置:启动主线上「
tauri-runtime-wry用 wry 的WebViewBuilder建 WKWebView:协议成了WKURLSchemeHandler,脚本成了WKUserScript→ 加载tauri://localhost/」这一格;调用主线上「fetch POST ipc://localhost/greet→ wry 把请求交给ipc协议的处理函数」和最后把响应交回 WebKit 的那一步;事件主线上每个 webview 的那段eval。tauri://、ipc://处理函数里做什么归 tauri:// 协议与 CSP 和 ipc 协议处理,这一页只讲 wry 怎么把请求送到处理函数、把回答送回去。
sequenceDiagram participant JS as 页面 JS participant WK as WebKit participant H as SchemeHandler 类 participant F as Rust 处理函数 participant T as 任意线程 JS->>WK: fetch rt://localhost/slow WK->>H: startURLSchemeTask 主线程 H->>H: 拼 http::Request H->>F: function(id, request, responder) F->>T: 把 responder 移过去 F-->>H: 立即返回 T->>WK: didReceiveResponse 和 didFinish WK-->>JS: Promise 兑现
本页讲 wry 0.57.1(tag wry-v0.57.1)的 src/lib.rs 和 src/wkwebview/。tauri 用到的 wry API 只有上一页 create_webview 里那几个;为了把机制看清楚,实验不经过 tauri,直接用 wry =0.57.1 和 tao =0.37.2 写了一个小程序:一个 with_visible(false) 的 tao 窗口,一个 wry webview,一个自定义协议 rt://,两条初始化脚本,一个 IPC 处理函数,程序自己退出事件循环。平台 M2 Max、macOS 27、rustc 1.99.0,debug 构建。Windows(WebView2)和 Linux(WebKitGTK)的部分只读了源码,没有实测。
只能在主线程上建,也只能在主线程上用
WebViewBuilder::build 在 macOS 上进 InnerWebView::new_ns_view,第一行就是:
let mtm = MainThreadMarker::new().ok_or(Error::NotMainThread)?;
MainThreadMarker 是 objc2 的零大小类型,只有在主线程上才造得出来,而它不是 Send。InnerWebView 把它存成字段 mtm,于是整个 wry::WebView 都不是 Send。实验里试着把一个 WebView move 进 thread::spawn:
let other = WebViewBuilder::new().build_as_child(&window).unwrap();
thread::spawn(move || {
let _ = other.evaluate_script("1");
});
error[E0277]: `*mut ()` cannot be sent between threads safely
--> src/main.rs:220:19
...
note: required because it appears within the type `objc2::main_thread_marker::MainThreadMarker`
--> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/objc2-0.6.5/src/main_thread_marker.rs:199:12
...
note: required because it appears within the type `wry::wkwebview::InnerWebView`
--> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/wry-0.57.1/src/wkwebview/mod.rs:174:19
...
note: required because it appears within the type `WebView`
--> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/wry-0.57.1/src/lib.rs:2085:12
紧接着还有第二个错误:「ThreadKindAutoTraits<dyn MainThreadOnly> cannot be shared between threads safely」,来自 WryWebView 这个 MainThreadOnly 的 Objective-C 类。编译器把「WebKit 对象只许在主线程用」变成了类型错误。这就是 tauri-runtime-wry 把 webview 留在主线程的 WindowsStore 里、只给别的线程派发器的原因(上一页)。实验程序里别的线程要执行脚本,只能 proxy.send_event(UserEvent::Eval(..)),由主线程上的事件循环回调去调 evaluate_script_with_callback。
自定义协议:每个 scheme 一个 Objective-C 类
wry 有两个注册函数。with_custom_protocol 收一个 Fn(WebViewId, Request<Vec<u8>>) -> Response<..>,内部包成「算完就 responder.respond(..)」;with_asynchronous_custom_protocol 收 Fn(WebViewId, Request<Vec<u8>>, RequestAsyncResponder),tauri 只用后者。两者都存进同一张 custom_protocols 表;同一个名字注册两次,build 返回错误。实验里连续两次 with_custom_protocol("rt".into(), ..):
[dup] build: Err(DuplicateCustomProtocol("rt"))
这两个函数的文档都写着「When registering a custom protocol with the same name, only the last registered one will be used.」,和行为不符。
new_ns_view 遍历这张表,为每个 scheme 调 url_scheme_handler::create 在运行时造一个 Objective-C 类:
pub fn create(name: &str) -> &AnyClass {
unsafe {
// Include the address of WEBVIEW_STATE in the class name so that each dylib in the process
// gets its own ObjC class with method pointers into its own code and data segments.
let unique_id = std::ptr::addr_of!(WEBVIEW_STATE) as usize;
let scheme_name = format!("{name}URLSchemeHandler_{unique_id:x}\0");
let scheme_name = CStr::from_bytes_with_nul(scheme_name.as_bytes()).unwrap();
let cls = ClassBuilder::new(scheme_name, NSObject::class());
match cls {
Some(mut cls) => {
cls.add_ivar::<*mut c_char>(c"webview_id");
cls.add_ivar::<usize>(c"protocol_index");
cls.add_method(
objc2::sel!(webView:startURLSchemeTask:),
start_task as extern "C" fn(_, _, _, _),
);
cls.add_method(
objc2::sel!(webView:stopURLSchemeTask:),
stop_task as extern "C" fn(_, _, _, _),
);
cls.register()
}
None => AnyClass::get(scheme_name).expect("Failed to get the class definition"),
}
}
}
类实现 WKURLSchemeHandler 协议的两个方法:webView:startURLSchemeTask: 和 webView:stopURLSchemeTask:。每个 webview 再 new 一个实例,把 webview id 和「第几个协议」写进两个 ivar,用 config.setURLSchemeHandler_forURLScheme 挂到 WKWebViewConfiguration 上。Rust 闭包本身不进 Objective-C 对象,而是存在全局的 WEBVIEW_STATE(RwLock<HashMap<String, WebViewState>>)里,按 webview id 找、按下标取。tauri 的一个窗口因此在进程里有 tauriURLSchemeHandler_..、ipcURLSchemeHandler_.. 这样的类,各自只服务一个 scheme。
请求进来:主线程,拼成 http::Request
WebKit 在主线程上调 start_task。它从 task.request()(一个 NSURLRequest)里取 URL、HTTPMethod、正文和 allHTTPHeaderFields,拼成 http::Request<Vec<u8>>。正文先看 HTTPBody,没有就打开 HTTPBodyStream,每次读 128 字节读完。然后造回答闭包,调用户函数:
function(
webview_id,
final_request,
RequestAsyncResponder { responder },
);
function 返回之后 start_task 就结束了,WebKit 不会等。这时请求还没有响应,WKURLSchemeTask 处于打开状态,直到有人调回答闭包。
回答出去:哪个线程调,就在哪个线程上交给 WebKit
RequestAsyncResponder 包着一个 Box<dyn FnOnce(Response<Cow<'static, [u8]>>)>,并且:
// SAFETY: even though the webview bindings do not indicate the responder is Send,
// it actually is and we need it in order to let the user do the protocol computation
// on a separate thread or async task.
unsafe impl Send for RequestAsyncResponder {}
macOS 上这个闭包做的事是:校验 webview 还在、task 还是同一个,把 http::Response 的状态码、头和 Content-Length 拼进 NSHTTPURLResponse,然后依次调 task.didReceiveResponse、task.didReceiveData、task.didFinish,每一步之前都重新校验一次,每一步都包在 objc2::exception::catch 里。它没有把自己派发回主线程:调用 respond 的是哪个线程,这三个 Objective-C 方法就在哪个线程上执行。
校验用的是 WryWebView 里一张「task 地址 → NSUUID」的表:start_task 时登记一个新 UUID,stop_task(WebKit 取消请求,比如页面跳走了)时删掉,回答前比对 UUID,不一致就静默放弃。只比地址不够:task 停掉后它的地址可能被下一个 task 复用,代码注释把这两种情况都列了出来。
PR #1484(「fix(macOS): race conditions around WKURLSchemeTask」,wry 0.49.0)的提交说明列了三个问题:objc2/catch-all feature 把所有 Objective-C 异常变成 panic,让 objc2::exception::catch(...) 失效;「The webview and task objects passed to start_task were not retained, leading to use-after-free issues in the responder.」;「WryWebView::remove_custom_task_key is called from both stop_task and response which may be in different threads, so custom_protocol_task_ids needs to be protected by a Mutex.」修法就是现在代码里的样子:闭包里拿的是 webview.retain()、task.retain(),custom_protocol_task_ids 是 Mutex<HashMap<usize, Retained<NSUUID>>>,CHANGELOG 上这一版记的是「Removed obj-exception feature.」。之前 start_task 拿到的两个对象只在这次回调期间有效,回答闭包一旦被移到别的线程、晚些再调,就可能碰到已经释放的对象。
实验的协议处理函数:/ 和 /frame.html 当场回答;/slow 起一个线程,睡 300ms 再回答;/echo 把方法、正文长度和请求头原样写回去:
"/slow" => {
thread::Builder::new()
.name("slow-responder".into())
.spawn(move || {
thread::sleep(Duration::from_millis(300));
println!("[protocol] /slow responding on {}", th());
responder.respond(respond_text("late", "text/plain"));
})
.unwrap();
println!("[protocol] /slow handler returned on {}", th());
}
页面先 fetch('rt://localhost/slow') 计时,再 fetch('rt://localhost/echo?q=1', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Rie': 'yes' }, body })。Rust 侧:
[main] main/ThreadId(1)
[main] evaluate_script queued before load
[protocol] GET rt://localhost/ on main/ThreadId(1)
[protocol] GET rt://localhost/slow on main/ThreadId(1)
[protocol] /slow handler returned on main/ThreadId(1)
[protocol] /slow responding on slow-responder/ThreadId(3)
[protocol] POST rt://localhost/echo?q=1 on main/ThreadId(1)
[protocol] /echo POST body=314B headers=accept=*/*,content-type=application/json,referer=rt://localhost/,user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko),x-rie=yes
JS 侧量到 "slow":"late","slow_ms":307。三件事:
- 每个请求都在
main/ThreadId(1)上进来。处理函数在主线程上运行,耗时的工作必须挪走,否则整个界面跟着等。tauri 的tauri://处理函数spawn一个 tokio 任务、async 命令在 tokio 线程上回答,都是这个原因。 /slow的处理函数先返回了,300ms 后在slow-responder线程上回答,fetch 正常拿到late。- POST 的正文 314 字节完整到达;请求头里有
Referer,没有Origin(这是同源请求)。tauri 的 IPC 从Origin或Referer判断来源,见 ipc 协议处理。
初始化脚本:WKUserScript,可选只进主 frame
with_initialization_script_for_main_only 把脚本和一个 for_main_frame_only 存进 initialization_scripts(with_initialization_script 就是传 true)。建 webview 时逐条交给 InnerWebView::init:
fn init(&self, js: &str, for_main_only: bool) {
// Safety: objc runtime calls are unsafe
unsafe {
let userscript = WKUserScript::alloc(self.mtm);
let script = WKUserScript::initWithSource_injectionTime_forMainFrameOnly(
userscript,
&NSString::from_str(js),
WKUserScriptInjectionTime::AtDocumentStart,
for_main_only,
);
self.manager.addUserScript(&script);
}
}
注入时机是 AtDocumentStart:每次导航,新文档建好、页面自己的任何脚本运行之前。脚本加在 WKUserContentController 上,以后每个页面都有,不用重复注入。
window.ipc 也是这样来的。只要设了 IPC 处理函数,new_ns_view 在用户脚本之前先 init 一条:
r#"Object.defineProperty(window, 'ipc', { value: Object.freeze({ postMessage: function(s) { window.webkit.messageHandlers.ipc.postMessage(s) } }) });"#,
true,
第二个参数是 true:只进主 frame。
实验注册了两条脚本,window.__MAIN_ONLY = 1;(true)和 window.__ALL_FRAMES = 1;(false)。主页面读 typeof,再插入两个 iframe:同源的 rt://localhost/frame.html 和跨源的 rt://other/frame.html,iframe 里读同样三个值,parent.postMessage 送回主页面:
parent.postMessage({
href: location.href,
main_only: typeof window.__MAIN_ONLY,
all_frames: typeof window.__ALL_FRAMES,
ipc: typeof window.ipc,
}, '*');
typeof window.__ALL_FRAMES 和 typeof window.ipc 各是什么?如果 iframe 里的页面想调 Rust,它手里有什么?tauri 自己的初始化脚本(__TAURI_INTERNALS__、IPC、事件)都按「只进主 frame」注册,tauri-runtime 另有 initialization_script_on_all_frames 给应用用,见 初始化脚本。
window.ipc.postMessage:WKScriptMessageHandler
ipc 这个名字是 IPC_MESSAGE_HANDLER_NAME。WryWebViewDelegate::new 用 addScriptMessageHandler_name 把自己挂到 WKUserContentController 上,JS 的 window.webkit.messageHandlers.ipc.postMessage(s) 就进了它的 did_receive。它只接受字符串(body.downcast::<NSString>(),别的类型直接丢掉,开了 tracing feature 时记一条警告),把 msg.frameInfo().request().URL()(发消息的 frame 的 URL)当作请求 URI,拼成 Request<String> 交给 IPC 处理函数。实验:
[ipc] uri=rt://localhost/ on main/ThreadId(1)
主线程,同步调用,没有回答通道:要回话只能另走 evaluate_script。tauri 的 IPC 主路是 fetch 到 ipc:// 协议,postMessage 是协议不可用时的退路,见 JS 侧:invoke 与回调。
evaluate_script:首次导航之前会排队
WebView::evaluate_script 和 evaluate_script_with_callback 都进 InnerWebView::eval:
pub fn eval(&self, js: &str, callback: Option<impl Fn(String) + Send + 'static>) -> Result<()> {
if let Some(scripts) = &mut *self.pending_scripts.lock().unwrap() {
scripts.push(js.into());
} else {
// Safety: objc runtime calls are unsafe
unsafe {
// ...
// we need to check if the callback exists outside the handler otherwise it's a segfault
if let Some(callback) = callback {
let handler = block2::RcBlock::new(move |val: *mut AnyObject, _err: *mut NSError| {
// ...
let mut result = String::new();
if !val.is_null() {
let json_ns_data = NSJSONSerialization::dataWithJSONObject_options_error(
&*val,
objc2_foundation::NSJSONWritingOptions::FragmentsAllowed,
)
.unwrap();
// ...
result = json_string.to_string();
}
callback(result);
});
self
.webview
.evaluateJavaScript_completionHandler(&NSString::from_str(js), Some(&handler));
} else {
// ...
}
}
}
Ok(())
}
pending_scripts 一开始是 Some(Vec::new())。第一次导航提交时,导航代理的 did_commit_navigation 把排着的脚本逐条 evaluateJavaScript,然后把它设成 None,以后的 eval 直接执行。注意排队的分支把 callback 丢掉了:页面加载前调 evaluate_script_with_callback,回调不会被调用。结果经 NSJSONSerialization 转成 JSON 字符串交给回调,val 是空指针时回调拿到空串。
实验在 build 之后、事件循环开始之前调了一次:
webview
.evaluate_script("window.__early = location.href + ' ' + document.readyState;")
.unwrap();
页面脚本一开始读 String(window.__early),等两个 iframe 都回话后再读一次。后来 worker 线程经 proxy 让主线程执行 ({ n: 1 + 1, href: location.href }) 并取回结果:
[worker] asking main thread to eval, on worker/ThreadId(4)
[loop] UserEvent::Eval on main/ThreadId(1)
[eval cb] result={"n":2,"href":"rt:\/\/localhost\/"} on main/ThreadId(1)
JS 侧两次读 __early:"early":"undefined"、"early_later":"rt://localhost/ complete"。排队的脚本确实在第一个页面里执行了,但页面自己的内联脚本运行时它还没执行,它看到的 document.readyState 已经是 complete(连续跑三次都是这样)。所以「页面脚本运行前就要有」的东西必须用初始化脚本,eval 只适合页面起来之后的推送。回调在主线程上被调,结果是 JSON 文本(/ 被转义成了 \/,这是 NSJSONSerialization 的输出)。
Windows 和 Linux 上是什么(只读源码)
下表的 Windows、Linux 两列只读了 wry 0.57.1 的源码,没有实测:
| 功能 | macOS(WKWebView) | Windows(WebView2) | Linux(WebKitGTK) |
|---|---|---|---|
| 自定义协议 | 每个 scheme 一个 WKURLSchemeHandler 类 | attach_custom_protocol_handler:AddWebResourceRequestedFilter 加 WebResourceRequested 事件;URL 改写成 http(s)://<scheme>.<path>(custom_protocol_workaround.rs,只在 Windows、Android 编译) | register_uri_scheme:注册在 WebContext 上,同名只能一次 |
| 在别的线程回答 | 就在那个线程上调 didReceiveResponse / didFinish | 先 GetDeferral,回答时不在主线程就 dispatch_handler 发回窗口线程再 SetResponse | MainContext::default().invoke(..) 回到 GTK 主上下文再 finish_with_response |
| 初始化脚本 | WKUserScript,AtDocumentStart,可选只进主 frame | add_script_to_execute_on_document_created 调 AddScriptToExecuteOnDocumentCreated,文档写明总会进子 frame | init 建 UserScript,UserContentInjectedFrames::TopFrame 或 AllFrames |
window.ipc.postMessage | window.webkit.messageHandlers.ipc | window.chrome.webview.postMessage | window.webkit.messageHandlers['ipc'] |
evaluate_script | evaluateJavaScript:completionHandler: | ExecuteScript / ExecuteScriptWithResult | run_javascript |
macOS 是三者里唯一一个「回答闭包在哪个线程调用、就在哪个线程上碰 WebKit」的实现。页面来源也不同:macOS、Linux 上是 <scheme>://...,Windows 上是 http://<scheme>.localhost,with_custom_protocol 的文档专门警告过这一点(tauri 的 tauri://localhost 在 Windows 上变成 http://tauri.localhost,见 tauri:// 协议与 CSP)。
动脑筋答案
"frame rt://localhost/frame.html":{"href":"rt://localhost/frame.html","main_only":"undefined","all_frames":"number","ipc":"undefined"},
"frame rt://other/frame.html":{"href":"rt://other/frame.html","main_only":"undefined","all_frames":"number","ipc":"undefined"}
(从 [ipc] body= 那一行里摘出来的两段;主页面三个值都是 number / number / object。)两个 iframe 里 __ALL_FRAMES 是 number,__MAIN_ONLY 和 ipc 都是 undefined,同源跨源一样:WKUserScript 的 forMainFrameOnly 只看是不是主 frame,不看来源。window.ipc 那条脚本是用 true 注入的,所以 iframe 里没有 postMessage 通道。iframe 手里只剩 fetch 自定义协议(rt:// 对 iframe 一样可用,两个 frame.html 本身就是经它加载的)和 parent.postMessage 让主页面转达。
小结
wry::WebView只能在主线程上建(MainThreadMarker::new()),也不是Send;别的线程要操作它,只能经事件循环。- 自定义协议:每个 scheme 一个运行时造出来的
WKURLSchemeHandler类,闭包存在全局WEBVIEW_STATE。请求在主线程进来,start_task拼好http::Request调用户函数后立即返回;RequestAsyncResponder是unsafe impl Send的,在哪个线程调,就在哪个线程上didReceiveResponse/didReceiveData/didFinish,前后用 task UUID 校验,这些是 #1484(0.49.0)修 use-after-free 时定下的。 - 同名协议注册两次,
build返回DuplicateCustomProtocol,与文档说的「只用最后一个」不符。 - 初始化脚本是
AtDocumentStart的WKUserScript,for_main_frame_only决定进不进 iframe;window.ipc只进主 frame,实测两个 iframe 里都没有。 window.ipc.postMessage在主线程上同步到达,URI 是发消息那个 frame 的 URL,没有回答通道。- 首次导航提交前的
evaluate_script会排队(带的回调被丢掉),实测执行时页面已经complete;页面脚本之前要有的东西用初始化脚本。 - Windows、Linux 的回答都会回到 UI 线程,初始化脚本在 Windows 上总进子 frame(只读源码)。