Rust in Everything
基于 tauri 2.12.5

convertFileSrc 的图片加载失败

主线位置:不在 invoke 上,是启动主线 prepare_pending_webview 挂上的 asset 协议,页面直接按 URL 读本地文件。协议本身(URL 怎么编码、作用域怎么匹配、Range 请求)见 asset:// 协议与文件作用域;本页把一张图片显示不出来的几种原因逐个复现,给出区分它们的办法。

flowchart TB
  img["img onerror"] --> f{"fetch 同一个 URL"}
  f -->|"Load failed 无违规"| off["protocol-asset 没开"]
  f -->|"Load failed 有违规"| csp["CSP 没放行 asset"]
  f -->|403| scope["路径不在作用域"]
  f -->|404| missing["文件不存在"]

实验 crate exp-tauri/pit3-asset:从模板 base 复制,files/a.png 和 files/sub/b.png 是两张 32×32 的 PNG。命令 paths 返回两张图的绝对路径,页面对每个路径 convertFileSrc,先当 <img> 加载(记 onload / onerror),再 fetch 一次记状态码,并收集 securitypolicyviolation:

  for (const p of await T.invoke('paths')) {
    const url = T.convertFileSrc(p);
    const name = p.split('/files/')[1];
    out[`img ${name}`] = await img(url);
    try { const r = await fetch(url); out[`fetch ${name}`] = `${r.status} ${r.headers.get('content-type')}`; }
    catch (e) { out[`fetch ${name}`] = String(e); }
  }

四种配置依次构建、运行。tauri =2.12.5,--features tauri/custom-protocol,隐藏窗口,debug 构建,平台 M2 Max、macOS 27、rustc 1.99.0(WKWebView)。

现象

一:没开 protocol-asset。 tauri 依赖没有这个 feature,配置里也没有 assetProtocol,不设 CSP:

REPORT {
 "img a.png": "error",
 "fetch a.png": "TypeError: Load failed",
 "img sub/b.png": "error",
 "fetch sub/b.png": "TypeError: Load failed",
 "url": "asset://localhost/%2Fx%2Fa.png",
 "violations": []
}

convertFileSrc 照样给出了 URL(它在 core.js 里,和 feature 无关),但 WKWebView 里没有注册 asset 这个 scheme,请求直接失败,没有状态码。

二:开了协议,CSP 没放行 asset:。 feature 和 assetProtocol 都开了,作用域是 files/*,CSP 是一份常见的严格写法:

    "security": {
      "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost",
      "assetProtocol": { "enable": true, "scope": ["~/exp-tauri/pit3-asset/files/*"] }
    }
REPORT {
 "img a.png": "error",
 "fetch a.png": "TypeError: Load failed",
 "img sub/b.png": "error",
 "fetch sub/b.png": "TypeError: Load failed",
 "url": "asset://localhost/%2Fx%2Fa.png",
 "violations": [
  "img-src asset://localhost/%2FUsers%2Fhal%2Fbuild%2Fui-ecosystem%2Fexp-tauri%2Fpit3-asset%2Ffiles%2Fa.png",
  "connect-src asset://localhost/%2FUsers%2Fhal%2Fbuild%2Fui-ecosystem%2Fexp-tauri%2Fpit3-asset%2Ffiles%2Fa.png",
  "img-src asset://localhost/%2FUsers%2Fhal%2Fbuild%2Fui-ecosystem%2Fexp-tauri%2Fpit3-asset%2Ffiles%2Fsub%2Fb.png",
  "connect-src asset://localhost/%2FUsers%2Fhal%2Fbuild%2Fui-ecosystem%2Fexp-tauri%2Fpit3-asset%2Ffiles%2Fsub%2Fb.png"
 ]
}

页面上的结果和第一种一模一样,区别只在违规列表:<img> 被 img-src(回落到 default-src 'self')拦下,fetch 被 connect-src 拦下。请求没有到达 Rust。

三:CSP 放行了,图片在子目录里。 CSP 加上 img-src 'self' asset: http://asset.localhost,connect-src 也加上 asset: 和 http://asset.localhost,作用域还是 files/*:

REPORT {
 "img a.png": "loaded 32x32",
 "fetch a.png": "200 image/png",
 "img sub/b.png": "error",
 "fetch sub/b.png": "403 null",
 "url": "asset://localhost/%2Fx%2Fa.png",
 "violations": []
}

a.png 出来了,sub/b.png 是 403,正文为空。

出处

三种失败落在三个地方:

  • 协议没注册。asset 协议的注册在 prepare_pending_webview 末尾,整段在 #[cfg(feature = "protocol-asset")] 里;assetProtocol.enable 运行时不读,只在编译期由 tauri-build 要求和 feature 一致(asset:// 协议与文件作用域)。
  • CSP。tauri 改写 CSP 时只碰 script-src 和 style-src(set_csp),img-src、media-src、connect-src 由你写什么就是什么。没写 img-src 时回落到 default-src,'self' 只覆盖 tauri://localhost,asset://localhost 是另一个源。
  • 作用域。get_response 在读文件之前查作用域,不通过就回空的 403:
  if !scope.is_allowed(&safe_path) {
    log::error!("asset protocol not configured to allow the path: {path}");
    return resp.status(403).body(Vec::new().into()).map_err(Into::into);
  }

作用域的 glob 用 require_literal_separator: true 编译(scope/fs.rs),* 不匹配 /,所以 files/* 只覆盖 files/ 下一层。Unix 上通配符也不匹配以 . 开头的文件名;..、拒绝表也都回同样的空 403。

原因写在 log::error! 里,而 tauri 自己不装 logger:没有 tauri-plugin-log 或别的 log 实现时,这行字哪里也看不到。在实验里装一个最小的 log::Log(把 Error 级别的记录 println! 出来),第三种配置再跑一次:

[ERROR tauri::protocol::asset] asset protocol not configured to allow the path: ~/exp-tauri/pit3-asset/files/sub/b.png
[ERROR tauri::protocol::asset] asset protocol not configured to allow the path: ~/exp-tauri/pit3-asset/files/sub/b.png

两行:一行是 <img>,一行是 fetch。

修法

三处都要对:

tauri = { version = "=2.12.5", features = ["protocol-asset"] }
    "security": {
      "csp": "default-src 'self'; img-src 'self' asset: http://asset.localhost; connect-src 'self' ipc: http://ipc.localhost asset: http://asset.localhost",
      "assetProtocol": { "enable": true, "scope": ["~/exp-tauri/pit3-asset/files/**"] }
    }
REPORT {
 "img a.png": "loaded 32x32",
 "fetch a.png": "200 image/png",
 "img sub/b.png": "loaded 32x32",
 "fetch sub/b.png": "200 image/png",
 "url": "asset://localhost/%2Fx%2Fa.png",
 "violations": []
}
  • CSP 里同时写 asset: 和 http://asset.localhost:前者是 macOS 上的 URL,后者是 Windows 上 convertFileSrc 生成的形式(convertFileSrc 在 windows / android 上拼 http://asset.localhost/;Windows 没有实测)。只用 <img>、<video> 时 connect-src 可以不加,页面要 fetch 文件就得加。
  • 作用域要递归就写 **。实际项目里常写 $APPDATA/** 这类带变量的路径;变量展开失败的条目会被静默跳过(见 asset:// 协议与文件作用域的「作用域」),也是一个 403 的来源。
  • 路径只在运行时才知道时,用 app.asset_protocol_scope().allow_file(..) / allow_directory(.., true) 临时放行,不要把作用域写成 **:asset 协议不经过 ACL,作用域就是它唯一的权限。

怎么确认没中招

  • 不要只看 <img>,它对所有原因都只给一个 onerror。对同一个 URL fetch 一次,按上面的流程图分:TypeError: Load failed 且没有违规事件,是协议没注册;有 img-src / connect-src 违规,是 CSP;403 是作用域(或含 ..、点文件);404 是文件不存在。
  • 403 的具体原因只在 log::error! 里,开发构建装上 tauri-plugin-log(或任意 log 实现)就能看到。
  • Rust 侧用 app.asset_protocol_scope().is_allowed(path) 对一个真实路径先问一次,能把作用域问题和前端问题分开。

小结

  • convertFileSrc 只负责拼 URL,加载成不成要看三样:protocol-asset feature(加 assetProtocol.enable)、CSP 的 img-src / connect-src 放行 asset: 和 http://asset.localhost、路径在 assetProtocol.scope 里。
  • 前两种在页面上都是 TypeError: Load failed,靠 securitypolicyviolation 区分;作用域问题是空正文的 403,原因只写进日志。
  • 作用域里 * 不跨目录、不匹配点文件,子目录要写 **。

💬 关联讨论

发起讨论
加载中...