diff --git a/.claude/skills/tauri-ohos-design/references/ohos-constraints.md b/.claude/skills/tauri-ohos-design/references/ohos-constraints.md index 6c43c471e724..d5577a6f62b6 100644 --- a/.claude/skills/tauri-ohos-design/references/ohos-constraints.md +++ b/.claude/skills/tauri-ohos-design/references/ohos-constraints.md @@ -42,6 +42,17 @@ | Tray `rect()` 始终返回 None | StatusBar API 不提供图标位置/尺寸。`AvoidArea.topRect` 返回整个状态栏区域, 不是单个图标 | | Tray 事件数据有限 | 只有 `iconClickType` ("leftClick"/"rightClick") 和 `menuCode`。无坐标、无双击、无 hover、无中键 | +### 1.5 tao OHOS 层 ExternalError 错误转换限制 + +| 规则 | 说明 | +|------|------| +| `ExternalError` 无 `From` | tao 的 `ExternalError` 仅 `NotSupported(NotSupportedError)` / `Os(OsError)` 两变体,OHOS `OsError` 是 unit struct(`pub struct OsError;`)不携带消息字符串。**不能** `ExternalError::from(e.to_string())` 编译 | +| ability 函数失败只能 `warn! + NotSupported` | tao OHOS 层调 `openharmony_ability::xxx()` 失败时,用 `warn!` 记录错误详情(`{:?}`),返回 `ExternalError::NotSupported(NotSupportedError::new())`(唯一可用变体) | +| 匹配文件 idiom | 对齐 `set_focus`/`set_focusable`/`set_decorations` 等:`warn!` 记录 + 静默/返回默认值,不携带具体错误消息到上层 | +| Err 仅表示桥接未就绪 | TSFN fire-and-forget 函数(`set_window_blur`/`set_window_touchable` 等)返回 Err 仅当 TSFN 未初始化或 call status 非 Ok(init/编程错误),**不是** 1300002/1300003 运行时失败 — 那些 Promise reject 在 ArkTS `.catch` 捕获、不反向通知 Rust | + +> 来源:ohos-window-ignore-cursor-events Phase 2 实现期审计(design D4 原写的 `ExternalError::from(e.to_string())` 无法编译)。 + --- ## 2. NAPI / TSFN 规则 diff --git a/crates/tauri-cli/templates/mobile/open-harmony/entry_desktop/src/main/module.json5 b/crates/tauri-cli/templates/mobile/open-harmony/entry_desktop/src/main/module.json5 index f8dc6c8c3b7e..96a39de9c023 100644 --- a/crates/tauri-cli/templates/mobile/open-harmony/entry_desktop/src/main/module.json5 +++ b/crates/tauri-cli/templates/mobile/open-harmony/entry_desktop/src/main/module.json5 @@ -58,6 +58,9 @@ }, { "name": "ohos.permission.SET_WINDOW_TRANSPARENT" + }, + { + "name": "ohos.permission.PRINT" } ] } diff --git a/crates/tauri-cli/templates/mobile/open-harmony/entry_mobile/src/main/module.json5 b/crates/tauri-cli/templates/mobile/open-harmony/entry_mobile/src/main/module.json5 index bcb62137eec8..c1ca4a19ba89 100644 --- a/crates/tauri-cli/templates/mobile/open-harmony/entry_mobile/src/main/module.json5 +++ b/crates/tauri-cli/templates/mobile/open-harmony/entry_mobile/src/main/module.json5 @@ -52,6 +52,9 @@ }, { "name": "ohos.permission.SET_WINDOW_TRANSPARENT" + }, + { + "name": "ohos.permission.PRINT" } ] } diff --git a/crates/tauri-runtime-wry/src/lib.rs b/crates/tauri-runtime-wry/src/lib.rs index fae69a7c65a5..518c2036e2c7 100644 --- a/crates/tauri-runtime-wry/src/lib.rs +++ b/crates/tauri-runtime-wry/src/lib.rs @@ -5260,6 +5260,14 @@ You may have it installed on another user account, but it is not available for t if let Some(window_id) = window.window_id() { webview_builder = webview_builder.with_window_id(window_id); } + // Forward use_https_scheme to wry (OHOS branch was missing this — Windows/Android + // branch above sets it, but OHOS didn't, so pl_attrs.use_https was always false + // and rewrite_https_url_if_matching never triggered). See ohos-webview-https-scheme. + webview_builder = webview_builder.with_https_scheme(webview_attributes.use_https_scheme); + // Forward drag_drop_overlay to wry (OHOS-only: transparent Stack that receives + // ArkUI drag events when ArkWeb doesn't bubble OS file drags to Web handlers). + // See ohos-webview-drag-drop-overlay. + webview_builder = webview_builder.with_drag_drop_overlay(webview_attributes.drag_drop_overlay); } if let Some(background_throttling) = webview_attributes.background_throttling { diff --git a/crates/tauri-runtime/src/webview.rs b/crates/tauri-runtime/src/webview.rs index b7653b8c138c..a0898f872c5d 100644 --- a/crates/tauri-runtime/src/webview.rs +++ b/crates/tauri-runtime/src/webview.rs @@ -350,6 +350,11 @@ pub struct WebviewAttributes { pub initialization_scripts: Vec, pub data_directory: Option, pub drag_drop_handler_enabled: bool, + /// Whether to render a transparent overlay Stack that receives ArkUI drag events + /// and forwards them to drag_drop_handler. OHOS-only (ArkWeb may not bubble OS file + /// drags to Web-level handlers; the overlay is the fallback). See ohos-webview-drag-drop-overlay. + #[cfg(target_env = "ohos")] + pub drag_drop_overlay: bool, pub clipboard: bool, pub accept_first_mouse: bool, pub additional_browser_args: Option, @@ -519,6 +524,8 @@ impl WebviewAttributes { initialization_scripts: Vec::new(), data_directory: None, drag_drop_handler_enabled: true, + #[cfg(target_env = "ohos")] + drag_drop_overlay: false, clipboard: false, accept_first_mouse: false, additional_browser_args: None, @@ -744,6 +751,18 @@ impl WebviewAttributes { self } + /// Sets whether to render a transparent drag-drop overlay (OHOS-only). + /// + /// When enabled, a transparent Stack with `HitTestMode.Transparent` is rendered + /// above the Web component to receive ArkUI drag events (ArkWeb may not bubble + /// OS file drags to Web-level handlers). Pointer events pass through to the Web. + #[cfg(target_env = "ohos")] + #[must_use] + pub fn drag_drop_overlay(mut self, enabled: bool) -> Self { + self.drag_drop_overlay = enabled; + self + } + /// Whether web inspector, which is usually called browser devtools, is enabled or not. Enabled by default. /// /// This API works in **debug** builds, but requires `devtools` feature flag to enable it in **release** builds. diff --git a/crates/tauri/src/webview/mod.rs b/crates/tauri/src/webview/mod.rs index 1177671c4651..3938cebe83d9 100644 --- a/crates/tauri/src/webview/mod.rs +++ b/crates/tauri/src/webview/mod.rs @@ -1147,6 +1147,18 @@ fn main() { self } + /// Sets whether to render a transparent drag-drop overlay (OHOS-only). + /// + /// When enabled, a transparent Stack with `HitTestMode.Transparent` is rendered + /// above the Web component to receive ArkUI drag events (ArkWeb may not bubble + /// OS file drags to Web-level handlers). Pointer events pass through to the Web. + #[cfg(target_env = "ohos")] + #[must_use] + pub fn drag_drop_overlay(mut self, enabled: bool) -> Self { + self.webview_attributes.drag_drop_overlay = enabled; + self + } + /// Whether web inspector, which is usually called browser devtools, is enabled or not. Enabled by default. /// /// This API works in **debug** builds, but requires `devtools` feature flag to enable it in **release** builds. diff --git a/crates/tauri/src/webview/plugin.rs b/crates/tauri/src/webview/plugin.rs index cc2ba94ecdf3..d11a46c4e880 100644 --- a/crates/tauri/src/webview/plugin.rs +++ b/crates/tauri/src/webview/plugin.rs @@ -223,8 +223,10 @@ mod desktop_commands { pub fn init() -> TauriPlugin { #[allow(unused_mut)] let mut init_script = String::new(); - // window.print works on Linux/Windows; need to use the API on macOS - #[cfg(any(target_os = "macos", target_os = "ios"))] + // window.print works on Linux/Windows; need to use the API on macOS/iOS/OHOS. + // OHOS ArkWeb has no native window.print, so the print.js shim (which invokes + // plugin:webview|print → wry OHOS print → createPdf → @ohos.print) is required. + #[cfg(any(target_os = "macos", target_os = "ios", target_env = "ohos"))] { init_script.push_str(include_str!("./scripts/print.js")); } diff --git a/crates/tauri/src/webview/webview_window.rs b/crates/tauri/src/webview/webview_window.rs index 3673a98648bb..8372c78e5cc8 100644 --- a/crates/tauri/src/webview/webview_window.rs +++ b/crates/tauri/src/webview/webview_window.rs @@ -1156,6 +1156,18 @@ impl> WebviewWindowBuilder<'_, R, M> { self } + /// Sets whether to render a transparent drag-drop overlay (OHOS-only). + /// + /// When enabled, a transparent Stack with `HitTestMode.Transparent` is rendered + /// above the Web component to receive ArkUI drag events. Pointer events pass + /// through to the Web. See `WebviewBuilder::drag_drop_overlay`. + #[cfg(target_env = "ohos")] + #[must_use] + pub fn drag_drop_overlay(mut self, enabled: bool) -> Self { + self.webview_builder = self.webview_builder.drag_drop_overlay(enabled); + self + } + /// Whether web inspector, which is usually called browser devtools, is enabled or not. Enabled by default. /// /// This API works in **debug** builds, but requires `devtools` feature flag to enable it in **release** builds. diff --git a/doc/manual_tests.md b/doc/manual_tests.md index 79d658794d2d..ead017c18cd6 100644 --- a/doc/manual_tests.md +++ b/doc/manual_tests.md @@ -397,7 +397,7 @@ --- -## 十九、Deep-Link 手动用例 +## 二十、Deep-Link 手动用例 | 一级场景 | 二级场景 | 三级场景 | 用例名称 | 用例级别 | 预置条件 | 测试步骤 | 预期结果 | 备注 | |---------|---------|---------|---------|---------|---------|---------|---------|------| @@ -405,7 +405,7 @@ | core | deep-link | getCurrent | getCurrent 冷启动 — 首启动链接拉起 | **T0** | app 未运行 | 1. `hdc shell "aa force-stop com.tauri.api"` 2. `hdc shell "aa start -U taurideeplink://coldstart"` 3. 等 app 冷启动后在 TestRunner UI manual 区点击 "getCurrent" 按钮 | UI 消息区显示 `[deep-link] getCurrent → ["taurideeplink://coldstart"]` | 冷启动 onCreate want.uri 经 lazy take 注入 | | core | deep-link | 外部唤起 | 外部链接唤起 app — 跨 app 跳转 | **T0** | app 已安装 | 1. `hdc shell "aa force-stop com.tauri.api"` 2. `hdc shell "aa start -U taurideeplink://foreground-test"` | app 唤起到前台(onCreate 冷启动或 onNewWant 运行中) | aa start -U 与浏览器点击 `` 走相同系统 Want 路由(module.json5 skills 匹配);浏览器地址栏直接输入 scheme 会被当搜索词 | -## 二十、Window Operations(窗口操作)手动用例 +## 二十一、Window Operations(窗口操作)手动用例 | 一级场景 | 二级场景 | 三级场景 | 用例名称 | 用例级别 | 预置条件 | 测试步骤 | 预期结果 | 备注 | |---------|---------|---------|---------|---------|---------|---------|---------|------| @@ -415,7 +415,7 @@ | core | persisted-scope | save | fs scope 保存到文件 | **T0** | app 已运行(建议先点 "Persisted-Scope Clear" 清掉旧 `.persisted-scope` 避免残留干扰) | 1. 在 TestRunner UI 底部 "Window Operations & Persisted-Scope Manual Tests" 区点击 "Persisted-Scope Test" 按钮 2. 查看按钮下方显示的结果 3.(可选)`hdc shell ls -l <结果中的 state_file 路径>` 核对文件落盘 | ① `allow_directory: ✅ 成功` ② `.persisted-scope 文件: ✅ 已生成 (N bytes)` ③ `路径:` 显示 state_file 完整路径 | 因 OHOS 不支持 DragDrop(tao OHOS 未实现 DragDrop 事件),通过自定义 `test_persisted_scope` command 直接调 `scope.allow_directory(test_path, true)` 触发 PathAllowed 事件 → persisted-scope 插件监听该事件并把 `allowed_patterns()` 写入 `.persisted-scope`(bincode 二进制)。注意:该 command 返回 `allow_ok / test_path / state_file / state_file_exists / state_file_size`,**不返回 allowed_patterns 数量**,故本步只验证文件生成。 | | core | persisted-scope | restore | 重启后 fs scope 自动恢复 | **T0** | 已执行 save 用例(`.persisted-scope` 文件已生成) | 1. 重启 app:`hdc shell aa force-stop com.tauri.api` 后重新启动 2. 重启后**先不要点 Test**(点 Test 会再次 `allow_directory` 同一路径,使 count 恒为 2,掩盖 restore 是否生效,见备注) 3. 直接点击 "Persisted-Scope Clear" 按钮 4. 查看按钮下方**结果框**(mono 字体 div)的 `remaining_patterns_count`(注意:消息区会被随后的 "Console log saved" 覆盖,看结果框或 hilog) | ① `文件删除: ✅ 已删除`(证明 `.persisted-scope` 跨重启留存)+ `remaining_patterns_count > 0`(典型 = 2:`test_path` + `test_path/**`,因 `allow_directory(recursive=true)` 一次加 2 个 pattern,`crates/tauri/src/scope/fs.rs:284-287`)→ ✅ restore 生效 ② `remaining_patterns_count = 0` → ❌ restore 失败(文件未读 / app_data_dir 在 setup 时不可用 / 反序列化失败) | persisted-scope 插件 setup 时读取 `.persisted-scope`(bincode 反序列化)并对每个 allowed_paths 调 `allow_path`→`scope.allow_directory` 恢复 fs scope。`allow_directory(path, true)` 一次加 2 个 pattern(`path` + `path/**`),fs scope allowed_patterns 是 **HashSet**(`crates/tauri/src/scope/fs.rs`)对同路径幂等去重——故重启后点 Test 仍是 2(不新增),这正是"必须不点 Test 直接 Clear"的原因:不点 Test 时 count>0 证明 restore、count=0 证明失败;点了 Test 则 count 恒=2 无法区分。`clear_persisted_scope` 是唯一返回 count 的入口(读 `scope.allowed_patterns().len()`),但会删 `.persisted-scope`,重复验证需先点 Test 重新保存。 | -## 二十一、Opener(打开文件/URL)手动用例 +## 二十二、Opener(打开文件/URL)手动用例 > autotest 已移除(原 `category:'manual'` 被运行器一律 skip,零覆盖)。opener 的 OHOS 实现走 `openharmony_ability::open_with_system` / `reveal_in_dir`(系统意图),行为依赖系统,必须人眼验证。测试入口:TestRunner 底部 "Plugins Manual Tests" 区按钮。 @@ -427,7 +427,7 @@ --- -## 二十二、Store(持久化存储)手动用例 +## 二十三、Store(持久化存储)手动用例 > autotest 仅覆盖内存 CRUD(set/get/has/keys/entries/delete/close),**刻意不碰 Exit/Drop 路径**。store timeout 修复(OHOS Drop-skip `store.rs:644`、Exit `save_or_skip` `store.rs:555`/`lib.rs:454`)是 defense-in-depth,autotest 不覆盖;磁盘持久化(set→退出→重开→数据在)也需手动验证。测试入口:TestRunner "Plugins Manual Tests" 区。 @@ -439,7 +439,7 @@ --- -## 二十三、Upload(文件上传)手动用例 +## 二十四、Upload(文件上传)手动用例 > autotest 调 upload 并注册 progress 回调,但**只断言响应体非空,未断言 progress 回调触发**。本用例验证 progress 事件确实触发。测试入口:TestRunner "Plugins Manual Tests" 区。依赖 app 内 3003 端口 echo server(autotest upload 已验证可用)。 @@ -449,7 +449,7 @@ --- -## 二十四、Localhost(本地资源服务)手动用例 +## 二十五、Localhost(本地资源服务)手动用例 > autotest fetch `127.0.0.1:3005/index.html` 断言 200 + body,但**未直接断言 CORS 头**。本用例显式检查 `Access-Control-Allow-Origin`。测试入口:TestRunner "Plugins Manual Tests" 区。 @@ -459,7 +459,51 @@ --- -## 二十五、用例统计 +## 二十六、OHOS 适配真 gap 功能 手动用例 + +| 一级场景 | 二级场景 | 三级场景 | 用例名称 | 用例级别 | 预置条件 | 测试步骤 | 预期结果 | 备注 | +|---------|---------|---------|---------|---------|---------|---------|---------|------| +| ohos | drag-overlay | drag-in | Overlay 拖拽接收 — 文件拖入 webview | **T0** | 修改 app 配置添加 `.with_drag_drop_overlay(true)` + `drag_drop_handler`,重新构建部署;desktop 形态 | 1. 从文件管理器拖拽文件到 webview 区域 2. 释放 3. 观察 hilog 搜 `onDragAndDrop` | ① `Enter` → `Over` → `Drop(paths)` → `Leave` 事件序列 ② paths 含拖入文件的 URI ③ Web 级 handler 被抑制(不双发) | 若 overlay 也不触发 → ArkUI 不下发拖拽事件(平台限制);需改 app 配置重建 | +| ohos | drag-overlay | pointer-passthrough | Overlay 透传 — 鼠标/触摸不受影响 | **T0** | 同上(overlay 已渲染) | 1. 在 webview 区域点击、滚动、选中文本 2. 页内 HTML5 拖拽(DOM 元素间拖动) | ① 鼠标点击/滚动/触摸正常响应 ② 文本选择正常 ③ HTML5 DnD 不被 overlay 干扰 | `HitTestMode.Transparent` 透传指针事件 | +| ohos | https-scheme | page-load | HTTPS Scheme — 页面加载 | **T0** | 应用已启动,进入 Tests 页面 | 1. 点击 "HTTPS Scheme" 按钮 2. 观察弹出的测试窗口页面是否渲染 3. hilog 搜 `onInterceptRequest` | ① `onInterceptRequest` 触发 ② custom_protocol 闭包被调用 ③ 页面 HTML 正常渲染 | 若不触发 → onInterceptRequest 不对主框架导航生效(降级) | +| ohos | https-scheme | secure-context | HTTPS Scheme — Secure Context 验证 | **T0** | 同上;页面加载成功 | 1. 在测试窗口的 DevTools 控制台执行 `window.isSecureContext` 2. 执行 `crypto.subtle.digest('SHA-256', new TextEncoder().encode('hello'))` 3. hilog 搜 `isSecureContext` | ① `isSecureContext === true` ② `crypto.subtle.digest(...)` 返回 ArrayBuffer(32 bytes) ③ 不抛异常 | **最终验收门槛**:若 `false` → ArkWeb 不识别自定义 https origin(降级 A/B/C) | +| ohos | https-scheme | external-https | HTTPS Scheme — 外部 HTTPS 不被误拦截 | **T1** | 同上 | 1. 在测试窗口的 DevTools 控制台执行 `fetch('https://example.com')` 2. 观察请求是否正常完成 3. hilog 确认 `onInterceptRequest` 返回 null | ① 外部 https 请求正常完成 ② `onInterceptRequest` 返回 null(不匹配 custom protocol) | 非匹配 URL 返回 null,ArkWeb 走默认网络栈 | +| ohos | https-scheme | subresource | HTTPS Scheme — 子资源 fetch/XHR 拦截 | **T1** | 同上 | 1. 在测试窗口的 DevTools 控制台执行 `fetch('tauri://localhost/api')`(改写为 `https://tauri.localhost/api`) 2. hilog 搜 `onInterceptRequest` | ① `onInterceptRequest` 对 fetch/XHR 子资源触发 ② custom_protocol 闭包被调用 ③ fetch 返回闭包响应 | 验证子资源请求也被拦截 | + +## 二十七、OHOS 适配 8 项功能 手动用例 + +| 一级场景 | 二级场景 | 三级场景 | 用例名称 | 用例级别 | 预置条件 | 测试步骤 | 预期结果 | 备注 | +|---------|---------|---------|---------|---------|---------|---------|---------|------| +| ohos | monitor | refresh-rate | 刷新率真实值 — DisplayManager | **T0** | 应用已启动,进入 Tests 页面 | 1. 等待 auto 测试自动运行 2. 查看 `monitor.real-size` 结果 3. hilog 搜 `monitor` 看输出的 size/scaleFactor | ① auto 测试 PASS ② `size.width > 0 && size.height > 0` ③ 值不随窗口最小化/恢复变化(DisplayManager 物理像素) | `app.refresh_rate()` 取真实刷新率(非硬编码 60) | +| ohos | monitor | from-point | monitor_from_point — 边界判定 | **T1** | 应用已启动,进入 Tests 页面 | 1. 点击 "Monitor Info" 按钮 2. 查看输出的 monitor size + 测试点说明 3. hilog 确认 `monitor_from_point` 无 warn 日志 | ① 显示 monitor size(DisplayManager 物理像素) ② 屏幕内坐标返回 `Some(primary)` ③ 屏幕外坐标返回 `None` ④ 无 warn | OHOS 单显示器,边界判定 `0<=x **背景**: Tauri `Window::set_ignore_cursor_events(ignore)` 在 OHOS 映射到 `ohos.window.setWindowTouchable(!ignore)`(`ignore=true` 穿透 ↔ `touchable=false` 不消费事件,取反在 tao 层)。桥接走 TSFN fire-and-forget(对称 `set_window_blur`):Rust 始终返回 Ok,ArkTS Promise reject(1300002/1300003)由 `.catch` 捕获不闪退、不反向通知 Rust。 +> +> **API 版本矛盾(待真机定论)**: 本地缓存文档标注 setWindowTouchable API 9+/12+,但华为官方智能问答确认为 **API 15+(HarmonyOS 5.0.0+)**。tauri api demo 默认 `compatibleSdkVersion = API 12`。若设备 API < 15,`win.setWindowTouchable` 为 undefined → ArkTS 同步抛 TypeError → 被 ArkHelper `safeLogError` 捕获,**不闪退**,仅穿透不生效。真机验证设备实际 API level 为定论步骤(design R5)。 +> +> **测试入口**: `examples/api` 应用 → Tests 页面 → Manual Tests 区域 → `setIgnoreCursorEvents (3s toggle)` 按钮(smoke:toggle true→false 验证 TSFN 桥接 + 3s 穿透观察)。完整穿透验证需手动创建 Float overlay 子窗口(见 T0 用例)。 +> +> **日志监控**: `hdc shell hilog | grep -iE "setWindowTouchable|WindowManager"` + +| 一级场景 | 二级场景 | 三级场景 | 用例名称 | 用例级别 | 预置条件 | 测试步骤 | 预期结果 | 备注 | +|---------|---------|---------|---------|---------|---------|---------|---------|------| +| ohos | ignore-cursor-events | touch-passthrough | setIgnoreCursorEvents(true) 触摸穿透 | **T0** | 应用已启动;已创建一个 Float 子窗口叠在主窗口上方(如透明 overlay);设备 API ≥ 15 | 1. 在 overlay 子窗口上调用 `setIgnoreCursorEvents(true)` 2. 用手指/鼠标点击 overlay 覆盖区域 3. 观察主窗口是否收到点击 4. hilog 搜 `setWindowTouchable` 5. 调 `setIgnoreCursorEvents(false)` 恢复 | ① 点击穿透到下层主窗口(overlay 不消费触摸/鼠标事件)② hilog 输出 `setWindowTouchable: window N touchable=false`(debug)③ `setIgnoreCursorEvents(false)` 恢复后 overlay 重新消费事件 | `ignore=true` ↔ `touchable=false`(tao 层取反);fire-and-forget,Rust 返回 Ok 不代表 ArkTS 成功,以 hilog + 视觉为准 | +| ohos | ignore-cursor-events | hover-passthrough | setIgnoreCursorEvents hover 穿透 + API 版本 | **T1** | 同上 | 1. overlay 调 `setIgnoreCursorEvents(true)` 2. 鼠标悬停 overlay 覆盖区域 3. 观察下层主窗口的 hover/光标交互是否生效 4. 若 hover 不穿透,确认触摸仍穿透 5. 确认设备 API level(`hdc shell param get const.ohos.apicomversion` 或 deviceInfo.sdkApiVersion) | ① **API ≥ 15 且 hover 穿透**:单 setWindowTouchable 足够 ② **hover 不穿透但触摸穿透**:需追加组件级 `hitTestBehavior(HitTestMode.Transparent)`(参考 R72 drag-drop-overlay,task 4.3)③ **API < 15**:hilog 输出 `setWindowTouchable failed: ...`(TypeError),穿透完全不生效,需在 WindowManager 加 `deviceInfo.sdkApiVersion >= 15` 版本守卫静默跳过 | 真机为定论(design R1/R5);hover fallback 走 task 4.3;版本守卫属底层仓(openharmony-ability)职责,不加在 tao 层 | + + +## 二十九、手动用例统计汇总 | 模块 | T0 | T1 | 合计 | |------|-----|-----|------| @@ -494,5 +538,14 @@ | Store(持久化存储) | 2 | 1 | **3** | | Upload(文件上传) | 1 | 0 | **1** | | Localhost(本地资源服务) | 1 | 0 | **1** | -| **合计** | **75** | **58** | **133** | +| OHOS — Drag Overlay(拖拽降级) | 2 | 0 | **2** | +| OHOS — HTTPS Scheme(安全上下文) | 2 | 2 | **4** | +| OHOS — Monitor(真实值 + from-point) | 1 | 1 | **2** | +| OHOS — WebView Print(打印) | 1 | 0 | **1** | +| OHOS — Event Lifecycle(Start→Resumed + SaveState) | 1 | 1 | **2** | +| OHOS — Clipboard Flag(with_clipboard 开/关) | 2 | 0 | **2** | +| OHOS — Zoom Flag(with_zoom_hotkeys 开/关) | 2 | 0 | **2** | +| OHOS — Dialog Error(降级不 panic) | 0 | 1 | **1** | +| OHOS — Window Ignore Cursor Events(事件穿透) | 1 | 1 | **2** | +| **合计** | **87** | **64** | **151** | diff --git a/examples/api/src-tauri/Cargo.toml b/examples/api/src-tauri/Cargo.toml index bc3d58d78620..7cf9e99be50a 100644 --- a/examples/api/src-tauri/Cargo.toml +++ b/examples/api/src-tauri/Cargo.toml @@ -39,14 +39,15 @@ sentry = { version = "0.42", default-features = false, features = ["reqwest", "r tauri-plugin-sentry = { path = "../../../../sentry-tauri" } chrono = "0.4" url = "2" +# tungstenite is only used by the desktop-only ws echo server (lib.rs, #[cfg(desktop)]) +# NOTE: must be in [dependencies] not [target.'cfg(desktop)'.dependencies] +# because cfg(desktop) is a custom cfg set by tauri-build's build.rs at compile time, +# and Cargo cannot evaluate custom cfgs during dependency resolution. +tungstenite = "0.24" [target.'cfg(not(target_env = "ohos"))'.dependencies] tauri-plugin-dialog = { path = "../../../../plugins-workspace/plugins/dialog" } -[target.'cfg(desktop)'.dependencies] -# tungstenite is only used by the desktop-only ws echo server (lib.rs, cfg(desktop)) -tungstenite = "0.24" - [target.'cfg(target_env = "ohos")'.dependencies] napi-ohos = { version = "1.1" } napi-derive-ohos = { version = "1.1" } diff --git a/examples/api/src-tauri/build.rs b/examples/api/src-tauri/build.rs index b5585c942695..53d4596057dd 100644 --- a/examples/api/src-tauri/build.rs +++ b/examples/api/src-tauri/build.rs @@ -42,8 +42,10 @@ fn main() { "create_transparent_window", "create_borderless_window", "create_transparent_borderless_window", + "create_ohos_test_webview", "create_ui_ability_window", "create_ui_ability_windows_x3", + "create_transparent_ui_ability_window", "transparent_test_start", "dummy_command", "close_test_window", diff --git a/examples/api/src-tauri/capabilities/run-app.json b/examples/api/src-tauri/capabilities/run-app.json index 35bb5506e473..099f1a388504 100644 --- a/examples/api/src-tauri/capabilities/run-app.json +++ b/examples/api/src-tauri/capabilities/run-app.json @@ -40,6 +40,7 @@ "allow-create-transparent-window", "allow-create-borderless-window", "allow-create-transparent-borderless-window", + "allow-create-ohos-test-webview", "allow-create-ui-ability-window", "allow-create-ui-ability-windows-x3", "allow-create-transparent-ui-ability-window", diff --git a/examples/api/src-tauri/src/cmd.rs b/examples/api/src-tauri/src/cmd.rs index 8882478bb9ab..9e6b2dfbe060 100644 --- a/examples/api/src-tauri/src/cmd.rs +++ b/examples/api/src-tauri/src/cmd.rs @@ -1658,3 +1658,73 @@ pub fn clear_window_state( "note": "文件已删除。重启 app 后窗口不会恢复到保存的位置(无文件可读),将出现在默认位置(居中)。" })) } + +/// Create a test webview window with specific OHOS adapter flags. +/// Used by manual test buttons in TestRunner to verify clipboard/zoom/https flags +/// without needing to modify app config and rebuild. +#[command] +pub fn create_ohos_test_webview( + app: tauri::AppHandle, + window_id: String, + label: String, + clipboard: Option, + zoom_hotkeys: Option, + https_scheme: Option, + drag_drop_overlay: Option, +) -> tauri::Result<()> { + log::info!( + "[OHOS-TEST] Creating test webview '{}' (clipboard={:?}, zoom_hotkeys={:?}, https_scheme={:?}, drag_drop_overlay={:?})", + window_id, clipboard, zoom_hotkeys, https_scheme, drag_drop_overlay + ); + + let mut builder = tauri::WebviewWindowBuilder::new( + &app, + &window_id, + WebviewUrl::App("index.html".into()), + ) + .title(&label) + .inner_size(800.0, 600.0); + + if clipboard == Some(true) { + builder = builder.enable_clipboard_access(); + } + if let Some(z) = zoom_hotkeys { + builder = builder.zoom_hotkeys_enabled(z); + } + if let Some(h) = https_scheme { + builder = builder.use_https_scheme(h); + // Inject a script that logs isSecureContext + crypto.subtle availability + // to the webview console (visible in hilog as ARKWEB-CONSOLE). This lets + // us verify the https-scheme rewrite produced a secure context without + // needing DevTools (release build has no devtools feature). + builder = builder.initialization_script( + r#"window.addEventListener('DOMContentLoaded', () => { + console.log('[https-scheme] isSecureContext=' + window.isSecureContext); + console.log('[https-scheme] location.href=' + window.location.href); + try { + crypto.subtle.digest('SHA-256', new TextEncoder().encode('hello')).then(buf => { + console.log('[https-scheme] crypto.subtle OK, bytes=' + buf.byteLength); + }).catch(e => { + console.log('[https-scheme] crypto.subtle FAIL: ' + e); + }); + } catch(e) { + console.log('[https-scheme] crypto.subtle unavailable: ' + e); + } + });"#, + ); + } + + #[cfg(target_env = "ohos")] + { + if let Some(d) = drag_drop_overlay { + builder = builder.drag_drop_overlay(d); + } + } + #[cfg(not(target_env = "ohos"))] + { + let _ = drag_drop_overlay; + } + + builder.build()?; + Ok(()) +} diff --git a/examples/api/src-tauri/src/lib.rs b/examples/api/src-tauri/src/lib.rs index 19ed21200590..b5a18c20de4d 100644 --- a/examples/api/src-tauri/src/lib.rs +++ b/examples/api/src-tauri/src/lib.rs @@ -695,6 +695,7 @@ pub fn run_app) + Send + 'static>( cmd::test_persisted_scope, cmd::clear_persisted_scope, cmd::clear_window_state, + cmd::create_ohos_test_webview, cmd::create_isolated_window, cmd::dummy_command, cmd::create_window_with_custom_ua, diff --git a/examples/api/src/lib/tests/core.ts b/examples/api/src/lib/tests/core.ts index 1eebe4d268c4..65f7a95d6268 100644 --- a/examples/api/src/lib/tests/core.ts +++ b/examples/api/src/lib/tests/core.ts @@ -396,10 +396,14 @@ export const coreTests: TestCase[] = [ }, }, - // Test web_page_snapshot on OHOS: captures WebView content as RGBA bitmap + // Test web_page_snapshot on OHOS: captures WebView content as RGBA bitmap. + // Timeout 20s: the ArkTS webPageSnapshot() path has a 500ms initial delay + + // up to 3 retries (500ms apart) + the OHOS WebviewController snapshot call itself, + // routinely landing near 4.8–5s — too close to the 5s global default (flaky fail). { name: 'webview.webPageSnapshot', category: 'auto', + timeout: 20000, async fn() { const resultPromise = new Promise((resolve) => { const unlisten = listen('web-page-snapshot-result', (event) => { diff --git a/examples/api/src/lib/tests/ohos-adapter.ts b/examples/api/src/lib/tests/ohos-adapter.ts new file mode 100644 index 000000000000..51c16d568417 --- /dev/null +++ b/examples/api/src/lib/tests/ohos-adapter.ts @@ -0,0 +1,161 @@ +import type { TestCase } from '../test-runner'; +import { currentMonitor, getCurrentWindow } from '@tauri-apps/api/window'; +import { listen } from '@tauri-apps/api/event'; + +function assert(condition: boolean, msg: string) { + if (!condition) throw new Error(msg); +} + +/** + * Tests for the OHOS adapter features implemented via openspec changes + * ohos-webview-flag-clipboard / flag-zoom-hotkeys / dialog-error / + * event-lifecycle-forward / monitor-real-values / dialog-folder-picker / + * webview-print / webview-drag-drop. + * + * Most of these features need device interaction (keyboard, drag, system + * dialogs) and are classified 'manual' or 'side-effect'. Only monitor real + * values are fully 'auto'. + */ +export const ohosAdapterTests: TestCase[] = [ + // #5 ohos-monitor-real-values: size() now returns DisplayManager physical + // pixels (was content_rect). Assert non-zero display size. + { + name: 'ohos-adapter.monitor.real-size', + category: 'auto', + async fn() { + const m = await currentMonitor(); + assert(m !== null, 'currentMonitor returned null'); + assert( + m!.size.width > 0 && m!.size.height > 0, + `monitor size should be > 0 (DisplayManager physical px), got ${JSON.stringify(m!.size)}` + ); + assert(m!.scaleFactor > 0, `scaleFactor should be > 0, got ${m!.scaleFactor}`); + console.log(`[monitor] size=${m!.size.width}x${m!.size.height}, scaleFactor=${m!.scaleFactor}, name=${m!.name}`); + }, + }, + + // #6 ohos-dialog-folder-picker: covered by doc/manual_tests.md dialog open/directory + // (directory variant of dialog.open — interactive picker, manual category like + // the other dialog.open tests in plugins.ts). + + // #4 ohos-event-lifecycle-forward: MainEvent::Start → Event::Resumed. + // Manual: background then foreground the app to trigger SHOWN → Resumed. + { + name: 'ohos-adapter.event.resumed', + category: 'manual', + async fn() { + let fired = false; + const unlisten = await listen('tauri://resumed', () => { + fired = true; + }); + console.log('[resumed] listener registered — background then foreground the app to trigger'); + // Give a brief window; full verification requires manual background/foreground. + await new Promise((r) => setTimeout(r, 1000)); + unlisten(); + console.log('[resumed] fired within 1s:', fired, '(manual: background/foreground app to verify)'); + }, + }, + + // #7 ohos-webview-print: print() invokes @ohos.print (desktop) / no-op if + // page not loaded. Manual: verify system print dialog appears. + { + name: 'ohos-adapter.webview.print', + category: 'manual', + async fn() { + console.log('[manual] print: call webview print (e.g. via window.print() or a print button)'); + console.log('[manual] expected: system print dialog on desktop; temp PDF cleaned up after job'); + console.log('[manual] if PrintKit unavailable, falls back to createPdf + warn log'); + }, + }, + + // #1 ohos-webview-flag-clipboard + #2 ohos-webview-flag-zoom-hotkeys: + // flags are set per-webview at creation; verify via a test webview config. + { + name: 'ohos-adapter.flags.clipboard-zoom', + category: 'manual', + async fn() { + console.log('[manual] with_clipboard(false): select text + Ctrl+C → clipboard unchanged'); + console.log('[manual] with_clipboard(true): Ctrl+C → copies normally'); + console.log('[manual] with_zoom_hotkeys(false): Ctrl+= / Ctrl+- → no zoom'); + console.log('[manual] with_zoom_hotkeys(true): Ctrl+= / Ctrl+- → ArkWeb native zoom'); + console.log('[manual] programmatic pasteboard read/write unaffected by flag'); + }, + }, + + // #8 ohos-webview-drag-drop: drag a file onto the webview window. + { + name: 'ohos-adapter.webview.drag-drop', + category: 'manual', + async fn() { + console.log('[manual] drag a file onto the webview window → drag_drop_handler fires'); + console.log('[manual] expected events: Enter → Over → Drop(paths) → Leave'); + console.log('[manual] if no event fires, ArkWeb does not bubble OS drag → overlay fallback (see spec)'); + }, + }, + + // R80 ohos-webview-proxy-config: REVERTED — tauri doesn't expose proxy_config on any platform. + // wry-level implementation removed. See openspec/specs/ohos-webview-proxy-config/ for design reference. + + // R72 ohos-webview-drag-drop-overlay: overlay fallback when ArkWeb doesn't bubble. + // Requires with_drag_drop_overlay(true) + file drag. + { + name: 'ohos-adapter.webview.drag-drop-overlay', + category: 'manual', + async fn() { + console.log('[manual] overlay: set with_drag_drop_overlay(true) on a test webview'); + console.log('[manual] drag a file onto the webview → overlay Stack receives ArkUI drag events'); + console.log('[manual] expected: Enter → Over → Drop(paths) → Leave via overlay (not Web-level handlers)'); + console.log('[manual] verify: pointer interaction (click/scroll/touch) still passes through to Web'); + console.log('[manual] if overlay also doesn\'t fire → platform limitation (ArkUI doesn\'t deliver drag)'); + }, + }, + + // R75 ohos-webview-https-scheme: secure-context via onInterceptRequest. + // Requires with_https_scheme(true) + custom protocol registered. + { + name: 'ohos-webview.https-scheme', + category: 'manual', + async fn() { + console.log('[manual] https-scheme: set with_https_scheme(true) + register "tauri://" custom protocol'); + console.log('[manual] load tauri://localhost/index.html → URL rewritten to https://tauri.localhost/index.html'); + console.log('[manual] verify: page renders (onInterceptRequest intercepts + custom_protocol returns HTML)'); + console.log('[manual] verify: window.isSecureContext === true (hilog)'); + console.log('[manual] verify: typeof crypto?.subtle === "object" (secure-context API available)'); + console.log('[manual] verify: external https (https://example.com) loads normally (not intercepted)'); + console.log('[manual] verify: fetch("tauri://localhost/api") → intercepted by onInterceptRequest'); + console.log('[manual] if isSecureContext === false → ArkWeb doesn\'t recognize custom https origin (degradation)'); + }, + }, + + // ohos-window-ignore-cursor-events: Window::set_ignore_cursor_events maps to + // ohos.window.setWindowTouchable(!ignore) via TSFN fire-and-forget (mirrors + // set_window_blur). ignore=true (pass through) ↔ touchable=false (don't consume). + // Manual: overlay sub-window over the main window, set ignore=true, verify + // touch+hover reach the window below. API version: setWindowTouchable requires + // API 15+ per official Q&A (local docs say 9+/12+) — real device is the arbiter. + { + name: 'ohos-adapter.window.ignore-cursor-events', + category: 'manual', + async fn() { + // Safe smoke call: setIgnoreCursorEvents(false) exercises the full TSFN bridge + // (tao → openharmony_ability → ArkHelper → WindowManager → win.setWindowTouchable(true)) + // without making the test window non-touchable. fire-and-forget: always resolves + // Ok from Rust; real proof is hilog + visual pass-through, not this call's return. + try { + const win = getCurrentWindow(); + await win.setIgnoreCursorEvents(false); + console.log('[ignore-cursor-events] setIgnoreCursorEvents(false) returned OK (TSFN bridge wired)'); + } catch (e) { + console.log('[ignore-cursor-events] setIgnoreCursorEvents(false) rejected:', e); + } + console.log('[manual] setup: create a Float sub-window overlapping the main window (transparent overlay)'); + console.log('[manual] on the overlay call setIgnoreCursorEvents(true) → maps to setWindowTouchable(false)'); + console.log('[manual] verify: touch/click the overlay area → event reaches the main window below (pass-through)'); + console.log('[manual] verify: mouse hover over overlay → cursor interacts with content below'); + console.log('[manual] hilog: grep "setWindowTouchable" → debug log = API called; "failed" log = API<15 or window not found'); + console.log('[manual] API version: setWindowTouchable requires API 15+ (HarmonyOS 5.0.0+); demo targets API 12 → verify device API first'); + console.log('[manual] if touch passes but hover does not → add hitTestBehavior(HitTestMode.Transparent) fallback (design R1)'); + console.log('[manual] restore: setIgnoreCursorEvents(false) on the overlay to re-enable event consumption'); + }, + }, +]; diff --git a/examples/api/src/views/TestRunner.svelte b/examples/api/src/views/TestRunner.svelte index 08975eece245..a943fa896f07 100644 --- a/examples/api/src/views/TestRunner.svelte +++ b/examples/api/src/views/TestRunner.svelte @@ -9,6 +9,7 @@ import { imageTests } from '../lib/tests/image'; import { menuTests } from '../lib/tests/menu'; import { trayTests } from '../lib/tests/tray'; + import { ohosAdapterTests } from '../lib/tests/ohos-adapter'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { getCurrentWindow, currentMonitor, cursorPosition, Effect, PhysicalPosition, PhysicalSize } from '@tauri-apps/api/window'; @@ -63,7 +64,7 @@ pressedKeys.clear(); } - const allTests = [...coreTests, ...pluginTests, ...dpiTests, ...windowDpiTests, ...windowOpsTests, ...imageTests, ...menuTests, ...trayTests]; + const allTests = [...coreTests, ...pluginTests, ...dpiTests, ...windowDpiTests, ...windowOpsTests, ...imageTests, ...menuTests, ...trayTests, ...ohosAdapterTests]; const webview = getCurrentWebview(); async function runAll() { @@ -201,6 +202,56 @@ }); } + // setIgnoreCursorEvents smoke test (ohos-window-ignore-cursor-events). + // Toggle true → false on the current window: fire-and-forget TSFN bridge + // (tao set_ignore_cursor_events → openharmony_ability set_window_touchable → + // ArkHelper → WindowManager → win.setWindowTouchable). Rust always returns Ok; + // real proof is hilog `grep setWindowTouchable` + visual pass-through. Briefly + // setting true lets the user observe the window stop consuming events; false + // restores. For full pass-through verification create a Float overlay window. + async function manualIgnoreCursorEvents() { + await wrapManual('setIgnoreCursorEvents', async () => { + const win = getCurrentWindow(); + // 1. Safe restore first — verifies the TSFN bridge is wired (no throw). + await win.setIgnoreCursorEvents(false); + manualResult = 'setIgnoreCursorEvents(false) → OK (TSFN bridge wired, events consumed normally)'; + onMessage(manualResult); + // 2. Briefly enable ignore=true (events pass through) so the user can observe. + await win.setIgnoreCursorEvents(true); + onMessage('setIgnoreCursorEvents(true) → dispatched. For ~3s the window ignores events (pass-through). Click to test, then auto-restore.'); + await new Promise((r) => setTimeout(r, 3000)); + // 3. Auto-restore so the window doesn't get stuck non-interactive. + await win.setIgnoreCursorEvents(false); + manualResult = 'Restored: setIgnoreCursorEvents(false). Check hilog `grep setWindowTouchable` for debug logs.'; + onMessage(manualResult); + }); + } + + // RunEvent::Resumed manual test (ohos-event-lifecycle-forward). + // Listens for the 'tauri://resumed' event, then prompts the user to background + // and foreground the app. On OHOS, MainEvent::Start (SHOWN) is forwarded as + // Event::Resumed. Returns whether the event fired within the wait window. + async function manualEventResumed() { + await wrapManual('RunEvent::Resumed', async () => { + let fired = false; + const unlisten = await listen('tauri://resumed', () => { + fired = true; + }); + manualResult = 'Listening for tauri://resumed.\nBackground the app (Home/最小化) then bring it back to foreground.\nWaiting up to 30s...'; + onMessage(manualResult); + // Give the user up to 30s to background/foreground. + for (let i = 0; i < 30; i++) { + await new Promise((r) => setTimeout(r, 1000)); + if (fired) break; + } + unlisten(); + manualResult = fired + ? 'PASS: RunEvent::Resumed fired after background→foreground.' + : 'FAIL: RunEvent::Resumed did not fire within 30s. (background the app and return to trigger SHOWN→Resumed)'; + onMessage(manualResult); + }); + } + async function manualAppCacheDir() { await wrapManual('appCacheDir', async () => { const dir = await appCacheDir(); @@ -1487,6 +1538,115 @@ initial=${report.initial}, after_open=${report.after_open}, after_close=${report }); } + // ─── OHOS Adapter Manual Tests ─── + async function manualOhosPrint() { + await wrapManual('webview.print', async () => { + // window.print() is injected by tauri's print.js init script (plugin:webview|print + // → wry OHOS print → createPdf → @ohos.print). Webview class has no print method; + // the global window.print shim is the correct entry point on macOS/iOS/OHOS. + try { + await window.print(); + manualResult = 'window.print() called — check system print dialog (may take a few seconds for createPdf)'; + } catch (e) { + manualResult = `print() error: ${e}`; + } + onMessage(manualResult); + }); + } + + async function manualOhosMonitorFromPoint() { + await wrapManual('monitor_from_point', async () => { + const { currentMonitor } = await import('@tauri-apps/api/window'); + const m = await currentMonitor(); + if (!m) { manualResult = 'No monitor'; onMessage(manualResult); return; } + const cx = Math.floor(m.size.width / 2); + const cy = Math.floor(m.size.height / 2); + manualResult = `monitor size: ${m.size.width}x${m.size.height}\n` + + `test point (cx,cy)=(${cx},${cy}) should be Some(primary)\n` + + `test point (-1,0) should be None\n` + + `Note: monitor_from_point is tao-level, not exposed in JS API.\n` + + `Verify via hilog or Rust test.`; + onMessage(manualResult); + }); + } + + async function manualOhosDialogError() { + await wrapManual('dialog.error degrade', async () => { + manualResult = 'dialog::error() is an internal runtime function.\n' + + 'On OHOS it degrades to log::error! (no panic).\n' + + 'The function is only called under cfg(windows) in practice.\n' + + 'To verify: check hilog for "[dialog::error]" entries after\n' + + 'triggering a runtime error path. App should NOT crash.'; + onMessage(manualResult); + }); + } + + // OHOS adapter: create test webviews with specific flags + async function manualOhosTestClipboardOff() { + await wrapManual('clipboard=false', async () => { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('create_ohos_test_webview', { + windowId: 'test-cb-off-' + Date.now(), + label: 'Clipboard OFF test', + clipboard: false, + }); + manualResult = 'Test webview created with clipboard=false.\nSelect text + Ctrl+C → clipboard should NOT change.'; + onMessage(manualResult); + }); + } + + async function manualOhosTestClipboardOn() { + await wrapManual('clipboard=true', async () => { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('create_ohos_test_webview', { + windowId: 'test-cb-on-' + Date.now(), + label: 'Clipboard ON test', + clipboard: true, + }); + manualResult = 'Test webview created with clipboard=true.\nSelect text + Ctrl+C → clipboard should change.'; + onMessage(manualResult); + }); + } + + async function manualOhosTestZoomOff() { + await wrapManual('zoom_hotkeys=false', async () => { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('create_ohos_test_webview', { + windowId: 'test-zoom-off-' + Date.now(), + label: 'Zoom OFF test', + zoomHotkeys: false, + }); + manualResult = 'Test webview created with zoom_hotkeys=false.\nCtrl+= / Ctrl+- / Ctrl+0 → page zoom should NOT change.'; + onMessage(manualResult); + }); + } + + async function manualOhosTestZoomOn() { + await wrapManual('zoom_hotkeys=true', async () => { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('create_ohos_test_webview', { + windowId: 'test-zoom-on-' + Date.now(), + label: 'Zoom ON test', + zoomHotkeys: true, + }); + manualResult = 'Test webview created with zoom_hotkeys=true.\nCtrl+= / Ctrl+- / Ctrl+0 → page zoom should change.'; + onMessage(manualResult); + }); + } + + async function manualOhosTestHttpsScheme() { + await wrapManual('https_scheme=true', async () => { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('create_ohos_test_webview', { + windowId: 'test-https-' + Date.now(), + label: 'HTTPS Scheme test', + httpsScheme: true, + }); + manualResult = 'Test webview created with use_https_scheme=true.\nCheck hilog for onInterceptRequest + URL rewrite.\nVerify window.isSecureContext in DevTools.'; + onMessage(manualResult); + }); + } + // ─── Autostart Manual Tests ─── async function manualAutostartIsEnabled() { await wrapManual('autostart.isEnabled', async () => { @@ -2260,6 +2420,7 @@ Mutex released, no cascade deadlock: ${ok ? 'PASS ✅' : 'FAIL ❌'}`; {focusWatchActive ? 'Stop watching focus' : 'Watch onFocusChanged'} + @@ -2463,6 +2624,24 @@ Mutex released, no cascade deadlock: ${ok ? 'PASS ✅' : 'FAIL ❌'}`; +
+
OHOS Adapter Manual Tests
+
+ + + + +
+
+ + +
+
+ + + +
+
Autostart Manual Tests
diff --git a/openspec/changes/archive/2026-08-06-ohos-dialog-error/proposal.md b/openspec/changes/archive/2026-08-06-ohos-dialog-error/proposal.md new file mode 100644 index 000000000000..d470cd38b3b5 --- /dev/null +++ b/openspec/changes/archive/2026-08-06-ohos-dialog-error/proposal.md @@ -0,0 +1,10 @@ +## Why +`tauri-runtime-wry/src/dialog/mod.rs` 的 `error()` 在非 Windows 平台(含 OHOS)走 `unimplemented!()`,是 panic 隐患(footgun)。虽然运行时调用点仅在 `cfg(windows)` 触发,OHOS 实际不会走到,但函数体本身不应 panic。 + +## What Changes +- `error()` 拆分 cfg:`#[cfg(all(not(windows), target_env = "ohos"))]` 分支改为 `log::error!` 降级;其余非 Windows 平台保留 `unimplemented!()` 不变。 + +## Impact +- OHOS 不再因 error() panic +- 其他平台完全不变 +- 用户级错误对话框语义已由 `ohos-dialog-plugin` 的 `MessageDialogKind::Error` + `showMessageDialog` 覆盖(OHOS 不按 kind 切图标,已在 dialog-plugin spec 标注) diff --git a/openspec/changes/archive/2026-08-06-ohos-dialog-error/tasks.md b/openspec/changes/archive/2026-08-06-ohos-dialog-error/tasks.md new file mode 100644 index 000000000000..b35dcc0495fd --- /dev/null +++ b/openspec/changes/archive/2026-08-06-ohos-dialog-error/tasks.md @@ -0,0 +1,3 @@ +# ohos-dialog-error Tasks + +- [x] 1. `tauri-runtime-wry/src/dialog/mod.rs` `error()` 新增 `cfg(all(not(windows), target_env = "ohos"))` 分支,`log::error!` 降级;其余非 Windows 保留 `unimplemented!()` diff --git a/openspec/changes/archive/2026-08-06-ohos-window-ignore-cursor-events/.openspec.yaml b/openspec/changes/archive/2026-08-06-ohos-window-ignore-cursor-events/.openspec.yaml new file mode 100644 index 000000000000..1c37182ed648 --- /dev/null +++ b/openspec/changes/archive/2026-08-06-ohos-window-ignore-cursor-events/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-05 diff --git a/openspec/changes/archive/2026-08-06-ohos-window-ignore-cursor-events/design.md b/openspec/changes/archive/2026-08-06-ohos-window-ignore-cursor-events/design.md new file mode 100644 index 000000000000..2b3d98752732 --- /dev/null +++ b/openspec/changes/archive/2026-08-06-ohos-window-ignore-cursor-events/design.md @@ -0,0 +1,171 @@ +## Context + +Tauri/tao 提供 `Window::set_ignore_cursor_events(ignore: bool)`:`ignore=true` 时窗口不消费鼠标/触摸事件,事件穿透到下层窗口。Windows 用 `WindowFlags::IGNORE_CURSOR_EVENT`,macOS 用 `NSWindow setIgnoresMouseEvents`。OHOS 后端当前空实现返回 `NotSupported`。 + +OHOS `ohos.window.setWindowTouchable(isTouchable: boolean): Promise`(API 9+,元服务 12+,`SystemCapability.WindowManager.WindowManager.Core`)。官方智能问答(最新版)确认 `false` 时触摸+鼠标事件穿透到下层窗口;本地缓存文档与 ask_ai 在版本号和穿透语义上存在矛盾,**真机验证为定论步骤**。 + +当前 `ohdev` 旧模型已有两套 window 能力模式: +- **同步直调**(`set_window_decorations`/`focus_window`):`get_helper()` + `get_named_property("xxx").call()`,仅主线程 +- **TSFN 跨线程**(`set_window_blur`/`set_window_background_color`):`init_vibrancy_tsfn` 建全局 TSFN,任意线程 fire-and-forget 调 + +`set-touchable` 走 **TSFN 模式**(对称 `set_window_blur`),因为 tao 命令可能在 worker 线程,同步直调在 worker 上会因 `get_main_thread_env()==None` 失败。 + +## Goals / Non-Goals + +**Goals:** +- 在 `openharmony-ability` 新增 `set_window_touchable(window_id, touchable)` TSFN 函数,对称 `set_window_blur`。 +- ArkHelper 暴露 `setWindowTouchable(windowId, touchable)`,调 `wm.setWindowTouchable`,`.catch` 处理 Promise reject。 +- 为 Phase 2 的 tao `set_ignore_cursor_events` 填实提供函数基础。 +- 逻辑取反映射:Tauri `ignore=true`(穿透)↔ OHOS `touchable=false`(穿透),取反在 tao 层。 + +**Non-Goals:** +- 不在 Phase 1 填实 tao(Phase 2)。 +- 不做真机验证(Phase 2)。 +- 不实现组件级 `hitTestBehavior` 穿透(仅当 Phase 2 真机验证 hover 不穿透时才追加)。 +- 不改变 `set_window_blur` 等现有 TSFN 能力。 +- 不考虑新模型 plugin-window 重构(本设计基于当前 ohdev 旧模型)。 + +## Decisions + +### D1: TSFN 模式 — 对称 `set_window_blur` + +完全照搬 `set_window_blur` 的实现结构(`window/mod.rs:172-245`): + +```rust +// window/mod.rs +type SetWindowTouchableTsfn = ThreadsafeFunction<(i64, bool), (), FnArgs<(i64, bool)>, Status, false>; +static TSFN_SET_WINDOW_TOUCHABLE: OnceLock = OnceLock::new(); + +// 在 init_vibrancy_tsfn 内追加(或新建 init 函数): +let touchable_fn: Function<'_, FnArgs<(i64, bool)>, ()> = helper_obj + .get_named_property("setWindowTouchable")?; +let touchable_tsfn = touchable_fn + .build_threadsafe_function::<(i64, bool)>() + .callee_handled::() + .build_callback(move |ctx: ThreadsafeCallContext<(i64, bool)>| { + Ok(FnArgs { data: ctx.value }) + })?; +let _ = TSFN_SET_WINDOW_TOUCHABLE.set(touchable_tsfn); + +/// Sets window touchable state via TSFN (threadsafe, callable from any thread). +/// touchable=false → events pass through to windows below (ignore cursor events). +pub fn set_window_touchable(window_id: i64, touchable: bool) -> napi_ohos::Result<()> { + let tsfn = TSFN_SET_WINDOW_TOUCHABLE.get() + .ok_or_else(|| Error::from_reason("set_window_touchable TSFN not initialized"))?; + let status = tsfn.call((window_id, touchable), ThreadsafeFunctionCallMode::NonBlocking); + if status != Status::Ok { + return Err(Error::from_reason(format!("TSFN call failed: {:?}", status))); + } + Ok(()) +} +``` + +### D2: ArkTS 侧 — WindowManager 封装 + ArkHelper 转发 + +**审计修正**:旧模型 window 能力走两层——`ArkHelper.ets` 转发到 `WindowManager.ets` 的封装方法(参照 `setWindowFocusable`)。`WindowManager` 用 `getWindow(windowId)`(非 `getWindowById`)取窗口实例,再调 `win.setWindowTouchable(touchable).then().catch()`。 + +**WindowManager.ets**(对称 `setWindowFocusable:201-212`): +```typescript +setWindowTouchable(windowId: number, touchable: boolean): void { + const win = this.getWindow(windowId); + if (!win) { + hilog.warn(DOMAIN, 'WindowManager', 'setWindowTouchable: window %{public}d not found', windowId); + return; + } + win.setWindowTouchable(touchable).then(() => { + hilog.debug(DOMAIN, 'WindowManager', 'setWindowTouchable: window %{public}d touchable=%{public}s', windowId, String(touchable)); + }).catch((err: ESObject) => { + // 必须.catch:setWindowTouchable返回Promise,401/1300002/1300003均reject异步传递 + // 此处是Promise异步回调,不在NAPI-reentrant调用栈,hilog.error安全(参照setWindowFocusable:210) + hilog.error(DOMAIN, 'WindowManager', 'setWindowTouchable failed: %{public}s', JSON.stringify(err)); + }); +} +``` + +**ArkHelper.ets**(转发,对称 `setWindowFocusable:558-565`): +```typescript +setWindowTouchable: (windowId: number, touchable: boolean): void => { + try { + const wm = WindowManager.getInstance(); + wm.setWindowTouchable(windowId, touchable); + } catch (err) { + // 同步阶段异常(WindowManager构造或getWindow同步抛出) + // 此处在NAPI-reentrant调用栈(TSFN回调),用safeLogError避免hilog Argc mismatch + safeLogError('setWindowTouchable', err); + } +}, +``` + +**关键**: +- `setWindowTouchable` 返回 Promise,错误(401/1300002/1300003)通过 reject 异步传递(审计确认)。必须 `.catch`,否则 ArkTS 闪退。 +- `WindowManager` 里的 catch 是 Promise 异步回调,**不在 NAPI-reentrant 调用栈**,`hilog.error` 安全(参照 `setWindowFocusable:210` 直接用 hilog)。 +- `ArkHelper` 里的同步 catch 在 NAPI-reentrant 上下文(TSFN 回调),用 `safeLogError`(已确认它 try hilog → catch → console,安全)。 + +### D3: fire-and-forget 的错误传播限制(F3 不对称) + +TSFN fire-and-forget 模式下,ArkTS 的 Promise reject **无法反向通知 Rust**——Rust 侧 `set_window_touchable` 始终返回 `Ok(())`(只要 TSFN call status==Ok)。这和 `set_window_blur` 是同样的限制(`ArkHelper.ets:630` 注释明说"error is NOT propagated to Rust")。 + +**后果**:1300002/1300003 发生时,Rust 侧以为成功,但实际没设置。对 `setIgnoreCursorEvents` 影响有限——它是"尽量设置"语义,失败只是穿透没生效,不致命。 + +**若需错误感知**(Phase 2 视需求):改用 `call_with_return_value` + oneshot channel(如 `clipboard_write_image` 模式),让 Rust await ArkTS 的 Promise 结果。但这会引入阻塞,Phase 1 先用 fire-and-forget,Phase 2 真机验证后再定。 + +### D4: 逻辑取反在 tao 层(Phase 2) + +ability 层 `set_window_touchable(touchable)` 直传 bool,不取反(和 `set_window_blur` 直传 radius 一样)。tao 的 `set_ignore_cursor_events(ignore)` 调用时取反: + +```rust +// tao/platform_impl/ohos/mod.rs (Phase 2) +// Window struct: app: OpenHarmonyApp, window_id: Option (mod.rs:816-817) +pub fn set_ignore_cursor_events(&self, ignore: bool) -> Result<(), ExternalError> { + let window_id = self.window_id + .ok_or_else(|| error::ExternalError::NotSupported(error::NotSupportedError::new()))?; // Option → i64 + // 取反:Tauri ignore=true(穿透) ↔ OHOS touchable=false(不消费事件) + if let Err(e) = openharmony_ability::set_window_touchable(window_id, !ignore) { + warn!("set_ignore_cursor_events: set_window_touchable failed for window {}: {:?}", window_id, e); + return Err(error::ExternalError::NotSupported(error::NotSupportedError::new())); + } + Ok(()) +} +``` + +**错误转换修正(实现期审计发现)**:原设计的 `.map_err(|e| error::ExternalError::from(e.to_string()))` **无法编译** — tao 的 `ExternalError` 无 `From` 实现,OHOS `OsError` 是 unit struct(`pub struct OsError;`)不携带消息字符串。实际采用 `warn!` 记录错误详情 + 返回 `NotSupported`(唯一可用变体),匹配文件内 `set_focus`/`set_focusable` 的 idiom(它们也是 `warn!` + 静默/返回默认值)。此为 tao OHOS 层的通用约束,已记入 [`ohos-constraints.md`](../../../.claude/skills/tauri-ohos-design/references/ohos-constraints.md) §1.5。 + +**Err 语义说明**:`set_window_touchable` 是 TSFN fire-and-forget,返回 Err 仅当 TSFN 未初始化或 call status 非 Ok(init/编程错误)—— **不是** 1300002/1300003 等运行时失败,那些 Promise reject 在 ArkTS `.catch` 捕获、不反向通知 Rust(见 D3)。故此处的 NotSupported 实际只在桥接未就绪时触发。 + +**审计确认**:`Window` struct 有 `app: OpenHarmonyApp` + `window_id: Option` 字段(`mod.rs:816-817`),`set_ignore_cursor_events(&self, ...)` 可直接访问。但 `window_id` 是 `Option`,需 `ok_or` 解包(None 时返回 NotSupported,表示该 window 无 OS 窗口 id,如嵌入式 webview)。 + +| 调用方 | 参数 | 语义 | +|--------|------|------| +| tauri/tao `set_ignore_cursor_events(ignore)` | `ignore=true` | 忽略事件 = 穿透 | +| ability `set_window_touchable(touchable)` | `touchable=false` | 不可触 = 穿透 | + +## Risks / Trade-offs + +### R1: 穿透语义未真机验证(最高风险) +官方两版文档矛盾。Phase 2 真机为定论。 +- 触摸+hover 都穿透 → 单 `setWindowTouchable` 足够。 +- 触摸 OK 但 hover 不穿透 → Phase 2 追加组件级 `hitTestBehavior(HitTestMode.Transparent)`(R72 drag-drop-overlay 已验证)。 + +### R2: fire-and-forget 错误不可感知 +D3 所述。Phase 1 接受此限制(与 `set_window_blur` 一致)。Phase 2 若需感知改 oneshot 模式。 + +### R3: setWindowTouchable 的 Promise reject 闪退风险 +ArkTS 侧必须 `.catch`(D2)。漏 catch 会闪退。Phase 1 design 已要求 catch,Phase 2 真机验证 catch 是否在 NAPI-reentrant 上下文安全。 + +### R4: 1300002 跨进程约束 +tao 多窗口同进程,OK。 + +### R5: API 版本差异 +本地 9+/12+ vs ask_ai 7+/11+。demo API 12 满足。 + +### R6: TSFN 传 bool 无现成先例 +`set_window_blur`(i64,f64) / clipboard(Uint8Array,u32,u32) 都没传过 bool。`set_window_decorations`/`set_window_focusable` 同步直调用 `Function<'_, (i64, bool), ()>` 传 bool 是 OK 的,TSFN 传 bool 理论可行(napi-ohos 支持 bool 的 ToNapiValue/FromNapiValue)。但无现成 TSFN+bool 先例验证,Phase 2 真机需确认 `(i64, bool)` 元组经 TSFN 到 ArkTS 后 `touchable` 字段类型正确(boolean 而非被转成 number)。若出问题,fallback 改用 `(i64, u32)`(0/1)再 ArkTS 侧 `!!touchable` 转换。 + +### R6: 逻辑取反易错 +D4 的 `!ignore` 在 tao 层。ability 直传,design 已显式标注映射表。 + +## Alternatives Considered + +- **同步直调模式(`set_window_decorations` 那种)**:worker 上 `get_main_thread_env()==None` 失败,tao 命令可能跑 worker。TSFN 更合适。 +- **oneshot 返回值模式(`clipboard_write_image`)**:能感知错误,但引入阻塞。Phase 1 先 fire-and-forget,Phase 2 视需求升级。 +- **组件级 `hitTestBehavior` 替代窗口级**:Tauri 语义是窗口级,`setWindowTouchable` 更贴 Tauri。组件级作为 hover fallback(R1)。 diff --git a/openspec/changes/archive/2026-08-06-ohos-window-ignore-cursor-events/proposal.md b/openspec/changes/archive/2026-08-06-ohos-window-ignore-cursor-events/proposal.md new file mode 100644 index 000000000000..a814d60d8db6 --- /dev/null +++ b/openspec/changes/archive/2026-08-06-ohos-window-ignore-cursor-events/proposal.md @@ -0,0 +1,25 @@ +## Why + +Tauri/tao 的 `Window::set_ignore_cursor_events(ignore)` 用于实现窗口事件穿透(ignore=true 时本窗口不消费鼠标/触摸事件,事件落到下层窗口)。OHOS 后端当前是空实现(`tao/platform_impl/ohos/mod.rs:1215` 直接返回 `NotSupported`),导致依赖该 API 的功能(如悬浮信息层、拖拽预览层让事件穿透到下层 webview)在 OHOS 上不可用。OHOS `ohos.window` 的 `setWindowTouchable(false)` 可实现窗口级事件穿透,需按当前 `ohdev` 旧模型(TSFN + ArkHelper)接入。 + +## What Changes + +- 在 `openharmony-ability/crates/ability/src/window/mod.rs` 新增 `set_window_touchable(window_id, touchable)` TSFN 函数,模式对称现有 `set_window_blur`(`TSFN_SET_WINDOW_TOUCHABLE` + init + fire-and-forget 调用)。 +- 在 `init_vibrancy_tsfn`(或等价 ArkHelper setup 点)追加 touchable TSFN 初始化,从 ArkHelper 取 `setWindowTouchable` 方法建 TSFN。 +- `ArkHelper.ets` 新增 `setWindowTouchable(windowId, touchable)` 方法,调 `wm.setWindowTouchable(touchable)` 并 `.catch` 处理 Promise reject(避免闪退)。 +- `tao` 填实 `set_ignore_cursor_events`:`ignore=true` → `set_window_touchable(window_id, false)`(逻辑取反:Tauri "ignore=穿透" ↔ OHOS "touchable=false=穿透")。 + +## Capabilities + +### New Capabilities +- `ohos-window-ignore-cursor-events`: OHOS 窗口事件穿透能力,映射 Tauri `setIgnoreCursorEvents` 到 `setWindowTouchable`,包含 TSFN 桥接、ArkHelper 暴露、Promise reject 处理、逻辑取反映射、真机验证约束。 + +### Modified Capabilities +- 无(`set_window_blur`/`set_window_background_color` 等现有 TSFN 能力不变;新增独立的 touchable TSFN)。 + +## Impact + +- **openharmony-ability**:`window/mod.rs` 加 touchable TSFN + 公开函数;`ArkHelper.ets` 加 `setWindowTouchable` 方法;`lib.rs` re-export。 +- **tao**:`platform_impl/ohos/mod.rs` 填实 `set_ignore_cursor_events`(Phase 2)。 +- **其他平台**:无影响(OHOS 改动 `cfg(target_env = "ohos")` 隔离)。 +- **真机验证依赖**:`setWindowTouchable(false)` 穿透语义(触摸 + hover)官方两版文档矛盾,Phase 2 真机为定论。 diff --git a/openspec/changes/archive/2026-08-06-ohos-window-ignore-cursor-events/specs/ohos-window-ignore-cursor-events/spec.md b/openspec/changes/archive/2026-08-06-ohos-window-ignore-cursor-events/specs/ohos-window-ignore-cursor-events/spec.md new file mode 100644 index 000000000000..caca26d449b0 --- /dev/null +++ b/openspec/changes/archive/2026-08-06-ohos-window-ignore-cursor-events/specs/ohos-window-ignore-cursor-events/spec.md @@ -0,0 +1,64 @@ +## ADDED Requirements + +### Requirement: set_window_touchable TSFN 函数 +`openharmony-ability` SHALL provide `set_window_touchable(window_id: i64, touchable: bool) -> Result<()>`,通过全局 TSFN(`TSFN_SET_WINDOW_TOUCHABLE`)fire-and-forget 调用 ArkHelper 的 `setWindowTouchable` 方法,任意线程可调。 + +#### Scenario: 正常调用 +- **WHEN** 任意线程调 `set_window_touchable(window_id, false)` 且 TSFN 已初始化 +- **THEN** TSFN 将 `(window_id, false)` 路由到 ArkTS,Rust 返回 `Ok(())`(fire-and-forget,不等待 ArkTS 结果) + +#### Scenario: TSFN 未初始化 +- **WHEN** 调 `set_window_touchable` 但 `init_vibrancy_tsfn` 未执行 +- **THEN** 返回 `Err("set_window_touchable TSFN not initialized")` + +#### Scenario: TSFN call 失败 +- **WHEN** `tsfn.call(...)` 返回非 Ok status +- **THEN** 返回 `Err("TSFN call failed: {:?}")` + +### Requirement: TSFN 初始化 +`TSFN_SET_WINDOW_TOUCHABLE` SHALL 在 ArkHelper setup 阶段(主线程,`init_vibrancy_tsfn` 内或等价点)从 ArkHelper 取 `setWindowTouchable` 方法建 TSFN,`callee_handled::()`。 + +#### Scenario: init 幂等 +- **WHEN** `init_vibrancy_tsfn` 被多次调用 +- **THEN** touchable TSFN 只建一次(`OnceLock::set` 已有值时跳过) + +#### Scenario: ArkHelper 缺方法 +- **WHEN** ArkHelper 对象无 `setWindowTouchable` 属性 +- **THEN** `get_named_property` 返回 Err,init 失败(与 `setWindowBlur` 缺失时行为一致) + +### Requirement: ArkHelper setWindowTouchable 转发 + WindowManager 封装 +`ArkHelper.ets` SHALL 暴露 `setWindowTouchable(windowId, touchable): void`,转发到 `WindowManager.setWindowTouchable`。`WindowManager.ets` SHALL 用 `getWindow(windowId)` 取窗口实例(非 `getWindowById`),调 `win.setWindowTouchable(touchable).then().catch()`(对称 `setWindowFocusable:201-212`)。 + +#### Scenario: 成功设置 +- **WHEN** ArkHelper 转发 `setWindowTouchable(id, false)` 到 WindowManager,窗口存在 +- **THEN** `win.setWindowTouchable(false)` Promise resolve,`hilog.debug` 记录 + +#### Scenario: Promise reject (1300002/1300003) +- **WHEN** 窗口状态异常或 UI 未加载,`setWindowTouchable` Promise reject +- **THEN** WindowManager 的 `.catch` 捕获,`hilog.error` 记录(Promise 异步回调上下文,hilog 安全),**不闪退**;Rust 不感知(fire-and-forget) + +#### Scenario: 窗口不存在(同步) +- **WHEN** `getWindow(id)` 返回 undefined +- **THEN** WindowManager `hilog.warn` 记录并 return,不抛出 + +#### Scenario: ArkHelper 同步异常 +- **WHEN** ArkHelper 转发时同步抛出 +- **THEN** ArkHelper 的 try/catch 用 `safeLogError` 记录(NAPI-reentrant 上下文,hilog 可能 Argc mismatch) + +### Requirement: 逻辑取反在 tao 层(Phase 2 预留) +ability `set_window_touchable(touchable)` SHALL 直传 bool;tao `set_ignore_cursor_events(ignore)` SHALL 调 `set_window_touchable(window_id, !ignore)`。 + +#### Scenario: ignore=true 映射 touchable=false +- **WHEN** tauri 调 `set_ignore_cursor_events(true)`(穿透) +- **THEN** tao 调 `set_window_touchable(id, false)`(不可触=穿透) + +#### Scenario: ignore=false 恢复 +- **WHEN** tauri 调 `set_ignore_cursor_events(false)` +- **THEN** tao 调 `set_window_touchable(id, true)` + +### Requirement: 不影响其他平台 +OHOS `set_ignore_cursor_events` 填实 SHALL 使用 `cfg(target_env = "ohos")` 隔离,其他平台实现不动。 + +#### Scenario: 非 OHOS 编译 +- **WHEN** 为 Windows/macOS/Linux 编译 tao +- **THEN** `set_ignore_cursor_events` 走各平台原有实现,不引用 `set_window_touchable` diff --git a/openspec/changes/archive/2026-08-06-ohos-window-ignore-cursor-events/tasks.md b/openspec/changes/archive/2026-08-06-ohos-window-ignore-cursor-events/tasks.md new file mode 100644 index 000000000000..2145e67d5b3b --- /dev/null +++ b/openspec/changes/archive/2026-08-06-ohos-window-ignore-cursor-events/tasks.md @@ -0,0 +1,31 @@ +## 1. Rust 侧 — TSFN + 公开函数 + +- [x] 1.1 在 `openharmony-ability/crates/ability/src/window/mod.rs` 新增 `type SetWindowTouchableTsfn = ThreadsafeFunction<(i64, bool), (), FnArgs<(i64, bool)>, Status, false>` + `static TSFN_SET_WINDOW_TOUCHABLE: OnceLock<...>` +- [x] 1.2 在 `init_vibrancy_tsfn`(`window/mod.rs:186`)内追加 touchable TSFN 初始化:`helper_obj.get_named_property("setWindowTouchable")` → `build_threadsafe_function::<(i64, bool)>().callee_handled::().build_callback(...)` → `TSFN_SET_WINDOW_TOUCHABLE.set(...)` +- [x] 1.3 新增 `pub fn set_window_touchable(window_id: i64, touchable: bool) -> napi_ohos::Result<()>`,对称 `set_window_blur`(`window/mod.rs:241`):取 TSFN → `tsfn.call((window_id, touchable), NonBlocking)` → 校验 status +- [x] 1.4 `set_window_touchable` 通过 `lib.rs:115 pub use window::*` 自动 re-export(无需手动加,确认 `set_window_blur` 同样自动导出) +- [x] 1.5 `cargo check -p openharmony-ability`(ohos target)编译通过,无 unused warning +- [x] 1.6 确认 `init_vibrancy_tsfn` 在 `render/xcomponent.rs:37` 已被调用(无需新增调用点,touchable TSFN 在该函数内追加即可) + +## 2. ArkTS 侧 — WindowManager 封装 + ArkHelper 转发 + +- [x] 2.1 在 `openharmony-ability/native_ability/src/main/ets/window/WindowManager.ets` 新增 `setWindowTouchable(windowId: number, touchable: boolean): void`(对称 `setWindowFocusable:201-212`):`this.getWindow(windowId)` → 若无 `hilog.warn` return → `win.setWindowTouchable(touchable).then(hilog.debug).catch(hilog.error)` +- [x] 2.2 在 `openharmony-ability/native_ability/src/main/ets/ability/ArkHelper.ets` 新增 `setWindowTouchable: (windowId: number, touchable: boolean): void`(位置参照 `setWindowFocusable:558`),转发到 `WindowManager.getInstance().setWindowTouchable(windowId, touchable)`,外层 try/catch 用 `safeLogError` +- [x] 2.3 确认 `WindowManager.getWindow` 方法存在(`setWindowFocusable:202` 用的就是 `this.getWindow(windowId)`) +- [x] 2.4 确认 WindowManager 的 `.catch` 用 `hilog.error`(Promise 异步回调,非 NAPI-reentrant,安全);ArkHelper 的同步 catch 用 `safeLogError`(NAPI-reentrant 上下文) + +## 3. 验证 + +- [x] 3.1 `cargo check`(ohos target)通过 +- [x] 3.2 人工核对:TSFN 类型签名 `(i64, bool)` 与 ArkHelper 方法参数 `(windowId: number, touchable: boolean)` 类型对齐 +- [x] 3.3 人工核对:`callee_handled::()`(C2 规范) +- [x] 3.4 人工核对:ArkTS `.catch` 已处理 Promise reject(避免闪退) +- [x] 3.5 确认未触碰 `set_window_blur`/`set_window_background_color` 等现有 TSFN 代码路径 + +## 4. Phase 2 预留(不在本 Phase 执行) + +- [x] 4.1 (Phase 2) 填实 `tao/src/platform_impl/ohos/mod.rs:1215` `set_ignore_cursor_events`:`self.window_id.ok_or(NotSupported)?` 解包 Option → 调 `openharmony_ability::set_window_touchable(window_id, !ignore)`,错误转 `ExternalError` +- [x] 4.2 (Phase 2) 真机验证 `setWindowTouchable(false)` 穿透语义:触摸点击 + 鼠标 hover 是否落到下层窗口 +- [x] 4.3 (Phase 2) 若 hover 不穿透,追加组件级 `hitTestBehavior(HitTestMode.Transparent)`(参考 R72 drag-drop-overlay)—— 真机验证穿透 OK,无需追加 fallback +- [x] 4.4 (Phase 2) 若需错误感知,将 TSFN fire-and-forget 升级为 `call_with_return_value` + oneshot(参考 `clipboard_write_image`)—— deferred:fire-and-forget 满足 setIgnoreCursorEvents「尽量设置」语义,D3 已接受错误不可感知限制,无需升级 +- [x] 4.5 (Phase 2) 手动测试用例归档到 `tauri/doc/manual_tests.md` + `ohos-adapter.ts` diff --git a/openspec/changes/archive/2026-08-07-ohos-webview-drag-drop/proposal.md b/openspec/changes/archive/2026-08-07-ohos-webview-drag-drop/proposal.md new file mode 100644 index 000000000000..c33c88d69003 --- /dev/null +++ b/openspec/changes/archive/2026-08-07-ohos-webview-drag-drop/proposal.md @@ -0,0 +1,44 @@ +## Why +wry OHOS 未接 `drag_drop_handler`(解构时落入 `..`);ability `drag.rs` 仅 stub;ETS Web 组件未挂拖拽事件。文件拖入窗口无响应。基础设施(feature flag + NAPI 闘包 + ETS onDragAndDrop 字段)已存在,缺接通。 + +## What Changes +- **ability drag.rs**:从 stub 扩展为 `DragDropEvent` enum(`Enter{paths,position}`/`Over{position}`/`Drop{paths,position}`/`Leave`,镜像 wry),`from_arkts_pipe(&str)`/`to_arkts_pipe(&self)` 解析管道串 `||,`(路径 `\0` 分隔以兼容含逗号路径)+ round-trip 单测 +- **wry Cargo.toml**:openharmony-ability dep 启用 `drag_and_drop` feature(经 `target.'cfg(target_env = "ohos")'` 隔离,非 ohos 不编译) +- **wry mod.rs**:`new_inner` 解构 `drag_drop_handler`,包装为 `on_drag_and_drop` 闭包(管道串 → `DragDropEvent::from_arkts_pipe` → 1:1 映射 wry `DragDropEvent` → handler) +- **DefaultWebview.ets**:WebBuilder + EmbeddedWebBuilder 的 Web 组件挂 `.onDragEnter/.onDragMove/.onDrop/.onDragLeave`,经模块级 `buildDragPipe` helper(纯函数,符合 ohos-constraints §4.1)发管道串;`extractDragPaths` 用 UDMF `getData().getRecords()` → `getTypes()/getEntry()` 分派(`FILE_URI`→`FileUri.oriUri` 主路径 + `Image.imageUri` 兜底)→ 剥 `file://`/`datashare://` scheme → `\0` join 多文件 +- **onLoadIntercept file:// 拦截**(ArkWeb drop 消费降级,核心):WebBuilder + EmbeddedWebBuilder 两处 `onLoadIntercept` 加 `file://` 分支——ArkWeb 消费 OS 文件 drop 时会导航到 `file://<拖入文件>` 致白屏,`onLoadIntercept` 在导航前触发,return true 取消导航(阻止白屏)+ `decodeURIComponent`+`stripDragScheme` 取路径 + 转发 `drop|path|0,0`。整面 webview 成释放区、不挡触摸、不依赖时灵时不灵的 onDrop。安全:Tauri OHOS 初始加载走自定义协议(`tauri://`/`https://.localhost`)或 inline html,从不 `file://`(`wry/src/ohos/mod.rs:198/209`),故拦 `file://` 不影响正常加载 +- **tauri/tauri-runtime cfg 卫生**:`drag_drop_overlay` 字段/方法 6 处补 `#[cfg(target_env = "ohos")]`(API 卫生,对齐 spec「非 OHOS 平台无此字段」) + +## Impact +- 文件拖入 webview 时 drag_drop_handler 收到 `DragDropEvent`,前端 `onDragDropEvent` 收到文件路径 +- 不影响其他平台(所有改动经 `cfg(target_env = "ohos")` 或 `feature = "drag_and_drop"` 门控) +- ArkWeb drop 消费白屏问题解决(onLoadIntercept 拦截) + +## tauri 层 handler 接通说明(核实修正) +tauri `WebviewWindowBuilder`/`WebviewBuilder` 无用户态 `drag_drop_handler(F)` setter 是 **跨平台设计惯例,非阻塞**:`tauri-runtime-wry/src/lib.rs:5268` 在 `drag_drop_handler_enabled`(默认 true)时自动装入内部 handler,把 wry `DragDropEvent` 转 tauri 事件转发到前端 `onDragDropEvent`。因此 wry `attributes.drag_drop_handler` 在 OHOS 上为 `Some`,`new_inner`(`wry/src/ohos/mod.rs`)接通 `openharmony_ability::WebViewBuilder::on_drag_and_drop`,ArkTS `data.onDragAndDrop` 不会恒 undefined。**无需** 独立 change `ohos-tauri-drag-drop-handler-api`。 + +## 设备验证结果(2026-08-07,API 23 desktop) +- ✅ 拖文件入 webview **不再白屏**(onLoadIntercept file:// 拦截 ArkWeb drop 消费导航成功;旧版每次必 `ERR_ACCESS_DENIED` 白屏) +- ✅ Web 级 onDrop 触发拿路径(hilog `drag drop: 1 record(s) received`,UDMF `FILE_URI`→`FileUri.oriUri` 提取链工作) +- ✅ 前端 `onDragDropEvent` 收到并显示路径(端到端打通) +- 关键根因:ArkWeb 对 OS 文件 drop 有**桌面 Tauri 没有的内核行为**——抢先消费 drop 导航到 `file://` 致白屏。`setResult(DRAG_SUCCESSFUL)` 对 Web 组件无效(Web 组件不走 ArkUI 通用拖拽协议);`HitTestMode.Block` 释放区可行但挡触摸、区外仍白屏。`onLoadIntercept` 拦 `file://` 是最优解。详见 `openspec/ohos-webview-drag-drop-plan.md` Phase 4。 + +## 风险 +- ~~ArkWeb 是否冒泡 OS 文件拖拽到 ArkUI .onDrop~~ 已验证:会冒泡但 ArkWeb 同时内部消费 drop 致白屏(onLoadIntercept 解决) +- ~~`dragEvent.getData()` 文件 URI 格式~~ 已验证:`file://` URI,`FILE_URI`→`FileUri.oriUri` 提取 + `stripDragScheme` 剥 scheme 工作;`datashare://` 本次未触发(文件管理器走 file://),其他来源待验证 +- ~~ability `drag_and_drop` feature 对非 ohos 构建的影响~~ 已验证:feature 经 wry `Cargo.toml` 的 `target.'cfg(target_env = "ohos")'` 隔离,非 ohos 不编译,无影响 +- 次要待办:双发去重(onDrop + onLoadIntercept 可能都触发 drop)、HTML5 页内 DnD 不受影响确认 + +## 状态 +本 change 已归档(2026-08-07),核心功能端到端打通并经设备验证。逐 task 状态见 `tasks.md`: +- task 1–10:✅ 完成(drag.rs 实体 + wry/ArkTS 接通 + cfg 卫生 + 设备验证核心达成) +- task 11:⏸ Deferred(见下「遗留项」) + +最终采用方案:**onLoadIntercept 拦截 file:// 导航**(overlay 释放区因 appfreeze 已回退为非默认路径;onLoadIntercept 为默认且更优——整面 webview 成释放区、不挡触摸)。 + +## 遗留项 (Deferred) +以下项不阻塞归档,列为后续跟进: +- **task 11 — drag.rs 单测设备执行**:`from_arkts_pipe`/`to_arkts_pipe` round-trip 单测已编写并在宿主编译通过;OHOS 交叉链接器缺失,未在设备经 `ohos-rust-ut` 执行。待设备环境就绪后补跑。 +- **双发去重**:onDrop(Web 级,带真实坐标)与 onLoadIntercept file:// 分支(带 `0,0` 坐标)可能对同一次物理 drop 各转发一次 `drop|...`,导致 wry 收到两个 `DragDropEvent::Drop`。需在 ArkTS 侧加去重状态(onLoadIntercept 拦截后抑制同次 onDrop 的 drop 转发,或反之)。 +- **HTML5 页内 DnD 不受影响**:页内 DOM 拖拽不应产生 `DragDropEvent`、不被 onLoadIntercept file:// 分支误拦——待设备确认。 +- **datashare:// 来源**:本次设备验证仅触发 `file://`(文件管理器);`datashare://` URI 是否需 `fileIo`/`DataShareHelper` 解析为绝对路径,待其他拖拽来源验证。 diff --git a/openspec/changes/archive/2026-08-07-ohos-webview-drag-drop/tasks.md b/openspec/changes/archive/2026-08-07-ohos-webview-drag-drop/tasks.md new file mode 100644 index 000000000000..26cb0e3617f0 --- /dev/null +++ b/openspec/changes/archive/2026-08-07-ohos-webview-drag-drop/tasks.md @@ -0,0 +1,13 @@ +# ohos-webview-drag-drop Tasks + +- [x] 1. ability drag.rs:`DragDropEvent` enum(镜像 wry,`paths: Vec, position: (i32,i32)`)+ `from_arkts_pipe`/`to_arkts_pipe`(`\0`-split 路径解析)+ round-trip 单测 +- [x] 2. wry Cargo.toml:openharmony-ability 启用 `drag_and_drop` feature +- [x] 3. wry new_inner:解构 `drag_drop_handler` + `on_drag_and_drop` 闭包调 `DragDropEvent::from_arkts_pipe` + 1:1 映射到 wry `DragDropEvent` +- [x] 4. DefaultWebview.ets WebBuilder:挂 .onDragEnter/.onDragMove/.onDrop/.onDragLeave +- [x] 5. DefaultWebview.ets EmbeddedWebBuilder:同上 +- [x] 6. 设备验证:ArkWeb **会**冒泡文件拖拽到 `.onDrop`(`drag drop: 1 record(s) received`,多设备验证)。但 **ArkWeb 同时内部消费 drop,把拖入文件加载成页面**(导航到 `file://<文件>` → `ERR_ACCESS_DENIED`/`httpStatus:0` → 白屏),破坏 webview。.html 和 .txt 均触发,问题普遍。**setResult(DRAG_SUCCESSFUL) 无效**(Web 组件不走 ArkUI 通用拖拽协议)。**最终解法:onLoadIntercept 拦 file:// 导航**——在 WebBuilder/EmbeddedWebBuilder 两处 onLoadIntercept(已存在,line 395/574)加 file:// 分支:return true 取消导航(阻止白屏)+ decodeURIComponent+stripDragScheme 取路径 + 转发 `drop|path|0,0`。设备验证成功:拖文件**不再白屏**(旧版每次必白屏)+ onDrop 仍触发拿路径。整面 webview 成释放区、不挡触摸。安全:Tauri OHOS 初始加载走自定义协议(tauri:// / https://.localhost)或 inline html,从不 file://(wry/src/ohos/mod.rs:198/209)。启动期另有 `THREAD_BLOCK_6S` appfreeze(store 插件锁竞争,与拖拽无关,进程未死)。 +- [x] 7. 设备验证:dragEvent.getData() 文件 URI 实际 scheme = **`file://`**(`file:///storage/Users/currentUser/.../新建 文本文档.txt`,URL 编码中文)。`UniformDataType.FILE_URI`→`uniformDataStruct.FileUri.oriUri` 提取在设备上工作(ask_ai 给的 API 经真机验证,本地 unified-data-channels.md:150-158 验证 getTypes/getEntry)。`stripDragScheme` 剥 `file://` 后得绝对路径 `/storage/Users/currentUser/...`。datashare:// 本次未触发(文件管理器拖拽走 file://),是否需 fileIo/DataShareHelper 解析待其他来源验证。 +- [x] 8. Phase 3:ArkTS 路径正确性——`DefaultWebview.ets` WebBuilder + EmbeddedWebBuilder 4 组回调(Web 级 + overlay)改用 `buildDragPipe` helper:`getData()` 返 `UnifiedData`(修正旧 `typeof d === 'string'` 误判 bug,旧码 path 恒 `''`)→ `getRecords()` → `getTypes()/getEntry()` 分派(`FILE_URI`→`FileUri.oriUri` 主路径 + `Image.imageUri` 兜底)→ 剥 `file://`/`datashare://` scheme → `\0` join 多文件;`getX/getY` 读坐标(`0,0` 兜底);hilog 记录数 + 未知类型诊断。arkts-helper 确认 FILE_URI 类型,本地 unified-data-channels.md 验证 getTypes/getEntry API。ArkTS 无法在 Windows 宿主编译复核,验证 deferred 到设备(task 10)。 +- [x] 9. Phase 4:tauri/tauri-runtime 层 `drag_drop_overlay` 字段+方法补 `#[cfg(target_env = "ohos")]`(API 卫生,对齐 spec「非 OHOS 平台无此字段」)。6 编辑点:tauri-runtime/src/webview.rs(字段 357 / new() 528 / 方法 761)、tauri/src/webview/mod.rs:1157、webview_window.rs:1164、examples/api cmd.rs:1450(调用点包 cfg + 非 ohos `let _ =` 消未用警告)。验证:Windows host `cargo check` 通过(tauri-runtime + tauri + tauri-runtime-wry 编译干净,无 fallout);ohos 由构造不变(cfg 求值 true,字段/方法照常存在)。cmd.rs 编译复核被 api 例子 pre-existing 的 tauri-build 插件权限发现问题(deep-link/global-shortcut 未解析)阻断,与拖拽无关。 +- [x] 10. Phase 5:设备端到端验证——**核心达成**。拖文件入 webview:(1) **白屏消失**(onLoadIntercept file:// 拦截 ArkWeb drop 消费导航成功);(2) Web 级 onDrop 触发拿路径(hilog `drag drop: 1 record(s) received`);(3) 前端 onDragDropEvent 收到 payload(drop|path|0,0)。OHOS 文件拖拽端到端打通。剩余次要项:HTML5 页内 DnD 不受影响确认、双发去重(onDrop + onLoadIntercept 可能都触发,wry 侧需去重)待补充。 +- [ ] 11. drag.rs 单测在设备运行(ohos-rust-ut skill)——**Deferred**:宿主机 ohos 交叉链接器缺失,单测已编写并编译通过,待设备执行(见 proposal.md「遗留项」) diff --git a/openspec/changes/ohos-dialog-folder-picker/proposal.md b/openspec/changes/ohos-dialog-folder-picker/proposal.md new file mode 100644 index 000000000000..9e40850f6dc8 --- /dev/null +++ b/openspec/changes/ohos-dialog-folder-picker/proposal.md @@ -0,0 +1,13 @@ +## Why +`tauri-plugin-dialog` 在 OHOS 上对 `options.directory=true` 统一返回 `FolderPickerNotImplemented`。经 SDK 核实,`DocumentViewPicker` 配 `DocumentSelectMode.FOLDER`(API 11+)支持目录选择,**仅 2-in-1/桌面设备**。desktop 应实现,mobile 维持降级。 + +## What Changes +- **dialog lib.rs**:`FileDialogPayload` 加 `directory: bool`;`payload(multiple, directory)`;`pick_folder`/`pick_folders`/`blocking_pick_folder`/`blocking_pick_folders` 的 cfg 从 `all(desktop, not(ohos))` 放宽到 `desktop`(含 OHOS-desktop) +- **dialog mobile.rs**:新增 `pick_folder`/`pick_folders`(showFilePicker with `directory=true`);pick_file/files/save_file 调整 payload 调用 +- **dialog commands.rs**:folder 分支拆三:非OHOS-desktop(原 blocking_pick_folder)/ OHOS-desktop(FOLDER 实现 + scope)/ mobile(FolderPickerNotImplemented) +- **tauri-cli 模板 Plugin.ets**:`OpenArgs` 加 `directory`;`handleOpen` 传递;`showDocumentPicker` 在 directory 时设 `selectMode = DocumentSelectMode.FOLDER` + +## Impact +- OHOS desktop 支持文件夹选择 +- OHOS mobile / android / iOS 维持不支持(明确错误) +- 非 OHOS 桌面完全不变 diff --git a/openspec/changes/ohos-dialog-folder-picker/tasks.md b/openspec/changes/ohos-dialog-folder-picker/tasks.md new file mode 100644 index 000000000000..d07addb96b16 --- /dev/null +++ b/openspec/changes/ohos-dialog-folder-picker/tasks.md @@ -0,0 +1,9 @@ +# ohos-dialog-folder-picker Tasks + +- [x] 1. lib.rs `FileDialogPayload` 加 `directory` + `payload(multiple, directory)` +- [x] 2. mobile.rs pick_file/files/save_file payload 调用更新 +- [x] 3. mobile.rs 新增 `pick_folder`/`pick_folders` +- [x] 4. lib.rs `pick_folder`/`pick_folders`/`blocking_pick_folder`/`blocking_pick_folders` cfg → `desktop` +- [x] 5. commands.rs folder 分支拆三(OHOS-desktop FOLDER 实现 / mobile 错误 / 非OHOS-desktop 不变) +- [x] 6. Plugin.ets `OpenArgs.directory` + `handleOpen` + `showDocumentPicker` FOLDER 模式 +- [ ] 7. 设备验证:desktop 选目录返回 URI;mobile 返回错误 diff --git a/openspec/changes/ohos-event-lifecycle-forward/proposal.md b/openspec/changes/ohos-event-lifecycle-forward/proposal.md new file mode 100644 index 000000000000..9e45442c7e2f --- /dev/null +++ b/openspec/changes/ohos-event-lifecycle-forward/proposal.md @@ -0,0 +1,12 @@ +## Why +tao OHOS 事件循环对 `MainEvent::Start`(SHOWN)与 `MainEvent::SaveState` 仅 `warn!` 丢弃,应用无法感知"窗口恢复显示"。`Start` 是 OHOS 最重要的"对用户可见"信号(从最近任务切回),应转发。 + +## What Changes +- `tao/src/platform_impl/ohos/mod.rs`:`MainEvent::Start` 转发为 `event::Event::Resumed`(与 SurfaceCreate/Resume 一致,接受重复触发,下游幂等) +- `MainEvent::SaveState`:tao 无对应 Event/StartCause 变体,降级为 `debug!` 日志(不再 `warn!`) +- 移除 `XXX: how to forward` 注释,替换为本 spec 处置说明 + +## Impact +- 应用能通过 `RunEvent::Resumed` 感知窗口恢复显示 +- SaveState 不再产生 warn 噪音 +- 不影响其他平台 diff --git a/openspec/changes/ohos-event-lifecycle-forward/tasks.md b/openspec/changes/ohos-event-lifecycle-forward/tasks.md new file mode 100644 index 000000000000..88f2ed60eab4 --- /dev/null +++ b/openspec/changes/ohos-event-lifecycle-forward/tasks.md @@ -0,0 +1,11 @@ +# ohos-event-lifecycle-forward Tasks + +- [x] 1. `MainEvent::Start` 转发 `Event::Resumed` + 注释说明 +- [x] 2. `MainEvent::SaveState` 降级 `debug!` + 注释说明(移除 warn 与 XXX 注释) + +## 真机验证发现(2026-08-06,API 23 desktop) + +- [ ] 3. **`tauri://resumed` 事件真机不触发(已知不工作)**:代码转发链 `MainEvent::Start → Event::Resumed → RunEvent::Resumed` 已实现(tao mod.rs:559-566 + tauri app.rs:2628),但真机切后台→切回后,前端 `listen('tauri://resumed')` 30s 内未收到事件。与自动测试 #33 `RunEvent::Resumed fires on startup` 一直 FAIL 一致。 + - hilog 有 `WMSLife: NotifyAfterLifecycleResumed: in`(系统层 resumed 信号),但 tao `MainEvent::Start` 未触发或 `Event::Resumed` emit 链路断裂。 + - **结论**:OHOS 上 Resumed 事件不触发是已知现状,暂不深挖(与 #33 长期 FAIL 一致,非本次适配引入)。 + - **影响**:依赖 Resumed 的插件(如 deep-link 冷启动后恢复、状态恢复)在 OHOS 上不工作。后续如需修复,排查 `MainEvent::Start`(SHOWN)在 OHOS 2in1 切后台切回时是否产生 + `Event::Resumed` 到 JS `tauri://resumed` 的 emit 链路。 diff --git a/openspec/changes/ohos-monitor-real-values/proposal.md b/openspec/changes/ohos-monitor-real-values/proposal.md new file mode 100644 index 000000000000..3d6f394ffbf2 --- /dev/null +++ b/openspec/changes/ohos-monitor-real-values/proposal.md @@ -0,0 +1,16 @@ +## Why +tao OHOS `MonitorHandle::video_modes()` 硬编码 `refresh_rate: 60`、`monitor_from_point` 始终返回 None+warn。高刷新率设备(90/120Hz)无法反映真实值;点-显示器查询无意义返回 None。 + +## What Changes +- **openharmony-ability app.rs**:新增 `refresh_rate()`/`display_width()`/`display_height()` 方法,封装 `ohos-display-binding` 的 `default_display_*`(遵守铁律#1,tao 不直依赖 binding) +- **tao MonitorHandle**: + - `video_modes()` refresh_rate 取 `app.refresh_rate()` 真实值 + - `size()` 取 DisplayManager 物理像素,0 时回退 content_rect + warn + - `monitor_from_point`(EventLoopWindowTarget + Window)基于单显示器边界判定返回 Some(primary)/None,不再 warn + +## Impact +- 高刷新率设备返回真实 refresh_rate +- monitor_from_point 屏幕内坐标返回 Some,屏幕外 None +- 不影响其他平台 +## 风险(待构建验证) +- `default_display_width`/`default_display_height` 函数名按 `default_display_*` 模式推断(refresh_rate agent 已确认),width/height 需构建校验 diff --git a/openspec/changes/ohos-monitor-real-values/tasks.md b/openspec/changes/ohos-monitor-real-values/tasks.md new file mode 100644 index 000000000000..d92c6b22a5e4 --- /dev/null +++ b/openspec/changes/ohos-monitor-real-values/tasks.md @@ -0,0 +1,7 @@ +# ohos-monitor-real-values Tasks + +- [x] 1. openharmony-ability app.rs:import + `refresh_rate()`/`display_width()`/`display_height()` 方法 +- [x] 2. tao `video_modes()` refresh_rate 取 `app.refresh_rate()` +- [x] 3. tao `size()` 取 DisplayManager 物理像素 + content_rect 回退 +- [x] 4. tao `monitor_from_point`(EventLoopWindowTarget + Window)边界判定 +- [ ] 5. 构建验证 `default_display_width/height` 函数名与返回类型 diff --git a/openspec/changes/ohos-webview-flag-clipboard/proposal.md b/openspec/changes/ohos-webview-flag-clipboard/proposal.md new file mode 100644 index 000000000000..ac0d6032f416 --- /dev/null +++ b/openspec/changes/ohos-webview-flag-clipboard/proposal.md @@ -0,0 +1,23 @@ +## Why + +wry 的 `with_clipboard(bool)` 在 OHOS 后端被静默丢弃——`InnerWebView::new_inner` 解构 `WebViewAttributes` 时 `clipboard` 落入 `..` catch-all,导致开发者设 `false` 无法禁用剪贴板。ArkWeb 默认允许页面剪贴板访问并原生响应 Ctrl+C/X/V/A/Z/Y,因此功能"默认能用"但"关不掉",与 Windows/macOS/Linux 的 flag 语义不一致(跨平台 API 契约缺口)。 + +旧 `webview-desktop-features` spec 的 R82 决策"clipboard always-on (platform limitation)"将 OHOS 与 macOS 等同,但 macOS 是 WebKit 引擎级限制无 toggle,OHOS 可通过组合键拦截实现禁用,二者不应等同。本 change 取代该决策。 + +## What Changes + +- **wry**:`src/ohos/mod.rs` `new_inner` 显式解构 `clipboard`,调用 `WebViewBuilder::clipboard(clipboard)` +- **openharmony-ability (Rust)**:`WebViewBuilder` 新增 `clipboard: Option` 字段 + setter;`WebViewInitData` 新增 `clipboard` 字段;`build()` 透传 +- **openharmony-ability (ArkTS)**:`WebviewInitData` 接口加 `clipboard?: boolean`;`accelerator_matcher.ets` 新增 per-window flag 存储(`setClipboardEnabled`/`isClipboardEnabled`/`clearClipboardEnabled`)+ `AcceleratorMatcher.matchesClipboardShortcut(event)`;`ArkHelper.createWebview` 注册 flag;`MainPage`/`FloatPage` `onKeyPreIme` 在 flag=false 且匹配 CLIPBOARD_ACCELERATORS 时 `return true` 消费事件 + +## Capabilities + +### Modified Capabilities +- `ohos-webview-flag-clipboard`: wry `with_clipboard` flag 在 OHOS 生效——`false` 拦截剪贴板组合键,`true` 维持 ArkWeb 原生行为。取代 `webview-desktop-features` R82。 + +## Impact + +- 跨平台契约对齐:OHOS 行为与 Windows/Linux 一致(flag=false 禁用剪贴板快捷键) +- 不影响其他平台:所有改动在 `cfg(target_env = "ohos")` 或 OHOS 专属 ETS 文件内 +- 不影响程序化 `@ohos.pasteboard` 读写(仅拦截键盘组合键) +- 默认行为不变(flag 默认 true = ArkWeb 原生) diff --git a/openspec/changes/ohos-webview-flag-clipboard/tasks.md b/openspec/changes/ohos-webview-flag-clipboard/tasks.md new file mode 100644 index 000000000000..692d499afc0c --- /dev/null +++ b/openspec/changes/ohos-webview-flag-clipboard/tasks.md @@ -0,0 +1,26 @@ +# ohos-webview-flag-clipboard Tasks + +## 1. Rust flag 转发 + NAPI 桥接 + +- [x] 1.1 `openharmony-ability/crates/ability/src/webview/mod.rs`:`WebViewBuilder` 新增 `pub clipboard: Option` 字段 +- [x] 1.2 `openharmony-ability/crates/ability/src/webview/mod.rs`:新增 `pub fn clipboard(self, clipboard: bool)` setter +- [x] 1.3 `openharmony-ability/crates/ability/src/helper/webview.rs`:`WebViewInitData` 新增 `pub clipboard: Option` 字段 +- [x] 1.4 `openharmony-ability/crates/ability/src/webview/mod.rs`:`build()` 构造 `WebViewInitData` 时透传 `clipboard: self.clipboard` +- [x] 1.5 `wry/src/ohos/mod.rs`:`new_inner` 解构 `clipboard`(移出 `..`),builder 链加 `.clipboard(clipboard)` + +## 2. ETS onKeyPreIme 拦截 + +- [x] 2.1 `openharmony-ability/.../ets/webview/DefaultWebview.ets`:`WebviewInitData` 接口加 `clipboard?: boolean` +- [x] 2.2 `openharmony-ability/.../ets/helper/accelerator_matcher.ets`:新增 per-window flag 存储 `setClipboardEnabled`/`isClipboardEnabled`/`clearClipboardEnabled` +- [x] 2.3 `openharmony-ability/.../ets/helper/accelerator_matcher.ets`:`AcceleratorMatcher` 新增 `matchesClipboardShortcut(event)` 方法(复用 getKeyText/isModifierPressed + CLIPBOARD_ACCELERATORS) +- [x] 2.4 `openharmony-ability/.../ets/ability/ArkHelper.ets`:`createWebview` 调 `setClipboardEnabled(windowId, data?.clipboard ?? true)`;init 透传 `clipboard` +- [x] 2.5 `openharmony-ability/.../ets/components/MainPage.ets`:`onKeyPreIme` 加拦截分支(flag=false 且 matchesClipboardShortcut → return true) +- [x] 2.6 `openharmony-ability/.../ets/components/FloatPage.ets`:同上(用 `this.windowId`) + +## 3. 验证与协调(待设备验证) + +- [ ] 3.1 `with_clipboard(false)` + 选中文本 + Ctrl+C → 剪贴板不变 +- [ ] 3.2 `with_clipboard(true)` + Ctrl+C → 正常复制 +- [ ] 3.3 `with_clipboard(false)` + 菜单含 Ctrl+C 加速器 + Ctrl+C → 既不复制也不触发菜单 +- [ ] 3.4 `with_clipboard(false)` + Ctrl+F → 正常(不拦截非剪贴板键) +- [ ] 3.5 程序化 `@ohos.pasteboard` 读写不受影响 diff --git a/openspec/changes/ohos-webview-flag-zoom-hotkeys/proposal.md b/openspec/changes/ohos-webview-flag-zoom-hotkeys/proposal.md new file mode 100644 index 000000000000..a939b8052ced --- /dev/null +++ b/openspec/changes/ohos-webview-flag-zoom-hotkeys/proposal.md @@ -0,0 +1,21 @@ +## Why + +wry 的 `with_zoom_hotkeys_enabled(bool)` / `with_hotkeys_zoom` 在 OHOS 后端被静默丢弃——`InnerWebView::new_inner` 解构时 `zoom_hotkeys_enabled` 落入 `..`,开发者设 `false` 无法禁用。ArkWeb 原生响应 Ctrl+=/-/0 缩放,功能"默认能用"但"关不掉",跨平台契约缺口。取代 `webview-desktop-features` R91 旧决策。 + +## What Changes + +- **wry**:`new_inner` 解构 `zoom_hotkeys_enabled`,调用 `WebViewBuilder::zoom_hotkeys_enabled(...)` +- **openharmony-ability (Rust)**:`WebViewBuilder` 加 `zoom_hotkeys_enabled` 字段 + setter;`WebViewInitData` 加字段;`build()` 透传 +- **openharmony-ability (ArkTS)**:`WebviewInitData` 加 `zoomHotkeysEnabled`;`accelerator_matcher.ets` 加 `matchesZoomShortcut` + per-window flag 存储;`ArkHelper.createWebview` 注册;`MainPage`/`FloatPage` `onKeyPreIme` 在 flag=false 且匹配 zoom 组合键时消费事件 + +## Capabilities + +### Modified Capabilities +- `ohos-webview-flag-zoom-hotkeys`: wry zoom hotkeys flag 在 OHOS 生效。取代 `webview-desktop-features` R91。 + +## Impact + +- 跨平台契约对齐:flag=false 禁用缩放热键 +- 默认行为不变(flag=true = ArkWeb 原生 Ctrl+=/-/0) +- 程序化 `controller.zoom()` 不受影响 +- 方案A(短路 zoom-hotkey.js 注入):经核查 OHOS 无 JS 注入路径,无需处理 diff --git a/openspec/changes/ohos-webview-flag-zoom-hotkeys/tasks.md b/openspec/changes/ohos-webview-flag-zoom-hotkeys/tasks.md new file mode 100644 index 000000000000..c03f604103b7 --- /dev/null +++ b/openspec/changes/ohos-webview-flag-zoom-hotkeys/tasks.md @@ -0,0 +1,23 @@ +# ohos-webview-flag-zoom-hotkeys Tasks + +## 1. Rust flag 转发 +- [x] 1.1 `WebViewBuilder` 加 `pub zoom_hotkeys_enabled: Option` +- [x] 1.2 新增 `pub fn zoom_hotkeys_enabled(self, ..)` setter +- [x] 1.3 `WebViewInitData` 加 `pub zoom_hotkeys_enabled: Option` +- [x] 1.4 `build()` 透传 `zoom_hotkeys_enabled: self.zoom_hotkeys_enabled` +- [x] 1.5 `wry/src/ohos/mod.rs` `new_inner` 解构 `zoom_hotkeys_enabled` + `.zoom_hotkeys_enabled(zoom_hotkeys_enabled)` + +## 2. ETS onKeyPreIme 拦截 +- [x] 2.1 `DefaultWebview.ets` `WebviewInitData` 加 `zoomHotkeysEnabled?: boolean` +- [x] 2.2 `accelerator_matcher.ets` 加 per-window flag 存储 `setZoomHotkeysEnabled`/`isZoomHotkeysEnabled`/`clearZoomHotkeysEnabled` +- [x] 2.3 `accelerator_matcher.ets` 加 `matchesZoomShortcut(event)`(Ctrl + =/-/0/equals/minus) +- [x] 2.4 `ArkHelper.createWebview` 调 `setZoomHotkeysEnabled(windowId, data?.zoomHotkeysEnabled ?? true)` + init 透传 +- [x] 2.5 `MainPage.ets` onKeyPreIme 加 zoom 拦截分支 +- [x] 2.6 `FloatPage.ets` onKeyPreIme 加 zoom 拦截分支 + +## 3. 验证(待设备) +- [ ] 3.1 `with_zoom_hotkeys(false)` + Ctrl+= → 不缩放 +- [ ] 3.2 `with_zoom_hotkeys(true)` + Ctrl+= → ArkWeb 原生缩放 +- [ ] 3.3 `with_zoom_hotkeys(false)` + Ctrl+0 → 不重置 +- [ ] 3.4 程序化 `controller.zoom()` 不受影响 +- [ ] 3.5 keyCode/keyText 映射(= / - / 0 的 KEYCODE_* 形式)设备确认 diff --git a/openspec/changes/ohos-webview-print/proposal.md b/openspec/changes/ohos-webview-print/proposal.md new file mode 100644 index 000000000000..d73a03a770a1 --- /dev/null +++ b/openspec/changes/ohos-webview-print/proposal.md @@ -0,0 +1,16 @@ +## Why +wry OHOS `print()` 是 `Ok(())` no-op,无法打印。`createPdf` 链路已完整可复用,`@ohos.print` 接受文件 URI 数组。 + +## What Changes +- **wry mod.rs**:`print()` 加 page_loaded guard + 生成 temp PDF 路径(`std::env::temp_dir()`,与 create_pdf 一致)→ 调 `Webview::print(path)` +- **ability helper/webview.rs**:新增 `print(path: String)` NAPI 方法(调 ArkTS `print` 属性) +- **DefaultWebview.ets**:import `@ohos.print`;新增 `printPage(path)`(createPdf 生成 PDF → `printKit.print([path])`);JsHelper 加 `print` +- **Utils.ets**:JsHelper 接口 + ProxyJsHelper 加 `print(path)` 缓存 + +## Impact +- print() 不再 no-op,触发系统打印流程 +- PrintKit 不可用时 createPdf 仍生成 PDF(降级) +- 不影响其他平台 +## 风险(待设备验证) +- `printKit.print([path])` 无 context 重载的实际行为(是否需 Context) +- createPdf→print 端到端是否真正出打印任务 diff --git a/openspec/changes/ohos-webview-print/tasks.md b/openspec/changes/ohos-webview-print/tasks.md new file mode 100644 index 000000000000..8204f2eed240 --- /dev/null +++ b/openspec/changes/ohos-webview-print/tasks.md @@ -0,0 +1,32 @@ +# ohos-webview-print Tasks + +- [x] 1. wry `print()`:page_loaded guard + temp 路径 + 调 `Webview::print(path)` +- [x] 2. ability `Webview::print(path: String)` NAPI 方法 +- [x] 3. DefaultWebview.ets `printPage(path)`(createPdf → `@ohos.print`)+ import +- [x] 4. Utils.ets JsHelper 接口 + ProxyJsHelper 加 `print` +- [ ] 5. 设备验证:print 触发系统打印;PrintKit 不可用降级 + +## 真机验证发现(2026-08-06,API 23 desktop) + +- [ ] 6. **`webview.print()` JS API 未暴露(FAIL,已修复待重验)**:wry/tauri Rust 侧 `print()` 已实现(wry lib.rs:2107 + ohos/mod.rs:407),但两个问题导致前端调用失败: + - **根因 1**:`tauri/src/webview/plugin.rs:227` print.js 注入脚本(`window.print = invoke('plugin:webview|print')`)只在 `cfg(macos/ios)` 注入,OHOS 不在内 → `window.print` 未重写。**已修复**:加入 `target_env = "ohos"` 条件。 + - **根因 2**:`manualOhosPrint` 调 `getCurrentWebview().print()`(Webview 类方法),但 Webview JS 类无 print 方法;正确入口是 `window.print()`(print.js 注入的全局函数)。**已修复**:改调 `window.print()`。 + - hilog:`[ManualTest] print() error: TypeError: e(...).print is not a function` + - **待重验**:重建部署后点 WebView Print 按钮,预期触发 createPdf → @ohos.print 系统打印对话框。 + +## 二次验证发现(2026-08-06,print.js 修复后) + +- [x] 7. **print.js 注入修复验证(PASS)**:`window.print()` 不再报 not a function,全链路执行: + - wry OHOS print 生成 PDF:`print(/data/storage/el2/base/cache/wry_print_*.pdf)` + - createPdf 渲染:`OhosPrintManager page 0` + - @ohos.print 创建任务:`jobId: *_940` +- [x] 8. **print 权限缺失(ErrorCode 201,已修复验证通过)**:`printkit: no permission to access print service`——app 缺 `ohos.permission.PRINT`。 + - **已修复**:tauri-cli 模板 `entry_desktop/src/main/module.json5` + `entry_mobile/src/main/module.json5` 的 `requestPermissions` 加 `ohos.permission.PRINT`。 + - **已验证**:权限通过后打印任务创建成功(`jobId: *_149` + `call client's StartPrint interface`),系统打印对话框弹出。 + +## 三次修复叠加(2026-08-06) + +1. **print.js 注入**(`tauri/src/webview/plugin.rs`):OHOS 加 `target_env = "ohos"` 到 print.js 注入条件(原只 macOS/iOS)。 +2. **PRINT 权限**(tauri-cli 模板 module.json5):`requestPermissions` 加 `ohos.permission.PRINT`。 +3. **printPage 路径转换**(`openharmony-ability DefaultWebview.ets`):`printKit.print([path])` → `printKit.print([fileUri.getUriFromPath(path)], getContext())`——print 要求 file URI(非绝对路径)+ UIAbilityContext。 +- 真机验证(API 23 desktop):点 WebView Print → 系统打印对话框弹出 ✓ diff --git a/openspec/ohos-dialog-path-process-gap-plan.md b/openspec/ohos-dialog-path-process-gap-plan.md new file mode 100644 index 000000000000..d0dc98cc9010 --- /dev/null +++ b/openspec/ohos-dialog-path-process-gap-plan.md @@ -0,0 +1,60 @@ +# OHOS 对话框/路径/进程/启动画面 缺口补齐计划 + +**创建时间**:2026-07-20 +**功能描述**:补齐对话框(R181 文件夹选择、R184 错误对话框)、路径 API(R190 桌面目录降级)、进程 API(R192 重启契约补档)、启动画面(R226 系统配置)、平台限制降级(R195/R223-224/R227-230)的 openspec 契约文档。 +**判断依据**:复核已有代码后,多数项已实现或为平台限制降级,仅需补档契约;R184 需小幅代码修改。 + +## 现状复核结论 + +| 行 | 功能 | 现有代码 | 判定 | +|----|------|---------|------| +| R181 | 文件夹选择对话框 | `commands.rs` OHOS 分支返回 `FolderPickerNotImplemented` | 平台限制降级,契约补档 | +| R184 | 错误对话框 | `tauri-runtime-wry/dialog/mod.rs` 非 Windows `unimplemented!()` | 需 OHOS 安全降级实现 | +| R190 | 其他路径(桌面/字体/运行时/模板) | `path/mod.rs` cfg 隔离,OHOS 不暴露 | 平台限制降级,契约补档 | +| R192 | 重启应用 | `app.rs::do_restart` + plugin-process `ohos::restart` 已用 `appRecovery.restartApp` | 已实现,契约补档 | +| R193 | AppImage 检测 | `process.rs` `cfg(all(linux, not(ohos)))` 已隔离 | 平台限制降级,归入 R192 规范 | +| R194 | 单实例限制 | `ohos-single-instance` spec 已存在 | 契约已满足 | +| R195 | 多进程 | OHOS 无通用 spawn | 平台限制降级 | +| R196 | 自动启动 | `ohos-autostart` spec 已存在 | 契约已满足 | +| R222 | 全局快捷键 | 3 phase 已归档实现 | 契约已满足(归档) | +| R223/224 | 全局托盘/菜单事件监听 | desktop 形态归 tray/menu 规范(只读) | 降级/归其他规范 | +| R226 | 启动画面 | OHOS 系统 splash via module.json5 | 模板配置降级 | +| R227 | 字体 | 无 Tauri 字体插件 | 平台限制降级 | +| R228 | 应用接续 | OHOS continuationManager,无 Tauri 对应 | 未来工作 | +| R229 | 截图取色 | OHOS screenshot(系统应用),无 Tauri 插件 | 未来工作 | +| R230 | 无障碍 | OHOS accessibility,无 Tauri 对应 | 未来工作 | + +## Phase 列表 + +| Phase | 名称 | 涉及 spec | 代码改动 | 状态 | +|-------|------|----------|---------|------| +| 1 | 契约补档(无代码) | ohos-dialog-folder-picker, ohos-path-desktop-dirs, ohos-process-restart, ohos-splash, ohos-platform-limitations | 无 | ✓ spec 已写 | +| 2 | R184 dialog::error OHOS 降级 | ohos-dialog-error | `tauri-runtime-wry/src/dialog/mod.rs` 增加 `#[cfg(target_env = "ohos")]` log 分支 | 待实现 | +| 3 | 审计已有 spec 完整性 | ohos-dialog-plugin, ohos-single-instance, ohos-autostart | 无 | ✓ 审计完成(见报告) | + +## Phase 2 详细说明(唯一需代码改动项) + +### 目标 +将 `tauri-runtime-wry::dialog::error()` 在 OHOS target 从 `unimplemented!()` 改为 `log::error!` 安全降级。 + +### 文件列表 +- `crates/tauri-runtime-wry/src/dialog/mod.rs`: + - 当前 `#[cfg(not(windows))]` 分支 `unimplemented!()` + - 新增 `#[cfg(target_env = "ohos")]` 分支:`log::error!("[dialog::error] {}", _err.as_ref())` + - 调整 cfg 优先级:`#[cfg(windows)]` → `#[cfg(target_env = "ohos")]` → 其余 `#[cfg(not(any(windows, target_env = "ohos")))]` 保留 `unimplemented!()` 或同步降级(不强制) + +### 验证 +- `cargo check -p tauri-runtime-wry --target ohos`:编译通过,无 `unimplemented!` 在 OHOS 分支 +- 单元测试:无法直接测试 log 输出,但可通过 `cargo test` 确认函数不 panic +- 设备端:由于 `webview_runtime_installed` 在 OHOS 始终为 true,`dialog::error` 实际不被调用;本改动为防御性契约补齐 + +## 关键未知项 +1. **OHOS 文件夹选择 API**:截至 API 21 确认无第三方目录选择器;若 API 22+ 新增需升级 `ohos-dialog-folder-picker` 规范。 +2. **appRecovery.restartApp 设备覆盖**:API 9+ 模块,理论支持全设备形态;wearable 返回 801 时当前实现已 `log::error!` + `exit(0)` 降级,符合契约。 +3. **OHOS 系统 splash 模板字段**:需确认 `tauri-cli` OHOS 模板 `module.json5` 是否已生成 `startWindowIcon`;若未生成需在模板层补齐(属 tauri-cli 范围,本计划仅记录)。 + +## 不创建新 spec 的项 +- **ohos-global-shortcut**:3 phase 已归档(`p1/p2/p3-global-shortcut`),契约已满足,不重复创建 active spec。 +- **ohos-single-instance**:active spec 已存在且完整。 +- **ohos-autostart**:active spec 已存在且完整。 +- **ohos-dialog-plugin**:active spec 已存在,覆盖 open/save/message/ask/confirm;R179/180/182/183 契约已满足。 diff --git a/openspec/ohos-event-monitor-tray-plan.md b/openspec/ohos-event-monitor-tray-plan.md new file mode 100644 index 000000000000..70636e8c2475 --- /dev/null +++ b/openspec/ohos-event-monitor-tray-plan.md @@ -0,0 +1,116 @@ +# OHOS 事件/显示器/托盘适配计划 + +**创建时间**:2026-07-20 +**功能描述**:tao 事件生命周期转发(Start/SaveState)、tao 显示器真实值与降级、 +tray-icon 平台限制降级的 openspec 设计补齐。 +**判断依据**:复核 tao `platform_impl/ohos/mod.rs`、muda `platform_impl/ohos/mod.rs`、 +tray-icon `platform_impl/ohos/mod.rs`、openharmony-ability `event.rs`/`app.rs`、 +`ohos-display-binding` / `ohos-display-sys` crate API 面。 + +## 范围与判定总表 + +| 行 | 功能 | 现有spec? | 复核后真实代码 | 契约判定 | 处置 | +|----|------|----------|---------------|---------|------| +| R135 | SaveState | 无 | `MainEvent::SaveState` warn 未转发 | 平台限制降级(tao 无对应 Event/StartCause 变体) | spec `ohos-event-lifecycle-forward`(降级 + warn→debug) | +| R136 | Start (NewEvents-Start) | 无 | `MainEvent::Start` warn 未转发 | 需新实现(转发为 `Event::Resumed`) | spec `ohos-event-lifecycle-forward` | +| R137 | 销毁事件 | 无 | `MainEvent::Destroy → Event::LoopDestroyed` 已转发 | 契约已满足 | 不写 spec(报告说明) | +| R139 | 位深 | 无 | 硬编码 32 | 平台限制降级(OHOS 无 bit-depth API,32=RGBA8888 真实值) | spec `ohos-monitor-degradation` | +| R140 | 刷新率 | 无 | 硬编码 60 | 需新实现(`default_display_refresh_rate()` 可用) | spec `ohos-monitor-real-values` | +| R142 | 显示器位置 | 无 | 固定 (0,0) | 平台限制降级(单显示器,原点真实为 0,0) | spec `ohos-monitor-degradation` | +| R143 | 显示器名称 | 无 | 固定 "OpenHarmony Device" | 平台限制降级(OHOS 无 name API) | spec `ohos-monitor-degradation` | +| R147 | monitor_from_point | 无 | 返回 None + warn | 需新实现(单显示器边界判定) | spec `ohos-monitor-real-values` | +| muda | 菜单系统 | menu-auto-tests 已覆盖 | append/insert/remove/popup 委托共享 impl | 契约已满足 | 不写 spec(报告说明) | +| R176 | 托盘临时目录 | 无 | `set_temp_dir_path` no-op | 平台限制降级(NAPI RGBA 传输,无临时目录) | spec `ohos-tray-degradation` | +| R177 | 托盘 rect() | 无 | `rect()` 返回 None | 平台限制降级(StatusBar 不提供位置) | spec `ohos-tray-degradation` | +| R178 | 托盘模板图标 | tray-icon-template 已覆盖 | white/black 双图标已实现 | 契约已满足 | 不写 spec(报告说明) | + +## Phase 列表 + +| Phase | 名称 | openspec change | 状态 | 涉及仓 | 预估文件 | 验证方式 | +|-------|------|----------------|------|--------|---------|---------| +| 1 | 事件生命周期转发 + 降级 | ohos-event-lifecycle-forward | ✓ 设计完成 | tao | 1 | cargo check(ohos) + 设备端 SHOWN/SaveState 验证 | +| 2 | 显示器真实值 + 点查询 | ohos-monitor-real-values | ✓ 设计完成 | openharmony-ability + tao | 2-3 | cargo check(ohos) + 高刷新率设备验证 refresh_rate | +| 3 | 显示器降级文档化 | ohos-monitor-degradation | ✓ 设计完成 | tao | 1 | 注释审查 + cargo check | +| 4 | 托盘降级文档化 | ohos-tray-degradation | ✓ 设计完成 | tray-icon | 1 | 注释审查 + cargo check | + +## Phase 详细说明 + +### Phase 1: 事件生命周期转发 + 降级(ohos-event-lifecycle-forward) + +- **目标**: + - `MainEvent::Start` → `Event::Resumed`(移除 warn) + - `MainEvent::SaveState` → `debug!` 降级(移除 warn) +- **关键发现**: + - tao `StartCause` 枚举仅 `ResumeTimeReached`/`WaitCancelled`/`Poll`/`Init`,**无 `Autosave` 变体** → SaveState 无法映射到 `NewEvents(StartCause::*)`。 + - `Event::Resumed` 是 OHOS SHOWN 信号的最接近语义(tao 无 window-shown 事件)。 + - 与 SurfaceCreate/Resume 重复触发 Resumed 需下游幂等(tauri `RunEvent::Resumed` 已具备)。 +- **文件**:`tao/src/platform_impl/ohos/mod.rs`(run_loop 闭包内 Start/SaveState 分支) +- **依赖**:无 + +### Phase 2: 显示器真实值 + 点查询(ohos-monitor-real-values) + +- **目标**: + - `MonitorHandle::video_modes()` 刷新率取自 `default_display_refresh_rate()` + - `monitor_from_point` 基于单显示器边界判定 + - `MonitorHandle::size()` 取自 DisplayManager 物理像素 +- **关键发现**: + - `ohos-display-binding` crate 提供 `default_display_refresh_rate()` / `default_display_width/height`(已存在,openharmony-ability 已用其 `default_display_scaled_density`)。 + - OHOS DisplayManager 仅有 "default display" API,无多屏枚举 → `monitor_from_point` 用边界判定返回 Some(primary)/None。 + - 按 CLAUDE.md 铁律#1,tao 不得直接依赖 `ohos-display-binding`,须经 openharmony-ability 暴露。 +- **文件**: + - `openharmony-ability/crates/ability/src/app.rs`(新增 `refresh_rate()` / `display_size()` 方法) + - `tao/src/platform_impl/ohos/mod.rs`(MonitorHandle::video_modes / size / monitor_from_point) +- **依赖**:Phase 1 无关,可并行 + +### Phase 3: 显示器降级文档化(ohos-monitor-degradation) + +- **目标**:bit_depth=32、position=(0,0)、name="OpenHarmony Device" 的降级在源码 + 注释中显式说明并引用 spec。 +- **关键发现**: + - OHOS DisplayManager API 面已审计:无 `BitDepth`/`Name`/多屏枚举。 + - 32 位深 = OHOS RGBA8888 真实值(非近似);(0,0) = 单屏真实原点。 +- **文件**:`tao/src/platform_impl/ohos/mod.rs`(MonitorHandle::name/position/video_modes 注释) +- **依赖**:Phase 2(同文件协同修改) + +### Phase 4: 托盘降级文档化(ohos-tray-degradation) + +- **目标**:`set_temp_dir_path` no-op 与 `rect()` 返回 None 在源码注释中引用 spec, + `set_temp_dir_path` 移除潜在 warn(当前已是空函数体,仅需注释补充)。 +- **关键发现**: + - `rect()` 既有注释已说明 AvoidArea.topRect 不可用,本 phase 仅补充 spec 引用。 + - `set_temp_dir_path` 当前 `pub fn set_temp_dir_path

(&mut self, _path: Option

) {}` 无 warn。 + - 与 Linux 行为对齐(Linux `rect()` 也返回 None)。 +- **文件**:`tray-icon/src/platform_impl/ohos/mod.rs` +- **依赖**:无 + +## 已满足契约(不写 spec) + +- **R137 销毁事件**:`MainEvent::Destroy → Event::LoopDestroyed` 已在 mod.rs:592-596 转发; + 另 `WindowDestroy` 分支补发 `CloseRequested` + `Destroyed`,契约完整。 +- **muda 菜单系统**:`Menu::add_menu_item`/`remove`/`items`/`popup`/`refresh_menubar` + 均在 ohos `mod.rs` 实现;`MenuItemData` 序列化 + ArkTS 渲染链路完整; + `menu-auto-tests` spec 已覆盖 popup/insert/remove 自动测试。措辞"共享 impl 委托" + 复核后:ohos 确有独立 `Menu`/`MenuChild` impl(非共享 cfg),功能等价,契约满足。 +- **R178 托盘模板图标**:`tray-icon-template` spec 已覆盖 white/black 双图标、 + `set_icon_as_template` 运行时切换、`set_icon_with_as_template` 组合设置, + 实现已于 `icon.rs` + `mod.rs::build_item_from_attrs` 完成。 + +## 平台限制降级清单(确认无法实现) + +| 项 | OHOS API 现状 | 降级处置 | +|----|--------------|---------| +| R135 SaveState 转发 | tao Event/StartCause 无对应变体 | 不转发,debug 日志 | +| R139 bit_depth | DisplayManager 无 bit-depth API | 固定 32(=RGBA8888 真实值) | +| R142 position | 无多屏 API | 固定 (0,0)(单屏真实原点) | +| R143 name | 无 display-name API | 固定 "OpenHarmony Device" | +| R176 set_temp_dir_path | StatusBar 用 NAPI RGBA 传输 | no-op | +| R177 rect | StatusBar 不提供托盘位置 | 返回 None | + +## OHOS API 关键未知项 + +- **DisplayManager 多屏**:当前 NDK 仅暴露 default display。若未来 OHOS NDK 新增 + `GetAllDisplays`,`available_monitors` / `monitor_from_point` 可升级为真实多屏。 +- **DisplayCutoutInfo**:`default_display_cutout_info()` 已可用但 tao 未消费, + 若需刘海屏安全区可后续引入。 +- **DisplayChangeListener**:`OH_NativeDisplayManager_RegisterDisplayChangeListener` + 已存在,可用于监听刷新率/分辨率动态变化(当前 spec 仅做静态查询)。 diff --git a/openspec/ohos-webview-drag-drop-overlay-plan.md b/openspec/ohos-webview-drag-drop-overlay-plan.md new file mode 100644 index 000000000000..bdec8588446f --- /dev/null +++ b/openspec/ohos-webview-drag-drop-overlay-plan.md @@ -0,0 +1,111 @@ +# OHOS WebView 文件拖拽 Overlay 降级 (ohos-webview-drag-drop-overlay) 计划 + +**创建时间**:2026-07-20 +**功能描述**:当 ArkWeb `Web` 组件不向 ArkUI 冒泡 OS 级文件拖拽事件时,在 `Web` 组件外层 `Stack` 中叠一层透明 `Stack` overlay 接收 ArkUI 通用组件级拖拽事件并转发给 wry `drag_drop_handler`,作为 `ohos-webview-drag-drop` 主路径的降级方案。 +**目标设备形态**:OHOS desktop(HarmonyPC / 大屏);mobile 标注不适用。 +**判断依据**:主路径已实现但 ArkWeb 冒泡行为未验证;overlay 方案需新增 ArkTS 节点 + wry 开关 + ability NAPI 字段,涉及 3 个代码层、约 5 个文件 → 单 Phase 可完成。 +**目标级别**:完整实现 overlay 降级,使其在主路径失效时仍能端到端交付 DragDropEvent。 + +## 与主路径 (ohos-webview-drag-drop) 的关系 +- **主路径**:`Web` 组件自身挂 `.onDragEnter/.onDragMove/.onDrop/.onDragLeave`,依赖 ArkWeb 冒泡。已实现(`DefaultWebview.ets` WebBuilder + EmbeddedWebBuilder)。 +- **本 overlay 降级**:仅当设备探测确认 ArkWeb 不冒泡时启用。启用时 overlay 是唯一事件源,Web 级回调被抑制以避免双发。 +- **触发条件**:`WebviewInitData.dragDropOverlay === true`。默认 `false`。 +- **共存**:两条路径不会同时产生事件(ArkWeb 平台行为固定:要么冒泡要么不冒泡)。开关由 wry 侧根据设备探测结果设置。 + +## OHOS API 关键点(已确认 / 待验证) +1. **ArkUI `CommonAttribute` 通用拖拽回调**:`.onDragEnter/.onDragMove/.onDragLeave/.onDrop` 是所有 ArkUI 组件通用的拖拽接口,不依赖 ArkWeb。挂在透明 `Stack` 上即可接收 OS 文件拖拽。**待设备验证**:OHOS 桌面态是否向应用下发 ArkUI 拖拽事件(若连 overlay 也不触发,则整体为平台限制)。 +2. **`HitTestMode.Transparent`**:本节点响应触摸/拖拽事件,同时事件向兄弟/下层节点透传。overlay 用此模式可接收 drag 事件,同时让鼠标/触摸/HTML5 DnD 穿透到下层 Web。 +3. **`DragEvent` 文件 URI 提取**:`dragEvent.getData()` 返回 primtive 数据;`dragEvent.primitive` / `dragEvent.summary` 可能含文件 URI 列表。预期 `file://`/`datashare://` URI。需去除 scheme 后转绝对路径。**待设备验证**返回格式。 +4. **坐标语义**:`DragEvent.getX()/getY()` 返回窗口坐标。需减去 `data.style.x/y`(Web 在 Stack 中的偏移)换算为 Web 内容区坐标,与主路径 Web 级 `.onDrop` 一致。 +5. **线程模型**:ArkUI 拖拽回调在 JS 线程;`data.onDragAndDrop` 是 NAPI `Function`,在 JS 线程直接调用即可(与主路径相同,无需额外同步)。 +6. **ArkTS 约束**:`@Builder` 内 pre-build 注册事件回调(ohos-constraints §4.1);overlay 节点必须在 `@Builder` 内静态声明,不能动态挂接。 + +## Phase 列表 + +| Phase | 名称 | 涉及层 | 预估文件 | 验证方式 | +|-------|------|--------|---------|---------| +| 1 | overlay 降级端到端实现 | openharmony-ability Rust + ArkTS + wry | 5 | 设备端拖文件入 webview,wry 收到 Drop 事件 | + +单 Phase:改动集中、文件数 ≤ 5、无独立可验证底层切片,强行拆分反而割裂 ArkTS 与 wry 的字段透传链。 + +## Phase 1: Overlay 降级端到端实现 + +### 目标 +1. 在 `openharmony-ability` Rust 侧新增 `WebViewBuilder::drag_drop_overlay(bool)` + `WebViewInitData.drag_drop_overlay: bool` NAPI 字段(受 `feature = "drag_and_drop"` 门控)。 +2. 在 `wry` 侧 `PlatformSpecificWebViewAttributes`(OHOS 专属,与 `use_https` 同结构,铁律 #2)暴露 `drag_drop_overlay` 开关 + `WebViewBuilderExtOhos::with_drag_drop_overlay(bool)`,`new_inner` 透传到 ability builder。非 OHOS 平台无此字段。 +3. 在 `DefaultWebview.ets` 的 `WebBuilder`/`EmbeddedWebBuilder` 中,当 `data.dragDropOverlay === true` 时: + - 抑制 Web 级 `.onDragEnter/.onDragMove/.onDrop/.onDragLeave` 挂接(避免双发) + - 在 `Stack` 中 Web 之后追加透明 `Stack` overlay(`HitTestMode.Transparent`) + - overlay 挂 `.onDragEnter/.onDragMove/.onDragLeave/.onDrop`,提取 URI + 坐标,构造管道串调 `data.onDragAndDrop` +4. 设备端验证:拖文件入 webview,wry `drag_drop_handler` 收到 `DragDropEvent::{Enter, Over, Drop, Leave}`。 + +### 文件列表 +- `openharmony-ability/crates/ability/src/webview/mod.rs` — `WebViewBuilder` 新增 `drag_drop_overlay` 字段 + 链式方法;`WebViewInitData` 新增 `pub drag_drop_overlay: bool` +- `openharmony-ability/crates/ability/helper/webview.rs` — NAPI object 序列化新增 `dragDropOverlay` camelCase 键 +- `openharmony-ability/native_ability/src/main/ets/webview/DefaultWebview.ets` — `WebviewInitData` interface 加 `dragDropOverlay?: boolean`;WebBuilder/EmbeddedWebBuilder 条件渲染 overlay + 条件挂接/抑制 Web 级回调 +- `wry/src/ohos/mod.rs` — `new_inner` 读取 `attributes.drag_drop_overlay`,调 `webview_builder.drag_drop_overlay(...)` +- `wry/src/lib.rs`(`PlatformSpecificWebViewAttributes` 定义处,与 `use_https` 同结构) — 新增 `pub drag_drop_overlay: bool` 字段 + 默认 `false` + `WebViewBuilderExtOhos::with_drag_drop_overlay` builder 方法(`cfg(target_env = "ohos")` 门控) +- `tauri/examples/api/src-tauri/gen/ohos/...`(可选) — 探测脚本/手动用例 + +### 依赖 +- `ohos-webview-drag-drop` 主路径已实现(drag.rs / wry 闭包 / ETS Web 级挂接就位) + +### 验证方式 +- **编译**:`cargo build --target aarch64-linux-ohos --features drag_and_drop` 通过;非 ohos 平台 `cargo build` 不受影响(cfg 隔离)。 +- **设备端 manual**: + 1. 在 `examples/api` 中开启 `drag_drop_overlay = true`,注册 `drag_drop_handler` 打印事件 + 2. 从 OHOS 文件管理器拖文件入 webview + 3. 观察 hilog + Rust 日志:应看到 `enter → over → drop` 序列,`drop` 携带正确文件路径 + 4. 拖拽过程中点击 webview 内按钮、滚动、文本选择 → 应正常工作(overlay 透传) + 5. 页内 HTML5 DnD(如拖 DOM 元素)→ 应正常工作,不产生 `DragDropEvent` +- **去重验证**:单次物理 drop 只产生一个 `DragDropEvent::Drop`(overlay 启用时 Web 级回调被抑制)。 + +### 未知项 / 风险 +1. **ArkUI 是否向应用下发 OS 文件拖拽事件**:若连 overlay 也不触发,回退为「平台限制」,更新 spec MODIFIED Requirement,建议应用层用 HTML5 `` 兜底。 +2. **`DragEvent` 文件 URI 格式**:`getData()` / `primitive` / `summary` 字段实际返回值需设备确认。若返回 `datashare://` URI 需额外解析(可能需 `fileIo` 或 `dataShareHelper` 转换为绝对路径)。 +3. **坐标换算**:`DragEvent.getX()` 语义(窗口坐标 vs 组件坐标)需确认;若已是组件坐标则无需减 `style.x/y`。 +4. **overlay 与 Web 同层 Stack 的渲染顺序**:ArkUI `Stack` 后声明者在上层;overlay 必须在 Web 之后声明。`BuilderNode.update` 不重建子节点结构(ohos-constraints §4.1),故 overlay 的渲染条件必须在 build 时确定(`data.dragDropOverlay` 不能运行时切换;若需切换只能重建 webview)。 +5. **`hitTestBehavior(HitTestMode.Transparent)` 对拖拽事件的影响**:需验证 Transparent 模式下 overlay 是否仍接收 `.onDragEnter` 等(Transparent 主要影响触摸 hit-test,拖拽事件分发机制可能不同)。若不接收,改用 `HitTestMode.Default` + overlay 仅在拖拽期间 `visibility(Visible)`、平时 `Hidden`,但这需要外部信号触发显隐——若无信号则不可行,需依赖 Transparent 透传。 + +## 状态 +- ○ 待开始 + +## 实现期发现(2026-08-06 验证时,2026-08-06 核实修正) + +> ⚠️ **原"tauri 层 API 断裂"诊断经代码核实为误判,已撤销。** 见下方修正。 + +**原诊断(已撤销)**:曾认为 tauri `WebviewWindowBuilder` 无 `drag_drop_handler` setter → wry 收不到 handler → `data.onDragAndDrop` 恒 undefined → 需独立 change `ohos-tauri-drag-drop-handler-api` 补 setter。 + +**核实真相**:`tauri-runtime-wry/src/lib.rs:5268` 在 `drag_drop_handler_enabled`(默认 true)时**自动装入内部 handler**,把 wry `DragDropEvent` 转 tauri 事件转发到前端 `onDragDropEvent`——这是跨平台惯例(Windows/macOS/Linux 同模式),OHOS 也走。因此: + +| 层 | 状态 | 说明 | +|----|------|------| +| ArkTS(ability DefaultWebview.ets) | ✅ `.onDragEnter/.onDrop` 已挂 | 调 `data.onDragAndDrop(...)` | +| wry(ohos/mod.rs) | ✅ `on_drag_and_drop` 管道已接 | 闭包调 `DragDropEvent::from_arkts_pipe` 解析管道串 | +| tauri-runtime-wry | ✅ **自动装 handler** | `lib.rs:5268` 内部 handler 转 tauri 事件,`data.onDragAndDrop` **不会**恒 undefined | +| tauri builder | ✅ 无需 setter | 跨平台设计惯例,用户经 tauri 事件系统监听 `DragDropEvent` | + +**结论**:Rust 管道端到端通(ArkTS → wry 闭包 → `DragDropEvent` → tauri-runtime-wry → tauri 事件 → 前端 `onDragDropEvent`)。**`ohos-tauri-drag-drop-handler-api` 独立 change 不需要,取消。** + +**真实剩余工作**:①ArkTS 路径简化(未剥 scheme、坐标恒 0,0、单文件不 join)②drag.rs 曾是死代码(已重构为真实 `DragDropEvent` + `from_arkts_pipe`/`to_arkts_pipe`)③`drag_drop_overlay` 在 tauri/tauri-runtime 层缺 cfg 隔离(API 卫生)④设备验证未做(ArkWeb 是否冒泡、getData 格式、overlay 是否仍 appfreeze)。 + +**真机拖拽支持确认**(arkts-helper):API 23(2in1 桌面)支持文件拖拽到 Web/ArkUI 组件,`onDragEnter/onDragMove/onDrop/onDragLeave` 会触发。R72"真 gap"风险低,问题在 ArkTS 路径正确性 + 设备验证,而非 tauri API。 + +### tauri API 已补 + overlay 渲染 appfreeze(2026-08-06) + +**tauri API 已补**(已 commit): +- `tauri-runtime/src/webview.rs`:`WebviewAttributes` 加 `drag_drop_overlay: bool` 字段 + builder 方法 +- `tauri/src/webview/mod.rs` + `webview_window.rs`:`WebviewBuilder`/`WebviewWindowBuilder` 加 `drag_drop_overlay` 透传 +- `tauri-runtime-wry/src/lib.rs`:OHOS 分支加 `with_drag_drop_overlay(webview_attributes.drag_drop_overlay)` +- `examples/api/src-tauri/src/cmd.rs`:`create_ohos_test_webview` 加 `drag_drop_overlay` 参数 + +**overlay 渲染导致 appfreeze(FAIL)**:`create_ohos_test_webview(dragDropOverlay: true)` 创建测试窗口时,overlay Stack 渲染 + `OnSizeChange` 事件导致主线程阻塞 6 秒 → `THREAD_BLOCK_6S` appfreeze。ArkTS 侧 `DefaultWebview.ets` 的 overlay Stack(line 378+)在 build 时和 Web 组件渲染冲突。 +- **已回退**:TestRunner 的 Drag Overlay 按钮已删除(`manualOhosTestDragOverlay` 函数 + 按钮移除),避免触发 appfreeze。tauri API 改动保留(无害,默认 false 不触发 overlay)。 +- **待排查**:overlay Stack 渲染死锁根因——可能 `dragDropOverlay` 条件下 Stack 和 Web 组件的 build 顺序/线程问题。需 ArkTS 侧排查(`BuilderNode.update` 不刷新组件属性约束 §4.1,overlay 渲染条件需 build 时确定)。 +- **主窗口拖拽**:Web 组件级 `.onDragEnter` 等已挂(主窗口拖文件有 `+` 号图标)。`data.onDragAndDrop` **已由 tauri-runtime-wry 自动装入的 handler 接通**(`lib.rs:5268` 内部 handler → wry `new_inner` `on_drag_and_drop` 管道 → ArkTS `onDragAndDrop`),前端经 `appWindow.onDragDropEvent` 收事件。若主窗口拖文件未触发 `DragDropEvent`,根因待设备验证(ArkWeb 是否冒泡 OS 文件拖拽到 `.onDrop`),非 `onDragAndDrop` 未设。 + +## 备注 +- **铁律遵守**:ArkTS 调用经 `openharmony-ability`;wry 不直接调 ArkTS;所有改动 `cfg(target_env = "ohos")` 或 `feature = "drag_and_drop"` 门控;不影响 Windows/macOS/Linux。 +- **版本守卫**:`HitTestMode.Transparent`、ArkUI 通用拖拽回调均为 API 12 基线能力,无需版本守卫。若 `DragEvent.primitive`/`summary` 为高版本 API,需加 `deviceInfo.sdkApiVersion` 守卫并回退 `getData()`。 +- **降级链**:ArkWeb 冒泡(主路径)→ ArkUI overlay(本降级)→ HTML5 页内 DnD(最终降级)。三层降级在 spec 中显式标注。 +- **mobile 形态**:mobile 形态下 `drag_and_drop` feature 默认关闭(无文件管理器拖拽场景),overlay 不激活;仅 desktop 形态启用 `drag_and_drop` feature 时 overlay 链路才编译/生效。 diff --git a/openspec/ohos-webview-drag-drop-plan.md b/openspec/ohos-webview-drag-drop-plan.md new file mode 100644 index 000000000000..a07eac276f44 --- /dev/null +++ b/openspec/ohos-webview-drag-drop-plan.md @@ -0,0 +1,84 @@ +# OHOS WebView 文件拖拽 (ohos-webview-drag-drop) 计划 + +**创建时间**:2026-07-20 +**功能描述**:激活 wry OHOS 的 `drag_and_drop` feature,接通 `drag_drop_handler`,补全 openharmony-ability `drag.rs` 与 ArkTS `onDragAndDrop` 事件挂接,使外部文件拖入 webview 时以 `DragDropEvent` 回传给 wry 用户回调。 +**目标设备形态**:含 OHOS 桌面/大屏(desktop 形态为主;mobile 形态标注不适用) +**判断依据**:feature flag + Rust 闭包 + ArkTS 字段已存在但未端到端接通 → 重新评估旧 plan Phase 4 "平台限制" 结论 +**目标级别**:完整实现(若 ArkWeb 不暴露 OS 文件拖拽事件则降级为 overlay 方案并显式标注) + +## 与旧 plan 的关系 +`openspec/webview-gap-completion-plan.md` Phase 4 标注 `✗ 平台限制`。复核发现: +- `crates/ability/src/webview/mod.rs` 已有 `#[cfg(feature = "drag_and_drop")] on_drag_and_drop` 字段与 NAPI 闭包桥接(line 284-296、439-443) +- `WebViewInitData.on_drag_and_drop` 已在 NAPI object 中声明(`helper/webview.rs:123`) +- `DefaultWebview.ets` `WebviewInitData.onDragAndDrop` 字段已声明(line 120)但 **WebBuilder/EmbeddedWebBuilder 从未挂接到 Web 组件** +- `drag.rs` 仅 stub `pub enum DragEvent { Enter {} }`,无序列化/反序列化 + +结论:旧 plan "平台限制" 结论 **过时/不准确** —— 基础设施 90% 就位,缺的是 ArkTS 事件挂接 + drag.rs 实体 + wry 层 handler 接通。Phase 4 应改为"可激活",本计划取代旧 Phase 4。 + +## OHOS API 关键未知项 +1. **ArkWeb Web 组件是否冒泡 OS 文件拖拽事件到 ArkUI `onDrop`**:华为文档未明确。ArkWeb 内部消费 HTML5 DnD,外部文件拖入时是否触发 ArkUI `onDragEnter`/`onDrop` 需设备验证。 + - 验证方法:在 WebBuilder 的 Web 组件上加 `.onDrop((event) => hilog.info(...))`,从文件管理器拖文件进去看是否触发。 + - 若不触发 → 采用 overlay 方案:在 `Stack` 中 Web 组件上方叠一层透明 `Column`/`Stack` 接收 ArkUI 拖拽事件,drop 时把焦点/可见性切换让 Web 响应,或直接由 overlay 消费并转发管道串 `||,`。 +2. **`DragEvent` 中文件 URI 格式**:OHOS 拖拽事件 `event.dragBehavior` / `primitive` / `summary` 字段如何提取文件路径。预期为 `file://` 或 `datashare://` URI,需去除 scheme 后转绝对路径。 +3. **wry `DragDropEvent` 与 OHOS 事件类型映射**: + - `Enter` ↔ ArkUI `onDragEnter` + - `Over` ↔ ArkUI `onDragMove` + - `Drop` ↔ ArkUI `onDrop` + - `Leave` ↔ ArkUI `onDragLeave` +4. **线程模型**:ArkUI 拖拽回调在 JS 线程;wry `drag_drop_handler` 期望在事件循环线程。需通过 NAPI TSFN 或 `get_main_thread_env` 同步入队(参考 `on_page_begin` 等已有模式)。 + +## Phase 列表 + +| Phase | 名称 | 涉及层 | 预估文件 | 验证方式 | +|-------|------|--------|---------|---------| +| 1 | 底层 NAPI + drag.rs 实体 | openharmony-ability Rust | 2 | drag.rs 编译 + 管道串解析单测(`from_arkts_pipe`/`to_arkts_pipe` 往返) | +| 2 | wry 接通 drag_drop_handler | wry | 1 | wry builder 设置 handler 后 NAPI 闭包非空 | +| 3 | ArkTS Web 组件事件挂接 | ArkTS | 2 | 设备端拖文件入 webview,wry 收到 Drop 事件 | +| 4 | 验证与降级 | 全层 | 1 | 若 ArkWeb 不冒泡则实现 overlay 方案 | + +## Phase 详细说明 + +### Phase 1: 底层 NAPI + drag.rs 实体 +- **目标**:把 `drag.rs` 从 stub 扩展为完整 `DragDropEvent` enum(`Enter { paths, position }`/`Over { position }`/`Drop { paths, position }`/`Leave`,与 `wry::DragDropEvent` 对齐),提供 `from_arkts_pipe(&str)` 方法(`splitn(3, '|')` + `,`-split 解析管道串 `||,`);提供 `to_arkts_pipe(&self)` 反向构造管道串供测试/调试使用。确认 NAPI 闭包签名 `Function` 与 wry 侧 `splitn(3, '|')` 解析匹配。 +- **文件**: + - `openharmony-ability/crates/ability/src/webview/drag.rs`(替换 stub) + - `openharmony-ability/crates/ability/src/webview/mod.rs`(如需调整 on_drag_and_drop 闭包签名) +- **未知项**:无 + +### Phase 2: wry 接通 drag_drop_handler +- **目标**:在 `wry/src/ohos/mod.rs` `new_inner` 中读取 `attributes.drag_drop_handler`,转换为 `openharmony_ability::WebViewBuilder::on_drag_and_drop` 闭包;闭包内对管道串 `||,` 执行 `raw.splitn(3, '|')`,第二段按 `,` split 过滤空串得 `paths: Vec`,第三段按 `,` split 解析为 `position: (i32, i32)`(失败回退 `(0,0)`),按 `type` 映射到 `DragDropEvent::{Enter, Over, Drop, Leave}` 并调用用户 handler。 +- **文件**: + - `wry/src/ohos/mod.rs`(new_inner 增加 drag_drop_handler 分支,见 line 148-178 实现已落地) +- **依赖**:Phase 1 +- **未知项**:wry `WebViewAttributes.drag_drop_handler` 字段类型(`Option>`)—— 需确认跨平台签名一致 + +### Phase 3: ArkTS Web 组件事件挂接 +- **目标**:在 `DefaultWebview.ets` `WebBuilder`/`EmbeddedWebBuilder` 中,当 `data.onDragAndDrop` 为函数时,给 Web 组件(或外层 Stack)挂 `.onDragStart`/`.onDragEnter`/`.onDragMove`/`.onDragLeave`/`.onDrop`,从 `DragEvent` 提取文件 URI,去除 `file://`/`datashare://` scheme,按管道串协议 `||,` 拼接,调 `data.onDragAndDrop('drop|' + paths_csv + '|' + x + ',' + y)` 等。 +- **文件**: + - `openharmony-ability/native_ability/src/main/ets/webview/DefaultWebview.ets`(WebBuilder + EmbeddedWebBuilder) + - `openharmony-ability/native_ability/src/main/ets/webview/Utils.ets`(如需提取 URI 的工具函数) +- **依赖**:Phase 1 +- **未知项**:ArkWeb Web 组件是否冒泡 OS 文件拖拽事件(见上「关键未知项 1」) + +### Phase 4: 验证与降级 +- **目标**:设备端验证拖文件入 webview 是否触发 wry `DragDropEvent::Drop`。若 ArkWeb 不冒泡,实现 overlay 方案:在 Web 组件上方叠透明 `Stack` 接收 ArkUI 拖拽事件并转发。验证 HTML5 页内 DnD 不受影响。 +- **文件**: + - `openharmony-ability/native_ability/src/main/ets/webview/DefaultWebview.ets`(overlay Stack,按需) + - `tauri/examples/api`(新增 drag_drop 测试命令 + 手动用例) +- **依赖**:Phase 1-3 +- **未知项**:overlay 方案是否会阻挡 Web 组件的鼠标/触摸输入(需 `hitTestBehavior` 透传) + +## 状态 +- **Phase 1(drag.rs)**:✅ 完成。`DragDropEvent` enum(镜像 wry)+ `from_arkts_pipe`/`to_arkts_pipe`(`\0`-split)+ round-trip 单测;wry `new_inner` 闭包改为调 `from_arkts_pipe` + 1:1 映射。tauri crate ohos target 编译通过。 +- **Phase 2(wry 接通)**:✅ 已落地(`9e3f8aa`)。`drag_drop_handler` → `on_drag_and_drop` 闭包接通,解析管道串。 +- **Phase 3(ArkTS Web 级挂接)**:✅ 完成。`DefaultWebview.ets` WebBuilder + EmbeddedWebBuilder 4 组回调(Web 级 + overlay)全部改用模块级 `buildDragPipe` helper(纯函数,无 `this`,符合 ohos-constraints §4.1)。核心修正:`getData()` 返 `UnifiedData`(旧码 `typeof d === 'string'` 误判 → path 恒 `''`,已修)→ `getRecords()` → `getTypes()/getEntry()` 分派(`UniformDataType.FILE_URI`→`uniformDataStruct.FileUri.oriUri` 主路径,arkts-helper 确认 + 本地 `unified-data-channels.md:150-158` 验证 getTypes/getEntry API 与 PLAIN_TEXT→PlainText 约定;`Image.imageUri` 兜底 Photos-app 拖拽)→ 剥 `file://`/`datashare://` scheme → `\0` join 多文件(与 wry `from_arkts_pipe` 对齐);`getX()/getY()` 读坐标(`0,0` 兜底,四事件均可读);hilog 记录数 + 未知类型诊断助 Phase 5。ArkTS 无 Windows 宿主工具链,编译复核 deferred 到设备。 +- **Phase 0(arkts-helper 查证)**:✅ 完成(降级路径)。`refresh_ai_auth` 失败(30 天会话过期,secureCookie blank),改用 `ask_ai` 匿名态 + 本地文档查证:getData() 返 UnifiedData、getX/getY 四事件可读、FILE_URI→FileUri.oriUri(getTypes/getEntry 经本地文档验证)。剩余 3 项(ArkWeb 冒泡 / hitTestBehavior Transparent 拖拽 / getX 窗口 vs 组件坐标)为设备依赖,归 Phase 5。 +- **Phase 4(验证与降级)**:✅ 设备验证完成(2026-08-07)。5 次拖拽铁证:ArkWeb **会**冒泡 OS 文件拖拽到 `.onDrop`(前 3 次触发了 `drag drop: N record(s)`),但 **ArkWeb 内部消费 drop 是浏览器内核行为,优先于 ArkUI onDrop**——导航到 `file://<拖入文件>`(.txt/.html 均触发,普遍)→ `ERR_ACCESS_DENIED`/`httpStatus:0` → 白屏/错误页。**setResult(DRAG_SUCCESSFUL) 无效**:Web 组件 onDrop 不走 ArkUI 通用拖拽协议(拖拽指南完全未提 Web 组件,setResult/优先 onDrop 只对通用 ArkUI 组件生效);且 Web 组件 onDrop 时灵时不灵(后 2-3 次完全不触发,因 ArkWeb 抢先消费后 ArkUI 不再派发 onDrop)。**结论:Main 路径(Web 级 .onDrop)+ setResult 在鸿蒙不可行**——ArkWeb 内核消费不可控、setResult 无效、handler 不可靠。**降级方案验证(2026-08-07,本地文档)**:拖拽是指向性事件,走命中测试(`arkts-interaction-basic-principles` 明确"拖拽"与触摸/鼠标同经 hit-test);后渲染 overlay(右子树优先)若 `HitTestMode.Block` 命中则阻塞兄弟节点 Web 进入响应链 → Web 收不到 drop → ArkWeb 无从消费。故 `dragDropOverlay` 释放区**技术可行**,但有内在缺陷:Block overlay 拦 drop 同时也挡触摸,故只能覆盖小区域(释放区);释放区外无 overlay → drop 落到 Web → ArkWeb 消费 → 白屏。全屏 Block overlay 会令 webview 不可交互。残余风险:ArkWeb 是否绕过命中测试在内核层直接消费(Hypothesis B),只能设备证伪。 +**选定方案:onLoadIntercept 拦截 file:// 导航(更优,已实现 + 设备验证成功 2026-08-07)**。ArkWeb 消费 drop 的表现即"导航到 `file://<拖入文件>`"——而 `onLoadIntercept`(Web 组件事件,API 10+,`DefaultWebview.ets` 已有挂接 line 395/574)在导航前触发,`event.data.getRequestUrl()` 取 URL,返回 true 取消导航。在两处 onLoadIntercept 加 `file://` 分支:拦掉导航(阻止白屏)+ `decodeURIComponent`+`stripDragScheme` 取路径 + 转发 `drop|path|0,0`。**整面 webview 都是释放区、不挡触摸、不依赖时灵时不灵的 onDrop**。安全:Tauri OHOS 初始加载走 `ctrl.loadUrl(data.url)`(自定义协议 `tauri://`/`https://.localhost`)或 `loadData(html)`,从不 `file://`(`wry/src/ohos/mod.rs:198/209` 确认),故拦 file:// 不影响正常加载。Web 级 onDrop(enter/over/leave 悬停反馈)保留;`buildDragPipe` 的 setResult 保留为无害 no-op(对通用组件仍正确)。**设备验证结果**:装机后拖文件,**白屏消失**(旧版每次必白屏/ERR_ACCESS_DENIED,现在不会)+ Web 级 onDrop 触发(hilog `drag drop: 1 record(s) received`,UDMF 路径提取链工作)。onLoadIntercept file:// 拦截方案确认成功——OHOS 文件拖拽端到端打通。setResult 改动保留在 buildDragPipe(对通用组件 onDrop 仍正确,无害),但 Web 组件上无效。setResult 改动保留在 buildDragPipe(对通用组件 onDrop 仍正确,无害),但 Web 组件上无效。启动期另有 `THREAD_BLOCK_6S` appfreeze(store 插件锁竞争,与拖拽无关,进程未死)。 +- **tauri setter 阻塞点**:✅ 不存在。`tauri-runtime-wry/src/lib.rs:5268` 自动装内部 handler,Rust 管道端到端通(详见 overlay plan「实现期发现」修正段)。`ohos-tauri-drag-drop-handler-api` 独立 change 取消。 +- **cfg 卫生(task 9)**:✅ 完成。`drag_drop_overlay` 在 tauri/tauri-runtime 层 6 处补 `#[cfg(target_env = "ohos")]`(字段/new()/方法 ×3 + cmd.rs 调用点)。Windows host `cargo check` 通过,tauri-runtime + tauri + tauri-runtime-wry 编译干净、无 fallout;ohos 由构造不变。对齐 spec「非 OHOS 平台无此字段」。 + +## 备注 +- 不影响其它平台:所有改动限于 `cfg(target_env = "ohos")` 路径或 `feature = "drag_and_drop"` 门控 +- 铁律遵守:ArkTS 调用经 openharmony-ability,不在 wry 直接调 ArkTS +- 若 Phase 4 验证后确认 ArkWeb 完全不支持外部文件拖拽且 overlay 方案不可行,则回退为"平台限制"并更新 spec 的 MODIFIED Requirement diff --git a/openspec/ohos-webview-flag-clipboard-plan.md b/openspec/ohos-webview-flag-clipboard-plan.md new file mode 100644 index 000000000000..62e4c651952b --- /dev/null +++ b/openspec/ohos-webview-flag-clipboard-plan.md @@ -0,0 +1,59 @@ +# ohos-webview-flag-clipboard 实施计划 + +**创建时间**:2026-07-20 +**功能描述**:让 wry `with_clipboard(bool)` 在 OHOS 后端生效——flag=false 时拦截剪贴板组合键(Ctrl+C/X/V/A/Z/Y),flag=true 时维持 ArkWeb 原生行为。 +**关联 spec**:`openspec/specs/ohos-webview-flag-clipboard/spec.md` +**取代**:`webview-desktop-features` spec 中「R82 Clipboard attribute is always-on」旧决策 + +## 背景 +ArkWeb 默认允许页面剪贴板访问。wry 的 `clipboard` 字段在 `wry/src/ohos/mod.rs:61-84` 解构时落入 `..` catch-all 被丢弃,开发者设 false 无法禁用。`accelerator_matcher.ets` 已有 `CLIPBOARD_ACCELERATORS` 集合用于「菜单加速器跳过剪贴板键」,本计划复用该集合作为拦截源。 + +## Phase 列表 + +| Phase | 名称 | 涉及层 | 预估文件 | 验证方式 | +|-------|------|--------|---------|---------| +| 1 | ETS 端 onKeyPreIme 拦截 | ArkTS | 3 | clipboard=false 时 Ctrl+C 不复制 | +| 2 | Rust flag 转发 + NAPI 桥接 | wry+OHA | 4 | WebviewInitData.clipboard 正确传递 | +| 3 | 验证与协调 | 全栈 | 0 | clipboard=true 原生行为 + 与加速器协调 | + +## Phase 详细说明 + +### Phase 1: ETS 端 onKeyPreIme 拦截 +- **目标**:在 `MainPage.ets` / `FloatPage.ets` 的 `onKeyPreIme` 中新增剪贴板拦截分支;在 `WebviewInitData` 新增 `clipboard?: boolean` 字段 +- **文件列表**: + - `openharmony-ability/native_ability/src/main/ets/webview/DefaultWebview.ets`(`WebviewInitData` 加 `clipboard` 字段) + - `openharmony-ability/native_ability/src/main/ets/components/MainPage.ets`(onKeyPreIme 加拦截分支) + - `openharmony-ability/native_ability/src/main/ets/components/FloatPage.ets`(同上,浮窗路径) +- **拦截逻辑**(伪代码): + ```ts + // 在 AcceleratorMatcher.matches 调用之前 + if (event.type === KeyType.Down && data?.clipboard !== true) { + const combo = buildCombo(event); // ctrl+c / ctrl+x / ... + if (CLIPBOARD_ACCELERATORS.has(combo)) return true; // 消费,阻止下发 ArkWeb + } + ``` +- **协调**:与 `AcceleratorMatcher.matches` 既有的 CLIPBOARD_ACCELERATORS 跳过逻辑正交——matcher 总是跳过剪贴板键(返回 false 不触发菜单),拦截器在 flag=false 时消费。两者组合见 spec 协调 Requirement。 +- **依赖**:Phase 2 提供 `data.clipboard` 字段;Phase 1 可先用硬编码 false 验证拦截,再接 Phase 2 + +### Phase 2: Rust flag 转发 + NAPI 桥接 +- **目标**:`InnerWebView::new_inner` 显式解构 `clipboard`,经 `WebViewBuilder::clipboard(bool)` → NAPI → ArkTS `WebviewInitData.clipboard` +- **文件列表**: + - `wry/src/ohos/mod.rs`(解构 `clipboard`,调用 `.clipboard(clipboard)`) + - `openharmony-ability/crates/ability/src/native_web/mod.rs`(`WebViewBuilder` 加 `clipboard` setter,存入 init data) + - `openharmony-ability/crates/ability/src/helper/webview.rs`(如需 NAPI 透传,视实现而定) + - `openharmony-ability/native_ability/src/main/ets/webview/DefaultWebview.ets`(`WebviewInitData.clipboard` 字段已在 Phase 1 添加;本 Phase 确认 build 路径透传) +- **依赖**:Phase 1 的 `WebviewInitData.clipboard` 字段定义 + +### Phase 3: 验证与协调 +- **目标**:端到端验证三种场景 + 与菜单加速器协调 +- **验证用例**: + 1. `with_clipboard(false)` + 页面选中文本 + Ctrl+C → 剪贴板内容不变 + 2. `with_clipboard(true)` + Ctrl+C → 正常复制 + 3. `with_clipboard(false)` + 菜单含 Ctrl+C 加速器 + Ctrl+C → 既不复制也不触发菜单(拦截器消费) + 4. `with_clipboard(false)` + Ctrl+F(非剪贴板键) → 正常(不拦截) + 5. 程序化 `@ohos.pasteboard` 读写不受影响 +- **依赖**:Phase 1 + Phase 2 完成 + +## 风险 +- ArkUI `onKeyPreIme` 对 Web 组件焦点的覆盖范围:需确认 Web 组件获得焦点时父容器的 onKeyPreIme 仍能收到事件(既有加速器路径已验证此点,剪贴板拦截复用同一入口,风险低) +- `CLIPBOARD_ACCELERATORS` 含 `ctrl+a/z/y`——`ctrl+a`(全选)拦截可能影响文本框全选体验。这是 flag=false 的预期语义(与 Windows `with_clipboard(false)` 一致),但需在文档中明确 diff --git a/openspec/ohos-webview-flag-zoom-hotkeys-plan.md b/openspec/ohos-webview-flag-zoom-hotkeys-plan.md new file mode 100644 index 000000000000..a31426e33b9f --- /dev/null +++ b/openspec/ohos-webview-flag-zoom-hotkeys-plan.md @@ -0,0 +1,70 @@ +# ohos-webview-flag-zoom-hotkeys 实施计划 + +**创建时间**:2026-07-20 +**功能描述**:让 wry `zoom_hotkeys_enabled` 在 OHOS 后端真正禁用缩放热键——flag=false 时拦截 ArkWeb 原生 Ctrl+=/-/0;flag=true 时协调 Tauri JS 注入路径与 ArkWeb 原生路径避免双重缩放。 +**关联 spec**:`openspec/specs/ohos-webview-flag-zoom-hotkeys/spec.md` +**取代**:`webview-desktop-features` spec 中「R91 Hotkey zoom works on OHOS desktop」旧结论(仅覆盖 JS 路径,未覆盖 flag=false 缺口) + +## 背景 +OHOS 桌面端缩放有两路: +1. Tauri 注入 `zoom-hotkey.js`(`crates/tauri/src/manager/webview.rs:562-581`,`cfg(all(desktop, not(target_os = "windows")))`)——已尊重 flag,false 时不注入 +2. ArkWeb 原生 Ctrl+=/-/0——不受 flag 控制,flag=false 时仍生效 + +契约差距 = 第 2 路无法禁用。本计划转发 flag + onKeyPreIme 拦截原生热键。 + +## Phase 列表 + +| Phase | 名称 | 涉及层 | 预估文件 | 验证方式 | +|-------|------|--------|---------|---------| +| 1 | ETS 端 onKeyPreIme 拦截 + ZOOM_HOTKEY_ACCELERATORS | ArkTS | 4 | flag=false 时 Ctrl+= 不缩放 | +| 2 | Rust flag 转发 + NAPI 桥接 | wry+OHA | 4 | WebviewInitData.zoomHotkeys 正确传递 | +| 3 | JS/原生双重缩放协调 | tauri | 1 | flag=true 时 Ctrl+= 仅缩放一档 | +| 4 | 验证 | 全栈 | 0 | 三场景 + 程序化缩放不受影响 | + +## Phase 详细说明 + +### Phase 1: ETS 端 onKeyPreIme 拦截 +- **目标**:在 `accelerator_matcher.ets` 新增 `ZOOM_HOTKEY_ACCELERATORS` 常量;在 `MainPage.ets` / `FloatPage.ets` 的 `onKeyPreIme` 新增 zoom 拦截分支(仅 desktop);在 `WebviewInitData` 新增 `zoomHotkeys?: boolean` 字段 +- **文件列表**: + - `openharmony-ability/native_ability/src/main/ets/helper/accelerator_matcher.ets`(新增 `ZOOM_HOTKEY_ACCELERATORS`;`matches` 跳过这些组合键的菜单匹配) + - `openharmony-ability/native_ability/src/main/ets/webview/DefaultWebview.ets`(`WebviewInitData.zoomHotkeys` 字段) + - `openharmony-ability/native_ability/src/main/ets/components/MainPage.ets`(onKeyPreIme zoom 拦截,门控 `__openharmony_ability_is_desktop__`) + - `openharmony-ability/native_ability/src/main/ets/components/FloatPage.ets`(同上) +- **拦截逻辑**(伪代码): + ```ts + if (event.type === KeyType.Down && this.isDesktop && data?.zoomHotkeys !== true) { + const combo = buildCombo(event); + if (ZOOM_HOTKEY_ACCELERATORS.has(combo)) return true; + } + ``` +- **依赖**:Phase 2 提供 `data.zoomHotkeys`;Phase 1 可先硬编码 false 验证 + +### Phase 2: Rust flag 转发 + NAPI 桥接 +- **目标**:`InnerWebView::new_inner` 显式解构 `zoom_hotkeys_enabled`,经 `WebViewBuilder::zoom_hotkeys_enabled(bool)` → NAPI → ArkTS `WebviewInitData.zoomHotkeys` +- **文件列表**: + - `wry/src/ohos/mod.rs`(解构 `zoom_hotkeys_enabled`,调用 `.zoom_hotkeys_enabled(...)`) + - `openharmony-ability/crates/ability/src/native_web/mod.rs`(`WebViewBuilder` 加 setter) + - `openharmony-ability/crates/ability/src/helper/webview.rs`(如需 NAPI 透传) + - `openharmony-ability/native_ability/src/main/ets/webview/DefaultWebview.ets`(build 路径透传) +- **依赖**:Phase 1 的 `WebviewInitData.zoomHotkeys` 字段定义 + +### Phase 3: JS/原生双重缩放协调 +- **目标**:flag=true 时避免 `zoom-hotkey.js` 与 ArkWeb 原生同时缩放 +- **文件列表**: + - `tauri/crates/tauri/src/manager/webview.rs`(OHOS desktop 短路 JS 注入,方案 A;或 `zoom-hotkey.js` 模板加 `os_name === "ohos"` 早退,方案 B) +- **决策**:推荐方案 A(OHOS desktop 不注入 JS,完全依赖 ArkWeb 原生 + `controller.zoom()` 程序化 API),因 ArkWeb 原生已覆盖 Ctrl+=/-/0 +- **依赖**:Phase 1 + Phase 2 + +### Phase 4: 验证 +- **验证用例**: + 1. `zoom_hotkeys_enabled=false` + OHOS desktop + Ctrl+= → 不缩放 + 2. `zoom_hotkeys_enabled=true` + OHOS desktop + Ctrl+= → 缩放一档(非两档) + 3. `zoom_hotkeys_enabled=false` + 程序化 `webview.zoom(1.5)` → 正常缩放 + 4. `zoom_hotkeys_enabled=false` + Ctrl+C(非 zoom 键) → 不拦截 + 5. mobile 形态 + `zoom_hotkeys_enabled=false` + Ctrl+= → 不拦截(mobile 不门控) +- **依赖**:Phase 1-3 完成 + +## 风险 +- ArkWeb 原生 Ctrl+=/-/0 的 keyCode/keyText 需确认与 `accelerator_matcher.getKeyText` 归一化输出匹配(`=`、`-`、`0`)。若 OHOS 返回 `KEYCODE_EQUALS` 等需在 SPECIAL_KEY_MAP 加映射 +- 方案 A 短路 JS 注入会改变 OHOS desktop 既有行为(原本 JS 路径生效),需确认 ArkWeb 原生缩放级别与 JS 路径 `set_webview_zoom` IPC 的级别语义一致(`controller.zoom(factor)` vs JS `document.body.style.zoom`) +- 若既有用户依赖 JS 路径的 `set_webview_zoom` IPC 命令,方案 A 移除后需评估兼容性 diff --git a/openspec/ohos-webview-https-scheme-plan.md b/openspec/ohos-webview-https-scheme-plan.md new file mode 100644 index 000000000000..b11aa6cd63a3 --- /dev/null +++ b/openspec/ohos-webview-https-scheme-plan.md @@ -0,0 +1,195 @@ +# OHOS WebView HTTPS 协议 (ohos-webview-https-scheme) 适配计划 + +**创建时间**:2026-07-20 +**功能描述**:让 wry OHOS 的 `WebViewBuilderExtOhos::with_https_scheme(true)` 真正生效——custom protocol 请求以 `https://.` 为 origin,使 secure-context API(`crypto.subtle`、service workers 等)在 OHOS webview 中可用。当前状态:API 外壳存在(`PlatformSpecificWebViewAttributes.use_https` 字段 + `with_https_scheme` 方法),但 `wry/src/ohos/mod.rs:338-340` 仅 `log::warn!` 提示「未实现」。 + +**目标设备形态**:OHOS 桌面/移动(desktop + mobile 均适用,无设备形态差异代码) + +**判断依据**: +- 涉及 3 个代码层:openharmony-ability(NAPI + ArkTS)、wry(Rust 适配)、ArkTS ETS(`DefaultWebview.ets` / `ArkHelper.ets` / `Utils.ets`) +- 预估影响 7 个文件 +- 既有底层 NAPI + ArkTS 链路改造,又有 wry 上层集成与端到端验证 → 拆分 + +**目标级别**:完整实现(ArkWeb 支持自定义 https origin secure-context 的前提下)+ 显式降级(设备验证不支持时回退为 no-op + warn,保留 API 形态) + +## 现状(已核实) + +- **wry 外壳**:`wry/src/lib.rs:1934-1971` 已定义 `PlatformSpecificWebViewAttributes.use_https` 与 `WebViewBuilderExtOhos::with_https_scheme` +- **wry 消费**:`wry/src/ohos/mod.rs:101-104` 读取 `use_https` 仅 debug log;`:325-336` 的 `custom_protocol_async` 注册仅注册原始 scheme(经 `OH_ArkWeb_SetSchemeHandler` 原生 API,不拦截 https);`:338-340` warn 未实现 +- **openharmony-ability**:`crates/ability/src/webview/mod.rs` `WebViewBuilder` 无 `use_https_intercept` / `https_intercept_protocols` 字段;`crates/ability/src/helper/webview.rs` `Webview` 无 `register_https_intercept` NAPI 方法 +- **ArkTS**:`DefaultWebview.ets` 的 `WebBuilder` / `EmbeddedWebBuilder` 挂载了 `onLoadIntercept`(用于 `onNavigationRequest` 与 close-window URL),但未挂载 `onInterceptRequest`;`Utils.ets` `JsHelper` 接口无 `registerHttpsIntercept` 签名 +- **ohos_web_binding 0.1.1**:`Web::custom_protocol` 调用 `OH_ArkWeb_SetSchemeHandler(protocol, web_tag, handle)`,只对原始 scheme 生效;`OH_ArkWeb_RegisterCustomSchemes` 必须在 web init 前调(`CustomProtocol::register()`)。不能用于 `https`(会全局拦截所有 https) +- **参考实现**:Android wry(`wry/src/android/mod.rs:211-288`)使用 `shouldInterceptRequest` + `custom_protocol_workaround` 模式,把 `https://.localhost/` 还原为 `://localhost/`。OHOS 的 `onInterceptRequest` 是 Android `shouldInterceptRequest` 的直接等价物(`web.d.ts:8719`,since 11/12——since 11 deprecated + since 12 current,无 since 9) + +## OHOS API 关键未知项(需设备验证) + +1. **`onInterceptRequest` 是否对主框架导航触发**:文档(`web.d.ts:8693-8719`)描述为「resources loading is intercepted」,对主框架 `loadUrl` 是否触发需设备验证。若不触发,初始 URL 加载需 `onLoadIntercept` 配合(fallback 见 Phase 2)。 + - 验证方法:在 `onInterceptRequest` 回调内 `hilog.info('intercept: ' + url)`,加载 `https://tauri.localhost/index.html`,观察日志是否出现主框架 URL。 +2. **`WebResourceResponse.setResponseIsReady(false)` + 异步 `setResponseIsReady(true)` 异步交付模式是否成立**:`web.d.ts:4048` `setResponseIsReady(IsReady: boolean)` since 9,文档未明确「先返回 false 后异步填数据再设 true」是否触发 ArkWeb 交付。若不支持,需降级为同步阻塞(违反 ohos-constraints §1.2 线程模型,不可行)或改用 service worker 方案。 + - 验证方法:构造最小用例——`onInterceptRequest` 返回 `setResponseIsReady(false)` 的 response,`setTimeout(() => { response.setResponseData('hello'); response.setResponseIsReady(true); }, 100)`,观察页面是否收到 `hello`。 +3. **ArkWeb 是否把 `https://.localhost` 识别为 secure context**:W3C 标准 `localhost` 是 secure context,但 ArkWeb 是否对 `tauri.localhost` 这类自定义子域应用 secure-context 规则需验证。若不支持,`crypto.subtle` 仍不可用,本特性失去意义。 + - 验证方法:加载 `https://tauri.localhost/test.html`,页面内执行 `console.log(window.isSecureContext, typeof crypto?.subtle)`,hilog 观察输出。 +4. **`onInterceptRequest` 是否对 `fetch()` / `XMLHttpRequest` 子资源请求触发**:文档说「resources loading」,预期触发,但需确认是否包括 XHR/fetch(Android `shouldInterceptRequest` 触发)。 + - 验证方法:页面内 `fetch('https://tauri.localhost/api')`,观察 `onInterceptRequest` 日志。 +5. **请求 headers / method 透传**:`WebResourceRequest.getRequestHeader()` 与 `getRequestMethod()` 可用(since 8/11),但 NAPI 侧 `dispatchHttpsIntercept` 是否需要把这些透传给 Rust 的 `http::Request`?若不透传,custom_protocol 闭包收到的请求 method 恒为 GET、headers 为空——对 GET-only 资源(前端静态资源)无影响,对 POST/XHR 有影响。 + - **首期决策**:首期只透传 url,method 默认 GET,headers 为空。POST/XHR 完整透传作为 Phase 5 增强项(设备验证后再加)。 +6. **`setResponseData(ArrayBuffer)` vs `setResponseData(string)`**:`web.d.ts:3904` 接受 `string | number | Resource | ArrayBuffer`。二进制响应(图片、wasm)必须用 `ArrayBuffer`;文本响应可用 string。NAPI 侧 `applyResponse` 应统一传 `Uint8Array`(ArkTS 自动视为 ArrayBuffer)。 +7. **`onInterceptRequest` 回调返回 null 与返回 `undefined` 的等价性**:文档说「If the response value is null, the Web will continue to load」。ArkTS `undefined` 是否等价 `null`?保守起见显式 `return null`。 + +## Phase 列表 + +| Phase | 名称 | 涉及层 | 预估文件 | 验证方式 | 状态 | +|-------|------|--------|---------|---------|------| +| 1 | 底层 ArkTS `onInterceptRequest` + NAPI dispatchHttpsIntercept | openharmony-ability (Rust + ArkTS) | 4 | cargo check + 设备端最小用例(手测 onInterceptRequest 触发) | ○ 待开始 | +| 2 | wry 消费 `use_https`:URL 改写 + register_https_intercept 调用 | wry | 1 | cargo check + 设备端 `with_https_scheme(true)` 端到端加载 | ○ 待开始 | +| 3 | secure-context 端到端验证 + 降级路径 | 全层 + 测试 | 2 | 设备端 `crypto.subtle` 可用性测试 + 降级开关 | ○ 待开始 | + +## Phase 详细说明 + +### Phase 1: 底层 ArkTS `onInterceptRequest` + NAPI dispatchHttpsIntercept + +- **目标**: + - `openharmony-ability/crates/ability/src/webview/mod.rs` `WebViewBuilder` 增加 `use_https_intercept: bool` 与 `https_intercept_protocols: Vec` 字段及 builder 方法;`build()` 透传到 `WebViewInitData`。 + - `openharmony-ability/crates/ability/src/helper/webview.rs`: + - `WebViewInitData` NAPI 结构增加 `use_https_intercept: Option` 与 `https_intercept_protocols: Option>` 字段。 + - `Webview` 增加 `pub fn register_https_intercept(&self, protocols: Vec) -> Result<()>`,NAPI 调 `ret.controller.registerHttpsIntercept(protocols)`。 + - 新增 `pub fn dispatch_https_intercept(...)`(或在 `custom_protocol_async` 闭包内捕获 webview 引用,由闭包直接调 `applyResponse` NAPI 回调)——具体形态见下方「实现说明」。 + - `openharmony-ability/native_ability/src/main/ets/webview/DefaultWebview.ets`: + - `WebViewInitData` 接口增加 `useHttpsIntercept?: boolean` 与 `httpsInterceptProtocols?: string[]` 字段。 + - `WebBuilder` 与 `EmbeddedWebBuilder` 在 `data.useHttpsIntercept === true` 时挂载 `.onInterceptRequest(callback)`。callback 实现:URL 匹配 → 创建 `WebResourceResponse` → `setResponseIsReady(false)` → 异步调 Rust → 返回 response;不匹配 → 返回 `null`。 + - `openharmony-ability/native_ability/src/main/ets/webview/Utils.ets`:`JsHelper` 接口增加 `registerHttpsIntercept: (protocols: string[]) => void` 签名;`buildJsHelper` 返回对象增加 no-op stub;`ProxyJsHelper` 增加缓存 + 回放。 + - `openharmony-ability/native_ability/src/main/ets/ability/ArkHelper.ets`:`createWebview` / `createEmbeddedWebview` 在 `ret.controller` 挂载 `registerHttpsIntercept(protocols: string[])` 实现(合并入 per-webview `httpsInterceptProtocols: Set`)。 + +- **文件**: + - `openharmony-ability/crates/ability/src/webview/mod.rs` + - `openharmony-ability/crates/ability/src/helper/webview.rs` + - `openharmony-ability/native_ability/src/main/ets/webview/DefaultWebview.ets` + - `openharmony-ability/native_ability/src/main/ets/webview/Utils.ets` + - `openharmony-ability/native_ability/src/main/ets/ability/ArkHelper.ets` + - (`package/` 目录下的 mirror 副本同步更新,不计入预估文件数) + +- **依赖**:无 + +- **实现说明(dispatchHttpsIntercept 形态选择)**: + - **方案 A(推荐)**:不新增独立 NAPI 函数。在 `custom_protocol_async` 闭包内捕获 `webview: Webview` 引用 + `applyResponse: Function`(由 ArkTS 传入)。当 `use_https_intercept=true` 时,ArkTS `onInterceptRequest` 不直接调 NAPI,而是把 `applyResponse` 函数存入 per-request 上下文,然后调用 `controller.dispatchHttpsIntercept(url, applyResponse)` NAPI 方法。Rust 侧 `dispatch_https_intercept` 方法内:还原 URL → 找到对应 protocol 的 `custom_protocol_async` 闭包 → 构造 Request + responder → 闭包执行 → responder 触发时 `Function::call(applyResponse, FnArgs{ data: (statusCode, headers, mimeType, body) })`。 + - **方案 B**:新增模块级 NAPI 函数 `dispatch_https_intercept(webview_id, url, applyResponse)`,通过全局 `HashMap` 查找闭包。**不推荐**——违反 ohos-constraints §2.2「TSFN 数据必须通过泛型参数携带,不是全局 Mutex」。 + - 选用方案 A:把 `applyResponse` 函数作为 `dispatchHttpsIntercept` 的参数传入,闭包内 capture。 + +- **未知项**:1、2、4、5、6、7(设备验证) + +### Phase 2: wry 消费 `use_https`:URL 改写 + register_https_intercept 调用 + +- **目标**: + - `wry/src/ohos/mod.rs` `InnerWebView::new_inner`: + 1. 删除 `:102-104` 的 `log::debug!`(保留 `use_https` 读取)与 `:338-340` 的 `log::warn!`。 + 2. 在 `let webview_builder = WebViewBuilder::new()...` 链中,若 `use_https && !custom_protocols.is_empty()`:调用 `.use_https_intercept(true).https_intercept_protocols(protocols.clone())`,其中 `protocols` 是 `custom_protocols.keys().collect::>()`。 + 3. 在 url/html 分支前,若 `use_https && initial_url` 匹配某 custom_protocol scheme:用 `custom_protocol_workaround::apply_uri_work_around(url, "https", protocol)` 改写 `initial_url`,再传给 `webview_builder.url(...)`。 + - 现有 `custom_protocol_async` 注册(`:325-336`)**保持不变**——原始 scheme 注册仍保留(向后兼容,custom_protocol_workaround 模式下不会被触发,因为页面 url 已改写为 https)。 + - IPC handler 闭包(`:303-323`)保持不变:`ipc_webview.url()` 在 https 模式下返回 `https://...`,与 webview 当前 url 一致,无需改写。 + +- **文件**: + - `wry/src/ohos/mod.rs` + +- **依赖**:Phase 1 完成 + +- **未知项**:无新增(依赖 Phase 1 验证结果) + +- **降级路径**:若 Phase 1 验证发现 `onInterceptRequest` 不触发主框架导航(未知项 1),且 fallback 经 `onLoadIntercept` 也不可行,则 Phase 2 在 `use_https=true` 时改为: + - 仍改写 url(让 origin 为 https) + - 不挂 `onInterceptRequest`,但保留 `custom_protocol_async` 经 `OH_ArkWeb_SetSchemeHandler` 注册原始 scheme + - 这样 https 请求会失败(custom_protocol 闭包收不到 https 请求)——退化为本特性「不支持」状态,需在 `with_https_scheme` doc 显式标注 + +### Phase 3: secure-context 端到端验证 + 降级路径 + +- **目标**: + - 在 `tauri api demo` 或独立测试 app 中:`with_https_scheme(true)` + 注册 `tauri://` custom_protocol,加载 `tauri://localhost/index.html`(自动改写为 `https://tauri.localhost/index.html`)。 + - 页面内执行: + ```js + console.log('isSecureContext:', window.isSecureContext); + console.log('crypto.subtle:', typeof crypto?.subtle); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode('hello')); + console.log('digest ok:', digest.byteLength === 32); + ``` + - 通过 hilog 观察输出。 + - 若 `isSecureContext === true` 且 `crypto.subtle` 可用 → 特性验收通过。 + - 若 `isSecureContext === false` 或 `crypto.subtle === undefined` → 触发降级路径: + - **降级 A**:尝试反向域名 `https://localhost./index.html`(修改 `custom_protocol_workaround` 增加反向模式)。 + - **降级 B**:在 `with_https_scheme` doc 显式标注「OHOS ArkWeb 当前版本不支持自定义 https origin secure-context」,保留 API 形态为 no-op + warn。 + - **降级 C**:调研 `OH_ArkWeb_RegisterCustomSchemes("https", Standard)` 是否可重注册 https(几乎确定不行——会破坏外部 https,但需验证确认)。 + - 在 spec.md 的「Secure-context behavior SHALL be verified on device」Requirement 下记录验证结论。 + +- **文件**: + - `tauri/examples/ohos-api-demo`(或现有测试 app,增加测试页面) + - `openspec/specs/ohos-webview-https-scheme/spec.md`(追加验证结论 Scenario) + +- **依赖**:Phase 1-2 完成 + +- **未知项**:3(核心未知项) + +## 实现顺序建议 + +1. **先做 Phase 1 的 ArkTS 改造**(`DefaultWebview.ets` / `ArkHelper.ets` / `Utils.ets`)—— `onInterceptRequest` 挂载 + 协议集合管理 + `WebResourceResponse` 创建/填充。这部分可在设备上独立验证(hardcode 一个 protocol,手动触发 fetch,看 hilog)。 +2. **再做 Phase 1 的 NAPI 桥接**(`webview/mod.rs` + `helper/webview.rs`)—— `dispatchHttpsIntercept` 闭包模式 + `applyResponse` 回调。 +3. **Phase 2 wry 改造**—— URL 改写 + 字段透传。 +4. **Phase 3 端到端验证**。 + +## 测试用例设计 + +### auto(可自动断言) +- `custom_protocol_workaround::apply_uri_work_around("tauri://localhost/x", "https", "tauri")` == `"https://tauri.localhost/x"`(已有 UT,OHOS 复用) +- `custom_protocol_workaround::revert_uri_work_around("https://tauri.localhost/x", "https", "tauri")` == `"tauri://localhost/x"` +- `is_work_around_uri("https://tauri.localhost/x", "https", "tauri")` == `true` +- `is_work_around_uri("https://example.com/x", "https", "tauri")` == `false` +- wry OHOS `with_https_scheme(true)` + `custom_protocols={"tauri"}` + url=`tauri://localhost/index.html` → 传给 `WebViewBuilder::build()` 的 url == `https://tauri.localhost/index.html`(需要 mock WebViewBuilder 或提取改写逻辑为纯函数) + +### side-effect(有副作用但可验证) +- 设备端加载 `https://tauri.localhost/index.html` → `onInterceptRequest` 触发 → custom_protocol 闭包被调用 → 页面渲染闭包返回的 HTML +- `register_https_intercept(["tauri"])` 后,新发起的 `https://tauri.localhost/...` 请求被拦截 + +### manual(需人工确认) +- `window.isSecureContext === true`(hilog 观察) +- `crypto.subtle.digest(...)` 成功(hilog 观察) +- 外部 https 站点(`https://example.com`)正常加载(未被误拦截) +- 主框架导航到 `https://tauri.localhost/index.html` 正常加载(验证未知项 1) +- 子资源 fetch/XHR 正常被拦截(验证未知项 4) + +## 风险与缓解 + +| 风险 | 缓解 | +|------|------| +| `setResponseIsReady(false)` 异步模式不被 ArkWeb 支持 | Phase 1 先做最小验证用例;不支持则改方案为同步阻塞(需评估线程模型)或 service worker | +| `onInterceptRequest` 不触发主框架导航 | 用 `onLoadIntercept` 配合,但 `onLoadIntercept` 只返回 boolean(block/allow),无法交付 response——需让 `onLoadIntercept` 对匹配 URL 返回 false(允许),同时让 `onInterceptRequest` 接管资源加载;主框架 HTML 由 `onInterceptRequest` 交付 | +| ArkWeb 不识别 `tauri.localhost` 为 secure context | 降级 A/B/C(见 Phase 3) | +| NAPI `Function::call` 在 `onInterceptRequest` 上下文静默失败(ohos-constraints §2.3) | `applyResponse` 不在 `render()` 上下文调;`onInterceptRequest` 是事件回调,非 render。但仍需设备验证 | +| `custom_protocol_async` 闭包捕获 `webview: Webview` 导致循环引用 | `Webview` 内部 `Rc` + `Rc`,无强引用环;闭包持有 `Webview` clone(Rc 引用计数 +1),生命周期与 webview 一致,Drop 时释放 | +| 请求 method/headers 未透传导致 POST 请求失败 | 首期只支持 GET(前端静态资源场景);POST 透传作为 Phase 5 增强项 | + +## 真机验证发现(2026-08-06,API 23 desktop) + +通过 TestRunner `HTTPS Scheme` 按钮(`create_ohos_test_webview` + `https_scheme=true`)验证: + +- **根因(已修复)**:`tauri-runtime-wry/src/lib.rs` OHOS 分支(`#[cfg(target_env = "ohos")]`)只传了 `with_window_id`,**漏传 `with_https_scheme`**——而 Windows/Android 分支都传了。导致 OHOS 上 `pl_attrs.use_https` 始终为 false(默认),`rewrite_https_url_if_matching` 条件 `use_https &&` 不满足,URL 不改写。hilog 确认导航 URL 仍为 `tauri://localhost/`。 +- **`custom_protocols` 非空**:tauri `manager/webview.rs:275` 注册 `tauri://` 到 `pending.register_uri_scheme_protocol`,build 时传给 wry `custom_protocols`——所以 `custom_protocols.is_empty()` 不是问题,`use_https=false` 才是。 +- **修复**:OHOS 分支加 `webview_builder = webview_builder.with_https_scheme(webview_attributes.use_https_scheme)`。 +- **重验结果(PASS)**:重建后点 HTTPS Scheme 按钮,hilog 确认 `onLoadIntercept → onNavigationRequest called: https://tauri.localhost/`(之前是 `tauri://localhost/`,现已改写为 `https://`)。URL 改写成功,origin 为 `https://tauri.localhost`。 +- **`isSecureContext` 最终验证(PASS)**:通过 init script 自动检查(无需 DevTools),hilog 确认: + - `isSecureContext=true` ✅ + - `location.href=https://tauri.localhost/` ✅(URL 改写成功) + - `crypto.subtle OK, bytes=32` ✅(SHA-256 digest 返回 32 字节,secure-context API 可用) + - R75 https-scheme 最终验收门槛全部通过。 + +## 与现有 spec 的关系 + +- **ohos-webview-bounds**(`specs/ohos-webview-bounds/spec.md`):无关,本特性不涉及 bounds +- **ohos-webview-drag-drop**:无关 +- **ohos-webview-print**:无关 +- **ohos-webview-proxy-config**:无关 +- **webview-transparent-bg**:无关 +- 本特性是 `wry/src/ohos/mod.rs:338-340` warn 标记的真 gap,独立设计 + +## 状态流转 + +- `○ 待开始` — 未开始设计 +- `● 进行中` — 正在设计或实现 +- `✓ 设计完成` — 设计文档已生成并通过审计 +- `✓ 已归档` — 已完成实现、测试并归档 diff --git a/openspec/ohos-webview-print-plan.md b/openspec/ohos-webview-print-plan.md new file mode 100644 index 000000000000..567e0e1fbc5d --- /dev/null +++ b/openspec/ohos-webview-print-plan.md @@ -0,0 +1,76 @@ +# OHOS WebView 打印 (ohos-webview-print) 计划 + +**创建时间**:2026-07-20 +**功能描述**:把 wry OHOS `print()` 从空 `Ok(())` no-op 改为真实实现,经 openharmony-ability NAPI 调 ArkTS `print()`,最终调用 OHOS `@kit.PrintKit`(`@ohos.print`)系统打印服务;PrintKit 不可用时降级为复用已有 `create_pdf` 生成 PDF。 +**目标设备形态**:OHOS 桌面/大屏(mobile 形态同样适用,打印服务在手机端亦可用) +**判断依据**:`create_pdf` 已实现(archive `2026-06-01-hmos-webview-create-pdf`),`print()` 可复用其 PDF 生成链路;旧 plan Phase 5 标 `○ 待开始` +**目标级别**:完整实现(PrintKit 可用时)+ 显式降级(PrintKit 不可用时映射到 create_pdf) + +## 与旧 plan 的关系 +`openspec/webview-gap-completion-plan.md` Phase 5「打印」标 `○ 待开始`。本计划取代 Phase 5,细化了: +- 不再「接 OHOS 打印服务 **或** 映射到 create_pdf」二选一悬而未决,而是 **PrintKit 优先 + create_pdf 降级** 的双路径 +- 明确 `print()` 复用 `page_loaded` guard(与 `create_pdf` 一致) +- 明确 `print()` 是运行时动作,不需扩展 `WebViewInitData` + +## OHOS API 关键未知项 +1. **`@kit.PrintKit` (`@ohos.print`) 的 API 形态**:华为文档需现场查证。预期主入口为 `print.print(documentName: string, callback)` 或 `print.printByPrinter(printDocumentAttributes, callback)`。是否接受 PDF 文件路径 / 文件描述符 / URI 是最大未知。 + - 验证方法:`import print from '@ohos.print';` 后 `typeof print.print`;若 import 失败 → 直接走降级路径。 + - 若 PrintKit 接受 `print.PrintDocumentAdapter` 回调流(流式分页),需实现 Adapter;若接受 PDF 文件 fd 则直接复用 create_pdf 产物。 +2. **API 版本要求**:`@ohos.print` 起始版本(API 12?13?)。若 > 当前最低支持 API 12,需 `deviceInfo.sdkApiVersion` guard。 +3. **ArkWeb 是否原生支持 `window.print()`**:若 ArkWeb 拦截 `window.print()` 并触发系统打印,则最简实现是 `controller.runJavaScript('window.print()')`,无需走 PrintKit。需设备验证。 +4. **临时 PDF 路径**:需用 app sandbox cache 目录(`PathResolver.cacheDir` 或 `getContext().cacheDir`),不能硬编码。 + +## Phase 列表 + +| Phase | 名称 | 涉及层 | 预估文件 | 验证方式 | +|-------|------|--------|---------|---------| +| 1 | 底层 NAPI + ArkTS print() | openharmony-ability (Rust + ArkTS) | 3 | ability Webview::print 编译;ArkTS print() 可调用 | +| 2 | wry 接通 print() | wry | 1 | wry print() 调 ability print() 而非 no-op | +| 3 | PrintKit 集成或降级 | ArkTS | 1 | 设备端 print() 触发系统打印 / 或降级生成 PDF | +| 4 | 验证 | 全层 | 1 | 手动用例 + 自动回归 | + +## Phase 详细说明 + +### Phase 1: 底层 NAPI + ArkTS print() +- **目标**: + - `openharmony-ability/crates/ability/src/helper/webview.rs` 增加 `pub fn print(&self) -> Result<()>`,查 `print` named property 并 call。 + - `Utils.ets` `JsHelper` 接口增加 `print: () => void`;`ProxyJsHelper` 增加 `print()` 委托 + pendingOperations 缓存。 + - `DefaultWebview.ets` `buildJsHelper` 返回对象增加 `print` 实现(Phase 3 填充真实逻辑,本 Phase 先放占位 `() => {}` 或直接调 createPdf 降级)。 +- **文件**: + - `openharmony-ability/crates/ability/src/helper/webview.rs` + - `openharmony-ability/native_ability/src/main/ets/webview/Utils.ets` + - `openharmony-ability/native_ability/src/main/ets/webview/DefaultWebview.ets` +- **未知项**:无(NAPI 模式与 `set_background_color` 一致) + +### Phase 2: wry 接通 print() +- **目标**:`wry/src/ohos/mod.rs` `pub fn print(&self) -> crate::Result<()>` 从 `Ok(())` 改为 `self.webview.print().map_err(...)`。 +- **文件**: + - `wry/src/ohos/mod.rs`(line 312-314) +- **依赖**:Phase 1 +- **未知项**:无 + +### Phase 3: PrintKit 集成或降级 +- **目标**:`DefaultWebview.ets` `buildJsHelper` 的 `print` 实现: + 1. 检查 `page_loaded`(通过 controller 状态或外部传入标志)—— 若未加载,hilog warn 并返回。 + 2. 尝试 `import print from '@ohos.print'`;若失败或 `typeof print.print !== 'function'` → 降级路径:调 `createPdf` 写入 `${cacheDir}/wry_print_.pdf`,hilog warn,返回。 + 3. 否则:调 `createPdf` 生成临时 PDF → 用 `@ohos.print` API 提交打印任务 → 清理临时文件。 +- **文件**: + - `openharmony-ability/native_ability/src/main/ets/webview/DefaultWebview.ets` +- **依赖**:Phase 1-2 +- **未知项**:见上「关键未知项 1-3」 + +### Phase 4: 验证 +- **目标**:设备端验证 `print()` 触发系统打印对话框(或降级生成 PDF);新增 `examples/api` `print_test` 命令 + 手动用例。 +- **文件**: + - `tauri/examples/api`(新增命令 + manual_tests.md) +- **依赖**:Phase 1-3 +- **未知项**:无 + +## 状态 +- ○ 待开始 + +## 备注 +- 不影响其它平台:`print()` 改动限于 `cfg(target_env = "ohos")`;wry 公共 `WebView::print()` 签名不变 +- 铁律遵守:ArkTS 调用经 openharmony-ability,不在 wry 直接调 NAPI +- 复用 `create_pdf` 链路:`PdfConfig` 默认值(A4)已在 `DefaultWebview.ets` 定义,print 直接复用 +- 与 `webview-gap-completion-plan.md` Phase 5 的区别:本计划明确「PrintKit 优先 + create_pdf 降级」双路径,不留二选一悬念 diff --git a/openspec/ohos-webview-proxy-config-plan.md b/openspec/ohos-webview-proxy-config-plan.md new file mode 100644 index 000000000000..c452e006f993 --- /dev/null +++ b/openspec/ohos-webview-proxy-config-plan.md @@ -0,0 +1,75 @@ +# ohos-webview-proxy-config 实施计划 + +**创建时间**:2026-07-20 +**功能描述**:让 wry `WebViewAttributes.proxy_config`(`ProxyConfig::Http` / `ProxyConfig::Socks5`)在 OHOS 后端真正生效——通过 ArkWeb `webview.ProxyController.applyProxyOverride` 将代理规则下发给 ArkWeb 引擎。 +**关联 spec**:`openspec/specs/ohos-webview-proxy-config/spec.md` +**取代**:—(真 gap,OHOS 端当前完全忽略 `proxy_config`,落入 `wry/src/ohos/mod.rs:61-87` 解构的 `..` catch-all) + +## 背景 + +- wry `WebViewAttributes.proxy_config: Option`(`wry/src/lib.rs:781`),由 `WebViewBuilder::with_proxy_config` 设置(`wry/src/lib.rs:1400`) +- `ProxyConfig` 枚举(`wry/src/proxy.rs`):`Http(ProxyEndpoint{host,port})` / `Socks5(ProxyEndpoint{host,port})` +- 已有实现: + - Windows(`wry/src/webview2/mod.rs:304-319`):拼 `--proxy-server=http://host:port` / `socks5://host:port` 到 `additional_browser_arguments` + - webkitgtk(`wry/src/webkitgtk/mod.rs:267-279`):`NetworkProxySettings::new("http://host:port" / "socks5://host:port")` → `website_data_manager.set_network_proxy_settings(Custom, ...)` +- OHOS 现状:`wry/src/ohos/mod.rs:61-87` 解构 `WebViewAttributes` 时未列出 `proxy_config`,落入 `..` 被静默丢弃。全文无 `proxy_config` / `ProxyConfig` 引用。 + +## ArkWeb 能力确认(关键判定) + +ArkWeb **具备**代理能力(`@ohos.web.webview` 模块,`SystemCapability.Web.Webview.Core`,`since 15`): + +- `class ProxyController`(静态类): + - `static applyProxyOverride(proxyConfig: ProxyConfig, callback: OnProxyConfigChangeCallback): void` + - `static removeProxyOverride(callback: OnProxyConfigChangeCallback): void` +- `class ProxyConfig`:`insertProxyRule(proxyRule: string, schemeFilter?: ProxySchemeFilter)`、`insertBypassRule(bypassRule: string)`、`insertDirectRule(schemeFilter?)` +- `proxyRule` 格式:`[scheme://]host[:port]`,scheme 必须是 `http` / `https` / `socks`,缺省为 `http` +- `enum ProxySchemeFilter { MATCH_ALL_SCHEMES=0, MATCH_HTTP=1, MATCH_HTTPS=2 }` +- **作用域**:app-wide("used by all Webs in the app")。等价于 Windows env-wide、webkitgtk context-wide,与既有平台语义一致。 +- **异步**:callback 在 UI 线程触发;"Requests are not guaranteed to use the new proxy immediately; wait for the listener before loading a page"。 +- **副作用**:`applyProxyOverride` 会使系统全局代理设置被忽略。 + +**版本守卫**:tauri api demo 默认 `compatibleSdkVersion = 12`。`ProxyController` `since 15`。必须用 `openharmony_ability::version::sdk_api_version() >= 15` 守卫,低版本静默跳过(与既有平台"不配置即用系统代理"语义对齐)。 + +## Phase 列表 + +| Phase | 名称 | 涉及层 | 预估文件 | 验证方式 | +|-------|------|--------|---------|---------| +| 1 | openharmony-ability 代理桥(NAPI + ArkTS) | openharmony-ability | 4 | Rust 单测 + 设备端验证 applyProxyOverride 被调用 | +| 2 | wry 透传 + 版本守卫 + 验证 | wry | 2 | 设备端:HTTP 代理拦截到流量;低版本静默跳过 | + +## Phase 详细说明 + +### Phase 1: openharmony-ability 代理桥 + +- **目标**:在 `openharmony-ability` 暴露 Rust API `apply_proxy_override(scheme: &str, host: &str, port: &str) -> Result<()>`,内部通过 NAPI 调用 ArkTS,ArkTS 构造 `webview.ProxyConfig` 调 `ProxyController.applyProxyOverride(config, cb)`。同时提供 `remove_proxy_override() -> Result<()>`。 +- **文件列表**: + - `openharmony-ability/crates/ability/src/webview/mod.rs`(或新建 `proxy.rs`):新增 `pub fn apply_proxy_override` / `remove_proxy_override`,通过 `get_main_thread_env` + `get_helper` 调 ArkTS 函数 `applyProxyOverride(scheme, host, port)` / `removeProxyOverride()` + - `openharmony-ability/crates/ability/src/helper/webview.rs` 或 `lib.rs`:导出新 API + - `openharmony-ability/crates/ability/src/lib.rs`:模块导出 + - `openharmony-ability/native_ability/src/main/ets/webview/DefaultWebview.ets`(或 ArkHelper.ets):实现 `applyProxyOverride(scheme: string, host: string, port: string): void` 函数 —— 构造 `webview.ProxyConfig`、`insertProxyRule(\`${scheme}://${host}:${port}\`)`、`ProxyController.applyProxyOverride(config, () => {})` +- **约束**: + - NAPI 函数名 camelCase(ArkTS 调用侧) + - `applyProxyOverride` 异步回调——Rust 侧**fire-and-forget**,不阻塞(避免 Chrome_IOThread × ArkTS 主线程死锁,见 ohos-constraints §1.2)。回调内仅可做 log,不能回 Rust(NAPI 重入限制,见 §2.3) + - 版本守卫放在 **Rust 侧**:`if version::sdk_api_version() < 15 { return Ok(()); }`(ArkTS 侧不需要再查,避免重复) + - `applyProxyOverride` 是 app-wide,文档化"多 webview 不同 proxy_config 时 last-write-wins" +- **依赖**:无 + +### Phase 2: wry 透传 + 版本守卫 + 验证 + +- **目标**:`wry/src/ohos/mod.rs` 解构 `WebViewAttributes` 时显式保留 `proxy_config`,转换 `ProxyConfig::Http/Socks5` 为 `(scheme, host, port)` 调用 Phase 1 的 `openharmony_ability::apply_proxy_override(...)`。scheme 映射:`ProxyConfig::Http` → `"http"`,`ProxyConfig::Socks5` → `"socks"`(ArkWeb scheme 仅接受 http/https/socks,不接受 `socks5`)。 +- **文件列表**: + - `wry/src/ohos/mod.rs`:解构新增 `proxy_config,`(不再落入 `..`);在 `webview_builder` 构建后、URL 加载前调用 `apply_proxy_override`;低版本静默跳过 + - `wry/src/ohos/mod.rs`:如需 `use crate::ProxyConfig;` 引入 +- **设计要点**: + - 调用时机:在 `WebViewBuilder::build()` 之后、`initial_url` load 之前调用——给 ArkWeb 一帧时间应用代理。但 ArkWeb 不保证 callback 完成前不加载页面;**文档化已知限制**:首次页面加载可能未走代理(与 Windows/webkitgtk 同样存在类似竞态,但它们在 env/context 创建期就设好代理,时序更紧;OHOS 的 fire-and-forget 更宽松但仍非阻塞同步) + - 多 webview:每次 `InnerWebView::new` 都会调 `apply_proxy_override`;后创建的覆盖先创建的。app-wide 行为由 ArkWeb 决定,不可绕过 + - 错误处理:NAPI 调用失败仅 `log::warn!`,不向上抛(与 Windows/webkitgtk 一致——代理失败不应阻塞 webview 创建) +- **依赖**:Phase 1 完成 + +## 风险 + +- **异步竞态**:ArkWeb `applyProxyOverride` 回调未返回前页面已加载 → 首次 URL 可能不走代理。文档化为已知限制,建议开发者在 `setup` 阶段尽早设置 proxy_config(在 load_url 之前)。如未来需要严格同步,可考虑 TSFN NonBlocking + 一次性 callback 回 Rust(但成本高,当前不实现) +- **app-wide 语义**:ArkWeb `ProxyController` 不支持 per-webview 代理。多 webview 场景 last-write-wins。文档化,建议应用层避免多 webview 不同代理 +- **低版本降级**:API < 15 静默跳过,与 Windows "无 proxy_config 即用系统代理" 不完全对齐(OHOS 低版本即使有 proxy_config 也用系统代理)。文档化 +- **系统代理被覆盖**:`applyProxyOverride` 会使 ArkWeb 忽略系统全局代理。开发者设置 `proxy_config` 后,所有 webview 流量都走指定代理,包括未显式设置 proxy_config 的 webview(因 app-wide)。文档化 +- **ProxyController 单例时机**:需确认 `webview.ProxyController` 是否需在 webview controller 初始化后才能调;若首帧调失败,可在 `onPageBegin` 首次触发后再 apply(实现时验证) diff --git a/openspec/ohos-window-ignore-cursor-events-plan.md b/openspec/ohos-window-ignore-cursor-events-plan.md new file mode 100644 index 000000000000..a8c4c996ec1a --- /dev/null +++ b/openspec/ohos-window-ignore-cursor-events-plan.md @@ -0,0 +1,63 @@ +# ohos-window-ignore-cursor-events 适配计划 + +**创建时间**:2026-08-05 +**功能描述**:为 Tauri/tao 的 `setIgnoreCursorEvents` 在 OHOS 上提供实现,基于 `ohos.window.setWindowTouchable(false)` 实现窗口级事件穿透(触摸 + 鼠标事件传给下层窗口)。 +**架构基线**:当前 `ohdev` 分支(旧模型:`get_helper()` + `get_named_property` + TSFN),**不考虑新模型 plugin-window 重构**。 +**判断依据**:涉及 3 个代码层(openharmony-ability / tao / ArkTS),预估 6 个文件。 + +## OHOS API 基线 + +- **API**:`ohos.window` 的 `setWindowTouchable(isTouchable: boolean): Promise` +- **语义**(官方智能问答最新版,待真机验证):`false` = 窗口不消费触摸/鼠标事件,事件穿透到 Z 轴下层窗口 +- **版本**:API 9+ 支持,元服务 API 12+;tauri demo 默认 API 12,满足 +- **系统能力**:`SystemCapability.WindowManager.WindowManager.Core` +- **错误码**:401(参数)、1300002(窗口状态异常/跨进程)、1300003(UI 未加载)—— **均通过 Promise reject 异步传递**(非同步抛出) +- **约束**:仅同进程窗口(1300002);UI 加载完成后调用(1300003) + +## Tauri API 映射 + +| Tauri/tao API | OHOS API | 语义 | +|---------------|----------|------| +| `Window::set_ignore_cursor_events(ignore: bool)` | `window.setWindowTouchable(!ignore)` | `ignore=true` → 穿透 ↔ `touchable=false`(逻辑取反) | + +## 旧模型实现模式(参照 `set_window_blur`) + +`set-touchable` 走 **TSFN fire-and-forget** 模式(和 `set_window_blur`/`set_window_background_color` 对称),不用同步直调(`set_window_decorations` 那种主线程限)——因为 tao 命令可能在 worker 线程。 + +- **Rust 侧**:`window/mod.rs` 加 `TSFN_SET_WINDOW_TOUCHABLE` + 在 `init_vibrancy_tsfn` 内追加初始化 + `set_window_touchable(window_id, touchable)`,TSFN 调 ArkHelper 的 `setWindowTouchable` 方法。`init_vibrancy_tsfn` 在 `render/xcomponent.rs:37` 的 XComponent render 初始化时被调用(非 ArkHelper setup) +- **ArkTS 侧**:`ArkHelper.ets` 加 `setWindowTouchable(windowId, touchable)` 方法,调 `wm.setWindowTouchable` 或 `WindowManager` 封装 +- **tao 侧**:填实 `set_ignore_cursor_events`,调 `openharmony_ability::set_window_touchable(window_id, !ignore)` + +## Phase 列表 + +| Phase | 名称 | 状态 | 涉及层 | 预估文件 | 验证方式 | +|-------|------|------|--------|---------|---------| +| 1 | 底层实现 — ability TSFN + ArkHelper | ✓ 已归档 | openharmony-ability + ArkTS | 3 | cargo check + 契约自洽(通过) | +| 2 | 上层集成 — tao 填实 + 真机验证 | ✓ 已归档 | tao + examples | 3 | 真机 setIgnoreCursorEvents 穿透测试(API 23 desktop 通过) | + +## Phase 详细说明 + +### Phase 1: 底层实现 — ability TSFN + ArkHelper +- **目标**:在 `openharmony-ability` 加 `set_window_touchable(window_id, touchable)` TSFN 函数(对称 `set_window_blur`),ArkHelper 暴露 `setWindowTouchable` 方法调 `wm.setWindowTouchable`。 +- **文件列表**: + - `openharmony-ability/crates/ability/src/window/mod.rs`(`TSFN_SET_WINDOW_TOUCHABLE` + `init_vibrancy_tsfn` 内追加 touchable TSFN 初始化 + `set_window_touchable` 公开函数) + - `openharmony-ability/native_ability/src/main/ets/ability/ArkHelper.ets`(`setWindowTouchable: (windowId, touchable) => { wm.setWindowTouchable... }` 方法) + - `openharmony-ability/crates/ability/src/lib.rs`(re-export `set_window_touchable`,若需要) +- **依赖**:无 +- **验证**:`cargo check`;TSFN init 在 ArkHelper setup 时调(参照 `init_vibrancy_tsfn` 调用点) + +### Phase 2: 上层集成 — tao 填实 + 真机验证 +- **目标**:填实 `tao/platform_impl/ohos/mod.rs:1215` 的 `set_ignore_cursor_events`(当前返回 NotSupported),调 `openharmony_ability::set_window_touchable(self.window_id, !ignore)`;加手动测试。 +- **文件列表**: + - `tao/src/platform_impl/ohos/mod.rs`(填实 `set_ignore_cursor_events`) + - `tauri/examples/api/src/lib/tests/ohos-adapter.ts`(手动测试) + - `tauri/doc/manual_tests.md`(手动用例归档) +- **依赖**:Phase 1 完成 +- **验证**:真机 — 子窗口叠主窗口,`setIgnoreCursorEvents(true)`,测触摸 + hover 是否穿透 + +## 风险与待验证 + +1. **真机验证穿透语义**:官方两版文档矛盾。Phase 2 真机为定论。hover 不穿透则叠加 `hitTestBehavior(HitTestMode.Transparent)`(R72 已验证)。 +2. **Promise reject 不可感知**:TSFN fire-and-forget 模式下,ArkTS `setWindowTouchable` 的 Promise reject 无法反向通知 Rust(和 `set_window_blur` 同样限制)。ArkTS 侧必须 `.catch` 处理避免闪退,但 Rust 侧始终返回 Ok。若需错误感知,改 `call_with_return_value` + oneshot(如 `clipboard_write_image`)——Phase 2 视需求决定。 +3. **1300002 跨进程约束**:tao 多窗口同进程,OK。 +4. **逻辑取反**:`ignore=true` ↔ `touchable=false`,取反在 tao 层。 diff --git a/openspec/specs/ohos-dialog-error/spec.md b/openspec/specs/ohos-dialog-error/spec.md new file mode 100644 index 000000000000..a6dfc221a412 --- /dev/null +++ b/openspec/specs/ohos-dialog-error/spec.md @@ -0,0 +1,53 @@ +# ohos-dialog-error Specification + +## Purpose +定义 `tauri-runtime-wry` 中底层 `dialog::error()` 函数在 OHOS 平台的行为契约。该函数在 Windows 上弹出原生错误对话框(用于 WebView2 运行时缺失等致命错误提示),但在非 Windows 平台当前为 `unimplemented!()`,会在误调用时导致进程 panic。本规范要求 OHOS 平台提供安全的降级实现(记录日志而非 panic),补齐 R184(错误对话框)的跨平台契约。 + +## 现状审计 +- 调用点:`tauri-runtime-wry/src/lib.rs::create_webview` 中 `#[cfg(all(not(debug_assertions), windows))]` 分支调用 `dialog::error(...)` —— 该调用点本身仅 Windows 启用。 +- OHOS 上 `context.webview_runtime_installed` 始终为 `true`(ArkUI Web 组件随系统提供),故 `dialog::error()` 在 OHOS 运行时实际不会被调用。 +- 但 `dialog::error()` 函数体在 OHOS 编译时仍存在 `unimplemented!()` 分支,属于潜在 footgun:任何未来新增的调用点在 OHOS 上都会 panic。 +- 用户级"错误对话框"语义已由 `ohos-dialog-plugin` 的 `showMessageDialog` + `MessageDialogKind::Error` 覆盖;本规范仅针对 runtime-wry 底层 `dialog::error()` 函数。 + +## ADDED Requirements + +### Requirement: OHOS 平台 `dialog::error` SHALL 安全降级 +`tauri-runtime-wry::dialog::error()` 在 OHOS target 编译时 SHALL 不展开为 `unimplemented!()`,SHALL 通过 `log::error!` 记录错误信息并安全返回,不触发 panic。 + +#### Scenario: OHOS 调用 error 不 panic +- **WHEN** 在 OHOS target 编译的 `tauri-runtime-wry` 中调用 `dialog::error("some fatal message")` +- **THEN** 函数 SHALL 通过 `log::error!` 输出消息(带 `[dialog::error]` 前缀) +- **AND** 函数 SHALL 正常返回,不 `panic!` / `unimplemented!` +- **AND** 进程继续运行(由调用方决定后续退出逻辑) + +#### Scenario: 多行错误信息完整记录 +- **WHEN** 调用 `dialog::error` 传入多行字符串(如 WebView2 缺失提示) +- **THEN** 日志 SHALL 完整记录全部行 +- **AND** 不因换行符或长度截断而丢失信息 + +### Requirement: 实现 SHALL 通过 cfg 隔离不影响其他平台 +OHOS 降级实现 SHALL 通过 `cfg(target_env = "ohos")` 隔离;Windows 原生错误对话框实现 SHALL 保持不变;其他非 Windows 非 OHOS 平台的 `unimplemented!()` 行为可保留或同步降级,但不由本规范强制。 + +#### Scenario: Windows 实现不变 +- **WHEN** 在 Windows target 编译 +- **THEN** `dialog::error()` SHALL 调用 `windows::error(_err)` 弹出原生 MessageBox +- **AND** OHOS 降级代码不参与编译 + +#### Scenario: OHOS 实现隔离 +- **WHEN** 在 OHOS target 编译 +- **THEN** `dialog::error()` 函数体 SHALL 进入 OHOS 降级分支(`log::error!`) +- **AND** 不引用 `windows` 模块,不依赖任何 Windows API + +### Requirement: OHOS 降级 SHALL 不引入 ArkTS 桥接 +`dialog::error()` 是 runtime-wry 启动早期的底层函数,此时 openharmony-ability 的 TSFN 可能尚未初始化,因此 OHOS 降级 SHALL 仅使用 `log` crate,SHALL NOT 调用 `promptAction` 或任何 ArkTS 桥接 API。 + +#### Scenario: 不依赖 TSFN +- **WHEN** 在 OHOS ability 初始化之前 `dialog::error()` 被调用 +- **THEN** 函数 SHALL 仅依赖 `log` crate 输出 +- **AND** 不调用 `openharmony-ability` 任何 API +- **AND** 不因 TSFN 未初始化而失败 + +## 设计要点 +- 实现方式:在 `crates/tauri-runtime-wry/src/dialog/mod.rs` 增加 `#[cfg(target_env = "ohos")]` 分支,调用 `log::error!("[dialog::error] {}", _err.as_ref())`。 +- 可选:同时将"其他非 Windows 非 OHOS"平台从 `unimplemented!()` 改为 `log::error!` 降级,但本规范不强制(避免影响 macOS/Linux 现有行为)。 +- 不在 `ohos-dialog-plugin` 范围内重复实现——plugin 层的错误对话框语义已由 `MessageDialogKind::Error` 满足。 diff --git a/openspec/specs/ohos-dialog-folder-picker/spec.md b/openspec/specs/ohos-dialog-folder-picker/spec.md new file mode 100644 index 000000000000..9c0f51de9172 --- /dev/null +++ b/openspec/specs/ohos-dialog-folder-picker/spec.md @@ -0,0 +1,84 @@ +# ohos-dialog-folder-picker Specification + +## Purpose +定义 `tauri-plugin-dialog` 在 OHOS 平台上对"文件夹选择"(`options.directory = true`)请求的契约。本规范**修订**早期"OHOS 无目录选择器"的结论——经 SDK `.d.ts` 核实(`@ohos.file.picker.d.ts`),`DocumentViewPicker` 配合 `DocumentSelectOptions.selectMode = DocumentSelectMode.FOLDER`(API 11+)支持目录选择,**仅限 2-in-1 / 桌面设备**。因此: +- **OHOS desktop**(`TAURI_OHOS_DEVICE_TYPE=desktop`)SHALL 用 `DocumentViewPicker.select({ selectMode: FOLDER })` 实现文件夹选择; +- **OHOS mobile** SHALL 以显式错误降级(2-in-1 only 平台限制)。 + +本规范补齐跨平台契约中 R181(文件夹选择对话框)的 OHOS 分支。 + +## ADDED Requirements + +### Requirement: OHOS desktop 文件夹选择 SHALL 使用 DocumentViewPicker + FOLDER 模式 +当 `dialog.open` 命令在 OHOS desktop(`cfg(all(target_env = "ohos", desktop))`)被调用且 `options.directory == true` 时,插件 SHALL 调用 `run_mobile_plugin("showFilePicker", ...)`(或等价命令)并在 ArkTS 侧以 `new picker.DocumentViewPicker()` 调用 `select({ selectMode: picker.DocumentSelectMode.FOLDER, maxSelectNumber })`,返回选中的目录 URI 列表。SHALL NOT 返回 `FolderPickerNotImplemented`。 + +#### Scenario: desktop 单选目录 +- **WHEN** 前端在 OHOS desktop 调用 `dialog.open({ directory: true })` +- **THEN** 命令处理器进入 `#[cfg(all(target_env = "ohos", desktop))]` 分支 +- **AND** 经 `run_mobile_plugin` 派发到 ArkTS,ArkTS 以 `DocumentSelectMode.FOLDER` + `maxSelectNumber: 1` 调用 `DocumentViewPicker.select()` +- **AND** 返回用户选中的目录 URI(单条) + +#### Scenario: desktop 多选目录 +- **WHEN** 前端在 OHOS desktop 调用 `dialog.open({ directory: true, multiple: true })` +- **THEN** ArkTS 以 `DocumentSelectMode.FOLDER` + `maxSelectNumber > 1`(或上限值)调用 `DocumentViewPicker.select()` +- **AND** 返回用户选中的目录 URI 列表 + +#### Scenario: desktop 文件夹选择返回目录 URI +- **WHEN** `DocumentViewPicker.select({ selectMode: FOLDER })` resolve +- **THEN** 返回的 URI 指向目录(file URI scheme),非文件 +- **AND** 前端收到的路径为目录路径 + +### Requirement: OHOS mobile 文件夹选择 SHALL 返回明确错误 +当 `dialog.open` 命令在 OHOS mobile(`cfg(all(target_env = "ohos", mobile))`)被调用且 `options.directory == true` 时,插件 SHALL 返回 `Error::FolderPickerNotImplemented`,不弹出任何选择器 UI。`DocumentSelectMode.FOLDER` 的"仅 2-in-1 设备支持"限制使 mobile 无法使用该能力。 + +#### Scenario: mobile 单选/多选目录 +- **WHEN** 前端在 OHOS mobile 调用 `dialog.open({ directory: true [, multiple: true] })` +- **THEN** 命令处理器进入 `#[cfg(all(target_env = "ohos", mobile))]` 分支 +- **AND** 返回 `Err(crate::Error::FolderPickerNotImplemented)` +- **AND** 不调用 `run_mobile_plugin("showFilePicker", ...)`、不创建 `DocumentViewPicker` 实例 +- **AND** `multiple` 标志不影响降级结果 + +#### Scenario: 文件选择不受影响 +- **WHEN** 前端调用 `dialog.open({ directory: false })` 在 OHOS(任意设备形态) +- **THEN** 插件 SHALL 正常调用 `showFilePicker` 走 `DocumentViewPicker.select()`(`selectMode` 默认 FILE)路径 +- **AND** 文件选择功能不受文件夹选择分支的影响 + +### Requirement: cfg 隔离 SHALL 精确区分 OHOS desktop / mobile / 其它平台 +文件夹选择的 OHOS 分支 SHALL 按 `TAURI_OHOS_DEVICE_TYPE` 精确拆分: +- `cfg(all(target_env = "ohos", desktop))` → FOLDER 选择实现; +- `cfg(all(target_env = "ohos", mobile))` → 返回 `FolderPickerNotImplemented`; +- `cfg(all(desktop, not(target_env = "ohos")))` → 保留原有 `blocking_pick_folder` / `blocking_pick_folders`(Windows/macOS/Linux); +- `cfg(mobile)`(非 OHOS,如 Android/iOS)→ 保留原有降级。 + +当前代码 `commands.rs` 用 `cfg(any(mobile, target_env = "ohos"))` 统一返回错误,**需重构**为上述四分支。 + +#### Scenario: 桌面平台(非 OHOS)文件夹选择不变 +- **WHEN** 在 Windows/macOS/Linux 调用 `dialog.open({ directory: true })` +- **THEN** 走 `#[cfg(all(desktop, not(target_env = "ohos")))]` 分支 +- **AND** 调用 `dialog_builder.blocking_pick_folder()` 或 `blocking_pick_folders()` +- **AND** 返回选中的目录路径 + +### Requirement: 错误类型 SHALL 可被前端识别 +`Error::FolderPickerNotImplemented` SHALL 通过 Tauri 命令错误链路序列化为可被前端识别的错误,错误信息 SHALL 明确指出当前设备形态不支持文件夹选择。 + +#### Scenario: 前端捕获错误(mobile) +- **WHEN** 前端在 OHOS mobile `await dialog.open({ directory: true })` 收到拒绝 +- **THEN** 前端 SHALL 收到一个 error,其 message 包含 "folder picker" 或 "not implemented" 语义 +- **AND** 前端可据此显示替代 UI(如手动输入路径或使用文件选择) + +### Requirement: ArkTS 桥接 SHALL 经现有 showFilePicker 通道扩展 +desktop 文件夹选择 SHALL 复用 `tauri-cli` OHOS 模板中 `Plugin.ets` 的 `showFilePicker` 通道(经 `run_mobile_plugin`),通过入参携带 `directory` 标志,由 ArkTS 侧据此设置 `DocumentSelectOptions.selectMode`。SHALL NOT 在 plugin Rust 端直接 NAPI 调用 `DocumentViewPicker`(铁律 #1:openharmony-ability / 模板 ETS 是唯一 ArkTS 桥接层)。 + +#### Scenario: showFilePicker 携带 directory 标志 +- **WHEN** `run_mobile_plugin("showFilePicker", { directory: true, multiple: false })` 在 OHOS desktop 派发 +- **THEN** ArkTS `showFilePicker` 处理器 SHALL 构造 `DocumentSelectOptions` 并设 `selectMode = DocumentSelectMode.FOLDER` +- **AND** 调用 `DocumentViewPicker.select(options)` 返回目录 URI + +## 平台限制说明 +- `DocumentSelectMode.FOLDER`(`@ohos.file.picker`)自 **API 11** 起提供,文档明确 "Only 2-in-1 devices are supported"——即仅 OHOS 桌面/2-in-1 形态可用,mobile 不可用。证据:`@ohos.file.picker.d.ts` `DocumentSelectMode` 枚举与 `DocumentSelectOptions.selectMode` 字段。 +- `DocumentViewPicker.select()` 返回 `Promise>`(URI 数组);`selectMode` 默认 `FILE`。 +- mobile 降级为 `FolderPickerNotImplemented`,不属于"未实现"而是"平台能力限制"。 +- 替代方案:mobile 上应用可通过 `@ohos.file.fs` 自行实现目录浏览 UI,但该方案不属于本契约范围,应作为独立插件设计。 + +## 修订说明 +本 spec 推翻早期"OHOS 截至 API 21 无第三方目录选择器、统一返回错误"的结论。`TAURI_OHOS_DEVICE_TYPE=desktop` 场景下文件夹选择 SHALL 实现,不再降级。表格 R181 的处置相应从"平台限制(全 ❌)"调整为"desktop 可实现 / mobile 降级"。 diff --git a/openspec/specs/ohos-event-lifecycle-forward/spec.md b/openspec/specs/ohos-event-lifecycle-forward/spec.md new file mode 100644 index 000000000000..6f95c77d7fb0 --- /dev/null +++ b/openspec/specs/ohos-event-lifecycle-forward/spec.md @@ -0,0 +1,65 @@ +# OHOS Event Lifecycle Forward Specification + +## Purpose + +定义 OHOS `openharmony_ability::Event` 生命周期事件(`Start`、`SaveState`)到 tao +`event::Event` 的转发契约。当前实现中两者均以 `warn!` 静默丢弃,本 spec 明确: +- `Start`(`WindowStageEventType::SHOWN`)SHALL 转发为 `Event::Resumed`; +- `SaveState`(`onAbilitySaveState`)因 tao `Event`/`StartCause` 枚举无对应语义, + SHALL 显式降级为 `debug!` 日志(不再 `warn!`),并文档化平台限制。 + +## ADDED Requirements + +### Requirement: MainEvent::Start 转发为 Event::Resumed + +tao OHOS 事件循环 SHALL 将 `MainEvent::Start`(`WindowStageEventType.SHOWN`,窗口 +对用户可见)转发为 `event::Event::Resumed`,与 `MainEvent::SurfaceCreate` / +`MainEvent::Resume` 的现有行为保持一致。 + +tao 的 `Event::Resumed` 是最接近 OHOS "窗口已显示" 语义的生命周期信号(tao 没有 +独立的 "window-shown" 事件)。重复触发 `Resumed`(与 SurfaceCreate/Resume 一起) +是可接受的,下游 tauri `RunEvent::Resumed` 处理需具备幂等性。 + +#### Scenario: 窗口从隐藏恢复显示 +- **WHEN** 系统发出 `MainEvent::Start`(SHOWN),例如从最近任务列表切回应用 +- **THEN** 事件回调 SHALL 收到 `Event::Resumed` +- **AND** 不再出现 `warn!("TODO: forward onStart notification to application")` + +#### Scenario: 与 SurfaceCreate 共存 +- **WHEN** 冷启动序列中 `SurfaceCreate` 与 `Start` 先后到达 +- **THEN** 回调 SHALL 收到两次 `Event::Resumed`(一次来自 SurfaceCreate,一次来自 Start) +- **AND** 下游 tauri 逻辑 SHALL 对重复 Resumed 幂等处理 + +### Requirement: MainEvent::SaveState 显式降级 + +OHOS `onAbilitySaveState` 在系统内存压力下回收应用时触发,用于持久化应用状态。 +tao 的 `Event` 枚举与 `StartCause` 枚举(`ResumeTimeReached` / `WaitCancelled` / +`Poll` / `Init`)均无对应语义变体(特别是 `StartCause` 不存在 `Autosave` 变体), +因此无法在 tao 层暴露此信号。 + +tao OHOS 实现 SHALL 将 `MainEvent::SaveState` 作为平台限制降级处理: +- 不转发任何 `event::Event`; +- 日志级别 SHALL 从 `warn!` 下调为 `debug!`(该事件是预期行为,非错误); +- 注释 SHALL 说明降级原因与对应 OHOS 文档链接。 + +#### Scenario: 系统发起 SaveState +- **WHEN** 系统因内存回收调用 `onAbilitySaveState` +- **THEN** tao 事件回调 SHALL NOT 收到任何 `Event` +- **AND** 日志 SHALL 输出 `debug!` 级别说明("SaveState has no tao Event equivalent; dropped") +- **AND** 不再出现 `warn!` 噪音 + +#### Scenario: 应用无需感知状态保存 +- **WHEN** 跨平台应用依赖 tao 事件循环做状态持久化 +- **THEN** 应用 SHALL 通过 tauri `RunEvent::Exit` / `ExitRequested` 或自定义持久化逻辑处理 +- **AND** 不得假设 OHOS 上会收到 SaveState 信号 + +### Requirement: 注释与文档对齐 + +tao OHOS `mod.rs` 中 `MainEvent::Start` 与 `MainEvent::SaveState` 分支 SHALL 移除 +`XXX: how to forward this state to applications?` 疑问注释,替换为本 spec 的明确 +处置说明(转发 Resumed / 平台限制降级)。 + +#### Scenario: 源码注释更新 +- **WHEN** 审查 tao OHOS 事件循环 `run_loop` 闭包 +- **THEN** `MainEvent::Start` 分支注释 SHALL 说明 "forwarded as Event::Resumed (window-shown lifecycle signal)" +- **AND** `MainEvent::SaveState` 分支注释 SHALL 说明 "degraded: tao has no SaveState Event variant; see openspec ohos-event-lifecycle-forward" diff --git a/openspec/specs/ohos-monitor-degradation/spec.md b/openspec/specs/ohos-monitor-degradation/spec.md new file mode 100644 index 000000000000..4530378de899 --- /dev/null +++ b/openspec/specs/ohos-monitor-degradation/spec.md @@ -0,0 +1,80 @@ +# OHOS Monitor Degradation Specification + +## Purpose + +显式记录 tao OHOS `MonitorHandle` 中因 OHOS DisplayManager API 缺失而无法满足 +跨平台契约的字段,及其降级行为。涉及: +- 位深(`VideoMode::bit_depth`)— R139 +- 显示器位置(`MonitorHandle::position`)— R142 +- 显示器名称(`MonitorHandle::name`)— R143 + +OHOS `ohos-display-sys`(native_display_manager)仅暴露:`Id`、`Width`、`Height`、 +`Rotation`、`Orientation`、`VirtualPixelRatio`、`RefreshRate`、`DensityDpi`、 +`DensityPixels`、`ScaledDensity`、`DensityXdpi`、`DensityYdpi`、`CutoutInfo`、 +`IsFoldable`、`FoldDisplayMode`、DisplayChangeListener。无 `BitDepth` / `Name` / +多屏枚举 / 屏幕坐标 API。 + +## ADDED Requirements + +### Requirement: bit_depth 固定 32(OHOS 标准) + +OHOS DisplayManager 不提供位深查询 API。OHOS 设备普遍采用 RGBA8888(32 位)显示 +管线,硬编码 `bit_depth: 32` 与真实值一致。 + +`MonitorHandle::video_modes()` SHALL 返回 `bit_depth: 32`,并在源码注释中说明 +"OHOS DisplayManager has no bit-depth API; 32 is the OHOS standard (RGBA8888)"。 + +#### Scenario: 调用 video_modes +- **WHEN** 调用 `monitor.video_modes().next()` +- **THEN** `VideoMode::bit_depth()` SHALL 返回 32 +- **AND** 该值与 OHOS RGBA8888 显示管线一致,非近似 + +### Requirement: position 固定 (0,0)(单显示器原点) + +OHOS DisplayManager 仅暴露默认显示器,无多屏枚举与屏幕坐标空间概念。默认显示器 +原点为屏幕坐标 (0, 0)。 + +`MonitorHandle::position()` SHALL 返回 `PhysicalPosition::new(0, 0)`,并在源码 +注释中说明 "OHOS is single-display; default display origin is (0,0)"。 + +#### Scenario: 调用 position +- **WHEN** 调用 `monitor.position()` +- **THEN** SHALL 返回 `(0, 0)` +- **AND** 该值为真实原点(非占位),因 OHOS 无多屏偏移概念 + +### Requirement: name 固定 "OpenHarmony Device"(无 API) + +OHOS DisplayManager 不提供显示器名称查询 API。`MonitorHandle::name()` SHALL 返回 +`Some("OpenHarmony Device".to_owned())`,并在源码注释中说明 +"OHOS DisplayManager has no display-name API; returns fixed identifier"。 + +#### Scenario: 调用 name +- **WHEN** 调用 `monitor.name()` +- **THEN** SHALL 返回 `Some("OpenHarmony Device")` +- **AND** 该值为固定标识,不随设备型号变化 + +### Requirement: 多屏 API 显式返回单屏 + +OHOS DisplayManager 无 `getAllDisplays` 等多屏枚举 API。 +`available_monitors()` SHALL 返回仅含默认显示器的单元素集合; +`primary_monitor()` SHALL 返回该默认显示器。 + +#### Scenario: 调用 available_monitors +- **WHEN** 调用 `available_monitors()` +- **THEN** SHALL 返回长度为 1 的集合 +- **AND** 唯一元素为默认显示器 MonitorHandle + +#### Scenario: 外接显示器 +- **WHEN** 设备外接显示器(如 HiCar / 投屏) +- **THEN** OHOS DisplayManager 不暴露该屏,`available_monitors()` 仍返回 1 个 +- **AND** 此为已知平台限制,应用 SHALL NOT 假设能枚举所有屏 + +### Requirement: 降级行为文档化 + +本 spec 列出的所有降级项 SHALL 在 tao OHOS `mod.rs` 对应函数处通过注释引用 +`openspec/specs/ohos-monitor-degradation`,便于审计追溯。 + +#### Scenario: 源码注释引用 +- **WHEN** 审查 `MonitorHandle::name` / `position` / `video_modes` 源码 +- **THEN** 注释 SHALL 引用本 spec 名称 +- **AND** 不出现 `FIXME` / `TODO` 字样(降级是明确决策,非待办) diff --git a/openspec/specs/ohos-monitor-real-values/spec.md b/openspec/specs/ohos-monitor-real-values/spec.md new file mode 100644 index 000000000000..d263478d9cb5 --- /dev/null +++ b/openspec/specs/ohos-monitor-real-values/spec.md @@ -0,0 +1,94 @@ +# OHOS Monitor Real Values Specification + +## Purpose + +定义 tao OHOS `MonitorHandle` 与 `EventLoopWindowTarget` 对显示器真实属性与 +点-显示器查询的契约。当前实现: +- `video_modes()` 硬编码 `refresh_rate: 60`、`bit_depth: 32`; +- `monitor_from_point()` 始终返回 `None` 并 `warn!`。 + +本 spec: +- 要求刷新率 SHALL 取自 OHOS DisplayManager 真实值; +- 要求 `monitor_from_point` SHALL 基于单显示器边界判定返回 `Some(primary)` 或 `None`; +- 位深、显示器位置、显示器名称因 OHOS 无对应 API,由 `ohos-monitor-degradation` + spec 显式降级,本 spec 不涉及。 + +## ADDED Requirements + +### Requirement: 刷新率取自 OHOS DisplayManager 真实值 + +`MonitorHandle::video_modes()` SHALL 返回的 `VideoMode` 中 `refresh_rate` 字段取自 +OHOS DisplayManager 的 `OH_NativeDisplayManager_GetDefaultDisplayRefreshRate` 真实 +值,而非硬编码 60。 + +由于 OHOS `target_env = "ohos"` 下 `MonitorHandle` 只代表默认(唯一)显示器, +`video_modes()` SHALL 返回单个 `VideoMode`,其: +- `size` = 当前显示器物理尺寸(沿用 `content_rect`); +- `refresh_rate` = `default_display_refresh_rate()` 返回值(如 60/90/120); +- `bit_depth` = 32(见 ohos-monitor-degradation)。 + +#### Scenario: 高刷新率设备 +- **WHEN** 设备真实刷新率为 120Hz,调用 `monitor.video_modes().next()` +- **THEN** 返回的 `VideoMode::refresh_rate()` SHALL 为 120 +- **AND** 不再硬编码返回 60 + +#### Scenario: 标准 60Hz 设备 +- **WHEN** 设备真实刷新率为 60Hz +- **THEN** `refresh_rate()` SHALL 为 60(与真实值一致,非硬编码巧合) + +### Requirement: 刷新率 API 经由 openharmony-ability 暴露 + +为遵守 "openharmony-ability 是唯一桥接仓" 约束,OHOS DisplayManager 的刷新率 +查询 SHALL 通过 `openharmony-ability` 暴露(例如在 `OpenHarmonyApp` 上新增 +`refresh_rate()` 方法,或新增 `display` 模块 re-export +`ohos_display_binding::default_display_refresh_rate`)。 + +tao OHOS `Cargo.toml` SHALL NOT 直接依赖 `ohos-display-binding`;调用路径必须为 +`tao → openharmony_ability → ohos_display_binding`。 + +#### Scenario: tao 通过 openharmony-ability 查询刷新率 +- **WHEN** `MonitorHandle::video_modes()` 需要刷新率 +- **THEN** 调用 SHALL 经由 `self.app.refresh_rate()` 或等价 openharmony-ability API +- **AND** tao 的 Cargo.toml 不出现 `ohos-display-binding` 直接依赖 + +### Requirement: monitor_from_point 基于单显示器边界判定 + +OHOS 为单显示器系统(DisplayManager 仅暴露 `GetDefaultDisplay*` API,无多屏枚举)。 +`EventLoopWindowTarget::monitor_from_point(x, y)` 与 `Window::monitor_from_point(x, y)` +SHALL 基于默认显示器边界判定: +- 若 `(x, y)` 落在默认显示器矩形内(`0 <= x < width` 且 `0 <= y < height`,使用 + `default_display_width/height` 物理像素),返回 `Some(primary_monitor)`; +- 否则返回 `None`; +- SHALL NOT 输出 `warn!`(该判定是预期行为,非忽略)。 + +#### Scenario: 点在屏幕内 +- **WHEN** 调用 `monitor_from_point(100.0, 200.0)` 且屏幕分辨率为 1440×2960 +- **THEN** SHALL 返回 `Some(primary_monitor)` +- **AND** 不输出 warn + +#### Scenario: 点在屏幕外 +- **WHEN** 调用 `monitor_from_point(-1.0, 0.0)` 或 `monitor_from_point(99999.0, 0.0)` +- **THEN** SHALL 返回 `None` +- **AND** 不输出 warn + +#### Scenario: cursor_position 落点查询 +- **WHEN** 应用读取 `cursor_position()` 后调用 `monitor_from_point` 验证光标所在屏 +- **THEN** 在屏幕内坐标 SHALL 返回 `Some(primary)`,与单显示器语义一致 + +### Requirement: 显示器尺寸使用 DisplayManager 真实值 + +`MonitorHandle::size()` SHALL 返回 OHOS DisplayManager +`GetDefaultDisplayWidth/Height` 的物理像素值,而非 `content_rect`(content_rect 是 +窗口内容区,会随窗口状态变化,不适合代表显示器)。 + +当 DisplayManager 查询失败时,SHALL 回退到 `content_rect` 尺寸并 `log::warn!`。 + +#### Scenario: 正常查询 +- **WHEN** 调用 `monitor.size()` +- **THEN** 返回 DisplayManager 物理像素尺寸(例如 1440×2960) +- **AND** 该值不随窗口最小化/恢复变化 + +#### Scenario: DisplayManager 查询失败 +- **WHEN** `OH_NativeDisplayManager_GetDefaultDisplayWidth/Height` 返回非 0 +- **THEN** SHALL 回退到 `content_rect` 尺寸 +- **AND** 输出 `warn!` 记录回退 diff --git a/openspec/specs/ohos-path-desktop-dirs/spec.md b/openspec/specs/ohos-path-desktop-dirs/spec.md new file mode 100644 index 000000000000..070cc8b9c41d --- /dev/null +++ b/openspec/specs/ohos-path-desktop-dirs/spec.md @@ -0,0 +1,50 @@ +# ohos-path-desktop-dirs Specification + +## Purpose +定义 Tauri `PathResolver` 在 OHOS 平台对"桌面专用目录"(desktop / font / runtime / template / executable)的契约。这些目录在桌面 OS(Windows/macOS/Linux)由 `dirs` crate 提供,但在 OHOS 沙箱应用模型下无对应概念。本规范明确 OHOS 平台 SHALL 通过 cfg 隔离移除这些 API,调用方 SHALL 在 OHOS 上不引用这些方法,补齐 R190(其他路径)的跨平台契约。 + +## 现状审计 +- `crates/tauri/src/path/mod.rs` 中 `desktop_dir` / `font_dir` / `runtime_dir` / `template_dir` / `executable_dir` 方法及其在 `resolve()` 中的 `BaseDirectory::Desktop/Font/Runtime/Template/Executable` 分支均带 `#[cfg(all(not(target_os = "android"), not(target_env = "ohos")))]`。 +- `crates/tauri/src/path/ohos.rs` 未定义上述方法;OHOS `PathResolver` 仅提供 audio/cache/config/data/local_data/document/download/picture/public/video/resource/app_*/temp/home 等沙箱目录。 +- 因此 OHOS 平台编译产物中这些"桌面目录"API 不存在,调用方代码若引用会在 OHOS target 编译失败(契约强制隔离)。 + +## ADDED Requirements + +### Requirement: OHOS PathResolver SHALL 不提供桌面专用目录 +OHOS `PathResolver` SHALL 不实现 `desktop_dir` / `font_dir` / `runtime_dir` / `template_dir` / `executable_dir` 方法;这些方法 SHALL 通过 `cfg(all(not(target_os = "android"), not(target_env = "ohos"))))` 从 OHOS 编译产物中排除。 + +#### Scenario: OHOS 编译不含桌面目录方法 +- **WHEN** 使用 OHOS target 编译 `tauri` crate +- **THEN** `PathResolver` 结构体 SHALL 不含 `desktop_dir` / `font_dir` / `runtime_dir` / `template_dir` / `executable_dir` 方法 +- **AND** 引用这些方法的下游代码在 OHOS target 编译失败(编译期契约) + +#### Scenario: 桌面平台方法不变 +- **WHEN** 在 Windows/macOS/Linux 编译 +- **THEN** 这些方法 SHALL 通过 `dirs` crate 返回对应系统目录 +- **AND** 行为与 OHOS 适配前完全一致 + +### Requirement: BaseDirectory 枚举在 OHOS SHALL 排除桌面目录变体 +`path::BaseDirectory::Desktop` / `Font` / `Runtime` / `Template` / `Executable` 在 OHOS target SHALL 被排除,或在 `resolve()` 匹配分支被 cfg 隔离,使得 OHOS 上 `resolve(path, BaseDirectory::Desktop)` 不编译。 + +#### Scenario: resolve() 桌面分支在 OHOS 不存在 +- **WHEN** 在 OHOS target 调用 `resolver.resolve(p, BaseDirectory::Desktop)` +- **THEN** 该 match 分支 `#[cfg(all(not(target_os = "android"), not(target_env = "ohos")))]` 被排除 +- **AND** 编译期即阻止误用 + +### Requirement: OHOS 文档 SHALL 指明替代目录 +OHOS 平台文档 SHALL 指明:需要"桌面/字体/运行时/模板"语义的应用应映射到 OHOS 已有目录: +- 桌面 → 无对应(OHOS 无桌面概念);可降级为 `home_dir()` 或返回 `Error::UnknownPath` +- 字体 → 应用自有字体应放在 `resource_dir()` 下;系统字体无第三方 API +- 运行时 → OHOS 无 POSIX runtime dir 概念;可降级为 `temp_dir()` +- 模板 → OHOS 无模板目录概念;可降级为 `document_dir()` +- 可执行 → OHOS 不暴露应用二进制路径;使用 `resource_dir()` 或 `app_data_dir()` + +#### Scenario: 应用查询字体目录 +- **WHEN** 应用在 OHOS 需要加载自有字体 +- **THEN** 应用 SHALL 使用 `resource_dir()` 拼接字体资源路径 +- **AND** 不调用 `font_dir()`(该方法在 OHOS 不存在) + +## 平台限制说明 +- OHOS 应用沙箱模型不暴露桌面/字体系统目录/运行时目录/模板目录/可执行文件路径。 +- 这些限制对 `OHOS_DEVICE_TYPE=desktop` 同样成立:即便设备形态为 desktop,应用沙箱仍不提供这些目录(OHOS desktop 形态仅影响窗口/托盘/菜单 cfg,不改变文件沙箱)。 +- 若未来 OHOS 开放对应系统目录 API,本规范应升级为实现映射。 diff --git a/openspec/specs/ohos-platform-limitations/spec.md b/openspec/specs/ohos-platform-limitations/spec.md new file mode 100644 index 000000000000..52533b3ad768 --- /dev/null +++ b/openspec/specs/ohos-platform-limitations/spec.md @@ -0,0 +1,73 @@ +# ohos-platform-limitations Specification + +## Purpose +集中记录 Tauri 在 OHOS 平台上"需鸿蒙原生 API 但当前无 Tauri 插件对应、且短期内不实现"的功能降级判定。覆盖 R195(多进程)、R227(字体)、R228(应用接续)、R229(截图取色)、R230(无障碍)、R223/R224(全局托盘/菜单事件监听桌面特性)。本规范为降级报告,不定义新 API,仅声明契约边界。 + +## ADDED Requirements + +### Requirement: R195 多进程在 OHOS 降级为不支持 +OHOS 第三方应用 SHALL NOT 通过 Tauri API 派生任意子进程;OHOS 应用模型以 UIAbility / ExtensionAbility 为基本运行单元,每个 ability 实例可独立进程,但无通用 `spawn` 子进程能力。Tauri 的多进程 API(若存在)在 OHOS 上 SHALL 返回 `UnsupportedPlatform` 错误或通过 cfg 隔离不暴露。 + +#### Scenario: 应用请求派生子进程 +- **WHEN** 应用在 OHOS 调用任何多进程派生 API +- **THEN** SHALL 返回明确的平台不支持错误 +- **AND** 不调用 `std::process::Command::spawn` 创建任意子进程 +- **AND** 文档 SHALL 引导用户使用 OHOS `ExtensionAbility` 实现后台任务 + +### Requirement: R227 字体 API 在 OHOS 降级为不支持 +Tauri 无独立字体插件;OHOS `@ohos.graphics.font` 提供字体注册 API,但 Tauri 当前不暴露跨平台字体 API。OHOS 适配 SHALL NOT 新增字体插件;应用自有字体 SHALL 通过 `resource_dir()` 静态资源加载(由前端 CSS / ArkUI 处理),不通过 Tauri Rust API。 + +#### Scenario: 应用加载自有字体 +- **WHEN** 应用需要在 OHOS 使用自有字体 +- **THEN** 应用 SHALL 将字体文件放入 `resources/` 并通过前端 CSS `@font-face` 加载 +- **AND** 不通过 Tauri API 注册系统字体 +- **AND** `font_dir()` 在 OHOS 不可用(见 ohos-path-desktop-dirs 规范) + +### Requirement: R228 应用接续在 OHOS 暂不实现 +OHOS `@ohos.app.ability.continuationManager` / `connect` 提供跨设备应用接续能力,但 Tauri 无对应跨平台概念,且实现需深度集成 ability 生命周期与 UI 状态序列化。本项 SHALL 标记为"未来工作",当前 OHOS 适配 SHALL NOT 提供应用接续 API。 + +#### Scenario: 应用请求接续 +- **WHEN** 应用在 OHOS 期望使用跨设备接续 +- **THEN** Tauri SHALL NOT 暴露接续 API +- **AND** 文档 SHALL 指引用户直接使用 OHOS 原生 `continuationManager` 在 ArkTS 层实现 +- **AND** 该能力暂不纳入 Tauri 跨平台契约 + +### Requirement: R229 截图取色在 OHOS 暂不实现 +OHOS `@ohos.screenshot` 提供截图能力(系统应用权限),取色可通过 `@ohos.multimodalInput` 或图像像素读取。Tauri 无截图/取色插件。本项 SHALL 标记为"未来工作",当前 SHALL NOT 提供截图取色 API。 + +#### Scenario: 应用请求截图 +- **WHEN** 应用在 OHOS 期望截图 +- **THEN** Tauri SHALL NOT 暴露截图 API +- **AND** 文档 SHALL 指引:`@ohos.screenshot` 仅系统应用可用,第三方应用需通过 `window` 截图能力(属 `ohos-window-*` 范围,若有) + +### Requirement: R230 无障碍在 OHOS 暂不实现 +OHOS `@ohos.accessibility` 提供无障碍服务与辅助能力,但 Tauri 无跨平台无障碍 API。本项 SHALL 标记为"未来工作",当前 SHALL NOT 提供无障碍 API。Web 内容无障碍由 ArkWeb 自身 ARIA 支持处理,不属本规范。 + +#### Scenario: 应用请求无障碍能力 +- **WHEN** 应用在 OHOS 期望使用无障碍 API +- **THEN** Tauri SHALL NOT 暴露无障碍 API +- **AND** Web 内容无障碍 SHALL 依赖 ArkWeb 内置 ARIA 实现 +- **AND** 原生 UI 无障碍 SHALL 由 OHOS 系统辅助服务处理 + +### Requirement: R223/R224 全局托盘/菜单事件监听仅在 OHOS desktop 形态启用 +OHOS 全局托盘与菜单栏仅在 `OHOS_DEVICE_TYPE=desktop` 时通过 `cfg(all(target_env = "ohos", desktop))` 启用,归 `tray-*` / `menu-*` 规范范围(本规范只读引用)。在 mobile 形态下 SHALL 不存在。 + +#### Scenario: mobile 形态无托盘 +- **WHEN** `OHOS_DEVICE_TYPE=mobile`(默认) +- **THEN** 托盘/全局菜单 API SHALL 不编译 +- **AND** 应用不引用托盘相关类型 + +#### Scenario: desktop 形态托盘归 tray 规范 +- **WHEN** `OHOS_DEVICE_TYPE=desktop` +- **THEN** 托盘/菜单行为 SHALL 由 `ohos-tray-*` / `ohos-menu-*` 规范定义 +- **AND** 本规范不重复定义 + +## 平台限制汇总 +| 行 | 功能 | 判定 | 处置 | +|----|------|------|------| +| R195 | 多进程 | 平台限制降级 | 不支持,返回错误,引导 ExtensionAbility | +| R223/224 | 全局托盘/菜单事件监听 | 桌面形态归 tray/menu 规范 | mobile 降级,desktop 归其他规范 | +| R227 | 字体 | 平台限制降级 | 静态资源加载,无 Tauri API | +| R228 | 应用接续 | 未来工作 | 暂不实现,引导原生 API | +| R229 | 截图取色 | 未来工作 | 暂不实现,部分仅系统应用 | +| R230 | 无障碍 | 未来工作 | 暂不实现,依赖 ArkWeb/系统 | diff --git a/openspec/specs/ohos-process-restart/spec.md b/openspec/specs/ohos-process-restart/spec.md new file mode 100644 index 000000000000..8af93f62c0f7 --- /dev/null +++ b/openspec/specs/ohos-process-restart/spec.md @@ -0,0 +1,70 @@ +# ohos-process-restart Specification + +## Purpose +定义 Tauri 在 OHOS 平台"重启应用"(`process::restart` / `tauri-plugin-process` 的 `restart` 命令)的契约。OHOS 不允许第三方应用通过 `Command::new(exe).spawn()` 自行重启进程, SHALL 通过 `openharmony-ability` 桥接调用系统 `@ohos.app.ability.appRecovery.restartApp()` 实现原生重启。本规范补齐 R192(重启应用)的 OHOS 契约。 + +## 现状审计 +- tauri core:`crates/tauri/src/app.rs` 中 `do_restart(env)` 在 OHOS target 走 `#[cfg(target_env = "ohos")]` 分支,调用 `crate::ohos::APP.lock()` 后 `app_ref.restart()`,随后 `std::process::exit(0)`。非 OHOS 走 `crate::process::restart(env)`(`Command::spawn`)。 +- tauri-plugin-process:`plugins-workspace/plugins/process/src/lib.rs` 在 OHOS target 注册 `ohos::restart` 命令(替代 `commands::restart`);`src/ohos.rs` 调用 `app_ref.restart()`,成功后无限阻塞让 `restartApp` 杀死进程。 +- `openharmony-ability` 提供 `App::restart()` 通过 TSFN 调用 ArkTS `appRecovery.restartApp()`。 +- `tauri::process::current_binary` 在 OHOS 跳过 AppImage 检测(R193 已隔离)。 + +## ADDED Requirements + +### Requirement: OHOS 重启 SHALL 调用 appRecovery.restartApp +OHOS 平台调用 `tauri::process::restart` 或 `tauri-plugin-process` 的 `restart` 命令时,SHALL 通过 `openharmony-ability` 的 `App::restart()` 调用系统 `@ohos.app.ability.appRecovery.restartApp()`,SHALL NOT 使用 `std::process::Command::spawn` 启动新进程。 + +#### Scenario: core restart 路径 +- **WHEN** 用户代码在 OHOS 调用 `app.restart()`(最终走 `do_restart(env)`) +- **THEN** 进入 `#[cfg(target_env = "ohos")]` 分支 +- **AND** 获取 `crate::ohos::APP` 锁,调用 `app_ref.restart()` +- **AND** `restart()` 通过 TSFN 向主线程派发 `appRecovery.restartApp()` +- **AND** 随后调用 `std::process::exit(0)` +- **AND** 不调用 `Command::new(current_binary).spawn()` + +#### Scenario: plugin restart 命令路径 +- **WHEN** 前端调用 `process.restart()` 在 OHOS 平台 +- **THEN** 调用 `ohos::restart` 命令(`#[cfg(target_env = "ohos")]`) +- **AND** 调用 `app_ref.restart()` +- **AND** 若返回 `Ok(0)`,进入无限 `sleep` 循环阻塞当前线程,等待 `restartApp` 杀死进程 +- **AND** 若返回 `Ok(non-zero)` 或 `Err`,记录 `log::error!` 后 `std::process::exit(0)` + +### Requirement: OHOS 重启 SHALL NOT 触发 onDestroy +`appRecovery.restartApp()` 直接重启进程,SHALL NOT 保证 `onDestroy` 回调被触发。文档 SHALL 明确告知用户:重启前需自行保存状态(通过 `appRecovery.saveState()` 或自定义持久化)。 + +#### Scenario: 重启前保存状态 +- **WHEN** 应用需要在重启后恢复状态 +- **THEN** 用户代码 SHALL 在调用 `restart` 前手动持久化状态 +- **AND** 不依赖 `RunEvent::ExitRequested` / `onDestroy` 在重启路径上被触发 + +### Requirement: OHOS 重启 SHALL 通过 openharmony-ability 桥接 +所有 OHOS 原生重启系统调用 SHALL 经 `openharmony-ability` TSFN 桥接,SHALL NOT 在 tauri / plugin-process 中直接 NAPI 调用。 + +#### Scenario: 桥接链路 +- **WHEN** `restart` 被调用 +- **THEN** 调用链为:plugin-process / tauri core → `crate::ohos::APP` → `openharmony-ability::App::restart()` → TSFN → ArkTS `appRecovery.restartApp()` +- **AND** 不绕过 `openharmony-ability`(铁律 #1) + +### Requirement: cfg 隔离 SHALL 不影响其他平台 +OHOS 重启实现 SHALL 通过 `cfg(target_env = "ohos")` 隔离;Windows/macOS/Linux SHALL 保留 `Command::spawn` 路径不变。 + +#### Scenario: 非 OHOS 平台不变 +- **WHEN** 在 Windows/macOS/Linux 调用 `tauri::process::restart(env)` +- **THEN** 走 `#[cfg(not(target_env = "ohos"))]` 分支 +- **AND** 调用 `Command::new(current_binary).args(...).spawn()` +- **AND** OHOS 代码不参与编译 + +### Requirement: AppImage 检测在 OHOS SHALL 被排除 +`tauri::process::current_binary` 中的 AppImage 检测分支 SHALL 通过 `cfg(all(target_os = "linux", not(target_env = "ohos")))` 隔离;OHOS SHALL 不执行 AppImage 路径(R193 降级)。 + +#### Scenario: OHOS 不检测 AppImage +- **WHEN** 在 OHOS target 调用 `current_binary(env)` +- **THEN** 跳过 `_env.appimage` 检查 +- **AND** 直接返回 `tauri_utils::platform::current_exe()` 结果 +- **AND** `Env::appimage` 字段在 OHOS 始终为 `None` + +## 设计要点 +- 已实现:core `app.rs::do_restart` 与 plugin-process `ohos::restart` 均已落地,本规范为契约补档。 +- 关键未知项(已离线确认,2026-07-20):经 SDK `.d.ts` 核实,`appRecovery.restartApp()` 声明为 `@syscap SystemCapability.Ability.AbilityRuntime.Core`、`@StageModelOnly`、since 9/11——**Core 能力,设备覆盖广**(非 phone-only),mobile/desktop 均支持。wearable 等特殊形态若返回 801(能力不支持),当前实现已 `log::error!` + `exit(0)` 降级,符合契约。残留不确定仅限个别非 Core 能力设备,无需阻塞实现。 +- 权限:`appRecovery` 需在 `module.json5` 声明 `"abilities"` 中配置 `recoverable` 等属性;该配置由 tauri-cli 模板处理,不在本规范范围。 +- 权限:`appRecovery` 需在 `module.json5` 声明 `"abilities"` 中配置 `recoverable` 等属性;该配置由 tauri-cli 模板处理,不在本规范范围。 diff --git a/openspec/specs/ohos-splash/spec.md b/openspec/specs/ohos-splash/spec.md new file mode 100644 index 000000000000..7d9496816bcc --- /dev/null +++ b/openspec/specs/ohos-splash/spec.md @@ -0,0 +1,38 @@ +# ohos-splash Specification + +## Purpose +定义 OHOS 平台"启动画面"(splash screen)的契约边界。OHOS 在系统层提供启动画面能力(通过 `module.json5` 的 `splashIcon` / `backgroundColor` 等配置或 `window` 启动阶段),Tauri 不提供独立 `tauri-plugin-splashscreen` 插件,因此 OHOS 适配 SHALL 采用"系统配置 + 模板生成"方式,不在运行时通过 Rust/ArkTS API 控制启动画面。本规范评估 R226 的可实现性与降级边界。 + +## 现状审计 +- Tauri plugins-workspace 无 `splash` / `splashscreen` 插件;启动画面在桌面端通常由前端窗口控制(首窗口隐藏 → 加载完成显示)。 +- OHOS 系统 UI 在 ability 启动到 `onWindowStageCreate` 之间会显示系统级启动画面,由 `module.json5` 配置。 +- `tauri-cli` OHOS 模板(`templates/mobile/open-harmony/`)应在 `module.json5` 中预留 splash 配置位。 + +## ADDED Requirements + +### Requirement: OHOS 启动画面 SHALL 通过 module.json5 配置 +OHOS 启动画面 SHALL 通过 `module.json5` 中 ability 的 `startWindowIcon` / `startWindowBackground` 字段配置,SHALL NOT 通过运行时 Rust/ArkTS API 动态创建系统启动画面。 + +#### Scenario: 模板生成 splash 配置 +- **WHEN** `tauri-cli` 生成 OHOS 工程模板 +- **THEN** `entry/src/main/module.json5` SHALL 包含 `startWindowIcon` 指向应用图标资源 +- **AND** `startWindowBackground` 指向应用主题色资源 +- **AND** 系统在 ability 冷启动期间显示该启动画面 + +#### Scenario: 运行时不控制系统 splash +- **WHEN** 应用运行时 +- **THEN** Tauri SHALL NOT 提供 Rust API 关闭/显示系统启动画面 +- **AND** 系统 splash 由 OHOS 自动在首窗口绘制完成后消失 + +### Requirement: 应用内 splash 窗口 SHALL 走窗口 cfg 路径 +若应用需要应用内(非系统)splash 窗口(如前端 loading 视图),SHALL 通过 Tauri 窗口 API 实现,与本规范解耦;该路径属于 `ohos-window-*` 契约范围,本规范不重复定义。 + +#### Scenario: 应用内 loading 窗口 +- **WHEN** 应用需要加载完成前的 loading UI +- **THEN** 应用 SHALL 创建普通 Tauri 窗口承载 loading 视图 +- **AND** 不调用任何"启动画面专用"API + +## 平台限制说明 +- OHOS 系统 splash 仅在冷启动阶段显示,不支持运行时动态控制(显示/隐藏/动画)。 +- 若未来 OHOS 开放运行时 splash 控制 API(如 `window.setSplash`),本规范应升级。 +- 当前判定:R226 在 OHOS 上"系统 splash 已由平台提供,无需 Tauri 适配插件",降级为模板配置。 diff --git a/openspec/specs/ohos-tray-degradation/spec.md b/openspec/specs/ohos-tray-degradation/spec.md new file mode 100644 index 000000000000..bdaa080705e2 --- /dev/null +++ b/openspec/specs/ohos-tray-degradation/spec.md @@ -0,0 +1,64 @@ +# OHOS Tray Icon Degradation Specification + +## Purpose + +显式记录 tray-icon OHOS 实现中因 OHOS StatusBar API 缺失而无法满足跨平台契约 +的 API,及其降级行为。涉及: +- `TrayIcon::set_temp_dir_path`(R176)— Linux appindicator 临时图标目录语义, + OHOS 无对应概念; +- `TrayIcon::rect`(R177)— StatusBar API 不提供托盘图标位置/尺寸。 + +## ADDED Requirements + +### Requirement: set_temp_dir_path 为 no-op 并文档化 + +`TrayIcon::set_temp_dir_path` 在 Linux 上用于指定 appindicator 后端写入临时图标 +文件的目录。OHOS StatusBar 通过 NAPI 传递图标 RGBA 数据(非文件路径),无临时 +目录概念。 + +OHOS 实现 SHALL 保持 `set_temp_dir_path` 为 no-op(空函数体),并 SHALL 在源码 +注释中说明 "OHOS StatusBar uses NAPI RGBA transfer, no temp dir; see openspec +ohos-tray-degradation"。SHALL NOT 输出 `warn!`(no-op 是预期行为)。 + +#### Scenario: 调用 set_temp_dir_path +- **WHEN** 应用调用 `tray.set_temp_dir_path(Some("/tmp/myapp"))` +- **THEN** 调用 SHALL 不抛异常、无副作用 +- **AND** 不输出 warn 日志 +- **AND** 后续 set_icon 仍通过 NAPI RGBA 传输,不写临时文件 + +#### Scenario: 跨平台应用调用 +- **WHEN** 跨平台应用在所有平台调用 `set_temp_dir_path` +- **THEN** OHOS 上 SHALL 静默忽略,不影响 tray 图标显示 +- **AND** Linux 上仍按 appindicator 语义生效 + +### Requirement: rect 返回 None 并文档化 + +OHOS StatusBar API 不提供托盘图标在屏幕上的位置或尺寸。`AvoidArea.topRect` 返回 +整个状态栏区域(如 `{0, 0, 1440, 48}`),并非托盘图标本身——若用作近似会误导依赖 +`rect` 做 popup 定位或尺寸计算的调用方。 + +OHOS 实现 SHALL 使 `TrayIcon::rect()` 返回 `None`,与 Linux 行为一致。SHALL 在 +源码注释中说明降级原因(已在 `tray-icon/src/platform_impl/ohos/mod.rs` 既有注释 +中体现,本 spec 要求保留并引用本 spec 名称)。 + +#### Scenario: 调用 rect +- **WHEN** 应用调用 `tray.rect()` +- **THEN** SHALL 返回 `None` +- **AND** 不输出 warn(None 是明确语义,非忽略) + +#### Scenario: popup 定位回退 +- **WHEN** 应用依赖 `rect()` 做托盘菜单 popup 定位 +- **THEN** 应用 SHALL 在 OHOS 上回退到窗口中心或屏幕默认位置 +- **AND** SHALL NOT 假设 OHOS 上 `rect()` 返回 Some + +### Requirement: 降级行为文档化与一致性 + +本 spec 列出的降级行为 SHALL 与 Linux 平台行为对齐(Linux `rect()` 也返回 +`None`,`set_temp_dir_path` 在 Linux 有语义而在 OHOS 无语义)。SHALL 在 +`tray-icon/src/platform_impl/ohos/mod.rs` 对应函数注释中引用本 spec 名称。 + +#### Scenario: 跨平台行为对照 +- **WHEN** 审查 OHOS 与 Linux tray-icon 实现 +- **THEN** `rect()` 在 OHOS 与 Linux 均返回 `None` +- **AND** `set_temp_dir_path` 在 OHOS 为 no-op、在 Linux 有 appindicator 语义 +- **AND** OHOS 注释引用 `openspec/specs/ohos-tray-degradation` diff --git a/openspec/specs/ohos-webview-drag-drop-overlay/spec.md b/openspec/specs/ohos-webview-drag-drop-overlay/spec.md new file mode 100644 index 000000000000..206fae3a68da --- /dev/null +++ b/openspec/specs/ohos-webview-drag-drop-overlay/spec.md @@ -0,0 +1,116 @@ +# ohos-webview-drag-drop-overlay Specification + +> ⚠️ **验证状态:tauri API 已补,但 overlay 渲染导致 appfreeze(FAIL)。** tauri `drag_drop_overlay` API 已补全(tauri-runtime 字段 + tauri builder + tauri-runtime-wry OHOS 分支传递)。但 `create_ohos_test_webview(dragDropOverlay: true)` 创建窗口时 overlay Stack 渲染 + OnSizeChange 导致主线程阻塞 6s → appfreeze。Drag Overlay 按钮已回退删除。tauri API 改动保留(默认 false 无害)。overlay Stack 渲染死锁根因待排查(ArkTS 侧 build 顺序/线程问题)。 + +## Purpose +当 OHOS ArkWeb `Web` 组件在内部消费 OS 级文件拖拽事件、不向 ArkUI 冒泡 `.onDragEnter/.onDragMove/.onDrop/.onDragLeave` 时,主路径(`ohos-webview-drag-drop` spec)的 Web 级事件挂接不会触发。本规范定义 overlay 降级方案:在 `Web` 组件外层 `Stack` 中叠一层透明 `Stack` overlay,由 overlay 接收 ArkUI 通用组件级拖拽事件并转发为管道串给 `data.onDragAndDrop`,使 wry `drag_drop_handler` 仍能收到 `DragDropEvent::{Enter, Over, Drop, Leave}`。overlay 通过 `HitTestMode.Transparent` 透传鼠标/触摸给下层 Web,不影响页面正常交互与 HTML5 页内 DnD。 + +## Relationship to ohos-webview-drag-drop (主路径) +- **主路径**(`ohos-webview-drag-drop` spec):在 `Web` 组件自身挂 `.onDragEnter/.onDragMove/.onDrop/.onDragLeave`,依赖 ArkWeb 把外部文件拖拽冒泡到 ArkUI。已实现。 +- **本 overlay 降级**:仅当设备探测确认 ArkWeb 不冒泡 OS 文件拖拽时启用。启用时 overlay 是事件源,Web 级挂接保留但不会重复触发(因为 ArkWeb 不冒泡),从而避免双发。 +- **共存策略**:overlay 通过 `WebviewInitData.dragDropOverlay: boolean`(由 wry 侧决定)显式开启。默认 `false`,主路径生效;探测失败后 wry 设为 `true`,overlay 生效。两者不会同时产生事件(ArkWeb 要么冒泡要么不冒泡,平台行为固定)。 + +## ADDED Requirements + +### Requirement: ArkTS SHALL render a transparent drag overlay above the Web component +`DefaultWebview.ets` 的 `WebBuilder` 与 `EmbeddedWebBuilder` SHALL 在外层 `Stack` 中、`Web` 组件之后追加一个透明 `Stack` overlay 子节点(叠在 Web 之上),仅当 `data.dragDropOverlay === true` 时渲染。overlay SHALL 覆盖整个 Web 区域(`width("100%").height("100%")`)、`backgroundColor(Color.Transparent)`、`hitTestBehavior(HitTestMode.Transparent)`,使其自身能接收 ArkUI 拖拽事件同时把鼠标/触摸事件透传给下层 `Web`。 + +#### Scenario: overlay rendered when dragDropOverlay flag is true +- **WHEN** `WebviewInitData.dragDropOverlay === true` 且 `data.onDragAndDrop` 是函数 +- **THEN** `WebBuilder`/`EmbeddedWebBuilder` SHALL 在 `Stack` 中 `Web` 组件之后渲染一个透明 `Stack` overlay +- **AND** overlay SHALL 设置 `hitTestBehavior(HitTestMode.Transparent)` 以透传指针事件给下层 Web +- **AND** overlay SHALL 设置 `visibility` 跟随 `data.style.visible`(与 Web 一致,隐藏时 overlay 也隐藏) + +#### Scenario: overlay omitted when flag is false +- **WHEN** `data.dragDropOverlay` 为 `false`/`undefined` 或 `data.onDragAndDrop` 不是函数 +- **THEN** `WebBuilder`/`EmbeddedWebBuilder` SHALL NOT 渲染 overlay 节点 +- **AND** 主路径 Web 级 `.onDragEnter/.onDragMove/.onDrop/.onDragLeave` 挂接保持不变 + +#### Scenario: pointer interaction pass-through +- **WHEN** overlay 已渲染且用户在 Web 区域内进行鼠标点击/滚动/触摸/文本选择 +- **THEN** overlay SHALL NOT 拦截或消费这些指针事件 +- **AND** Web 组件 SHALL 正常接收并响应(与无 overlay 时行为一致) +- **AND** HTML5 页内拖拽(DOM 元素之间的 DnD)SHALL 不被 overlay 干扰 + +### Requirement: Overlay SHALL attach ArkUI drag handlers and forward pipe-string payloads +overlay `Stack` SHALL 挂接 ArkUI 通用组件级 `.onDragEnter/.onDragMove/.onDragLeave/.onDrop` 回调(这些是 `CommonAttribute` 上的通用方法,不依赖 ArkWeb 冒泡)。回调 SHALL 从 `DragEvent` 提取文件 URI,按主路径相同的管道串协议 `||,` 构造负载并调用 `data.onDragAndDrop(payload)`,使 wry 侧 `drag_drop_handler` 收到与主路径一致的 `DragDropEvent`。 + +#### Scenario: file dropped onto overlay +- **WHEN** 用户从 OHOS 文件管理器拖拽文件并释放在 webview 区域(overlay 上) +- **THEN** overlay 的 `.onDrop` 回调 SHALL 从 `dragEvent.getData()`(或 `dragEvent.primitive`/`summary`)读取被拖文件的 URI +- **AND** SHALL 去除 `file://`/`datashare://` scheme,以 `\0`(null byte)拼接为 `paths_nul`(兼容含逗号的路径) +- **AND** SHALL 从 `dragEvent.getX()`/`getY()`(或 `dragEvent.getArea()`/窗口坐标换算)得到 drop 点 `(x, y)` +- **AND** SHALL 调用 `data.onDragAndDrop('drop|' + paths_nul + '|' + x + ',' + y)` +- **AND** wry `drag_drop_handler` SHALL 收到 `DragDropEvent::Drop { paths, position }` + +#### Scenario: drag enter/over/leave forwarded +- **WHEN** 拖拽指针进入/在 overlay 上移动/离开 overlay +- **THEN** `.onDragEnter` SHALL 调用 `data.onDragAndDrop('enter||,')`(如能从 `DragEvent` 提取预览路径则填入,否则 `paths_nul` 为空) +- **AND** `.onDragMove` SHALL 调用 `data.onDragAndDrop('over||,')` +- **AND** `.onDragLeave` SHALL 调用 `data.onDragAndDrop('leave||0,0')` +- **AND** wry SHALL 映射为 `DragDropEvent::{Enter, Over, Leave}` + +#### Scenario: position coordinates +- **WHEN** overlay 收到拖拽事件 +- **THEN** 位置 `(x, y)` SHALL 以 Web 组件内容区左上角为原点(与主路径 Web 级 `.onDrop` 的坐标语义一致) +- **AND** 若 ArkUI `DragEvent` 仅提供窗口坐标,overlay SHALL 减去 `data.style.x`/`data.style.y`(Web 在 Stack 中的偏移)换算为 Web 内容区坐标 +- **AND** 若无法取得坐标,SHALL 回退为 `(0, 0)`(与主路径一致),不阻断事件转发 + +### Requirement: wry SHALL expose a dragDropOverlay switch +`wry::PlatformSpecificWebViewAttributes`(OHOS 专属,与 `use_https` 同结构,见铁律 #2)SHALL 提供一个 `drag_drop_overlay: bool` 字段(或等价 builder 方法 `WebViewBuilderExtOhos::with_drag_drop_overlay(bool)`),默认 `false`。该字段受 `cfg(target_env = "ohos")` 隔离,非 OHOS 平台无此字段、无副作用。`wry/src/ohos/mod.rs::new_inner` SHALL 把该值透传到 `openharmony_ability::WebViewBuilder`,最终作为 `WebviewInitData.dragDropOverlay` 字段抵达 ArkTS。当设备探测确认 ArkWeb 不冒泡 OS 文件拖拽时,应用层(或 tauri 默认配置)SHALL 把该开关设为 `true` 启用 overlay 降级。 + +#### Scenario: overlay flag propagated to ArkTS +- **WHEN** wry `PlatformSpecificWebViewAttributes.drag_drop_overlay` 设为 `true` +- **THEN** `openharmony_ability::WebViewInitData.dragDropOverlay` SHALL 为 `true` +- **AND** `DefaultWebview.ets` 的 `data.dragDropOverlay` SHALL 为 `true`,从而渲染 overlay 节点 + +#### Scenario: default off +- **WHEN** 应用未设置 `drag_drop_overlay` +- **THEN** 字段 SHALL 默认为 `false` +- **AND** ArkTS SHALL 不渲染 overlay(主路径生效) +- **AND** 非 OHOS 平台 SHALL 无该字段(`cfg(target_env = "ohos")` 隔离,无副作用) + +### Requirement: Overlay SHALL NOT produce duplicate events with the main path +当 overlay 启用时,Web 级 `.onDragEnter/.onDragMove/.onDrop/.onDragLeave`(主路径)可能依然挂在 `Web` 组件上。为避免 ArkWeb 在某些版本下既冒泡又触发 overlay 导致双发,overlay 启用时 ArkTS SHALL 显式跳过 Web 级拖拽回调的转发(或根本不挂接 Web 级回调)。事件源 SHALL 唯一为 overlay。 + +#### Scenario: overlay enabled suppresses Web-level handlers +- **WHEN** `data.dragDropOverlay === true` +- **THEN** `WebBuilder`/`EmbeddedWebBuilder` SHALL NOT 给 `Web` 组件挂接 `.onDragEnter/.onDragMove/.onDrop/.onDragLeave`(或挂接但回调内直接 return) +- **AND** 拖拽事件 SHALL 仅由 overlay 处理并转发一次 +- **AND** wry `drag_drop_handler` 对单次物理 drop SHALL 只收到一个 `DragDropEvent::Drop` + +### Requirement: openharmony-ability SHALL plumb dragDropOverlay through NAPI +`openharmony-ability` Rust crate SHALL 在 `WebViewBuilder` 上新增 `drag_drop_overlay(self, enabled: bool)` 链式方法(或等价字段),并在 `WebViewInitData` NAPI object 中新增 `drag_drop_overlay: bool` 字段,由 `helper/webview.rs` 序列化到 ArkTS。该字段 SHALL 受 `feature = "drag_and_drop"` 门控(与 `on_drag_and_drop` 一致),关闭 feature 时不编译。 + +#### Scenario: drag_drop_overlay field on WebViewInitData +- **WHEN** `cargo build --features drag_and_drop` 在 OHOS 上执行 +- **THEN** `crates/ability/src/webview/mod.rs` 的 `WebViewInitData` struct SHALL 包含 `pub drag_drop_overlay: bool` 字段 +- **AND** `helper/webview.rs` 的 NAPI object 构建 SHALL 写入 `dragDropOverlay` camelCase 键 +- **AND** `DefaultWebview.ets` 的 `WebviewInitData` interface SHALL 声明 `dragDropOverlay?: boolean` + +#### Scenario: feature-gated +- **WHEN** 未启用 `drag_and_drop` feature +- **THEN** `drag_drop_overlay` 字段与方法 SHALL 不编译(与 `on_drag_and_drop` 同样的 cfg 门控) +- **AND** 非拖拽功能场景下 SHALL 无任何开销 + +### Requirement: Platform limitation SHALL be documented when overlay is also unavailable +若设备探测确认 overlay 方案也无法接收外部文件拖拽(例如 OHOS 桌面态整体不向应用下发 ArkUI 拖拽事件),SHALL 在 `ohos-webview-drag-drop-overlay-plan.md` 中显式记录该平台限制,并将 spec 对应 Requirement 标记为 MODIFIED,回退为「平台限制:文件拖拽不支持」。 + +#### Scenario: overlay also cannot receive drag events +- **WHEN** 设备探测显示 overlay `Stack` 的 `.onDragEnter/.onDrop` 在外部文件拖入时也不触发 +- **THEN** plan 文件 SHALL 记录「ArkUI 通用组件级拖拽也不下发」结论 +- **AND** wry `drag_drop_handler` 在 OHOS 上 SHALL 文档化为「永远收不到 Drop 事件」 +- **AND** 应用层 SHALL 通过 HTML5 页内 DnD(`` 或 JS DnD API)作为最终降级 + +## Scenarios summary +| 场景 | 主路径状态 | overlay 状态 | wry 收到 | +|------|-----------|-------------|---------| +| ArkWeb 冒泡 OS 拖拽(默认假设) | 生效 | 不渲染 | DragDropEvent | +| ArkWeb 不冒泡,overlay 启用 | Web 级回调被抑制 | 渲染并接收事件 | DragDropEvent | +| ArkWeb 不冒泡且 ArkUI 也不下发 | N/A | 不触发 | 平台限制,无事件 | +| 页内 HTML5 DnD | 不影响 | 不影响 | 不产生 DragDropEvent | + +## Non-goals +- 不解决 OHOS mobile 形态的拖拽(mobile 通常无文件管理器拖拽场景,标注不适用) +- 不实现 drag-out(webview 内元素拖出到系统),仅 drag-in +- 不定义坐标系的像素级精度保证(与主路径一致,必要时回退 `(0,0)`) diff --git a/openspec/specs/ohos-webview-drag-drop/spec.md b/openspec/specs/ohos-webview-drag-drop/spec.md new file mode 100644 index 000000000000..8fd8f00e3115 --- /dev/null +++ b/openspec/specs/ohos-webview-drag-drop/spec.md @@ -0,0 +1,71 @@ +# ohos-webview-drag-drop Specification + +## Purpose +为 wry OHOS 的 `drag_and_drop` feature 提供端到端文件拖拽支持:激活 feature flag、接通 wry `drag_drop_handler`、补全 openharmony-ability `drag.rs`、并在 ArkTS `DefaultWebview.ets` 的 Web 组件上挂接 OHOS 拖拽事件,使外部文件拖入 webview 时能以 `DragDropEvent::{Enter, Over, Drop, Leave}` 形式回传给 wry 用户回调。 + +## ADDED Requirements + +### Requirement: wry SHALL activate the drag_and_drop feature flag on OHOS +`wry` OHOS build SHALL enable the `drag_and_drop` cargo feature by default (or document the activation path), and the `WebViewBuilder` SHALL accept a `drag_drop_handler` that is wired through to the OHOS webview. The existing `openharmony-ability` `on_drag_and_drop` builder field (already feature-gated) SHALL be populated when a handler is present. + +#### Scenario: drag_drop_handler set on builder +- **WHEN** a wry `WebViewBuilder` is configured with `drag_drop_handler(Some(handler))` on OHOS +- **THEN** `openharmony_ability::WebViewBuilder::on_drag_and_drop` SHALL receive a non-null closure +- **AND** the closure SHALL be transported to ArkTS as the `onDragAndDrop` field of `WebViewInitData` + +#### Scenario: no drag_drop_handler +- **WHEN** no `drag_drop_handler` is set +- **THEN** `WebViewInitData.onDragAndDrop` SHALL be `undefined`/`null` +- **AND** the Web component SHALL NOT attach drag event listeners (no overhead) + +### Requirement: openharmony-ability SHALL bridge on_drag_and_drop to ArkTS +The `openharmony-ability` Rust crate SHALL (under `feature = "drag_and_drop"`) expose `WebViewBuilder::on_drag_and_drop(self, handler: F)` (already present) and SHALL transport the handler as an NAPI `Function` in `WebViewInitData.onDragAndDrop`. The handler receives a **pipe-string payload** of the form `||,` (NOT JSON), matching the format consumed by `wry/src/ohos/mod.rs`. The `drag.rs` module SHALL define a `DragDropEvent` enum (`Enter { paths, position }`, `Over { position }`, `Drop { paths, position }`, `Leave`) — mirroring `wry::DragDropEvent` — and provide a `from_arkts_pipe(&str)` constructor that parses the pipe-string. + +The pipe-string wire format (identical to `ohos-webview-drag-drop-overlay` spec): +- `type` ∈ `enter` | `over` | `drop` | `leave` +- `paths_nul` = file URIs with `file://`/`datashare://` scheme stripped, joined by `\0` (null byte) so paths containing commas survive intact (empty string for `enter`/`over`/`leave` when no preview paths are available, or whenever `type` is not `drop`) +- `,` = drop position in webview content-area coordinates; fallback `0,0` when unavailable +- Fields are joined by `|`; the wry-side parser uses `raw.splitn(3, '|')` so `paths_nul` may never contain `|` (URIs don't), and `paths_nul` is split on `\0` with empty entries filtered out + +#### Scenario: DragDropEvent pipe-string shape +- **WHEN** an OHOS drag event of type Drop occurs with files `["file://docs/a.txt", "file://docs/b.pdf"]` at position `(120, 64)` +- **THEN** the ArkTS bridge SHALL invoke `data.onDragAndDrop` with the pipe-string `drop|docs/a.txt\0docs/b.pdf|120,64` +- **AND** the wry-side handler SHALL `splitn(3, '|')` it into `["drop", "docs/a.txt\0docs/b.pdf", "120,64"]`, split the middle on `\0` into paths, parse the tail as `(x, y)`, and produce `DragDropEvent::Drop { paths: Vec, position: (i32, i32) }` + +#### Scenario: enter/over/leave pipe-string shape +- **WHEN** the drag pointer enters/moves over/leaves the webview bounds +- **THEN** ArkTS SHALL call `data.onDragAndDrop` with `enter||,` / `over||,` / `leave||,` (when preview paths are unavailable, `paths_nul` is the empty string, e.g. `over||0,0` / `leave||0,0`) +- **AND** wry SHALL map them to `DragDropEvent::{Enter { paths, position }, Over { position }, Leave}` + +#### Scenario: drag.rs no longer a stub +- **WHEN** `cargo build` runs with `drag_and_drop` feature on OHOS +- **THEN** `crates/ability/src/webview/drag.rs` SHALL compile a non-stub `DragDropEvent` enum (mirroring `wry::DragDropEvent`: `Enter { paths: Vec, position: (i32, i32) }`/`Over { position }`/`Drop { paths, position }`/`Leave`) with a `from_arkts_pipe(&str) -> Option` constructor that performs `splitn(3, '|')` + `\0`-split path parsing, and a `to_arkts_pipe(&self) -> String` inverse for tests/debug + +### Requirement: ArkTS Web component SHALL attach drag event listeners +`DefaultWebview.ets` `WebBuilder` and `EmbeddedWebBuilder` SHALL, when `data.onDragAndDrop` is a function, attach OHOS ArkUI drag event handlers (`.onDragStart`/`.onDragEnter`/`.onDragMove`/`.onDragLeave`/`.onDrop`) to the `Web` component (or its wrapping `Stack`). The handlers SHALL extract the dragged file URIs from the OHOS `DragEvent` and forward a **pipe-string payload** `||,` to `data.onDragAndDrop` (same wire format as the overlay spec; NOT JSON). + +#### Scenario: file dropped onto webview +- **WHEN** a user drags a file from the OHOS file manager and drops it onto the webview +- **THEN** the `.onDrop` handler SHALL read `dragEvent.getData()`/`primitive`/`summary` URIs, strip the `file://`/`datashare://` scheme, join them with `\0` (null byte) into `paths_nul`, and call `data.onDragAndDrop('drop|' + paths_nul + '|' + x + ',' + y)` (matching `DefaultWebview.ets` line `data.onDragAndDrop('drop|' + path + '|0,0')`) +- **AND** the wry `drag_drop_handler` SHALL receive `DragDropEvent::Drop { paths, position }` on the Rust event loop thread + +#### Scenario: drag enter/over/leave forwarded +- **WHEN** the drag pointer enters/moves over/leaves the webview bounds +- **THEN** the corresponding `.onDragEnter`/`.onDragMove`/`.onDragLeave` handler SHALL call `data.onDragAndDrop` with `enter||,` / `over||,` / `leave||,` (when preview paths are unavailable, `paths_nul` is empty — e.g. `enter||0,0`, `over||0,0`, `leave||0,0`, matching `DefaultWebview.ets`) +- **AND** wry SHALL map them to `DragDropEvent::{Enter { paths, position }, Over { position }, Leave}` + +### Requirement: Platform limitation SHALL be documented when ArkWeb rejects file drops +If investigation reveals that the OHOS ArkWeb `Web` component does not surface OS-level file drag events to ArkUI (i.e., the Web component consumes HTML5 DnD internally and never emits ArkUI `onDrop`), the design SHALL fall back to one of: (a) rely on HTML5 drag-and-drop inside the page (no wry callback), or (b) overlay a transparent drop-target `Stack` above the Web component. The chosen fallback SHALL be documented in `ohos-webview-drag-drop-plan.md` and the spec updated with a MODIFIED Requirement naming the platform limitation. + +#### Scenario: ArkWeb consumes drag events internally +- **WHEN** OHOS ArkWeb does not bubble file drag events to ArkUI `onDrop` +- **THEN** the implementation SHALL use the overlay `Stack` drop-target approach (transparent `Stack` above `Web` that receives ArkUI drag events and forwards them) +- **AND** the wry `drag_drop_handler` SHALL still receive `DragDropEvent::Drop` with the file paths + +### Requirement: HTML5 in-page drag-and-drop SHALL remain functional +Activating the OHOS drag-and-drop bridge SHALL NOT break existing HTML5 drag-and-drop inside web pages (e.g., dragging elements within the DOM). The overlay (if used) SHALL not intercept in-page DnD events that originate inside the Web component. + +#### Scenario: in-page HTML5 DnD unaffected +- **WHEN** a web page implements HTML5 drag-and-drop between DOM elements +- **THEN** the OHOS drag bridge SHALL NOT interfere (no swallowed events, no duplicate callbacks) +- **AND** only OS-level file drag from outside the webview triggers `DragDropEvent` diff --git a/openspec/specs/ohos-webview-flag-clipboard/spec.md b/openspec/specs/ohos-webview-flag-clipboard/spec.md new file mode 100644 index 000000000000..ac239f2d087a --- /dev/null +++ b/openspec/specs/ohos-webview-flag-clipboard/spec.md @@ -0,0 +1,87 @@ +# ohos-webview-flag-clipboard Specification + +> ⚠️ **验证状态:代码已实现,真机验证未完成。** 代码见 `44e9bcc`(openharmony-ability)+ `9e3f8aa`(wry),TestRunner 有 Clipboard OFF/ON 按钮。但 openspec change `ohos-webview-flag-clipboard` 仍 ACTIVE(11/16,5 个设备验证 task TODO),spec 被提前合并到 `specs/`。待真机验证通过 + change archive 后去掉本标注。 + +## Purpose +让 wry 的 `with_clipboard(bool)` 开关在 OHOS 后端真正生效。ArkWeb 默认允许页面剪贴板访问(`document.execCommand('copy'/'cut'/'paste')`、Clipboard API、Ctrl+C/X/V 组合键),既存实现把 `clipboard` 字段在 `InnerWebView::new_inner` 解构时通过 `..` catch-all 丢弃,导致开发者即便调用 `.with_clipboard(false)` 也无法禁用剪贴板。本 spec 通过「flag 转发 + ArkUI onKeyPreIme 拦截」使 `false` 真正禁用剪贴板组合键,`true` 维持 ArkWeb 原生行为。 + +本 spec 取代 `webview-desktop-features` spec 中 "R82 Clipboard attribute is always-on (platform limitation)" 的旧决策——该决策将 OHOS 与 macOS 对齐为「始终启用」,但 macOS 是 WebKit 引擎级限制无 toggle,OHOS 则可通过组合键拦截实现禁用,二者不应等同。 + +## ADDED Requirements + +### Requirement: wry OHOS SHALL forward clipboard flag to WebviewInitData +`InnerWebView::new_inner` SHALL 在解构 `WebViewAttributes` 时显式保留 `clipboard` 字段(不再落入 `..` catch-all),并通过 `WebViewBuilder::clipboard(bool)`(新增)转发给 `openharmony-ability`,最终写入 `WebviewInitData.clipboard` 字段供 ArkTS 读取。默认值 `false` 与 `WebViewAttributes::default()` 一致。 + +#### Scenario: with_clipboard(false) reaches ArkTS +- **WHEN** 开发者调用 `.with_clipboard(false)` 创建 OHOS webview +- **THEN** `WebviewInitData.clipboard` SHALL 为 `false` +- **AND** Rust 端 SHALL 不再静默丢弃该字段 + +#### Scenario: with_clipboard(true) reaches ArkTS +- **WHEN** 开发者调用 `.with_clipboard(true)` 创建 OHOS webview +- **THEN** `WebviewInitData.clipboard` SHALL 为 `true` + +#### Scenario: default false when not set +- **WHEN** 开发者未调用 `with_clipboard` +- **THEN** `WebviewInitData.clipboard` SHALL 为 `false`(与 `WebViewAttributes::default().clipboard` 一致) + +### Requirement: WebviewInitData SHALL add clipboard field +`DefaultWebview.ets` 的 `WebviewInitData` 接口 SHALL 新增 `clipboard?: boolean` 字段(默认 `false`)。该字段在 `addWebview`/`createWebview` 路径下被保留进 `WebviewNodeData`,供 `onKeyPreIme` 拦截器读取。 + +#### Scenario: clipboard field optional +- **WHEN** `WebviewInitData` 未提供 `clipboard` +- **THEN** 拦截器 SHALL 视为 `false`(即拦截剪贴板组合键) + +### Requirement: onKeyPreIme SHALL block clipboard combos when clipboard=false +ArkUI 容器(`MainPage.ets` 主窗口、`FloatPage.ets` 浮窗)的 `onKeyPreIme` 处理器 SHALL 在 `data.clipboard !== true` 且按下组合键属于 `CLIPBOARD_ACCELERATORS`(`ctrl+c`/`ctrl+x`/`ctrl+v`/`ctrl+a`/`ctrl+z`/`ctrl+y`)时返回 `true` 消费事件,阻止其下发到 ArkWeb,从而禁用剪贴板操作。当 `data.clipboard === true` 时 SHALL 不拦截,让 ArkWeb 原生处理。 + +#### Scenario: clipboard=false blocks Ctrl+C +- **WHEN** `data.clipboard === false` 且用户按下 Ctrl+C +- **THEN** `onKeyPreIme` SHALL 返回 `true` +- **AND** ArkWeb SHALL NOT 收到该按键事件 +- **AND** 页面选中文本 SHALL NOT 被复制到系统剪贴板 + +#### Scenario: clipboard=false blocks Ctrl+V +- **WHEN** `data.clipboard === false` 且用户按下 Ctrl+V +- **THEN** `onKeyPreIme` SHALL 返回 `true` +- **AND** 系统剪贴板内容 SHALL NOT 被粘贴到页面 + +#### Scenario: clipboard=false blocks Ctrl+A/X/Z/Y +- **WHEN** `data.clipboard === false` 且用户按下 Ctrl+A / Ctrl+X / Ctrl+Z / Ctrl+Y 之一 +- **THEN** `onKeyPreIme` SHALL 返回 `true` +- **AND** 对应的全选/剪切/撤销/重做 SHALL NOT 在页面生效 + +#### Scenario: clipboard=true preserves native behavior +- **WHEN** `data.clipboard === true` 且用户按下 Ctrl+C/X/V/A/Z/Y +- **THEN** `onKeyPreIme` SHALL 返回 `false`(不拦截) +- **AND** ArkWeb SHALL 原生处理剪贴板组合键 + +#### Scenario: non-clipboard combos unaffected +- **WHEN** `data.clipboard === false` 且用户按下任意非 CLIPBOARD_ACCELERATORS 组合键(如 Ctrl+F、Ctrl+S) +- **THEN** `onKeyPreIme` SHALL 不因本规则拦截(其他加速器匹配逻辑照常) + +### Requirement: Clipboard interception SHALL coordinate with AcceleratorMatcher +`accelerator_matcher.ets` 的 `CLIPBOARD_ACCELERATORS` 常量 SHALL 作为拦截判定的唯一来源,避免重复维护组合键列表。`AcceleratorMatcher.matches` 既有的「剪贴板组合键跳过加速器匹配」逻辑(返回 `false` 不拦截)SHALL 保持不变——该逻辑用于「菜单加速器不抢占剪贴板键」,与本 spec 的「clipboard flag 拦截」正交:前者总是放行到 webview,后者仅在 flag=false 时拦截。二者组合行为: +- `clipboard=true`:AcceleratorMatcher 跳过剪贴板键 → onKeyPreIme 不拦截 → ArkWeb 原生处理 +- `clipboard=false`:AcceleratorMatcher 跳过剪贴板键 → onKeyPreIme 拦截器消费 → ArkWeb 收不到 + +#### Scenario: clipboard flag false takes precedence over menu accelerator skip +- **WHEN** `data.clipboard === false` 且菜单含 `Ctrl+C` 加速器,用户按下 Ctrl+C +- **THEN** `AcceleratorMatcher.matches` SHALL 返回 `false`(既有跳过逻辑) +- **AND** onKeyPreIme 剪贴板拦截器 SHALL 仍消费该事件(`clipboard=false` 优先) +- **AND** 菜单加速器 SHALL NOT 触发,ArkWeb SHALL NOT 复制 + +### Requirement: clipboard flag SHALL NOT affect programmatic pasteboard API +本 spec 仅拦截键盘组合键。Rust/ArkTS 通过 `@ohos.pasteboard` API 的程序化剪贴板读写 SHALL 不受 `clipboard` flag 影响(与 wry Linux/Windows 语义一致——该 flag 控制页面侧剪贴板访问,不控制宿主程序化访问)。 + +#### Scenario: programmatic pasteboard unaffected +- **WHEN** `data.clipboard === false` 且宿主代码调用 `@ohos.pasteboard` 读写剪贴板 +- **THEN** 程序化读写 SHALL 正常工作 +- **AND** SHALL NOT 受 onKeyPreIme 拦截影响 + +### Requirement: clipboard flag applies to all device form factors +`clipboard` flag 拦截 SHALL 在 mobile 与 desktop 形态下均生效。mobile 形态下软键盘通常无 Ctrl 组合键,但外接蓝牙键盘场景下拦截仍有意义;desktop 形态下为常见场景。 + +#### Scenario: mobile with bluetooth keyboard +- **WHEN** `OHOS_DEVICE_TYPE=mobile`、`data.clipboard === false` 且外接键盘按下 Ctrl+C +- **THEN** onKeyPreIme SHALL 拦截(与 desktop 一致) diff --git a/openspec/specs/ohos-webview-flag-zoom-hotkeys/spec.md b/openspec/specs/ohos-webview-flag-zoom-hotkeys/spec.md new file mode 100644 index 000000000000..37504661112e --- /dev/null +++ b/openspec/specs/ohos-webview-flag-zoom-hotkeys/spec.md @@ -0,0 +1,101 @@ +# ohos-webview-flag-zoom-hotkeys Specification + +> ⚠️ **验证状态:代码已实现,真机验证未完成。** 代码见 `44e9bcc`(openharmony-ability)+ `9e3f8aa`(wry),TestRunner 有 Zoom OFF/ON 按钮。但 openspec change `ohos-webview-flag-zoom-hotkeys` 仍 ACTIVE(11/16,5 个设备验证 task TODO),spec 被提前合并到 `specs/`。待真机验证通过 + change archive 后去掉本标注。 + +## Purpose +让 wry 的 `zoom_hotkeys_enabled` 开关在 OHOS 后端真正禁用缩放热键。当前 OHOS 桌面端有两路缩放: +1. Tauri 注入的 `zoom-hotkey.js`(`crates/tauri/src/manager/webview.rs:562-581`,`cfg(all(desktop, not(target_os = "windows")))`)——该路径**已正确**尊重 `zoom_hotkeys_enabled`:`false` 时不注入 JS。 +2. ArkWeb 引擎原生支持 Ctrl+= / Ctrl+- / Ctrl+0 缩放——该路径**不受 flag 控制**,即便 `zoom_hotkeys_enabled=false`,ArkWeb 仍会响应这些组合键。 + +契约差距 = 第 2 路无法禁用。本 spec 通过「flag 转发 + ArkUI onKeyPreIme 拦截 Ctrl+=/-/0」使 `false` 真正禁用原生缩放热键,`true` 维持 ArkWeb 原生行为(JS 路径由 Tauri 自行注入)。 + +本 spec 取代 `webview-desktop-features` spec 中 "R91 Hotkey zoom works on OHOS desktop" 的旧结论——该结论称「已实现」仅覆盖 JS 路径,未覆盖 flag=false 时 ArkWeb 原生热键仍生效的缺口。 + +## ADDED Requirements + +### Requirement: wry OHOS SHALL forward zoom_hotkeys_enabled flag to WebviewInitData +`InnerWebView::new_inner` SHALL 在解构 `WebViewAttributes` 时显式保留 `zoom_hotkeys_enabled` 字段(不再落入 `..` catch-all),并通过 `WebViewBuilder::zoom_hotkeys_enabled(bool)`(新增)转发给 `openharmony-ability`,最终写入 `WebviewInitData.zoomHotkeys` 字段供 ArkTS 读取。默认值 `false` 与 `WebViewAttributes::default()` 一致。 + +#### Scenario: zoom_hotkeys_enabled(false) reaches ArkTS +- **WHEN** 开发者创建 OHOS webview 且 `zoom_hotkeys_enabled = false` +- **THEN** `WebviewInitData.zoomHotkeys` SHALL 为 `false` +- **AND** Rust 端 SHALL 不再静默丢弃该字段 + +#### Scenario: zoom_hotkeys_enabled(true) reaches ArkTS +- **WHEN** 开发者调用 `.with_zoom_hotkeys(true)` 创建 OHOS webview +- **THEN** `WebviewInitData.zoomHotkeys` SHALL 为 `true` + +### Requirement: WebviewInitData SHALL add zoomHotkeys field +`DefaultWebview.ets` 的 `WebviewInitData` 接口 SHALL 新增 `zoomHotkeys?: boolean` 字段(默认 `false`)。该字段在 `addWebview`/`createWebview` 路径下被保留进 `WebviewNodeData`,供 `onKeyPreIme` 拦截器读取。 + +#### Scenario: zoomHotkeys field optional +- **WHEN** `WebviewInitData` 未提供 `zoomHotkeys` +- **THEN** 拦截器 SHALL 视为 `false`(即拦截原生缩放组合键) + +### Requirement: onKeyPreIme SHALL block zoom combos when zoomHotkeys=false +ArkUI 容器(`MainPage.ets` 主窗口、`FloatPage.ets` 浮窗)的 `onKeyPreIme` 处理器 SHALL 在 `data.zoomHotkeys !== true` 且按下组合键属于 `ZOOM_HOTKEY_ACCELERATORS`(`ctrl+=`、`ctrl+-`、`ctrl+0`)时返回 `true` 消费事件,阻止其下发到 ArkWeb。当 `data.zoomHotkeys === true` 时 SHALL 不拦截,让 ArkWeb 原生处理(同时 Tauri 注入的 `zoom-hotkey.js` 也会响应,二者协同——JS 路径调用 `set_webview_zoom` IPC,原生路径由 ArkWeb 直接缩放;为避免双重缩放,详见下方协调 Requirement)。 + +#### Scenario: zoomHotkeys=false blocks Ctrl+= +- **WHEN** `data.zoomHotkeys === false` 且用户按下 Ctrl+=(放大) +- **THEN** `onKeyPreIme` SHALL 返回 `true` +- **AND** ArkWeb SHALL NOT 收到该按键事件 +- **AND** webview 缩放级别 SHALL NOT 改变 + +#### Scenario: zoomHotkeys=false blocks Ctrl+- +- **WHEN** `data.zoomHotkeys === false` 且用户按下 Ctrl+-(缩小) +- **THEN** `onKeyPreIme` SHALL 返回 `true` +- **AND** webview 缩放级别 SHALL NOT 改变 + +#### Scenario: zoomHotkeys=false blocks Ctrl+0 +- **WHEN** `data.zoomHotkeys === false` 且用户按下 Ctrl+0(重置) +- **THEN** `onKeyPreIme` SHALL 返回 `true` +- **AND** webview 缩放级别 SHALL NOT 重置 + +#### Scenario: zoomHotkeys=true preserves native behavior +- **WHEN** `data.zoomHotkeys === true` 且用户按下 Ctrl+=/-/0 +- **THEN** `onKeyPreIme` SHALL 返回 `false`(不拦截) +- **AND** ArkWeb SHALL 原生响应缩放 + +#### Scenario: non-zoom combos unaffected +- **WHEN** `data.zoomHotkeys === false` 且用户按下任意非 ZOOM_HOTKEY_ACCELERATORS 组合键(如 Ctrl+C、Ctrl+F) +- **THEN** `onKeyPreIme` SHALL 不因本规则拦截 + +### Requirement: ZOOM_HOTKEY_ACCELERATORS SHALL be defined alongside CLIPBOARD_ACCELERATORS +`accelerator_matcher.ets` SHALL 新增 `ZOOM_HOTKEY_ACCELERATORS: Set` 常量,包含 `'ctrl+=`、`'ctrl+-'`、`'ctrl+0'`。该常量供 onKeyPreIme 拦截器读取。`AcceleratorMatcher.matches` SHALL 也跳过这些组合键的菜单加速器匹配(与剪贴板键同处理),避免菜单 Ctrl+= 抢占。 + +#### Scenario: zoom combos skipped by menu accelerator matching +- **WHEN** 菜单含 `Ctrl+=` 加速器且 `data.zoomHotkeys === true`,用户按下 Ctrl+= +- **THEN** `AcceleratorMatcher.matches` SHALL 返回 `false`(跳过) +- **AND** onKeyPreIme 拦截器 SHALL 不拦截(zoomHotkeys=true) +- **AND** ArkWeb SHALL 原生放大 + +### Requirement: zoomHotkeys flag SHALL coordinate with Tauri JS injection +当 `zoom_hotkeys_enabled=true` 时,Tauri (`crates/tauri/src/manager/webview.rs`) 注入 `zoom-hotkey.js` 并注册 `set_webview_zoom` IPC,ArkWeb 原生也响应 Ctrl+=/-/0。为避免 JS 路径与原生路径双重缩放(每次按键放大两次),SHALL 采取以下协调之一(实现时择一): +- 方案 A(推荐):OHOS 桌面端在 `manager/webview.rs` 的注入条件追加 `&& false` 短路,完全依赖 ArkWeb 原生缩放(flag=true 时 onKeyPreIme 放行 → ArkWeb 处理) +- 方案 B:保留 JS 注入,但 `zoom-hotkey.js` 在 OHOS 上 no-op(`os_name === "ohos"` 时早退) +两种方案下,flag=false 时 JS 不注入 + onKeyPreIme 拦截,彻底禁用缩放。 + +#### Scenario: no double zoom on OHOS desktop +- **WHEN** `zoom_hotkeys_enabled=true` 且 OHOS desktop 用户按下 Ctrl+= +- **THEN** webview SHALL 仅放大一档(不翻倍) +- **AND** `controller.zoom()` 与 ArkWeb 原生缩放 SHALL 不同时触发 + +### Requirement: Programmatic zoom SHALL NOT be affected +`InnerWebView::zoom(scale_factor)` 通过 `Webview::set_zoom` → `controller.zoom()` 程序化缩放 SHALL 不受 `zoomHotkeys` flag 影响。flag 仅控制键盘热键,不控制程序化 API。 + +#### Scenario: programmatic zoom works when flag false +- **WHEN** `zoom_hotkeys_enabled=false` 且 Rust 调用 `webview.zoom(1.5)` +- **THEN** webview SHALL 缩放到 1.5 倍 +- **AND** SHALL NOT 被拦截 + +### Requirement: zoomHotkeys interception SHALL be desktop-only +ArkWeb 原生 Ctrl+=/-/0 缩放仅在桌面形态(外接键盘)下有意义。mobile 形态下软键盘无 Ctrl 组合键,拦截无副作用但无必要。为与 Tauri JS 注入的 `cfg(desktop)` 门控对齐,onKeyPreIme 的 zoom 拦截 SHALL 仅在 `__openharmony_ability_is_desktop__` AppStorage 为 `true` 时生效;mobile 形态下 SHALL 不拦截(即便 `zoomHotkeys=false`,移动端本就无键盘热键触发场景)。 + +#### Scenario: mobile does not intercept zoom combos +- **WHEN** `OHOS_DEVICE_TYPE=mobile`、`data.zoomHotkeys === false` 且外接键盘按下 Ctrl+= +- **THEN** onKeyPreIme SHALL 不因 zoom 规则拦截(与 Tauri JS 不注入对齐) +- **AND** ArkWeb SHALL 原生响应(mobile 端原生缩放通常也禁用,由 ArkWeb 自身决定) + +#### Scenario: desktop intercepts when flag false +- **WHEN** `OHOS_DEVICE_TYPE=desktop`、`data.zoomHotkeys === false` 且用户按下 Ctrl+= +- **THEN** onKeyPreIme SHALL 拦截 diff --git a/openspec/specs/ohos-webview-https-scheme/spec.md b/openspec/specs/ohos-webview-https-scheme/spec.md new file mode 100644 index 000000000000..c9b31a3665b7 --- /dev/null +++ b/openspec/specs/ohos-webview-https-scheme/spec.md @@ -0,0 +1,248 @@ +# ohos-webview-https-scheme Specification + +> ✅ **验证状态:完全通过(2026-08-06,API 23 desktop)。** 根因是 `tauri-runtime-wry` OHOS 分支漏传 `with_https_scheme`(Windows/Android 传了,OHOS 没)→ `pl_attrs.use_https` 始终 false → URL 不改写。已修复。真机验证三项全通过:`isSecureContext=true` + `location.href=https://tauri.localhost/` + `crypto.subtle OK (SHA-256 32 bytes)`。 + +## ADDED Requirements + +### Requirement: wry OHOS SHALL honor `with_https_scheme(true)` by rewriting the initial URL and registering https interception + +当 `WebViewBuilderExtOhos::with_https_scheme(true)` 被调用且 `custom_protocols` 非空时,wry OHOS 后端的 `InnerWebView::new_inner` SHALL: + +1. 在调用 `WebViewBuilder::build()` 之前,对 `attributes.url` 中所有 scheme 命中 `custom_protocols` 键的 URL 应用 `custom_protocol_workaround::apply_uri_work_around(url, "https", protocol)`,把 `://localhost/path` 改写为 `https://.localhost/path`; +2. 通过 `WebViewBuilder::use_https_intercept(true)` 与 `https_intercept_protocols(Vec)` 把所有 `custom_protocols` 的协议名传给 openharmony-ability,由 ArkTS 侧 `onInterceptRequest` 完成转发; +3. 不再 emit 现有的 `log::warn!("[WRY OHOS] with_https_scheme: https scheme registration not yet implemented ...")` 警告(该警告仅在设计未实现期存在)。 + +当 `with_https_scheme(false)`(默认)或 `custom_protocols` 为空时,SHALL 保持现有行为不变:URL 不改写、不注册 https 拦截、custom_protocols 仍按原始 scheme 经 `OH_ArkWeb_SetSchemeHandler` 注册。 + +`with_https_scheme(true)` 与现有「按原始 scheme 注册 `custom_protocol_async`」**不互斥**——两条路径并存:原始 scheme 注册保留(向后兼容),新增的 https 拦截负责把 `https://./` 转回原始 URL 后投递给同一个 `custom_protocol_async` 闭包。 + +#### Scenario: with_https_scheme(true) rewrites tauri://localhost URL +- **WHEN** 调用方 `WebViewBuilder::new().with_url("tauri://localhost/index.html").with_https_scheme(true)` 且 `custom_protocols` 含 `"tauri"` +- **THEN** wry OHOS SHALL 在 build 前把 url 改写为 `"https://tauri.localhost/index.html"` +- **AND** SHALL 调用 `WebViewBuilder::use_https_intercept(true).https_intercept_protocols(["tauri".to_string()])` +- **AND** `WebViewBuilder::build()` 接收到的 `url` 字段为改写后的 `https://tauri.localhost/index.html` + +#### Scenario: with_https_scheme(false) preserves raw scheme +- **WHEN** 调用方未调用 `with_https_scheme`(默认 `false`),或显式 `with_https_scheme(false)` +- **THEN** wry OHOS SHALL 不改写 url(保持 `tauri://localhost/index.html`) +- **AND** SHALL 不调用 `use_https_intercept` +- **AND** 现有 `custom_protocol_async` 经 `OH_ArkWeb_SetSchemeHandler("tauri", ...)` 注册的路径 SHALL 继续工作 + +#### Scenario: with_https_scheme(true) but no custom_protocols registered +- **WHEN** `with_https_scheme(true)` 但 `custom_protocols` 为空 +- **THEN** wry OHOS SHALL 视为 no-op:不调用 `use_https_intercept`、不改写 url、不打 warn 日志 +- **AND** SHALL 不产生任何 https 拦截副作用 + +#### Scenario: with_https_scheme(true) and URL scheme not in custom_protocols +- **WHEN** `with_https_scheme(true)`,`custom_protocols = {"tauri"}`,但 `url = "https://example.com/page"` +- **THEN** wry OHOS SHALL 不改写该 url(scheme 不匹配任何 custom_protocol) +- **AND** 该 url 在 ArkTS 侧 `onInterceptRequest` 中 SHALL 被「不匹配任何已注册协议」分支处理(返回 null,让 ArkWeb 走默认网络栈) + +#### Scenario: warning log removed when implemented +- **WHEN** `with_https_scheme(true)` 且本特性已实现 +- **THEN** wry OHOS SHALL NOT emit `log::warn!("[WRY OHOS] with_https_scheme: https scheme registration not yet implemented ...")` +- **AND** 该 warn 字符串 SHALL 从 `wry/src/ohos/mod.rs` 删除 + +### Requirement: openharmony-ability WebViewBuilder SHALL carry use_https_intercept and https_intercept_protocols fields + +`openharmony-ability::WebViewBuilder` SHALL 新增两个字段及对应 builder 方法: + +- `use_https_intercept: bool`(默认 `false`),方法 `.use_https_intercept(self, bool) -> Self` +- `https_intercept_protocols: Vec`(默认空),方法 `.https_intercept_protocols(self, Vec) -> Self` + +`build()` SHALL 把这两个字段经 `WebViewInitData` NAPI 结构传给 ArkTS `createWebview` / `createEmbeddedWebview`。 + +#### Scenario: builder methods populate fields +- **WHEN** `WebViewBuilder::new().use_https_intercept(true).https_intercept_protocols(vec!["tauri".into()])` 调用后 `build()` +- **THEN** `WebViewInitData.use_https_intercept = Some(true)` +- **AND** `WebViewInitData.https_intercept_protocols = Some(vec!["tauri".to_string()])` + +#### Scenario: default values when not set +- **WHEN** `WebViewBuilder::new().build()` 未调用上述方法 +- **THEN** `WebViewInitData.use_https_intercept = Some(false)` +- **AND** `WebViewInitData.https_intercept_protocols = None`(或 `Some(vec![])`) + +### Requirement: Webview SHALL expose register_https_intercept NAPI method for late binding + +`openharmony-ability::Webview` SHALL 暴露 `pub fn register_https_intercept(&self, protocols: Vec) -> Result<()>` 方法,通过 NAPI 调用 ArkTS 控制器的 `registerHttpsIntercept` 方法。该方法用于「webview 已创建后追加 https 拦截协议」的场景(如 tauri-runtime-wry 在 `with_webview` 回调中补注册)。 + +ArkTS 侧 `ret.controller.registerHttpsIntercept(protocols: string[])` SHALL 把协议名合并入 webview 的 https-intercept 协议集合(去重),并保证后续 `onInterceptRequest` 回调能匹配到这些协议。 + +#### Scenario: Rust calls register_https_intercept +- **WHEN** Rust 调用 `webview.register_https_intercept(vec!["tauri".to_string()])` +- **THEN** SHALL 通过 NAPI 调用 ArkTS `ret.controller.registerHttpsIntercept(["tauri"])` +- **AND** ArkTS 侧 SHALL 把 `"tauri"` 加入该 webview 的 https-intercept 协议集合 + +#### Scenario: register_https_intercept fails when main thread env unavailable +- **WHEN** `get_main_thread_env()` 返回 `None` 时调用 `register_https_intercept` +- **THEN** SHALL 返回 `Error::from_reason("Failed to get main thread env")` + +### Requirement: ArkHelper SHALL attach registerHttpsIntercept to controller + +`ArkHelper.ets` 的 `createWebview` 和 `createEmbeddedWebview` SHALL 在 `ret.controller` 上挂载 `registerHttpsIntercept(protocols: string[])` 方法。该方法 SHALL: + +1. 把传入的协议名合并到该 webview 对应的内部 `httpsInterceptProtocols: Set`(per-webview 隔离,去重); +2. 不立即触发任何重渲染——协议集合在 `onInterceptRequest` 闭包中通过闭包捕获或 `data` 字段读取。 + +#### Scenario: registerHttpsIntercept on normal webview +- **WHEN** 通过 `createWebview` 创建 webview 后调用 `controller.registerHttpsIntercept(["tauri", "asset"])` +- **THEN** SHALL 把 `"tauri"`、`"asset"` 加入该 webview 的 https-intercept 协议集合 +- **AND** 后续 `onInterceptRequest` SHALL 能匹配 `https://tauri.localhost/...` 与 `https://asset.localhost/...` + +#### Scenario: per-webview isolation of protocol set +- **WHEN** webview A 调用 `registerHttpsIntercept(["tauri"])`,webview B 不调用 +- **THEN** webview A 的 `onInterceptRequest` SHALL 匹配 `https://tauri.localhost/...` +- **AND** webview B 的 `onInterceptRequest` SHALL 不匹配任何 https 协议(返回 null) + +### Requirement: DefaultWebview SHALL register onInterceptRequest when useHttpsIntercept is true + +`DefaultWebview.ets` 的 `WebBuilder` 与 `EmbeddedWebBuilder` SHALL 根据 `data.useHttpsIntercept === true` 条件挂载 `.onInterceptRequest(callback)` 属性。当 `data.useHttpsIntercept` 为 `false` 或 `undefined` 时 SHALL NOT 挂载该属性(保持现有行为)。 + +`onInterceptRequest` 回调 SHALL: + +1. 从 `event.request.getRequestUrl()` 读取 URL; +2. 用 `custom_protocol_workaround::is_work_around_uri(url, "https", protocol)` 等价逻辑(在 ArkTS 侧实现:`/^https:\/\/\./`)匹配 `data.httpsInterceptProtocols` 中的任一协议; +3. **匹配**:创建 `new WebResourceResponse()`,调用 `setResponseIsReady(false)`,异步调用 NAPI `dispatchHttpsIntercept(url, applyResponseFn)`,**同步返回** 该 response 对象; +4. **不匹配**:返回 `null`(让 ArkWeb 继续走默认网络栈)。 + +#### Scenario: matching https URL intercepted +- **WHEN** `data.useHttpsIntercept === true`,`data.httpsInterceptProtocols = ["tauri"]`,webview 发起 `fetch("https://tauri.localhost/api/data")` +- **THEN** `onInterceptRequest` SHALL 匹配 `tauri` +- **AND** SHALL 创建 `WebResourceResponse` 并调用 `setResponseIsReady(false)` +- **AND** SHALL 调用 NAPI `dispatchHttpsIntercept("https://tauri.localhost/api/data", applyResponseFn)` +- **AND** SHALL 同步返回该 response 对象(不返回 null) + +#### Scenario: non-matching https URL passes through +- **WHEN** `data.useHttpsIntercept === true`,`data.httpsInterceptProtocols = ["tauri"]`,webview 发起 `fetch("https://example.com/api")` +- **THEN** `onInterceptRequest` SHALL 不匹配任何协议 +- **AND** SHALL 返回 `null` +- **AND** ArkWeb SHALL 走默认 https 网络栈加载该请求 + +#### Scenario: useHttpsIntercept false does not attach onInterceptRequest +- **WHEN** `data.useHttpsIntercept` 为 `false` 或 `undefined` +- **THEN** Web 组件 SHALL NOT 挂载 `.onInterceptRequest` 属性 +- **AND** 所有 https 请求 SHALL 走 ArkWeb 默认网络栈 + +#### Scenario: onInterceptRequest covers sub-resource and main-frame requests +- **WHEN** `data.useHttpsIntercept === true` 且 webview 主框架导航到 `https://tauri.localhost/index.html` +- **THEN** `onInterceptRequest` SHALL 对该主框架请求触发 +- **AND** SHALL 按 matching 流程处理(创建 response、异步 dispatch) +- **NOTE**:ArkWeb 是否对主框架导航也触发 `onInterceptRequest` 需设备验证(见 plan 未知项 1);若不触发,初始 URL 加载需 `onLoadIntercept` 配合(fallback 设计见 plan Phase 2)。 + +### Requirement: NAPI dispatchHttpsIntercept SHALL bridge https URL to existing custom_protocol_async handler + +openharmony-ability SHALL 暴露 NAPI 函数 `dispatchHttpsIntercept(url: string, applyResponse: Function)`,行为: + +1. 接收 ArkTS 传入的 `https://./` URL 与一个 `applyResponse` 回调函数; +2. 用 `custom_protocol_workaround::revert_uri_work_around(url, "https", protocol)` 把 URL 还原为 `:///`(其中 `` 从 URL 解析得到,且必须命中该 webview 已注册的 custom_protocol 闭包集合); +3. 构造 `http::Request>`(method 默认 `GET`,headers 从 ArkTS 透传或为空——见未知项 4),调用对应 webview 的 `custom_protocol_async` 闭包(即 wry 在 `InnerWebView::new_inner` 中通过 `webview.custom_protocol_async(protocol, ...)` 注册的那个); +4. 闭包的 `RequestAsyncResponder` SHALL 在响应到达时把 `{statusCode, headers, mimeType, body}` 经 `Function::call` + `FnArgs` 元组模式回调 `applyResponse`(遵守 ohos-constraints §2.2 `callee_handled::()` + `FnArgs` 包装规则); +5. `applyResponse` 在 ArkTS 侧 SHALL 调用 `response.setResponseCode(statusCode)`、`response.setResponseMimeType(mimeType)`、`response.setResponseHeader(headers)`、`response.setResponseData(body)`,最后 `response.setResponseIsReady(true)`。 + +**线程模型**:`onInterceptRequest` 在 ArkUI JS 线程触发,NAPI `dispatchHttpsIntercept` 在同线程被调用。`custom_protocol_async` 闭包可能立即同步调 responder(资源已缓存),也可能异步调(文件 IO、网络)。responder 触发时通过 TSFN NonBlocking 调度回 ArkUI JS 线程执行 `applyResponse` 回调(遵守 ohos-constraints §1.2:禁止 `run_on_main_thread + recv()` 阻塞模式)。 + +#### Scenario: dispatchHttpsIntercept rewrites URL and invokes handler +- **WHEN** ArkTS 调用 `dispatchHttpsIntercept("https://tauri.localhost/index.html", applyResponse)` +- **THEN** Rust SHALL 把 URL 还原为 `"tauri://localhost/index.html"` +- **AND** SHALL 构造 `Request` 并调用 `"tauri"` 对应的 `custom_protocol_async` 闭包 +- **AND** SHALL 把闭包的 `RequestAsyncResponder` 包装成调用 `applyResponse({statusCode, headers, mimeType, body})` + +#### Scenario: responder applies response fields and marks ready +- **WHEN** `custom_protocol_async` 闭包调 `responder.respond(Response{ status: 200, headers: {"content-type": "text/html"}, body: b"..." })` +- **THEN** Rust SHALL 通过 NAPI `Function::call` 调用 `applyResponse`,参数为 `{ statusCode: 200, headers: [{headerKey:"content-type", headerValue:"text/html"}], mimeType: "text/html", body: Uint8Array }` +- **AND** ArkTS `applyResponse` SHALL 调用 `response.setResponseCode(200)`、`response.setResponseMimeType("text/html")`、`response.setResponseHeader([...])`、`response.setResponseData(uint8Array)` +- **AND** SHALL 调用 `response.setResponseIsReady(true)` 触发 ArkWeb 交付响应 + +#### Scenario: handler returns error response +- **WHEN** `custom_protocol_async` 闭包调 `responder.respond(Response{ status: 404, body: b"not found" })` +- **THEN** Rust SHALL 调 `applyResponse({ statusCode: 404, ... })` +- **AND** ArkTS SHALL 调 `response.setResponseCode(404)` 与 `setResponseIsReady(true)` +- **AND** ArkWeb SHALL 把该响应作为 404 交付给页面 + +#### Scenario: unknown protocol returns null response (defensive) +- **WHEN** ArkTS 调用 `dispatchHttpsIntercept("https://unknown.localhost/x", applyResponse)` 但 `"unknown"` 不在该 webview 的 custom_protocol 集合中 +- **THEN** Rust SHALL 不调用任何闭包 +- **AND** SHALL 通过 `applyResponse({ statusCode: 404, body: empty, mimeType: "text/plain" })` 通知 ArkTS +- **AND** ArkTS SHALL 调 `setResponseIsReady(true)` 让 ArkWeb 终结该请求 +- **NOTE**:这是防御性路径——正常情况下 ArkTS 侧 `onInterceptRequest` 已经过滤了未知协议;此场景仅在「ArkTS 协议集合与 Rust 闭包集合不一致」时触发 + +### Requirement: WebviewInitData SHALL carry use_https_intercept and https_intercept_protocols fields + +Rust NAPI 结构 `WebViewInitData` SHALL 新增字段: + +- `use_https_intercept: Option` +- `https_intercept_protocols: Option>` + +ArkTS 侧 `WebviewInitData` 接口(`DefaultWebview.ets`)SHALL 新增对应字段: + +- `useHttpsIntercept?: boolean` +- `httpsInterceptProtocols?: string[]` + +`ArkHelper.ets` `createWebview` / `createEmbeddedWebview` SHALL 在构造 `WebviewInitData` 透传对象时保留这两个字段(不剥离、不重命名)。 + +#### Scenario: fields flow from Rust to ArkTS +- **WHEN** wry 调用 `WebViewBuilder::new().use_https_intercept(true).https_intercept_protocols(["tauri"]).build()` +- **THEN** `WebViewInitData.use_https_intercept = Some(true)` 经 NAPI 传到 ArkTS +- **AND** ArkTS `data.useHttpsIntercept === true` +- **AND** ArkTS `data.httpsInterceptProtocols` 深度等于 `["tauri"]` + +#### Scenario: fields default to false/empty when not set +- **WHEN** wry 不调用 `use_https_intercept` 与 `https_intercept_protocols` +- **THEN** `WebViewInitData.use_https_intercept = Some(false)`(或 `None`,ArkTS 侧 `undefined`) +- **AND** ArkTS `data.useHttpsIntercept` 为 `false` 或 `undefined`(falsy) +- **AND** `onInterceptRequest` SHALL NOT 被挂载 + +### Requirement: JsHelper interface SHALL include registerHttpsIntercept method + +`Utils.ets` 的 `JsHelper` 接口 SHALL 新增 `registerHttpsIntercept: (protocols: string[]) => void` 方法签名,使 `ProxyJsHelper` 和 `buildJsHelper` 返回的对象均需实现此方法。 + +#### Scenario: ProxyJsHelper caches registerHttpsIntercept when controller not ready +- **WHEN** controller 未就绪时调用 `proxy.registerHttpsIntercept(["tauri"])` +- **THEN** `ProxyJsHelper` SHALL 将操作缓存到 `pendingOperations` +- **AND** 当 `bindToRealController` 被调用时 SHALL 回放 `registerHttpsIntercept(["tauri"])` 到真实 controller + +#### Scenario: buildJsHelper returns object with registerHttpsIntercept stub +- **WHEN** `buildJsHelper(controller)` 返回 `JsHelper` 对象 +- **THEN** 返回对象 SHALL 包含 `registerHttpsIntercept` no-op 桩函数(随后被 `ArkHelper.ets` 覆盖为真实实现) + +### Requirement: cfg isolation SHALL keep OHOS https-intercept code out of other platforms + +所有为支持 `with_https_scheme` 而新增的代码(URL 改写、`use_https_intercept` 字段、`register_https_intercept` NAPI 方法、`onInterceptRequest` 挂载、`dispatchHttpsIntercept` NAPI 函数)SHALL 通过 `cfg(target_env = "ohos")` 隔离,不影响 Windows/macOS/Linux/Android/iOS 的现有代码路径。 + +`custom_protocol_workaround` 模块(已存在,Android 共享)SHALL 在 OHOS 上也复用,不重复实现 URL 改写逻辑。 + +#### Scenario: OHOS-only fields do not appear on other platforms +- **WHEN** 在 Windows/macOS/Linux 上编译 wry +- **THEN** `PlatformSpecificWebViewAttributes` SHALL NOT 包含 `use_https` 或 `use_https_intercept` 字段 +- **AND** `WebViewBuilderExtOhos` trait SHALL NOT 在非 OHOS 平台可见 + +#### Scenario: custom_protocol_workaround shared between Android and OHOS +- **WHEN** OHOS 编译 wry +- **THEN** `wry/src/custom_protocol_workaround.rs` SHALL 被复用(不创建 OHOS 专属副本) +- **AND** `apply_uri_work_around(url, "https", protocol)` 与 `revert_uri_work_around(url, "https", protocol)` SHALL 在 OHOS 上下文中可用 + +### Requirement: Secure-context behavior SHALL be verified on device (verification gate) + +本特性的最终验收标准是 **`https://.localhost` origin 下 secure-context API 可用**——即页面内 `window.isSecureContext === true` 且 `crypto.subtle.digest(...)` 等 secure-only API 不抛错。此为运行时行为,依赖 ArkWeb 对 `https://.localhost` origin 的 secure-context 判定,无法仅靠编译期或单元测试断言,必须在设备端验证。 + +#### Scenario: secure context flag true under https scheme +- **WHEN** `with_https_scheme(true)` 且 webview 加载 `https://tauri.localhost/index.html` +- **THEN** 页面内 `window.isSecureContext` SHALL 等于 `true` +- **AND** `crypto.subtle` SHALL 不为 `undefined` + +#### Scenario: crypto.subtle digest succeeds under https scheme +- **WHEN** 页面执行 `await crypto.subtle.digest('SHA-256', new TextEncoder().encode('hello'))` +- **THEN** SHALL 返回 `ArrayBuffer`(不抛 `TypeError: crypto.subtle is undefined`) + +#### Scenario: fallback when ArkWeb does not treat custom https origin as secure +- **WHEN** 设备验证发现 `https://tauri.localhost` 下 `window.isSecureContext === false` 或 `crypto.subtle` 不可用 +- **THEN** 该 Scenario 标记为「未通过设备验证」,设计 SHALL 回退到 plan 的「未知项 3」分支: + - 评估改用 `https://localhost./` 反向域名形态 + - 或评估 `OH_ArkWeb_RegisterCustomSchemes` + Standard option 的方案 + - 或在文档中显式标注「OHOS 不支持 secure-context 自定义 origin」并保留 `with_https_scheme` API 形态为 no-op + warn + +#### Scenario: ipc_handler URL preserves https origin +- **WHEN** `with_https_scheme(true)` 且 webview 内 IPC 触发 `ipc_handler(Request{ uri })` +- **THEN** wry OHOS `ipc_handler` 收到的 `Request::uri()` SHALL 为 `https://tauri.localhost/...`(与 webview 当前 url 一致) +- **AND** `url()` 方法 SHALL 返回 `https://tauri.localhost/...` +- **NOTE**:现有 `InnerWebView::new_inner` 的 `on_controller_attach` IPC 注册闭包从 `ipc_webview.url()` 读取 url——在 https 模式下 url 已是 `https://...`,无需额外改写 diff --git a/openspec/specs/ohos-webview-print/spec.md b/openspec/specs/ohos-webview-print/spec.md new file mode 100644 index 000000000000..a73dc8148016 --- /dev/null +++ b/openspec/specs/ohos-webview-print/spec.md @@ -0,0 +1,67 @@ +# ohos-webview-print Specification + +## Purpose +为 wry OHOS 的 `print()` 提供真实实现,替换当前的空 `Ok(())` no-op。`print()` SHALL 调用 OHOS 打印服务(`@kit.PrintKit` / `@ohos.print`)打印当前 webview 内容;若打印服务在当前设备/SDK 不可用,SHALL 降级为复用已有 `create_pdf` 生成 PDF 并返回路径提示。 + +## ADDED Requirements + +### Requirement: wry print() SHALL invoke the OHOS print service +`wry` OHOS `InnerWebView::print()` SHALL NOT be a no-op. It SHALL delegate to `openharmony-ability` `Webview::print()`, which SHALL call the ArkTS `print()` method on the JsHelper. The ArkTS `print()` SHALL use OHOS `@kit.PrintKit` (`@ohos.print`) to launch the system print flow for the current webview content. + +#### Scenario: print() launches system print dialog +- **WHEN** `webview.print()` is called on OHOS +- **THEN** the system print dialog SHALL be presented to the user (or the default printer job is queued, depending on device) +- **AND** `print()` SHALL return `Ok(())` after the print job is submitted + +#### Scenario: print() no longer a no-op +- **WHEN** `webview.print()` is called +- **THEN** the implementation SHALL NOT return `Ok(())` without performing any print action +- **AND** a debug log SHALL be emitted indicating the print path was invoked + +### Requirement: ArkTS print() SHALL use OHOS PrintKit +The ArkTS `JsHelper.print()` method SHALL be added to the `JsHelper` interface (`Utils.ets`) and implemented in `buildJsHelper` (`DefaultWebview.ets`). It SHALL call `@ohos.print` with the current page's PDF (generated via the existing `controller.createPdf()` path) as the print input. + +**已确认 API 签名(SDK `.d.ts` 核实,2026-07-20)**:`@ohos.print` 暴露多个 `print` 重载,均接受**文件 URI 数组**(非 fd): +- `function print(files: Array): Promise`(无 context,本实现采用此重载——`buildJsHelper` 作用域无 `Context` 访问) +- `function print(files: Array, context: Context): Promise`(带 context,设备验证若发现无 context 重载不弹打印 UI,则改用此重载并从 `RustWebviewNodeController.uiContext` 取 context) +- 流式重载 `function print(jobName: string, printAdapter: PrintDocumentAdapter, printAttributes: PrintAttributes, context: Context): Promise` 供按页渲染(本实现不使用) + +本实现 SHALL 使用无 context 的 files 重载,将 `createPdf` 生成的临时 PDF 文件 URI 作为 `Array` 传入;打印完成/失败后 SHALL 用 `fileIo.unlinkSync` 清理临时 PDF。 + +#### Scenario: print via PrintKit with generated PDF +- **WHEN** `print()` is called and the page is fully loaded (`page_loaded == true`) +- **THEN** the ArkTS bridge SHALL generate a PDF via `controller.createPdf()` to a temp file and obtain its file URI +- **AND** SHALL call `@ohos.print` `print(files: Array, context)` with the temp PDF URI(签名 `print(files: Array, context): Promise`) +- **AND** SHALL clean up the temp file after the print job completes or fails + +#### Scenario: print called before page load +- **WHEN** `print()` is called and `page_loaded` is `false` +- **THEN** the implementation SHALL return `Err` with a "Page not fully loaded" message (mirroring `create_pdf`'s guard) +- **AND** SHALL NOT invoke the print service + +### Requirement: Fallback to create_pdf when PrintKit is unavailable +If `@ohos.print` is not available on the device(打印服务缺失或 `print.print` 不可调用),`print()` SHALL fall back to invoking the existing `create_pdf` behavior (generate a PDF to a temp path) and return `Ok(())` after writing the file, emitting a `log::warn!` that print degraded to PDF generation.(API 签名已确认存在;设备端是否实际完成打印仍需实机验证,见"待设备验证"。) + +#### Scenario: PrintKit unavailable degrades to PDF +- **WHEN** `print()` is called and `@ohos.print` import fails or `print.print` is not a function +- **THEN** the implementation SHALL fall back to `create_pdf` with a default temp path (e.g., `${cacheDir}/wry_print_.pdf`) +- **AND** SHALL emit `log::warn!("[wry] print: PrintKit unavailable, generated PDF at ")` +- **AND** SHALL return `Ok(())` + +### Requirement: print() SHALL be cfg-gated to OHOS only +The `print()` OHOS implementation SHALL be isolated under `cfg(target_env = "ohos")` and SHALL NOT affect the `print()` implementation of Windows/macOS/Linux/Android/iOS. + +#### Scenario: other platforms unaffected +- **WHEN** `webview.print()` is called on Windows/macOS/Linux +- **THEN** the existing platform-specific `print()` implementation SHALL run unchanged +- **AND** no OHOS code path SHALL be compiled in + +## MODIFIED Requirements + +### Requirement: openharmony-ability Webview SHALL expose print() +`openharmony-ability` `Webview` SHALL add a `print(&self) -> Result<()>` method that calls the ArkTS `print` named property on the JsHelper inner object, mirroring the pattern of `set_background_color`/`clear_all_browsing_data`. The `WebViewInitData` need not change (print is a runtime action, not a build-time attribute). + +#### Scenario: ability Webview::print dispatches to ArkTS +- **WHEN** `wry` calls `self.webview.print()` +- **THEN** `openharmony-ability` SHALL look up the `print` property on the inner ObjectRef and call it with no arguments +- **AND** SHALL propagate ArkTS errors as `Error::from_reason` diff --git a/openspec/specs/ohos-webview-proxy-config/spec.md b/openspec/specs/ohos-webview-proxy-config/spec.md new file mode 100644 index 000000000000..d0e13f9e3189 --- /dev/null +++ b/openspec/specs/ohos-webview-proxy-config/spec.md @@ -0,0 +1,166 @@ +# ohos-webview-proxy-config Specification + +## Purpose +让 wry `WebViewAttributes.proxy_config`(`ProxyConfig::Http` / `ProxyConfig::Socks5`,`wry/src/lib.rs:781`)在 OHOS 后端真正生效。当前 `wry/src/ohos/mod.rs:61-87` 解构 `WebViewAttributes` 时该字段落入 `..` catch-all 被静默丢弃,全文无 `proxy_config` 引用。本 spec 通过 `openharmony-ability` NAPI 桥调用 ArkWeb `webview.ProxyController.applyProxyOverride`(`@ohos.web.webview`,`SystemCapability.Web.Webview.Core`,`since 15`),将 wry `ProxyConfig` 映射为 ArkWeb 代理规则。 + +契约差距 = wry 公共字段 `proxy_config` 在 OHOS 无实现 → 流量始终走系统代理或直连,开发者通过 `WebViewBuilder::with_proxy_config(...)`(`wry/src/lib.rs:1400`)设置的代理被忽略。 + +## ADDED Requirements + +### Requirement: wry OHOS SHALL extract proxy_config from WebViewAttributes +`InnerWebView::new_inner`(`wry/src/ohos/mod.rs`)SHALL 在解构 `WebViewAttributes` 时显式列出 `proxy_config` 字段,不再让其落入 `..` catch-all。提取的 `Option` SHALL 在 webview 创建后、初始 URL 加载前,经 `openharmony_ability` 桥接下发到 ArkWeb。 + +#### Scenario: proxy_config is None +- **WHEN** 开发者未调用 `.with_proxy_config(...)`(`proxy_config = None`) +- **THEN** Rust 端 SHALL NOT 调用 `apply_proxy_override` +- **AND** ArkWeb SHALL 沿用系统代理设置(与 Windows / webkitgtk 行为一致) + +#### Scenario: proxy_config is Http +- **WHEN** 开发者调用 `.with_proxy_config(ProxyConfig::Http(ProxyEndpoint { host, port }))` +- **THEN** Rust 端 SHALL 调用 `openharmony_ability::apply_proxy_override("http", host, port)` +- **AND** ArkTS 端 SHALL 构造 `new webview.ProxyConfig()` 并 `insertProxyRule(\`http://${host}:${port}\`)`(无 schemeFilter = MATCH_ALL_SCHEMES) +- **AND** SHALL 调用 `webview.ProxyController.applyProxyOverride(config, callback)` + +#### Scenario: proxy_config is Socks5 +- **WHEN** 开发者调用 `.with_proxy_config(ProxyConfig::Socks5(ProxyEndpoint { host, port }))` +- **THEN** Rust 端 SHALL 调用 `openharmony_ability::apply_proxy_override("socks", host, port)`(wry `ProxyConfig::Socks5` 对应 ArkWeb scheme `"socks"`) +- **AND** ArkTS 端 SHALL `insertProxyRule(\`socks://${host}:${port}\`)` +- **AND** SHALL 调用 `webview.ProxyController.applyProxyOverride(config, callback)` + +### Requirement: openharmony-ability SHALL expose apply_proxy_override / remove_proxy_override +`openharmony-ability` crate(唯一 ArkTS 桥接仓,见 CLAUDE.md 三铁律 #1)SHALL 暴露 Rust 公共函数: +- `pub fn apply_proxy_override(scheme: &str, host: &str, port: &str) -> Result<()>` +- `pub fn remove_proxy_override() -> Result<()>` + +`apply_proxy_override` 内部 SHALL 通过 `get_main_thread_env()` + `get_helper()` 获取 ArkTS helper 对象,调用名为 `applyProxyOverride` 的 ArkTS 方法(camelCase,见 ohos-constraints §2.1)。`remove_proxy_override` 同理调用 `removeProxyOverride`。 + +ArkTS 侧 SHALL 在 helper 对象上实现: +```ts +applyProxyOverride(scheme: string, host: string, port: string): void { + const config = new webview.ProxyConfig(); + config.insertProxyRule(`${scheme}://${host}:${port}`); + webview.ProxyController.applyProxyOverride(config, () => { + // callback on UI thread; no Rust round-trip (NAPI reentry per ohos-constraints §2.3) + }); +} +removeProxyOverride(): void { + webview.ProxyController.removeProxyOverride(() => {}); +} +``` + +#### Scenario: applyProxyOverride NAPI name camelCase +- **WHEN** Rust 通过 NAPI 调用 ArkTS +- **THEN** ArkTS 方法名 SHALL 为 `applyProxyOverride`(不是 `apply_proxy_override`) +- **AND** 若误用 snake_case,`typeof helper.apply_proxy_override` SHALL 为 `undefined` 且静默失败(见 ohos-constraints §2.1) + +#### Scenario: applyProxyOverride is fire-and-forget +- **WHEN** Rust 调用 `apply_proxy_override(...)` +- **THEN** Rust SHALL NOT 阻塞等待 ArkWeb callback(避免 Chrome_IOThread × ArkTS 主线程死锁,见 ohos-constraints §1.2) +- **AND** ArkWeb callback SHALL 仅做 log,不回 Rust(NAPI 重入限制,见 ohos-constraints §2.3) +- **AND** Rust SHALL 在调用后立即继续 webview 创建流程 + +### Requirement: Version guard SHALL skip on API < 15 +ArkWeb `ProxyController` / `ProxyConfig` / `ProxySchemeFilter` 自 API 15 起可用(`@ohos.web.webview.d.ts:9005/9056/9334`)。tauri api demo 默认 `compatibleSdkVersion = 12`(见 ohos-constraints §6.4)。`apply_proxy_override` SHALL 在 Rust 侧检查 `openharmony_ability::version::sdk_api_version() >= 15`,低版本 SHALL 静默跳过(不调 ArkTS,不报错,不打 warn——与既有平台"静默跳过"策略一致,见 ohos-constraints §6.4)。 + +#### Scenario: API >= 15 applies proxy +- **WHEN** `version::sdk_api_version() >= 15` 且 `proxy_config = Some(...)` +- **THEN** SHALL 调用 ArkTS `applyProxyOverride` +- **AND** ArkWeb SHALL 应用代理规则 + +#### Scenario: API < 15 silently skips +- **WHEN** `version::sdk_api_version() < 15` 且 `proxy_config = Some(...)` +- **THEN** SHALL NOT 调用 ArkTS +- **AND** SHALL NOT 打日志 +- **AND** SHALL NOT 返回错误 +- **AND** ArkWeb SHALL 沿用系统代理(开发者无法通过 wry 设置代理,文档化) + +### Requirement: proxy_config SHALL be applied before initial URL load +`InnerWebView::new_inner` SHALL 在 `WebViewBuilder::build()` 完成后、`webview.load_url(initial_url)` 之前调用 `apply_proxy_override`。该时序使 ArkWeb 有最大窗口应用代理规则。 + +#### Scenario: proxy applied before first navigation +- **WHEN** 开发者创建 webview 并设置 `proxy_config` + `url` +- **THEN** Rust SHALL 在 load 初始 URL 前调用 `apply_proxy_override` +- **AND** 首次页面加载 SHALL 尽量走代理(受 ArkWeb 异步 callback 时序限制) + +### Requirement: NAPI failure SHALL NOT block webview creation +若 `apply_proxy_override` 因 NAPI 错误失败(env 不可用、helper 未就绪等),Rust 端 SHALL 仅 `log::warn!` 记录错误并继续 webview 创建流程,不向上抛 `Error`。该行为与 Windows / webkitgtk 一致——代理配置失败不应阻塞 webview 创建。 + +#### Scenario: env not available +- **WHEN** `get_main_thread_env()` 返回 `None` +- **THEN** SHALL `log::warn!` 并返回 `Ok(())` +- **AND** webview 创建 SHALL 继续 + +#### Scenario: helper not ready +- **WHEN** helper 对象未初始化(`get_helper()` 返回 `None`) +- **THEN** SHALL `log::warn!` 并返回 `Ok(())` +- **AND** webview 创建 SHALL 继续 + +## KNOWN_LIMITATIONS Requirements + +### Requirement: ArkWeb ProxyController is app-wide (not per-webview) +ArkWeb `ProxyController.applyProxyOverride` 文档明确:"Sets ProxyConfig which will be used by **all Webs in the app**"。OHOS 不支持 per-webview 代理。wry `proxy_config` 是 per-`WebViewAttributes` 字段,但 OHOS 实现下多次设置 `proxy_config`(多个 webview 或同一 webview 重复设置)SHALL 走 last-write-wins——后调用的覆盖先调用的。 + +#### Scenario: multiple webviews with different proxy_config +- **WHEN** 开发者创建 webview A(`proxy_config=Http(h1,p1)`)后创建 webview B(`proxy_config=Socks5(h2,p2)`) +- **THEN** webview A 和 B 的流量 SHALL 都走 Socks5 代理 `h2:p2`(last-write-wins) +- **AND** 文档 SHALL 引导开发者避免多 webview 不同代理的场景 + +#### Scenario: applyProxyOverride overrides system proxy +- **WHEN** 开发者设置 `proxy_config` 且 ArkWeb 应用成功 +- **THEN** ArkWeb SHALL 忽略系统全局代理设置("calling applyProxyOverride will cause any existing system wide setting to be ignored") +- **AND** 文档 SHALL 标注此副作用 + +### Requirement: First page load may bypass proxy (async race) +ArkWeb `applyProxyOverride` 异步:callback 在 UI 线程触发,"Requests are not guaranteed to use the new proxy immediately; wait for the listener before loading a page"。wry 采用 fire-and-forget(见 fire-and-forget Requirement),不阻塞等待 callback。因此首次页面加载可能未走代理。此为已知限制,SHALL 在文档中标注;开发者如需严格同步,建议在 `setup` 钩子中提前设置 `proxy_config` 或显式延后 `load_url`。 + +#### Scenario: first load races with proxy apply +- **WHEN** 开发者创建 webview + `proxy_config` + `url`,且 ArkWeb callback 未在 load 前返回 +- **THEN** 首次页面加载 SHALL 可能直连(不走代理) +- **AND** 后续导航 SHALL 走代理 +- **AND** 文档 SHALL 建议开发者在 setup 阶段尽早配置代理 + +## Test Scenarios + +### auto (Rust 单元测试,纯函数) +- `proxy_config` 字段从 `WebViewAttributes` 解构不被丢弃:UT 验证 `InnerWebView::new_inner` 路径在 `proxy_config=Some(Http(...))` 时调用 `apply_proxy_override`(mock helper 计数) +- 版本守卫:`sdk_api_version() < 15` 时 `apply_proxy_override` 立即返回 `Ok(())` 且不触达 NAPI + +### side-effect (设备端可验证) +- HTTP 代理:本地起 `mitmproxy` / `charles` 监听 `127.0.0.1:8080`,`with_proxy_config(ProxyConfig::Http(...))`,加载 `https://example.com`,代理端能抓到请求 +- SOCKS5 代理:本地起 SOCKS5 代理,`with_proxy_config(ProxyConfig::Socks5(...))`,加载页面,代理端能抓到请求 +- 移除代理:调用 `remove_proxy_override` 后,新加载页面不再走指定代理 + +### manual (需人工确认) +- 低版本设备(API 12/14):设置 `proxy_config` 后页面仍能正常加载(直连或走系统代理),不崩溃 +- 多 webview:两个 webview 设置不同代理,确认 last-write-wins 行为符合预期 + +## API Mapping + +| wry Rust API | OHOS ArkWeb API | 备注 | +|--------------|-----------------|------| +| `ProxyConfig::Http(ProxyEndpoint{host,port})` | `webview.ProxyConfig` + `insertProxyRule(\`http://${host}:${port}\`)` + `ProxyController.applyProxyOverride` | schemeFilter 省略 = MATCH_ALL_SCHEMES | +| `ProxyConfig::Socks5(ProxyEndpoint{host,port})` | `webview.ProxyConfig` + `insertProxyRule(\`socks://${host}:${port}\`)` + `ProxyController.applyProxyOverride` | wry Socks5 映射为 ArkWeb `socks://`(ArkWeb scheme 仅接受 http/https/socks) | +| `proxy_config = None` | 不调用 `applyProxyOverride` | 沿用系统代理 | +| — | `webview.ProxyController.removeProxyOverride(callback)` | 由 `openharmony_ability::remove_proxy_override` 暴露 | +| Windows: `--proxy-server=http://host:port` 参数 | OHOS: `ProxyController.applyProxyOverride` | 平台差异:Windows env-wide,OHOS app-wide | +| webkitgtk: `NetworkProxySettings` + `set_network_proxy_settings` | OHOS: `ProxyController.applyProxyOverride` | 平台差异:webkitgtk context-wide,OHOS app-wide | + +## Version Compatibility + +| API | since | 守卫 | +|-----|-------|------| +| `webview.ProxyController.applyProxyOverride` | 15 | `version::sdk_api_version() >= 15` | +| `webview.ProxyController.removeProxyOverride` | 15 | 同上 | +| `webview.ProxyConfig.insertProxyRule` | 15 | 同上 | +| `webview.ProxySchemeFilter` enum | 15 | 同上 | +| `atomicservice` since 19 变体 | 19 | 不依赖(用 since 15 路径即可) | + +## Platform Differences (显式标注) + +| 项 | Windows | webkitgtk | OHOS | +|----|---------|-----------|------| +| 作用域 | env-wide(CoreWebView2Environment) | context-wide(WebContext) | app-wide(ProxyController) | +| 设置时机 | env 创建时通过 `additional_browser_arguments` | web_context 创建后 `set_network_proxy_settings` | webview 创建后 `applyProxyOverride` | +| 同步性 | 同步(参数注入) | 同步 | 异步(callback on UI thread) | +| 系统代理覆盖 | 是(`--proxy-server` 覆盖) | 是(Custom mode 覆盖) | 是(applyProxyOverride 使系统设置被忽略) | +| 多 webview 隔离 | env 共享则共享代理 | context 共享则共享代理 | 始终 app-wide,无隔离 |