Rust in Everything
基于 tauri 2.12.5

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 复用,代码注释把这两种情况都列了出来。

📝NOTE
冷知识:回答闭包会在别的线程上运行,这是一个修过的 use-after-free 告诉我们的

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,
}, '*');
❗IMPORTANT
动脑筋:两个 iframe 里,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 发回窗口线程再 SetResponseMainContext::default().invoke(..) 回到 GTK 主上下文再 finish_with_response
初始化脚本WKUserScript,AtDocumentStart,可选只进主 frameadd_script_to_execute_on_document_created 调 AddScriptToExecuteOnDocumentCreated,文档写明总会进子 frameinit 建 UserScript,UserContentInjectedFrames::TopFrame 或 AllFrames
window.ipc.postMessagewindow.webkit.messageHandlers.ipcwindow.chrome.webview.postMessagewindow.webkit.messageHandlers['ipc']
evaluate_scriptevaluateJavaScript:completionHandler:ExecuteScript / ExecuteScriptWithResultrun_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(只读源码)。

💬 关联讨论

发起讨论
加载中...