From 38b07e2cae516d12a0cdd797fbeadf65b165383f Mon Sep 17 00:00:00 2001 From: ljy9810 Date: Thu, 9 Jul 2026 11:57:13 +0800 Subject: [PATCH] feat(ohos): deep-link integration and isDecorated badge fix - tauri-plugin: add update_ohos_module_json and deep-link api demo integration - deep-link: api demo config, ACL permissions, manual test cases and buttons (onOpenUrl, getCurrent, external launch), manual test docs - fix appfreeze: move plugin initialize outside lock, use try_lock for on_event - fix(ohos-build): entry module path and plugin config env vars for cargo build - fix isDecorated badge in test popup windows: use Tauri v2 invoke path, grant core:window:allow-is-decorated to popup windows, skip badge for autotest-created windows --- .../ohos-build/scripts/sign-and-install.sh | 8 +- crates/tauri-plugin/Cargo.toml | 2 + crates/tauri-plugin/src/build/mobile.rs | 70 +++++++++++- crates/tauri/src/app.rs | 16 ++- crates/tauri/src/plugin.rs | 12 +- doc/manual_tests.md | 11 +- examples/api/package.json | 1 + examples/api/src-tauri/Cargo.toml | 1 + .../api/src-tauri/capabilities/run-app.json | 4 + .../test-windows-is-decorated.json | 9 ++ examples/api/src-tauri/src/cmd.rs | 59 ++++++++-- examples/api/src-tauri/src/lib.rs | 8 +- examples/api/src-tauri/tauri.conf.json | 5 + examples/api/src/lib/tests/plugins.ts | 71 ++++++++++++ examples/api/src/views/TestRunner.svelte | 39 +++++++ .../2026-07-09-p1-deep-link/.openspec.yaml | 2 + .../archive/2026-07-09-p1-deep-link/README.md | 3 + .../archive/2026-07-09-p1-deep-link/design.md | 103 ++++++++++++++++++ .../2026-07-09-p1-deep-link/proposal.md | 32 ++++++ .../specs/ohos-deep-link-event/spec.md | 99 +++++++++++++++++ .../archive/2026-07-09-p1-deep-link/tasks.md | 42 +++++++ .../2026-07-09-p2-deep-link/.openspec.yaml | 2 + .../archive/2026-07-09-p2-deep-link/README.md | 3 + .../archive/2026-07-09-p2-deep-link/design.md | 85 +++++++++++++++ .../2026-07-09-p2-deep-link/proposal.md | 32 ++++++ .../spec.md | 65 +++++++++++ .../archive/2026-07-09-p2-deep-link/tasks.md | 18 +++ .../2026-07-09-p3-deep-link/.openspec.yaml | 2 + .../archive/2026-07-09-p3-deep-link/README.md | 3 + .../archive/2026-07-09-p3-deep-link/design.md | 76 +++++++++++++ .../2026-07-09-p3-deep-link/proposal.md | 27 +++++ .../specs/ohos-deep-link-testing/spec.md | 73 +++++++++++++ .../archive/2026-07-09-p3-deep-link/tasks.md | 34 ++++++ openspec/changes/deep-link-plan.md | 48 ++++++++ openspec/specs/ohos-deep-link-event/spec.md | 98 +++++++++++++++++ .../spec.md | 64 +++++++++++ openspec/specs/ohos-deep-link-testing/spec.md | 72 ++++++++++++ 37 files changed, 1262 insertions(+), 37 deletions(-) create mode 100644 examples/api/src-tauri/capabilities/test-windows-is-decorated.json create mode 100644 openspec/changes/archive/2026-07-09-p1-deep-link/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-09-p1-deep-link/README.md create mode 100644 openspec/changes/archive/2026-07-09-p1-deep-link/design.md create mode 100644 openspec/changes/archive/2026-07-09-p1-deep-link/proposal.md create mode 100644 openspec/changes/archive/2026-07-09-p1-deep-link/specs/ohos-deep-link-event/spec.md create mode 100644 openspec/changes/archive/2026-07-09-p1-deep-link/tasks.md create mode 100644 openspec/changes/archive/2026-07-09-p2-deep-link/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-09-p2-deep-link/README.md create mode 100644 openspec/changes/archive/2026-07-09-p2-deep-link/design.md create mode 100644 openspec/changes/archive/2026-07-09-p2-deep-link/proposal.md create mode 100644 openspec/changes/archive/2026-07-09-p2-deep-link/specs/ohos-deep-link-scheme-registration/spec.md create mode 100644 openspec/changes/archive/2026-07-09-p2-deep-link/tasks.md create mode 100644 openspec/changes/archive/2026-07-09-p3-deep-link/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-09-p3-deep-link/README.md create mode 100644 openspec/changes/archive/2026-07-09-p3-deep-link/design.md create mode 100644 openspec/changes/archive/2026-07-09-p3-deep-link/proposal.md create mode 100644 openspec/changes/archive/2026-07-09-p3-deep-link/specs/ohos-deep-link-testing/spec.md create mode 100644 openspec/changes/archive/2026-07-09-p3-deep-link/tasks.md create mode 100644 openspec/changes/deep-link-plan.md create mode 100644 openspec/specs/ohos-deep-link-event/spec.md create mode 100644 openspec/specs/ohos-deep-link-scheme-registration/spec.md create mode 100644 openspec/specs/ohos-deep-link-testing/spec.md diff --git a/.claude/skills/ohos-build/scripts/sign-and-install.sh b/.claude/skills/ohos-build/scripts/sign-and-install.sh index 3e73f7a20cb4..c24587f8f79f 100644 --- a/.claude/skills/ohos-build/scripts/sign-and-install.sh +++ b/.claude/skills/ohos-build/scripts/sign-and-install.sh @@ -9,12 +9,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/env.sh" OHOS_PROJECT="$PROJECT_ROOT/examples/api/src-tauri/gen/ohos" -if [ "$OHOS_DEVICE_TYPE" = "desktop" ]; then - ENTRY_DIR="entry_desktop" -else - ENTRY_DIR="entry_mobile" -fi -SIGNED_HAP="$OHOS_PROJECT/$ENTRY_DIR/build/default/outputs/default/$ENTRY_DIR-default-signed.hap" +ENTRY_MODULE="entry_${OHOS_DEVICE_TYPE:-desktop}" +SIGNED_HAP="$OHOS_PROJECT/${ENTRY_MODULE}/build/default/outputs/default/${ENTRY_MODULE}-default-signed.hap" # ─── 检查已签名 HAP ─── if [ ! -f "$SIGNED_HAP" ]; then diff --git a/crates/tauri-plugin/Cargo.toml b/crates/tauri-plugin/Cargo.toml index 022492dceed0..e18e169924c8 100644 --- a/crates/tauri-plugin/Cargo.toml +++ b/crates/tauri-plugin/Cargo.toml @@ -21,6 +21,7 @@ build = [ "dep:glob", "dep:plist", "dep:walkdir", + "dep:json5", ] runtime = [] @@ -35,6 +36,7 @@ glob = { version = "0.3", optional = true } # Our code requires at least 0.8.21 so don't simplify this to 0.8 schemars = { version = "0.8.21", features = ["preserve_order"] } walkdir = { version = "2", optional = true } +json5 = { version = "0.4", optional = true } [target."cfg(target_os = \"macos\")".dependencies] plist = { version = "1", optional = true } diff --git a/crates/tauri-plugin/src/build/mobile.rs b/crates/tauri-plugin/src/build/mobile.rs index 349e13232ef9..d764b317d3ce 100644 --- a/crates/tauri-plugin/src/build/mobile.rs +++ b/crates/tauri-plugin/src/build/mobile.rs @@ -52,6 +52,73 @@ pub fn update_android_manifest(block_identifier: &str, parent: &str, insert: Str tauri_utils::build::update_android_manifest(block_identifier, parent, insert) } +/// Updates the OHOS module.json5 by appending deep-link skill objects to abilities[0].skills. +/// +/// Reads `TAURI_OHOS_PROJECT_PATH` to locate the OHOS project directory (set by tauri-cli). +/// Self-gating is via `CARGO_CFG_TARGET_ENV == "ohos"` (cross-compilation safe). +/// Locates `entry_{OHOS_DEVICE_TYPE}/src/main/module.json5`. Uses json5 parse/serialize. +/// Idempotent: removes existing deep-link skills (by `ohos.want.action.viewData` signature) +/// before re-injecting, so repeated builds don't accumulate. Home entry skill is preserved. +/// +/// Limitations: +/// - Only `abilities[0]` is injected (single-ability Tauri OHOS apps; multi-ability projects +/// would need to target the entry ability by name). +/// - Output is serialized as strict JSON (`serde_json::to_string_pretty`); JSON5-only features +/// in the template (comments, trailing commas, unquoted keys) are lost on round-trip. +pub fn update_ohos_module_json(skills: serde_json::Value) -> Result<()> { + // Gate 1: only run on OHOS builds. CARGO_CFG_TARGET_ENV is set by Cargo for build scripts, + // reflecting the cross-compilation target (not the host). + if std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default() != "ohos" { + return Ok(()); + } + // Gate 2: TAURI_OHOS_PROJECT_PATH is set by tauri-cli (mod.rs:191) for OHOS builds. + // If unset (e.g. build-ohos.sh without tauri-cli), no-op gracefully. + let Some(project_path) = std::env::var_os("TAURI_OHOS_PROJECT_PATH") else { + return Ok(()); + }; + println!("cargo:rerun-if-env-changed=TAURI_OHOS_PROJECT_PATH"); + let device_type = std::env::var("OHOS_DEVICE_TYPE").unwrap_or_else(|_| "mobile".to_string()); + let module_json = PathBuf::from(project_path) + .join(format!("entry_{device_type}")) + .join("src/main/module.json5"); + if !module_json.exists() { + return Ok(()); + } + let content = std::fs::read_to_string(&module_json)?; + let mut json: serde_json::Value = json5::from_str(&content)?; + if let Some(abilities) = json + .get_mut("module") + .and_then(|m| m.get_mut("abilities")) + .and_then(|a| a.as_array_mut()) + { + if let Some(first_ability) = abilities.get_mut(0) { + // ensure the ability has a `skills` array; initialize an empty one if missing + // so deep-link skills are always injected (avoid silent skip on custom templates) + if let Some(obj) = first_ability.as_object_mut() { + let skills_arr = obj + .entry("skills") + .or_insert_with(|| serde_json::Value::Array(Vec::new())); + if let Some(skills_arr) = skills_arr.as_array_mut() { + // idempotent: remove existing deep-link skills (actions contains ohos.want.action.viewData) + skills_arr.retain(|s| { + !s.get("actions") + .and_then(|a| a.as_array()) + .map(|a| a.iter().any(|v| v == "ohos.want.action.viewData")) + .unwrap_or(false) + }); + // append new skills + if let Some(new_skills) = skills.as_array() { + skills_arr.extend(new_skills.iter().cloned()); + } + } + } + } + } + let serialized = serde_json::to_string_pretty(&json)?; + std::fs::write(&module_json, serialized)?; + Ok(()) +} + pub(crate) fn setup( android_path: Option, #[allow(unused_variables)] ios_path: Option, @@ -61,8 +128,7 @@ pub(crate) fn setup( let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); let mobile = if target_env == "ohos" { println!("cargo:rerun-if-env-changed=OHOS_DEVICE_TYPE"); - let device_type = - std::env::var("OHOS_DEVICE_TYPE").unwrap_or_else(|_| "mobile".to_string()); + let device_type = std::env::var("OHOS_DEVICE_TYPE").unwrap_or_else(|_| "mobile".to_string()); device_type != "desktop" } else { target_os == "ios" || target_os == "android" diff --git a/crates/tauri/src/app.rs b/crates/tauri/src/app.rs index 95ccac574fd4..a7f12942c91f 100644 --- a/crates/tauri/src/app.rs +++ b/crates/tauri/src/app.rs @@ -543,8 +543,9 @@ impl AppHandle { /// but accepts a boxed trait object instead of a generic type. #[cfg_attr(feature = "tracing", tracing::instrument(name = "app::plugin::register", skip(plugin), fields(name = plugin.name())))] pub fn plugin_boxed(&self, mut plugin: Box>) -> crate::Result<()> { + // initialize outside lock to avoid blocking on_event_loop_event (appfreeze fix) + crate::plugin::initialize(&mut plugin, self, &self.config().plugins)?; let mut store = self.manager().plugins.lock().unwrap(); - store.initialize(&mut plugin, self, &self.config().plugins)?; store.register(plugin); Ok(()) @@ -2686,11 +2687,14 @@ fn on_event_loop_event( _ => unimplemented!(), }; - manager - .plugins - .lock() - .expect("poisoned plugin store") - .on_event(app_handle, &event); + // try_lock to avoid blocking main thread when plugins lock is held by register (appfreeze fix) + if let Ok(mut store) = manager.plugins.try_lock() { + store.on_event(app_handle, &event); + } else { + // lock contended (e.g. during plugin register); skip on_event to avoid blocking the main + // thread. Log so a dropped event (e.g. a deep-link RunEvent::Opened) is traceable. + log::warn!("[tauri] plugin store lock busy, skipping on_event (appfreeze try_lock)"); + } event } diff --git a/crates/tauri/src/plugin.rs b/crates/tauri/src/plugin.rs index f09f430e5987..b6b4d434f4a2 100644 --- a/crates/tauri/src/plugin.rs +++ b/crates/tauri/src/plugin.rs @@ -892,16 +892,6 @@ impl PluginStore { len != self.store.len() } - /// Initializes the given plugin. - pub(crate) fn initialize( - &self, - plugin: &mut Box>, - app: &AppHandle, - config: &PluginConfig, - ) -> crate::Result<()> { - initialize(plugin, app, config) - } - /// Initializes all plugins in the store. pub(crate) fn initialize_all( &mut self, @@ -996,7 +986,7 @@ impl PluginStore { } #[cfg_attr(feature = "tracing", tracing::instrument(name = "plugin::hooks::initialize", skip(plugin, app), fields(name = plugin.name())))] -fn initialize( +pub(crate) fn initialize( plugin: &mut Box>, app: &AppHandle, config: &PluginConfig, diff --git a/doc/manual_tests.md b/doc/manual_tests.md index 3738c110dc4d..d389d136e7bb 100644 --- a/doc/manual_tests.md +++ b/doc/manual_tests.md @@ -397,6 +397,14 @@ --- +## 十九、Deep-Link 手动用例 + +| 一级场景 | 二级场景 | 三级场景 | 用例名称 | 用例级别 | 预置条件 | 测试步骤 | 预期结果 | 备注 | +|---------|---------|---------|---------|---------|---------|---------|---------|------| +| core | deep-link | onOpenUrl | onOpenUrl 事件触发 — 运行中收到外部链接 | **T0** | app 已运行 | 1. 在 TestRunner UI manual 区点击 "onOpenUrl (trigger with hdc)" 按钮注册监听 2. 执行 `hdc shell "aa start -U taurideeplink://manualtest"` | UI 消息区显示 `[deep-link] onOpenUrl received: ["taurideeplink://manualtest"]` | RunEvent::Opened urls 非空时触发 | +| 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 会被当搜索词 | + ## 二十、用例统计 | 模块 | T0 | T1 | 合计 | @@ -425,5 +433,6 @@ | Global Shortcut(全局快捷键) | 2 | 0 | **2** | | 窗口聚焦与热键缩放 | 1 | 1 | **2** | | Vibrancy(窗口模糊) | 3 | 2 | **5** | -| **合计** | **59** | **57** | **116** | +| Deep-Link(深度链接) | 3 | 0 | **3** | +| **合计** | **62** | **57** | **119** | diff --git a/examples/api/package.json b/examples/api/package.json index 39176eb6b4a9..d46ec03cf92f 100644 --- a/examples/api/package.json +++ b/examples/api/package.json @@ -13,6 +13,7 @@ "@tauri-apps/api": "../../packages/api/dist", "@tauri-apps/plugin-autostart": "file:../../../plugins-workspace/plugins/autostart", "@tauri-apps/plugin-clipboard-manager": "file:../../../plugins-workspace/plugins/clipboard-manager", + "@tauri-apps/plugin-deep-link": "file:../../../plugins-workspace/plugins/deep-link", "@tauri-apps/plugin-dialog": "file:../../../plugins-workspace/plugins/dialog", "@tauri-apps/plugin-fs": "file:../../../plugins-workspace/plugins/fs", "@tauri-apps/plugin-global-shortcut": "file:../../../plugins-workspace/plugins/global-shortcut", diff --git a/examples/api/src-tauri/Cargo.toml b/examples/api/src-tauri/Cargo.toml index 670733afab9e..4cc7e7c19272 100644 --- a/examples/api/src-tauri/Cargo.toml +++ b/examples/api/src-tauri/Cargo.toml @@ -48,6 +48,7 @@ hilog = "*" tauri-plugin-dialog = { path = "../../../../plugins-workspace/plugins/dialog" } tauri-plugin-single-instance = { path = "../../../../plugins-workspace/plugins/single-instance" } tauri-plugin-global-shortcut = { path = "../../../../plugins-workspace/plugins/global-shortcut" } +tauri-plugin-deep-link = { path = "../../../../plugins-workspace/plugins/deep-link" } [dependencies.tauri] path = "../../../crates/tauri" diff --git a/examples/api/src-tauri/capabilities/run-app.json b/examples/api/src-tauri/capabilities/run-app.json index 650f365ddc8c..5262178b5b11 100644 --- a/examples/api/src-tauri/capabilities/run-app.json +++ b/examples/api/src-tauri/capabilities/run-app.json @@ -159,6 +159,10 @@ "dialog:allow-save", "dialog:allow-message", "notification:default", + "deep-link:default", + "deep-link:allow-register", + "deep-link:allow-unregister", + "deep-link:allow-is-registered", "sentry:default", "allow-sentry-test-breadcrumb", "global-shortcut:allow-register", diff --git a/examples/api/src-tauri/capabilities/test-windows-is-decorated.json b/examples/api/src-tauri/capabilities/test-windows-is-decorated.json new file mode 100644 index 000000000000..59b5c1f28fc4 --- /dev/null +++ b/examples/api/src-tauri/capabilities/test-windows-is-decorated.json @@ -0,0 +1,9 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "test-windows-is-decorated", + "description": "Allow the read-only is_decorated query from any test popup window (e.g. borderless/transparent child windows) so the STATUS_SCRIPT badge can display the child window's decoration state. These windows are created with arbitrary labels that do not all match the run-app capability's window targeting.", + "windows": ["*"], + "permissions": [ + "core:window:allow-is-decorated" + ] +} diff --git a/examples/api/src-tauri/src/cmd.rs b/examples/api/src-tauri/src/cmd.rs index 88f1a89a9e18..8620b1f8359a 100644 --- a/examples/api/src-tauri/src/cmd.rs +++ b/examples/api/src-tauri/src/cmd.rs @@ -672,14 +672,35 @@ const STATUS_SCRIPT: &str = r##" statusDiv.style.cssText = 'position:fixed;bottom:10px;left:10px;background:rgba(0,0,0,0.8);color:#0f0;padding:8px 14px;border-radius:8px;font-size:13px;font-family:monospace;z-index:9999;'; statusDiv.textContent = 'isDecorated: checking...'; document.body.appendChild(statusDiv); + // Tauri v2 exposes the public invoke at `window.__TAURI__.core.invoke` (not the + // v1 top-level `window.__TAURI__.invoke`). The low-level bridge + // `window.__TAURI_INTERNALS__.invoke` is always present and is what the bundled + // @tauri-apps/api uses (proven to work on OHOS). Resolve whichever is available, + // and degrade gracefully instead of leaving the badge stuck on "checking...". + function resolveInvoke() { + var i = window.__TAURI_INTERNALS__; + if (i && typeof i.invoke === 'function') return i.invoke.bind(i); + var t = window.__TAURI__; + if (t && t.core && typeof t.core.invoke === 'function') return t.core.invoke.bind(t.core); + return null; + } + function setStatus(text, color) { + var el = document.getElementById('state-status'); + if (el) { el.textContent = text; el.style.color = color; } + } setInterval(function() { - window.__TAURI__.invoke('plugin:window|is_decorated').then(function(v) { - var el = document.getElementById('state-status'); - if (el) { - el.textContent = 'isDecorated: ' + v; - el.style.color = v ? '#0f0' : '#f80'; - } - }).catch(function() {}); + var inv = resolveInvoke(); + if (!inv) { setStatus('isDecorated: (n/a)', '#888'); return; } + try { + // No label arg: get_window() resolves to the current (this child) window. + inv('plugin:window|is_decorated').then(function(v) { + setStatus('isDecorated: ' + v, v ? '#0f0' : '#f80'); + }).catch(function() { + setStatus('isDecorated: (err)', '#f00'); + }); + } catch (e) { + setStatus('isDecorated: (err)', '#f00'); + } }, 500); "##; @@ -694,7 +715,13 @@ pub fn create_transparent_window( log::info!("Creating transparent window: {} (effect={:?}, radius={:?})", window_id, effect, radius); let close_link = CLOSE_LINK_HTML; - let status_script = STATUS_SCRIPT; + // Autotest-created windows (label prefix "test-") are created and closed + // programmatically; on OHOS programmatic close doesn't destroy the Float window + // (tao OHOS Window::close is unimplemented), so a lingering closed popup would + // poll is_decorated on an unregistered webview → "failed to acquire webview + // reference". Skip the live isDecorated badge for autotest windows to avoid that + // noisy error; manual test windows keep the badge (they stay open and work). + let status_script = if window_id.starts_with("test-") { "" } else { STATUS_SCRIPT }; let init_script = format!( r#" document.addEventListener('DOMContentLoaded', function() {{ @@ -762,7 +789,13 @@ pub fn create_borderless_window( log::info!("Creating borderless window: {}", window_id); let close_link = CLOSE_LINK_HTML; - let status_script = STATUS_SCRIPT; + // Autotest-created windows (label prefix "test-") are created and closed + // programmatically; on OHOS programmatic close doesn't destroy the Float window + // (tao OHOS Window::close is unimplemented), so a lingering closed popup would + // poll is_decorated on an unregistered webview → "failed to acquire webview + // reference". Skip the live isDecorated badge for autotest windows to avoid that + // noisy error; manual test windows keep the badge (they stay open and work). + let status_script = if window_id.starts_with("test-") { "" } else { STATUS_SCRIPT }; let init_script = format!( r#" document.addEventListener('DOMContentLoaded', function() {{ @@ -806,7 +839,13 @@ pub fn create_transparent_borderless_window( log::info!("Creating transparent borderless window: {}", window_id); let close_link = CLOSE_LINK_HTML; - let status_script = STATUS_SCRIPT; + // Autotest-created windows (label prefix "test-") are created and closed + // programmatically; on OHOS programmatic close doesn't destroy the Float window + // (tao OHOS Window::close is unimplemented), so a lingering closed popup would + // poll is_decorated on an unregistered webview → "failed to acquire webview + // reference". Skip the live isDecorated badge for autotest windows to avoid that + // noisy error; manual test windows keep the badge (they stay open and work). + let status_script = if window_id.starts_with("test-") { "" } else { STATUS_SCRIPT }; let init_script = format!( r#" document.addEventListener('DOMContentLoaded', function() {{ diff --git a/examples/api/src-tauri/src/lib.rs b/examples/api/src-tauri/src/lib.rs index 14a794cbd4a8..267d7c8d1713 100644 --- a/examples/api/src-tauri/src/lib.rs +++ b/examples/api/src-tauri/src/lib.rs @@ -101,7 +101,8 @@ pub fn run_app) + Send + 'static>( .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, None, - )); + )) + .plugin(tauri_plugin_deep_link::init()); if let Some(ref client) = sentry_client { builder = builder.plugin(tauri_plugin_sentry::init(client)); } @@ -122,6 +123,11 @@ pub fn run_app) + Send + 'static>( })); } + #[cfg(target_env = "ohos")] + { + builder = builder.plugin(tauri_plugin_deep_link::init()); + } + #[cfg(target_env = "ohos")] { builder = builder diff --git a/examples/api/src-tauri/tauri.conf.json b/examples/api/src-tauri/tauri.conf.json index 9b9320f19cb4..dc679c9d6d47 100644 --- a/examples/api/src-tauri/tauri.conf.json +++ b/examples/api/src-tauri/tauri.conf.json @@ -38,6 +38,11 @@ } }, "plugins": { + "deep-link": { + "mobile": [ + { "scheme": ["taurideeplink"] } + ] + }, "cli": { "description": "Tauri API example", "args": [ diff --git a/examples/api/src/lib/tests/plugins.ts b/examples/api/src/lib/tests/plugins.ts index e45f13863f06..78954ec52ad9 100644 --- a/examples/api/src/lib/tests/plugins.ts +++ b/examples/api/src/lib/tests/plugins.ts @@ -924,4 +924,75 @@ export const pluginTests: TestCase[] = [ } }, }, + // @tauri-apps/plugin-deep-link + { + name: '@tauri-apps/plugin-deep-link.getCurrent', + category: 'auto', + async fn() { + const { getCurrent } = await import('@tauri-apps/plugin-deep-link'); + const result = await getCurrent(); + console.log('[deep-link auto] getCurrent result:', JSON.stringify(result)); + assert(result === null || Array.isArray(result), `getCurrent should return null or array, got ${result}`); + }, + }, + { + name: '@tauri-apps/plugin-deep-link.isRegistered', + category: 'auto', + async fn() { + const { isRegistered } = await import('@tauri-apps/plugin-deep-link'); + const result = await isRegistered('myapp'); + assert(result === false, `isRegistered should return false on OHOS (no-op), got ${result}`); + }, + }, + { + name: '@tauri-apps/plugin-deep-link.register+unregister', + category: 'auto', + async fn() { + const { register, unregister } = await import('@tauri-apps/plugin-deep-link'); + // no-op on OHOS, should not throw + await register('myapp'); + await unregister('myapp'); + }, + }, + { + name: '@tauri-apps/plugin-deep-link.onOpenUrl register', + category: 'auto', + async fn() { + const { onOpenUrl } = await import('@tauri-apps/plugin-deep-link'); + const unlisten = await onOpenUrl(() => {}); + assert(typeof unlisten === 'function', `onOpenUrl should return UnlistenFn, got ${typeof unlisten}`); + unlisten(); + }, + }, + { + name: '@tauri-apps/plugin-deep-link.onOpenUrl trigger (manual)', + category: 'manual', + async fn() { + const { onOpenUrl } = await import('@tauri-apps/plugin-deep-link'); + const unlisten = await onOpenUrl((urls) => { + console.log('[deep-link manual] onOpenUrl received:', urls); + }); + console.log('[deep-link manual] Run: hdc shell aa start -a ohos.want.action.viewData -d taurideeplink://path'); + console.log('[deep-link manual] Expect onOpenUrl callback with ["taurideeplink://path"]'); + unlisten(); + }, + }, + { + name: '@tauri-apps/plugin-deep-link.getCurrent cold-start (manual)', + category: 'manual', + async fn() { + const { getCurrent } = await import('@tauri-apps/plugin-deep-link'); + const result = await getCurrent(); + console.log('[deep-link manual] getCurrent result:', JSON.stringify(result)); + console.log('[deep-link manual] Cold-start app via taurideeplink://path, expect getCurrent returns ["taurideeplink://path"]'); + }, + }, + { + name: '@tauri-apps/plugin-deep-link external launch (manual)', + category: 'manual', + async fn() { + console.log('[deep-link manual] Click taurideeplink://path link from browser/other app'); + console.log('[deep-link manual] Expect app brought to foreground + onOpenUrl fired'); + }, + }, ]; diff --git a/examples/api/src/views/TestRunner.svelte b/examples/api/src/views/TestRunner.svelte index 7dc03ca2054b..5d67bc6c442e 100644 --- a/examples/api/src/views/TestRunner.svelte +++ b/examples/api/src/views/TestRunner.svelte @@ -1690,6 +1690,35 @@ Mutex released, no cascade deadlock: ${ok ? 'PASS ✅' : 'FAIL ❌'}`; }); } + // Deep-Link manual tests + let deepLinkUnlisten = null; + let deepLinkListening = $state(false); + + async function manualDeepLinkOnOpenUrl() { + if (deepLinkListening) { + deepLinkUnlisten?.(); + deepLinkListening = false; + onMessage('[deep-link] Stopped listening for onOpenUrl events'); + return; + } + const { onOpenUrl } = await import('@tauri-apps/plugin-deep-link'); + deepLinkUnlisten = await onOpenUrl((urls) => { + onMessage(`[deep-link] onOpenUrl received: ${JSON.stringify(urls)}`); + }); + deepLinkListening = true; + onMessage('[deep-link] Listening for onOpenUrl. Trigger: hdc shell "aa start -U taurideeplink://test"'); + } + + async function manualDeepLinkGetCurrent() { + const { getCurrent } = await import('@tauri-apps/plugin-deep-link'); + const result = await getCurrent(); + onMessage(`[deep-link] getCurrent → ${JSON.stringify(result)}`); + } + + function manualDeepLinkExternalLaunch() { + onMessage('[deep-link] Click taurideeplink://path link from browser/other app. App should come to foreground.'); + } + async function toggleMouseTracking() { if (mouseTracking) { mouseUnlisteners.forEach((fn) => fn()); @@ -1809,6 +1838,16 @@ Mutex released, no cascade deadlock: ${ok ? 'PASS ✅' : 'FAIL ❌'}`; +
+
Deep-Link
+
+ + + +
+
Mouse Events (OHOS desktop / 2in1)
diff --git a/openspec/changes/archive/2026-07-09-p1-deep-link/.openspec.yaml b/openspec/changes/archive/2026-07-09-p1-deep-link/.openspec.yaml new file mode 100644 index 000000000000..43e65ca6e667 --- /dev/null +++ b/openspec/changes/archive/2026-07-09-p1-deep-link/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/archive/2026-07-09-p1-deep-link/README.md b/openspec/changes/archive/2026-07-09-p1-deep-link/README.md new file mode 100644 index 000000000000..ae01cc64178d --- /dev/null +++ b/openspec/changes/archive/2026-07-09-p1-deep-link/README.md @@ -0,0 +1,3 @@ +# p1-deep-link + +Phase 1: deep-link OHOS 编译打通 + 运行中事件接入(RunEvent::Opened) diff --git a/openspec/changes/archive/2026-07-09-p1-deep-link/design.md b/openspec/changes/archive/2026-07-09-p1-deep-link/design.md new file mode 100644 index 000000000000..34c93c494a43 --- /dev/null +++ b/openspec/changes/archive/2026-07-09-p1-deep-link/design.md @@ -0,0 +1,103 @@ +## Context + +`tauri-plugin-deep-link` 负责 URI scheme 唤起处理。当前实现(`plugins-workspace/plugins/deep-link/src/lib.rs`)按平台分三路:Android(`register_android_plugin` + Channel 回调)、iOS/macOS(`RunEvent::Opened` 消费)、Desktop(CLI 参数 + 注册表/xdg)。 + +OHOS 适配的现状是**零实现且无法编译**,根因有三: +1. `init_deep_link`(`lib.rs:19-85`)仅 android/ios/desktop 三分支,OHOS(`target_os="linux"` + `cfg(desktop)=false`)无匹配分支 → 函数无返回值。 +2. `register`/`unregister`/`is_registered` 的 `#[cfg(target_os="linux")]` 误命中 OHOS(`target_os="linux"`)→ 错误调用 `xdg-mime`。 +3. `Cargo.toml:45` 误把 `rust-ini` 引入 OHOS。 + +但同时,OHOS 运行时**已端到端产生 `RunEvent::Opened`**(`NativeAbility.onNewWant` → `lifecycle Event::NewWant{uri}` → `tao Event::Opened{urls}` → `tauri-runtime-wry` → `RunEvent::Opened`),且 cfg 已含 `target_env="ohos"`。`single-instance` 插件(`ohos-single-instance` spec)已验证此路径。deep-link 的 `on_event` 仅因 `#[cfg(any(macos, ios))]` 排除而丢弃了该事件。 + +**约束**(三条铁律):OHOS 代码用 `cfg(target_env="ohos")` 隔离;Linux 依赖加 `not(target_env="ohos")`;不影响其他平台。deep-link 是 plugins-workspace 插件仓,事件驱动型,无需 ArkTS Plugin 类。但首启动 `get_current` 需读取冷启动 `onCreate` 的 `want.uri`,该能力 openharmony-ability 当前缺失(`onCreate` 未提取 uri),需在 openharmony-ability 补 `take_initial_want_uri` getter(复刻 `take_want_parameters` 模式),涉及 openharmony-ability 的 ArkTS(NativeAbility.ets/type.ets)+ Rust(app.rs/lifecycle.rs)改动——这是 getter 通道,非插件类。 + +## Goals / Non-Goals + +**Goals:** +- deep-link crate 在 OHOS target `cargo check` 通过 +- app 运行中收到 `onNewWant` 有效 URI 时 emit `deep-link://new-url` +- 首启动(冷启动 `onCreate`)由链接拉起时 `get_current` 返回初始 URI(经 openharmony-ability `take_initial_want_uri` getter,由 `init_deep_link` 注入 `current`) +- 正确处理 OHOS 空 URI 再启动语义(不误触发) +- `register`/`unregister` 在 OHOS 返回 `Ok(())`(no-op),`is_registered` 返回 `Ok(false)` +- 零 tauri/tao/wry 核心仓改动 + +**Non-Goals:** +- scheme 注册声明(module.json5 skills 注入)→ Phase 2 +- 前端测试用例与 examples → Phase 3 +- 动态运行时 scheme 注册(OHOS 不支持,永久 Non-Goal) + +## Decisions + +### D1: 复用 `RunEvent::Opened` 事件链路,而非新建 ArkTS 插件 +**选择**:在 `on_event` 闭包扩展 cfg 含 `target_env="ohos"`,消费现成 `RunEvent::Opened{urls}`,emit `deep-link://new-url`。 + +**理由**: +- 链路已端到端就绪(tao `mod.rs:595` → tauri-runtime-wry `lib.rs:4737` → tauri `app.rs:2675`),`single-instance` 已验证 +- deep-link 是事件驱动型(非命令型),无需 ArkTS Plugin 类、无需 `register_ohos_plugin`、无需进 `STATIC_PLUGINS` +- 与 iOS 分支(`lib.rs:66-71`)行为一致:`init_deep_link` 仅返回 `DeepLink{app, current, config}` + +**备选(否决)**:仿 Android 建 ArkTS `DeepLinkPlugin` + `register_ohos_plugin` + `run_mobile_plugin("setEventHandler", Channel)`。否决理由:OHOS 的 `onNewWant` 不经 ArkTS 插件广播,而是直接进 runtime 事件循环(与 Android 无 `RunEvent` 直达不同);Android 的 Channel 模式是为弥补该缺口,OHOS 无此缺口,引入 ArkTS 插件属冗余。 + +### D2: 过滤 `urls.is_empty()` +**选择**:OHOS 分支在 `on_event` 中 `if !urls.is_empty()` 才 emit + 更新 `current`。 + +**理由**:OHOS singleton 模式下 `onNewWant` 每次再启动都触发,即使无 URI 也 emit 空 `Vec`(`tao mod.rs:596` `uri.is_empty() → vec![]`)。macOS/iOS 不会产生空 `Opened`,故现有 `#[cfg(any(macos, ios))]` 分支无需过滤;OHOS 必须过滤,否则前端 `on_open_url` 监听器会在无链接的再启动时误触发。 + +**实现**:由于 OHOS 与 macOS/iOS 共用同一 `on_event` 闭包,过滤逻辑对三者都安全(macOS/iOS 本就不产生空 `urls`),故直接在扩展后的统一分支内加 `if !urls.is_empty()`,无需再按平台细分。 + +### D3: register/unregister no-op,is_registered 返回 Ok(false) +**选择**:OHOS 独立分支(见 D4)中,`register`/`unregister` 返回 `Ok(())`(no-op),`is_registered` 返回 `Ok(false)`。 + +**理由**:OHOS scheme 声明通过 module.json5 skills(Phase 2),是构建时静态声明,无运行时动态注册 API。`register`/`unregister` no-op 使前端代码无需针对 OHOS 特殊处理(调用不报错,实际 scheme 由 module.json5 声明)。`is_registered` 返回 `Ok(false)` 表明 OHOS 无运行时注册状态(保守语义)。与 iOS(返回 UnsupportedPlatform)不同——这是 OHOS 的明确选择,使前端体验更平滑。 + +### D4: cfg 修复——独立 OHOS 分支 + Linux 分支隔离 +**选择**: +- 为 `register`/`unregister`/`is_registered` 新增独立 `#[cfg(target_env="ohos")]` 分支(D3 的 no-op 语义) +- Linux 分支:`#[cfg(target_os="linux")]` → `#[cfg(all(target_os="linux", not(target_env="ohos")))]`(避免 OHOS 同时命中 Linux 分支与 ohos 分支导致 E0592 重复定义) +- fallback 分支 `#[cfg(not(any(windows, target_os="linux")))]` 不变(OHOS 的 `target_os="linux"` 使其不命中 fallback,由 ohos 分支处理;macOS/iOS 仍命中 fallback 返回 UnsupportedPlatform) + +**理由**:OHOS 的 `target_os="linux"`,若不加独立 ohos 分支,OHOS 会命中 Linux 分支调 `xdg-mime`;若只加 ohos 分支不改 Linux 分支,OHOS 同时命中两个分支导致编译错误。故须:ohos 分支处理 OHOS,Linux 分支加 `not(ohos)` 排除 OHOS。fallback 无需改。此方案比"改 fallback cfg"更清晰——OHOS 有独立的 no-op 语义,与 macOS/iOS 的 UnsupportedPlatform 分开。 + +**备选(否决,原 D4)**:双分支联动改 fallback cfg。否决理由:fallback 返回 UnsupportedPlatform,无法表达 OHOS 的 no-op 语义;且 OHOS 需与 macOS/iOS 不同行为,独立分支更清晰。 + +### D5: `init_deep_link` OHOS 分支不调 `register_ohos_plugin` +**选择**:OHOS 分支返回 `DeepLink{app: app.clone(), current: Default::default(), config: api.config().clone()}`,与 iOS 分支(`lib.rs:66-71`)完全一致。 + +**理由**:deep-link 是事件驱动型,事件经 `RunEvent::Opened` 直达 `on_event`,无需 ArkTS 插件句柄。`DeepLink` 结构使用 `#[cfg(not(target_os="android"))]` 的 imp 模块(含 `current: Mutex` + `config`),OHOS 天然落入该模块,与 iOS 共用。 + +### D6: 首启动 get_current 提前到 Phase 1(take_initial_want_uri 由 init 注入 current) +**选择**:Phase 1 实现首启动 `get_current`。`openharmony-ability` 新增 `take_initial_want_uri()` getter(复刻 `take_want_parameters`,pull 模型,无新 Event 变体),在 `NativeAbility.onCreate` 提取 `want.uri` 存储;deep-link 的 `init_deep_link` OHOS 分支在返回前调 `take_initial_want_uri()`,将首启动 uri 解析为 `Url` 存入 `current`。`get_current` 无需特殊处理,统一返回 `current`(首启动值由 init 注入,运行中值由 `on_event` 更新)。 + +**理由**:`RunEvent::Opened` 仅 `onNewWant`(再启动)触发,`onCreate`(冷启动)不触发(`ohos-single-instance` spec 确认"首次启动不触发 callback")。首启动链接读取需 `onCreate` 的 `want.uri`。`take_want_parameters`(`app.rs:792/795/812`)已验证"ArkTS 主线程 store → Rust 事件循环线程 take"的跨线程 Mutex 模式,`take_initial_want_uri` 完全复刻。get_current 是 pull 模型,无需新 Event 变体。将 take 放在 `init_deep_link`(而非 get_current)避免 take 一次性语义导致的多次调用问题——init 只调一次,首启动值一次性注入 current,后续 get_current 直接读 current。 + +**时序**:`onCreate` 在 app 启动早期同步执行 store;`init_deep_link` 在 plugin setup 阶段(onCreate 之后)执行 take,时序天然满足。 + +### D7: build.rs 无需结构性改动(审计修正) +**选择**:Phase 1 不修改 deep-link `build.rs` 的 `try_build()` 调用(不新增 `.ohos_path()`)。 + +**理由**:`tauri-plugin` 的 `mobile::setup`(`tauri-plugin/src/build/mobile.rs:118-138`)OHOS 分支为 `if let Some(path) = ohos_path`——当 `ohos_path=None` 时**安全跳过、不报错**。OHOS 的 `CARGO_CFG_TARGET_OS="linux"` 走 `match` 的 `_` 分支,`android_path` 仅在 `target_os="android"` 时处理(`mobile.rs:74`),不会误触发 Android 复制逻辑。Phase 1 deep-link 无 ArkTS 插件,不需要 `ohos_path` 复制 tauri-api 框架。`update_android_manifest`(`build.rs:97`)与 entitlements(`build.rs:109`)仅在 `TAURI_DEEP_LINK_PLUGIN_CONFIG` 环境变量设置时执行,OHOS 构建流程不设置该变量时自动跳过。 + +**备选(否决)**:新增 `.ohos_path("openharmony")` + 空 openharmony 目录。否决理由:deep-link Phase 1 无 ArkTS 插件,`ohos_path` 仅用于复制 tauri-api 框架到插件 openharmony 目录,无插件实现时无意义,徒增空目录。Phase 2 的 scheme 注入若需介入 module.json5,届时再评估是否引入 `ohos_path`。 + +### D8: openharmony-ability 改动清单(take_initial_want_uri getter) +**选择**:在 openharmony-ability 新增 4 文件改动,建立 `onCreate want.uri → Rust getter` 通道: + +| 文件 | 改动 | +|------|------| +| `crates/ability/src/app.rs` | 新增 `static INITIAL_WANT_URI: Mutex` + `pub(crate) fn store_initial_want_uri(&str)` + `pub fn take_initial_want_uri() -> String`(紧邻 `WANT_PARAMETERS`,`app.rs:789-820`)。`lib.rs:90` 的 `pub use app::*` 自动导出 `take_initial_want_uri` | +| `crates/ability/src/lifecycle.rs` | `WindowStageEventCallback`(:21-33)新增 `on_ability_create_with_want` 字段;`create_lifecycle_handle`(:61)创建闭包:从 ctx 取 `uri` → `store_initial_want_uri`。不投递 Event(pull 模型) | +| `native_ability/src/main/ets/ability/type.ets` | `WindowStageEventCallback`(:28-39)新增 `onAbilityCreateWithWant: (data: { uri: string }) => void` | +| `native_ability/src/main/ets/ability/NativeAbility.ets` | `onCreate`(:80)中 `onAbilityCreate`(:127)附近新增 `forEachLifecycle((lifecycle) => lifecycle.windowStageEventCallback.onAbilityCreateWithWant?.({ uri: want.uri ?? '' }))` | + +**理由**:`take_want_parameters` 的 store 在 `on_new_want` 闭包(`lifecycle.rs:295`),该闭包已接收 `{uri, parametersJson}` 对象;而 `on_ability_create` 闭包(`lifecycle.rs:235-241`)签名 `move |_ctx|` 不接收 want 数据,故须新增 `onAbilityCreateWithWant` 闭包通道透传 uri。这是复刻时的唯一新增工作量。`index.d.ts` 由 NAPI 自动重新生成。 + +**备选(否决)**:扩展 `onAbilityCreate` 签名携带 uri。否决理由:改变现有 NAPI 契约(`index.d.ts:31`)和所有调用方,影响面大。新增独立闭包字段不破坏现有 `onAbilityCreate(restoredState)` 契约。 + +## Risks / Trade-offs + +- **[空事件误触发]** → D2 过滤 `urls.is_empty()`;macOS/iOS 共用分支本就不产生空 `urls`,过滤对它们无副作用。 +- **[cfg 分支冲突]** → D4 独立 ohos 分支 + Linux 分支加 `not(ohos)`,避免 E0592 重复定义;Step 5 审计逐函数核对 register/unregister/is_registered 三函数。 +- **[非 OHOS 平台回归]** → 所有改动用 `cfg(target_env="ohos")` 或 `not(target_env="ohos")` 隔离;Linux 依赖排除不影响真 Linux(`rust-ini` 仍对 `all(target_os="linux", not(target_env="ohos"))` 生效)。 +- **[openharmony-ability 改动跨 ArkTS+Rust]** → D8 复刻已验证的 `take_want_parameters` 模式,store/take 用 `Mutex` 保证线程安全;新增 `onAbilityCreateWithWant` 闭包不破坏现有 `onAbilityCreate` 契约。 +- **[take 一次性语义]** → `take_initial_want_uri` 读后清空;D6 将 take 放在 `init_deep_link`(只调一次),首启动值一次性注入 `current`,避免 get_current 多次调 take 取到空串。 +- **[OHOS 多 Ability 场景]** → OHOS app 为 singleton 模式(`ohos-single-instance` spec 语境),`onNewWant`/`onCreate` 投递到主 Ability,无多实例竞态。 diff --git a/openspec/changes/archive/2026-07-09-p1-deep-link/proposal.md b/openspec/changes/archive/2026-07-09-p1-deep-link/proposal.md new file mode 100644 index 000000000000..e50945849085 --- /dev/null +++ b/openspec/changes/archive/2026-07-09-p1-deep-link/proposal.md @@ -0,0 +1,32 @@ +## Why + +`tauri-plugin-deep-link` 完全未适配 OHOS,且现有条件编译在 OHOS 上**无法编译**:`init_deep_link`(`src/lib.rs:19-85`)仅有 android/ios/desktop 三分支,OHOS(`target_os="linux"` + `cfg(desktop)=false`)无匹配分支导致函数无返回值;`register`/`unregister`/`is_registered` 的 `#[cfg(target_os="linux")]` 误命中 OHOS,错误调用 `xdg-mime`;`Cargo.toml:45` 误将 `rust-ini` 引入 OHOS。 + +与此同时,OHOS 运行时**已端到端产生 `RunEvent::Opened`**(`NativeAbility.onNewWant` → `Event::NewWant{uri}` → tao `Event::Opened{urls}` → `tauri-runtime-wry` → `RunEvent::Opened`),且 `single-instance` 插件已验证此路径,但 deep-link 的 `on_event` 闭包被 `#[cfg(any(macos, ios))]` 排除,丢弃了 OHOS 产生的事件。本 Phase 打通编译、接入这条现成事件链路(运行中收链接),并通过 `openharmony-ability` 新增 `onCreate` want.uri getter 实现首启动 `get_current`,覆盖 deep-link 的核心唤起能力。 + +## What Changes + +- **编译打通**:`Cargo.toml` 声明 `openharmony` 平台支持;Linux 依赖加 `not(target_env="ohos")` 排除(避免 `rust-ini` 误入 OHOS);新增 `[target.'cfg(target_env="ohos")'.dependencies] tauri={features=["wry"]}`。 +- **`init_deep_link` 新增 OHOS 分支**:返回 `DeepLink{app, current, config}`(与 iOS 分支一致),**无需 `register_ohos_plugin`**(deep-link 是事件驱动型,非命令型插件)。 +- **`on_event` 接入事件链路(运行中事件)**:将 `#[cfg(any(target_os="macos", target_os="ios"))]` 扩展为含 `target_env="ohos"`,消费 `RunEvent::Opened{urls}`,emit `deep-link://new-url` 并更新 `current`。**关键:过滤 `urls.is_empty()`**——OHOS 的 `onNewWant` 每次再启动都触发,空 URI 也 emit 空 `Vec`(`tao mod.rs:596`),不过滤会误触发事件。 +- **首启动 `get_current`(冷启动)**:`openharmony-ability` 新增 `take_initial_want_uri()` getter(复刻 `take_want_parameters`,pull 模型,无新 Event),在 `NativeAbility.onCreate` 提取 `want.uri` 存储;deep-link 的 `get_current` 在 OHOS 调该 getter 读取首启动链接。 +- **修复 Linux 误命中**:`register`/`unregister`/`is_registered` 的 `#[cfg(target_os="linux")]` → `#[cfg(all(target_os="linux", not(target_env="ohos")))]`。 +- **`register`/`unregister`/`is_registered` OHOS 语义**:新增独立 `#[cfg(target_env="ohos")]` 分支——`register`/`unregister` 返回 `Ok(())`(no-op,scheme 注册由 Phase 2 module.json5 skills 处理);`is_registered` 返回 `Ok(false)`(OHOS 无运行时注册状态)。 +- **不影响其他平台**:所有改动通过 `cfg(target_env="ohos")` 隔离,Windows/macOS/Linux/iOS/Android 现有代码路径不变。 + +## Capabilities + +### New Capabilities +- `ohos-deep-link-event`: OHOS 平台 deep-link 插件的编译打通、运行中事件接入(`RunEvent::Opened`)、首启动 `get_current`(`take_initial_want_uri` getter)、`register`/`unregister` no-op、`is_registered` 返回 `Ok(false)`。 + +### Modified Capabilities + + +## Impact + +- **代码-deep-link 插件**:`plugins-workspace/plugins/deep-link/` 的 `Cargo.toml`、`src/lib.rs`、`src/commands.rs`(3 文件;`build.rs` 经审计无需结构性改动——`try_build()` 在 `ohos_path=None` 时 OHOS 安全跳过,见 design D7) +- **代码-openharmony-ability**:`crates/ability/src/app.rs`(INITIAL_WANT_URI+store+take)、`crates/ability/src/lifecycle.rs`(onAbilityCreateWithWant 闭包)、`native_ability/src/main/ets/ability/type.ets`(字段)、`native_ability/src/main/ets/ability/NativeAbility.ets`(onCreate 调用)— 4 文件 +- **依赖**:OHOS target 新增 `tauri` wry feature;移除 OHOS 误引的 `rust-ini` +- **无核心仓改动**:复用 tao(`platform_impl/ohos/mod.rs:595`)、tauri-runtime-wry(`lib.rs:4737`)、tauri 核心(`app.rs:2675`)已就绪的 `RunEvent::Opened` 链路 +- **平台隔离**:严格遵守 `cfg(target_env="ohos")` 隔离,Linux 依赖加 `not(target_env="ohos")` 排除(铁律 2) +- **后续 Phase**:scheme 注册声明(Phase 2)、测试文档(Phase 3)不在本 Phase 范围 diff --git a/openspec/changes/archive/2026-07-09-p1-deep-link/specs/ohos-deep-link-event/spec.md b/openspec/changes/archive/2026-07-09-p1-deep-link/specs/ohos-deep-link-event/spec.md new file mode 100644 index 000000000000..5b957981f61e --- /dev/null +++ b/openspec/changes/archive/2026-07-09-p1-deep-link/specs/ohos-deep-link-event/spec.md @@ -0,0 +1,99 @@ +# ohos-deep-link-event Specification + +## Purpose +OHOS 平台 tauri-plugin-deep-link 的编译打通、运行中事件接入、首启动 `get_current` 与命令语义规范:消费 `RunEvent::Opened` 事件链路实现运行中收链接;经 `openharmony-ability` 的 `take_initial_want_uri` getter 实现首启动 `get_current`;定义 `register`/`unregister` no-op、`is_registered` 返回 `Ok(false)`。 + +## ADDED Requirements + +### Requirement: deep-link crate 在 OHOS target 编译通过且不影响其他平台 +`tauri-plugin-deep-link` SHALL 在 OHOS target(`target_env="ohos"`)下编译成功。所有 OHOS 代码 SHALL 通过 `cfg(target_env="ohos")` 隔离。Linux 专属依赖(`rust-ini`)SHALL 加 `not(target_env="ohos")` 排除,不得引入 OHOS。Windows/macOS/Linux/iOS/Android 的现有代码路径 SHALL 保持不变。 + +#### Scenario: OHOS target 编译成功 +- **WHEN** 使用 OHOS target 编译 `tauri-plugin-deep-link` crate +- **THEN** SHALL 编译成功,`init_deep_link` 返回有效的 `DeepLink`,无"函数无返回值"或"Linux 分支误命中"错误 + +#### Scenario: 非 OHOS target 不受影响 +- **WHEN** 使用 Windows/macOS/Linux/iOS/Android target 编译 +- **THEN** 现有平台实现 SHALL 不受任何影响,行为与改动前一致 + +### Requirement: 运行中收到外部链接触发 deep-link 事件 +当 app 已在运行,OHOS 通过 `onNewWant` 投递携带有效 `want.uri` 的 Want 时,经 `Event::NewWant` → tao `Event::Opened{urls}` → `RunEvent::Opened{urls}` 链路,deep-link 插件的 `on_event` 闭包 SHALL emit `deep-link://new-url` 事件(payload 为 `Vec`),并更新内部 `current` 状态。 + +#### Scenario: OHOS 单 URL 场景 +- **WHEN** app 运行中,OHOS 调用 `onNewWant(want)` 且 `want.uri` 为 `"myapp://path"` +- **THEN** tao 将单个 `want.uri` 解析为 `vec!["myapp://path"]`,插件 SHALL emit `deep-link://new-url`,payload 为 `["myapp://path"]`,`current` 更新为该 URL + +#### Scenario: 多 URL 场景(仅 macOS/iOS) +- **WHEN** `RunEvent::Opened { urls }` 中 `urls` 含多个有效 URL(macOS/iOS 系统可能传多 URL) +- **THEN** 插件 SHALL 将完整 `Vec` 作为 payload emit,`current` 更新为该完整列表 +- **NOTE** OHOS 的 tao 实现将单个 `want.uri` 解析为单元素 `Vec`(`tao platform_impl/ohos/mod.rs:595-609`),不产生多 URL + +### Requirement: 空 URI 的再启动不触发 deep-link 事件 +OHOS 的 `onNewWant` 在 singleton 模式下每次"再启动"都触发,即使无 URI 也 emit 空 `Vec`(`tao platform_impl/ohos/mod.rs:596`)。deep-link 插件 SHALL 过滤 `urls.is_empty()`,不得在无链接的再启动时 emit `deep-link://new-url`,避免误触发前端监听器。 + +#### Scenario: onNewWant 空 URI +- **WHEN** app 运行中,OHOS 调用 `onNewWant(want)` 且 `want.uri` 为空字符串 +- **THEN** `RunEvent::Opened { urls: vec![] }` 被投递,但插件 SHALL 不 emit `deep-link://new-url`,`current` SHALL 保持不变 + +### Requirement: register/unregister 在 OHOS 返回 no-op,is_registered 返回 Ok(false) +OHOS 上运行时动态注册 scheme 不被支持(scheme 声明由 Phase 2 的 module.json5 skills 处理)。`register`/`unregister` SHALL 在 OHOS 独立 `#[cfg(target_env="ohos")]` 分支返回 `Ok(())`(no-op);`is_registered` SHALL 返回 `Ok(false)`(OHOS 无运行时注册状态)。`register`/`unregister`/`is_registered` 的 `#[cfg(target_os="linux")]` 分支 SHALL 修改为 `#[cfg(all(target_os="linux", not(target_env="ohos")))]` 避免与 ohos 独立分支冲突(E0592);fallback 分支 `#[cfg(not(any(windows, target_os="linux")))]` 不变(macOS/iOS 仍命中返回 UnsupportedPlatform)。 + +#### Scenario: 调用 register 返回 no-op +- **WHEN** 在 OHOS 上调用 `deep_link.register("myapp")` +- **THEN** SHALL 返回 `Ok(())`,不执行任何注册操作 + +#### Scenario: 调用 unregister 返回 no-op +- **WHEN** 在 OHOS 上调用 `deep_link.unregister("myapp")` +- **THEN** SHALL 返回 `Ok(())` + +#### Scenario: 调用 is_registered 返回 false +- **WHEN** 在 OHOS 上调用 `deep_link.is_registered("myapp")` +- **THEN** SHALL 返回 `Ok(false)` + +#### Scenario: Linux 分支不误命中 OHOS +- **WHEN** 在 OHOS 上调用 `register`/`unregister`/`is_registered` +- **THEN** SHALL 不执行 `xdg-mime`/`update-desktop-database` 等 Linux 桌面命令,不读写 `mimeapps.list` + +### Requirement: 首启动 get_current 经 take_initial_want_uri 注入 current +冷启动由链接拉起时,`NativeAbility.onCreate` 提取 `want.uri` 经 `onAbilityCreateWithWant` 闭包存储到 `INITIAL_WANT_URI`(openharmony-ability 新增,复刻 `take_want_parameters` 模式,pull 模型,无新 Event 变体);deep-link 的 `init_deep_link` OHOS 分支在返回前调 `openharmony_ability::take_initial_want_uri()`,将首启动 uri 解析为 `Url` 存入 `current`。`get_current` SHALL 返回 `current`(首启动值由 init 注入,运行中值由 `on_event` 更新)。 + +#### Scenario: 冷启动由链接拉起 +- **WHEN** app 未运行,由 `"myapp://path"` 链接拉起,`onCreate` 的 `want.uri="myapp://path"`,插件初始化后调用 `get_current` +- **THEN** SHALL 返回 `Ok(Some(vec!["myapp://path"]))` + +#### Scenario: 冷启动非链接拉起 +- **WHEN** app 冷启动但 `want.uri` 为空,插件初始化后调用 `get_current` +- **THEN** SHALL 返回 `Ok(None)` + +#### Scenario: 运行中收到链接后 get_current +- **WHEN** 已通过 `onNewWant` 收到 `"myapp://path"` 并触发 `RunEvent::Opened` 更新 `current`,调用 `get_current` +- **THEN** SHALL 返回 `Ok(Some(vec!["myapp://path"]))` + +### Requirement: on_open_url 监听 API 在 OHOS 行为一致 +deep-link 插件的 `on_open_url` 方法(`lib.rs:515`)SHALL 在 OHOS 上监听 `deep-link://new-url` 事件,收到时回调 `OpenUrlEvent{urls}`,返回 `EventId` 供 `unlisten` 使用。行为 SHALL 与 macOS/iOS 一致。 + +#### Scenario: 注册监听后收到链接 +- **WHEN** 前端调用 `on_open_url` 注册回调,app 运行中收到 `onNewWant` 携带 `"myapp://path"` +- **THEN** 回调 SHALL 被调用,`OpenUrlEvent.urls` 包含 `["myapp://path"]`,返回有效 `EventId` + +#### Scenario: unlisten 取消监听 +- **WHEN** 用返回的 `EventId` 调用 `Listener::unlisten` +- **THEN** 后续 `deep-link://new-url` 事件 SHALL 不再触发该回调 + +### Requirement: want.parameters 不影响 deep-link 事件 +OHOS 的 `want.parameters` 通过 `openharmony_ability::take_want_parameters()` 独立读取(`ohos-want-parameters` spec),**不随** `RunEvent::Opened{urls}` 传递。deep-link 插件 SHALL 只消费 `urls`,不读取 `want.parameters`。`take_initial_want_uri` 只存储 `want.uri`,不存储 parameters。 + +#### Scenario: onNewWant 携带 parameters 不影响 deep-link +- **WHEN** `onNewWant(want)` 携带 `want.uri="myapp://path"` 且 `want.parameters={"source":"widget"}` +- **THEN** deep-link 插件 emit 的 `deep-link://new-url` payload SHALL 仅含 `["myapp://path"]`,不包含 parameters 信息 + +### Requirement: scheme 匹配由系统 module.json5 skills 决定(Phase 2 范围) +OHOS 上 URI scheme 的匹配过滤由系统 `module.json5` 的 `skills/uris` 声明决定(Phase 2 实现)。Phase 1 范围内,deep-link 插件的 `on_event` SHALL 不对 `urls` 做二次 scheme 过滤——收到的 `urls` 均为系统 skills 匹配后路由到 app 的结果。 + +#### Scenario: Phase 1 不做 scheme 二次过滤 +- **WHEN** `RunEvent::Opened { urls }` 投递到 `on_event`(`urls` 已由系统 skills 匹配) +- **THEN** 插件 SHALL 直接 emit `urls`,不做 scheme 匹配过滤 + +#### Scenario: 未配置 skills 的 scheme 不唤起(Phase 2 范围) +- **WHEN** `module.json5` 未声明某 scheme 的 skills,外部链接使用该 scheme +- **THEN** 系统 SHALL 不路由到 app,`onNewWant` 不触发(此为 Phase 2 module.json5 skills 声明的职责,Phase 1 不处理) diff --git a/openspec/changes/archive/2026-07-09-p1-deep-link/tasks.md b/openspec/changes/archive/2026-07-09-p1-deep-link/tasks.md new file mode 100644 index 000000000000..ea476ef0f972 --- /dev/null +++ b/openspec/changes/archive/2026-07-09-p1-deep-link/tasks.md @@ -0,0 +1,42 @@ +## 1. Cargo.toml 平台支持与依赖隔离 + +- [x] 1.1 在 `[package.metadata.platforms.support]` 加 `openharmony = { level = "partial", notes = "运行中事件+首启动get_current+register no-op;scheme注册(Phase 2)待补" }` +- [x] 1.2 将 `[target."cfg(target_os = \"linux\")".dependencies]`(`rust-ini`)改为 `[target."cfg(all(target_os = \"linux\", not(target_env = \"ohos\")))".dependencies]`,排除 OHOS 误引 +- [x] 1.3 新增 `[target.'cfg(target_env = "ohos")'.dependencies] openharmony-ability = { path = "../../../openharmony-ability/crates/ability" }`(**实现调整**:移除原设计的 `tauri wry`——deep-link 不调 `register_ohos_plugin` 不需要 wry,且 wry→tao→gtk 在 OHOS target 引入 gdk-sys/pango-sys 编译失败;`[dependencies] tauri={workspace=true}` 已足够) + +## 2. openharmony-ability 新增 take_initial_want_uri getter + +- [x] 2.1 `crates/ability/src/app.rs`:紧邻 `WANT_PARAMETERS`(`app.rs:789-820`)新增 `static INITIAL_WANT_URI: Mutex` + `pub(crate) fn store_initial_want_uri(&str)` + `pub fn take_initial_want_uri() -> String`(复刻 `take_want_parameters` 模式,take 语义读后清空) +- [x] 2.2 `crates/ability/src/lifecycle.rs`:`WindowStageEventCallback`(:21-33)新增 `on_ability_create_with_want` 字段;`create_lifecycle_handle` 创建闭包从 ctx 取 `uri` → `crate::app::store_initial_want_uri(&uri)`(**不投递 Event**,pull 模型) +- [x] 2.3 `native_ability/src/main/ets/ability/type.ets`:`WindowStageEventCallback`(:28-39)新增 `onAbilityCreateWithWant: (data: { uri: string }) => void` +- [x] 2.4 `native_ability/src/main/ets/ability/NativeAbility.ets`:`onCreate`(:80)中 `onAbilityCreate`(:127)后新增 `lifecycle.windowStageEventCallback.onAbilityCreateWithWant?.({ uri: want.uri ?? '' })` + +## 3. src/lib.rs 编译打通(init_deep_link + cfg 独立分支) + +- [x] 3.1 `init_deep_link` 新增 `#[cfg(target_env = "ohos")]` 分支:返回 `DeepLink { app, current, config }`(与 iOS 一致,不调 `register_ohos_plugin`);返回前调 `openharmony_ability::take_initial_want_uri()`,非空则解析为 `Url` 存入 `current`(D6 首启动注入) +- [x] 3.2 `register`:新增 `#[cfg(target_env = "ohos")]` 独立分支返回 `Ok(())`(no-op);Linux 分支 `#[cfg(target_os = "linux")]` → `#[cfg(all(target_os = "linux", not(target_env = "ohos")))]`(replaceAll 统一);fallback 不变 +- [x] 3.3 `unregister`:新增 `#[cfg(target_env = "ohos")]` 独立分支返回 `Ok(())`(no-op);Linux 分支加 `not(target_env = "ohos")` +- [x] 3.4 `is_registered`:新增 `#[cfg(target_env = "ohos")]` 独立分支返回 `Ok(false)`;Linux 分支加 `not(target_env = "ohos")` + +## 4. src/lib.rs 事件接入(on_event 消费 RunEvent::Opened) + +- [x] 4.1 `on_event` 闭包内 `#[cfg(any(target_os = "macos", target_os = "ios"))]` 扩展为 `#[cfg(any(target_os = "macos", target_os = "ios", target_env = "ohos"))]` +- [x] 4.2 在 `RunEvent::Opened { urls }` 处理块内加 `if !urls.is_empty()` 过滤,仅非空时 emit `"deep-link://new-url"` 并更新 `current`(OHOS 空 URI 再启动不误触发) + +## 5. src/commands.rs 确认 + +- [x] 5.1 确认 `commands.rs` 的 `get_current`/`register`/`unregister`/`is_registered` 命令调用 `deep_link` 对应方法,OHOS 行为由 `lib.rs` 的 imp 实现承载,`commands.rs` 无需平台分支 + +## 6. build.rs 审计确认(无需改动) + +- [x] 6.1 确认 `tauri_plugin::Builder::try_build()`(`build.rs:76-79`,无 `ohos_path`)在 OHOS target 下安全跳过不报错(依据 `tauri-plugin/src/build/mobile.rs:118-138` 的 `if let Some(path) = ohos_path`),Phase 1 不引入 ArkTS 插件、不新增 `ohos_path` +- [x] 6.2 确认 `update_android_manifest`(`build.rs:97`)与 entitlements(`build.rs:109`)仅在 `TAURI_DEEP_LINK_PLUGIN_CONFIG` 设置时执行,OHOS 构建不设置该变量时自动跳过,不干扰构建 + +## 7. 验证 + +- [ ] 7.1 OHOS target `cargo check` 通过(`tauri-plugin-deep-link` + `openharmony-ability` crate)— **待 OHOS 构建环境**:当前环境缺 pkg-config/sysroot 交叉编译配置,single-instance(已适配)同样失败,证明是环境问题非代码问题;需在 tauri ohos build 完整环境验证 +- [x] 7.2 Desktop(windows)target `cargo check` 不回归(39.46s 通过) +- [ ] 7.3 设备端验证:app 运行中,`hdc shell aa start` 携带 `myapp://path` 唤起,前端 `on_open_url` 收到 `deep-link://new-url` 事件且 `get_current` 返回该 URL — **待设备** +- [ ] 7.4 设备端验证:无 URI 的再启动(`onNewWant` 空 uri)不触发 `deep-link://new-url` 事件 — **待设备** +- [ ] 7.5 设备端验证:app 冷启动由 `myapp://path` 拉起,插件初始化后 `get_current` 返回 `Ok(Some(["myapp://path"]))` — **待设备** +- [ ] 7.6 设备端验证:`register`/`unregister` 返回 `Ok(())`,`is_registered` 返回 `Ok(false)` — **待设备** diff --git a/openspec/changes/archive/2026-07-09-p2-deep-link/.openspec.yaml b/openspec/changes/archive/2026-07-09-p2-deep-link/.openspec.yaml new file mode 100644 index 000000000000..43e65ca6e667 --- /dev/null +++ b/openspec/changes/archive/2026-07-09-p2-deep-link/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/archive/2026-07-09-p2-deep-link/README.md b/openspec/changes/archive/2026-07-09-p2-deep-link/README.md new file mode 100644 index 000000000000..98ce699b6cec --- /dev/null +++ b/openspec/changes/archive/2026-07-09-p2-deep-link/README.md @@ -0,0 +1,3 @@ +# p2-deep-link + +Phase 2: deep-link OHOS scheme 注册声明——module.json5 skills/uris 构建时注入 diff --git a/openspec/changes/archive/2026-07-09-p2-deep-link/design.md b/openspec/changes/archive/2026-07-09-p2-deep-link/design.md new file mode 100644 index 000000000000..f53ccf2a987e --- /dev/null +++ b/openspec/changes/archive/2026-07-09-p2-deep-link/design.md @@ -0,0 +1,85 @@ +## Context + +Phase 1 实现了 deep-link 的事件接入和 `get_current`,但 OHOS 系统无法路由 deep link 到 app——`module.json5` 的 `skills` 仅 home 入口,无 `uris/scheme`。需构建时注入 skills。 + +**现有可复用基础设施**: +- `update_android_manifest`(`tauri-utils/build.rs:108-131`):env 自门控(读 `TAURI_ANDROID_PROJECT_PATH`,未设 no-op)+ 块注释幂等 +- `write_entry_device_types`(`tauri-cli/.../plugins.rs:649-677`):**OHOS 侧 json5 parse/serialize 修改 module.json5 的既定模式**(`parse_json5`/`serialize_json5`)——这是 OHOS 改 module.json5 的正确方式,非 Android 的块注释文本注入 +- `TAURI_DEEP_LINK_PLUGIN_CONFIG` 在 OHOS build 时已就绪(`helpers/config.rs:218-226`);`TAURI_OHOS_PROJECT_PATH`(`mod.rs:191`)、`OHOS_DEVICE_TYPE`(`build.rs:116`)均已设置 +- skills 语法(`module-configuration-file.md:363-393`):`scheme/host/path/pathStartWith/pathRegex` + `entity.system.browsable` + `ohos.want.action.viewData` +- 关键规则(`deep-linking-startup.md:18`):home skill 不能配 uris,**需创建独立 skill 对象** + +**时序兼容性**:deep-link build.rs(build 步骤6,`open_harmony/build.rs:229`)在 `write_entry_device_types`(步骤7,`:355`)前运行,两者都 json5 round-trip,deep-link 注入的 skills 被步骤7 保留(步骤7 只改 deviceTypes)。 + +**约束**(三条铁律):cfg/env 隔离;不影响其他平台;OHOS 代码不误入非 OHOS。 + +## Goals / Non-Goals + +**Goals:** +- 构建时把 deep-link `config.mobile` 的 scheme/domain 注入 `module.json5` 的 `skills/uris` +- 幂等(重复构建不累积) +- 不破坏 home 入口 skill +- 非 OHOS 平台 no-op +- 多 form(mobile/desktop)覆盖 + +**Non-Goals:** +- 运行时动态 scheme 注册(OHOS 不支持,永久 Non-Goal) +- tauri-cli 模板钩子(运行时注入即可,无需改模板) +- `path_suffix` 支持(OHOS 无对应字段,丢弃) + +## Decisions + +### D1: update_ohos_module_json 用 json5 parse/serialize(非块注释) +**选择**:新增 `update_ohos_module_json(skills: serde_json::Value)`,用 json5 parse module.json5 → mutate → serialize 写回,参考 `write_entry_device_types`(`plugins.rs:649-677`)。 + +**理由**:OHOS module.json5 是 JSON5 格式,Android 的块注释文本注入(`insert_into_xml`,`build.rs:133-167`)不适用(JSON5 数组内无法用块注释做幂等标记)。`write_entry_device_types` 已验证 json5 parse/serialize 是 OHOS 侧改 module.json5 的正确模式。 + +### D2: env 自门控(TAURI_OHOS_PROJECT_PATH) +**选择**:读 `TAURI_OHOS_PROJECT_PATH`,未设则 `return Ok(())` no-op。 + +**理由**:对标 `update_android_manifest` 读 `TAURI_ANDROID_PROJECT_PATH`(`build.rs:119`)。非 OHOS 构建时该 env 未设,函数自动 no-op,无需 cfg 门控。定位 entry 模块:`{project_path}/entry_{OHOS_DEVICE_TYPE}/src/main/module.json5`(默认 `entry_mobile`)。 + +### D3: 幂等——按 skill 签名去重 +**选择**:注入前先移除 `abilities[0].skills` 数组中已有的 deep-link skill(按 `actions` 含 `ohos.want.action.viewData` 单字段匹配),再按 config 重新注入。 + +**理由**:JSON5 无块注释做幂等标记。按 skill 签名(`actions` 含 `ohos.want.action.viewData`)去重是可靠方案——`ohos.want.action.viewData` 是 deep-link 专属 action,home skill 用 `action.system.home`,单字段即可严格区分,无需叠加 `entities` 条件。重复构建时先删后插,不累积。 + +### D4: 追加独立 skill 对象,不改 home skill +**选择**:把新生成的 deep-link skill 对象**追加**到 `abilities[0].skills` 数组末尾,不修改现有 home skill。 + +**理由**:`deep-linking-startup.md:18` 明确:"skills 标签下默认包含一个 skill 对象用于标识应用入口。应用跳转链接不能在该 skill 对象中配置,需要创建独立的 skill 对象。" home skill 必须保留(否则 app 无桌面图标入口)。 + +### D5: AssociatedDomain→OHOS skill 字段映射 +**选择**: + +| AssociatedDomain 字段 | OHOS skill 字段 | 说明 | +|---|---|---| +| `scheme`(Vec) | `uris[].scheme` | 多 scheme 生成多个 uris 对象(OHOS SkillUri 一个对象一个 scheme) | +| `host`(Option) | `uris[].host` | | +| `path`(Vec) | `uris[].path` | 全匹配 | +| `path_pattern` | `uris[].pathRegex` | **名称不同**:Android pathPattern → OHOS pathRegex | +| `path_prefix` | `uris[].pathStartWith` | **名称不同**:Android pathPrefix → OHOS pathStartWith | +| `path_suffix` | (丢弃) | OHOS 无对应字段 | +| `app_link=true` | `domainVerify: true` | App Linking 域名校验 | +| 固定 | `entities: ["entity.system.browsable"]` | | +| 固定 | `actions: ["ohos.want.action.viewData"]` | | + +**理由**:字段映射对照 OHOS 官方 `uris` 标签(`module-configuration-file.md:384-393`)+ Android intent_filter 映射(`build.rs:12-73`)。`path_suffix` 无 OHOS 对应,丢弃并日志告警。 + +### D6: tauri-plugin Cargo.toml 新增 json5 依赖 +**选择**:`tauri-plugin/Cargo.toml` 的 `[build-dependencies]` 加 `json5`(tauri-utils 已用 `json5 0.4`,`Cargo.toml:40,91`,但 tauri-plugin 未启用 `config-json5` feature,`Cargo.toml:30-32` `default-features=false`)。 + +**理由**:`update_ohos_module_json` 需 json5 解析。直接给 tauri-plugin 加 `json5` 依赖(而非启用 tauri-utils `config-json5`)更轻量,避免引入 tauri-utils 的 config 解析链。 + +### D7: 无需 tauri-cli 模板钩子 +**选择**:不改 `entry_mobile/src/main/module.json5` 和 `entry_desktop/src/main/module.json5` 模板,纯运行时注入。 + +**理由**:对标 `update_android_manifest`(纯运行时文本注入,无模板钩子)。时序兼容已验证:deep-link build.rs 注入 skills(步骤6)→ `write_entry_device_types`(步骤7)只改 deviceTypes 并 round-trip,保留 skills。 + +## Risks / Trade-offs + +- **[json5 依赖新增]** → D6 直接加 `json5 0.4`(tauri-utils 已验证可用),小风险。 +- **[幂等去重签名误匹配]** → D3 按 `ohos.want.action.viewData` 单字段签名匹配,与 home skill(`action.system.home`)严格区分;审计核对。 +- **[多 form 注入]** → `--app` 模式循环 set `OHOS_DEVICE_TYPE` 多次 build,deep-link build.rs 每次 form 切换重跑,注入到当前 `entry_{form}`;两个 entry 都被覆盖。低风险,env 驱动。 +- **[OHOS schema 校验]** → 注入字段对照 `module-configuration-file.md:363-393` 确认合法(scheme/host/path/pathStartWith/pathRegex/domainVerify/entities/actions)。 +- **[path_suffix 丢弃]** → OHOS 无对应字段,D5 丢弃并日志告警;影响小(path_suffix 使用率低)。 diff --git a/openspec/changes/archive/2026-07-09-p2-deep-link/proposal.md b/openspec/changes/archive/2026-07-09-p2-deep-link/proposal.md new file mode 100644 index 000000000000..0ba62d7fc11d --- /dev/null +++ b/openspec/changes/archive/2026-07-09-p2-deep-link/proposal.md @@ -0,0 +1,32 @@ +## Why + +Phase 1 实现了 deep-link 的运行中事件接入和首启动 `get_current`,但 OHOS 系统**当前无法路由 deep link 到 app**——工程 `module.json5` 的 `abilities[0].skills` 仅声明 home 入口(`entity.system.home`),**无 `uris/scheme` 声明**(`tauri-cli` 模板 `entry_mobile/src/main/module.json5:22-31`)。外部链接点击不会唤起 app。Phase 2 需在构建时把 deep-link 配置的 scheme/domain 注入 `module.json5` 的 `skills/uris`,让系统能识别并路由 deep link。 + +现有基础设施已就绪:`TAURI_DEEP_LINK_PLUGIN_CONFIG` 在 OHOS build 时已设置(`helpers/config.rs:218-226`),`TAURI_OHOS_PROJECT_PATH` 已设置(`open_harmony/mod.rs:191`);`write_entry_device_types`(`plugins.rs:649-677`)已验证 OHOS 侧 json5 parse/serialize 修改 module.json5 的既定模式。缺口仅是:无 OHOS 的 `update_ohos_module_json` 注入 API(`tauri-plugin` 仅有 `update_android_manifest`/`update_entitlements`),且 `tauri-plugin` 未启用 `json5` 依赖。 + +## What Changes + +- **新增 `update_ohos_module_json` 注入 API**(`tauri-plugin/src/build/mobile.rs`),对标 `update_android_manifest` 但用 **json5 parse/serialize** 模式(参考 `write_entry_device_types`)。 +- **env 自门控**:读 `TAURI_OHOS_PROJECT_PATH`,未设则 no-op(对标 `TAURI_ANDROID_PROJECT_PATH`)。 +- **幂等策略**:JSON5 无块注释,按 skill 签名(`actions` 含 `ohos.want.action.viewData`)去重——先移除旧 deep-link skill 再重新注入。 +- **追加独立 skill 对象**到 `abilities[0].skills`,不改 home 入口 skill(依据 `deep-linking-startup.md:18`:"应用跳转链接不能在 home skill 中配置,需创建独立 skill 对象")。 +- **AssociatedDomain→OHOS skill 字段映射**:`scheme`→`uris[].scheme`(多 scheme 多 uris 对象)、`host`→`uris[].host`、`path_pattern`→`pathRegex`、`path_prefix`→`pathStartWith`、`path_suffix`丢弃(OHOS 无对应)、`app_link`→`domainVerify`;固定 `entities:["entity.system.browsable"]`、`actions:["ohos.want.action.viewData"]`。 +- **tauri-plugin `Cargo.toml` 新增 `json5` build 依赖**。 +- **deep-link `build.rs` 新增 OHOS 分支**:读 `config.mobile`,生成 skills JSON,调 `update_ohos_module_json`。 +- **不影响其他平台**:注入 API env 自门控,非 OHOS 构建 no-op;deep-link build.rs OHOS 分支仅 `TAURI_OHOS_PROJECT_PATH` 存在时执行。 + +## Capabilities + +### New Capabilities +- `ohos-deep-link-scheme-registration`: OHOS 构建时把 deep-link 配置的 scheme/domain 注入 `module.json5` 的 `skills/uris`,让系统能识别并路由 deep link 到 app。 + +### Modified Capabilities + + +## Impact + +- **代码-tauri-plugin**:`crates/tauri-plugin/src/build/mobile.rs`(新增 `update_ohos_module_json`)、`crates/tauri-plugin/Cargo.toml`(加 `json5` build 依赖)— 2 文件 +- **代码-deep-link 插件**:`plugins-workspace/plugins/deep-link/build.rs`(新增 OHOS 分支 + `ohos_skill` 生成函数)— 1 文件 +- **无需 tauri-cli 模板改动**:运行时注入,时序兼容 `write_entry_device_types`(deep-link build.rs 步骤6 在 `write_entry_device_types` 步骤7 前,skills 字段被 round-trip 保留) +- **平台隔离**:注入 API env 自门控,非 OHOS no-op(铁律 2) +- **后续 Phase**:测试与文档(Phase 3)不在本 Phase 范围 diff --git a/openspec/changes/archive/2026-07-09-p2-deep-link/specs/ohos-deep-link-scheme-registration/spec.md b/openspec/changes/archive/2026-07-09-p2-deep-link/specs/ohos-deep-link-scheme-registration/spec.md new file mode 100644 index 000000000000..200b8adc4d4b --- /dev/null +++ b/openspec/changes/archive/2026-07-09-p2-deep-link/specs/ohos-deep-link-scheme-registration/spec.md @@ -0,0 +1,65 @@ +# ohos-deep-link-scheme-registration Specification + +## Purpose +OHOS 构建时把 tauri-plugin-deep-link 配置的 scheme/domain 注入 `module.json5` 的 `skills/uris`,让系统能识别并路由 deep link 到 app。定义 `update_ohos_module_json` 注入 API、幂等性、字段映射、home skill 保护规范。 + +## ADDED Requirements + +### Requirement: update_ohos_module_json 注入 API +`tauri-plugin` SHALL 提供 `mobile::update_ohos_module_json(skills: serde_json::Value)` 函数。该函数 SHALL 读 `TAURI_OHOS_PROJECT_PATH` 环境变量自门控(未设则 no-op 返回 `Ok(())`);定位 `{project_path}/entry_{OHOS_DEVICE_TYPE}/src/main/module.json5`;用 json5 parse → mutate → serialize 写回。skill 对象 SHALL 追加到 `module.abilities[0].skills` 数组。 + +#### Scenario: OHOS 构建时注入 skills +- **WHEN** OHOS 构建,`TAURI_OHOS_PROJECT_PATH` 已设,deep-link `config.mobile` 含 `AssociatedDomain{scheme:["myapp"]}` +- **THEN** `entry_mobile/src/main/module.json5` 的 `abilities[0].skills` SHALL 追加含 `uris:[{scheme:"myapp"}]` 的独立 skill 对象 + +#### Scenario: 非 OHOS 构建 no-op +- **WHEN** `TAURI_OHOS_PROJECT_PATH` 未设置(非 OHOS 构建) +- **THEN** `update_ohos_module_json` SHALL no-op,不修改任何文件,返回 `Ok(())` + +### Requirement: 幂等性——重复构建不累积 +重复构建时,`update_ohos_module_json` SHALL 先移除 `abilities[0].skills` 中已有的 deep-link skill(按 `actions` 含 `ohos.want.action.viewData` 且 `entities` 含 `entity.system.browsable` 签名匹配),再按 config 重新注入,不得累积重复 skill 对象。 + +#### Scenario: 重复构建不累积 +- **WHEN** 连续两次 OHOS 构建,`config.mobile` 不变 +- **THEN** `module.json5` 的 `skills` 数组 SHALL 只含一份 deep-link skill 对象,不重复 + +#### Scenario: 配置变更后重新注入 +- **WHEN** 第一次构建注入 `scheme:["myapp"]`,第二次构建 `config.mobile` 改为 `scheme:["myapp2"]` +- **THEN** 第二次构建后 SHALL 只含 `scheme:"myapp2"` 的 skill,旧的 `scheme:"myapp"` 被移除 + +### Requirement: home 入口 skill 不被破坏 +注入 SHALL 追加独立 skill 对象到 `abilities[0].skills` 末尾,不修改现有 home 入口 skill(`entities:["entity.system.home"]`、`actions:["action.system.home"]`)。 + +#### Scenario: home skill 保留 +- **WHEN** 注入 deep-link skills +- **THEN** home 入口 skill SHALL 保持不变(`entities:["entity.system.home"]`、`actions:["action.system.home"]`),deep-link skill 为独立新增对象 + +### Requirement: AssociatedDomain→OHOS skill 字段映射 +deep-link `build.rs` SHALL 把 `config.mobile` 的每个 `AssociatedDomain` 映射为一个 OHOS skill 对象:`scheme`→`uris[].scheme`(多 scheme 生成多个 uris 对象)、`host`→`uris[].host`、`path`→`uris[].path`、`path_pattern`→`uris[].pathRegex`、`path_prefix`→`uris[].pathStartWith`、`path_suffix`丢弃(OHOS 无对应)、`app_link=true`→`domainVerify:true`;固定 `entities:["entity.system.browsable"]`、`actions:["ohos.want.action.viewData"]`。 + +#### Scenario: 自定义 scheme 映射 +- **WHEN** `config.mobile` 含 `AssociatedDomain{scheme:["myapp"], host:None}` +- **THEN** 生成 skill `{entities:["entity.system.browsable"], actions:["ohos.want.action.viewData"], uris:[{scheme:"myapp"}], domainVerify:false}` + +#### Scenario: App Link(https)映射 +- **WHEN** `config.mobile` 含 `AssociatedDomain{scheme:["https"], host:"example.com", app_link:true}` +- **THEN** 生成 skill `{entities:["entity.system.browsable"], actions:["ohos.want.action.viewData"], uris:[{scheme:"https", host:"example.com"}], domainVerify:true}` + +#### Scenario: path 映射名称差异 +- **WHEN** `config.mobile` 含 `AssociatedDomain{scheme:["myapp"], path_pattern:["^/d+$"], path_prefix:["/app"]}` +- **THEN** uris 含 `pathRegex:"^/d+$"` 和 `pathStartWith:"/app"`(非 Android 的 pathPattern/pathPrefix) + +#### Scenario: 多 scheme 生成多 uris 对象 +- **WHEN** `config.mobile` 含 `AssociatedDomain{scheme:["myapp","myapp2"]}` +- **THEN** skill 的 `uris` 数组 SHALL 含两个对象 `{scheme:"myapp"}` 和 `{scheme:"myapp2"}` + +### Requirement: 多 form(mobile/desktop)覆盖 +注入 SHALL 根据 `OHOS_DEVICE_TYPE` 环境变量定位 `entry_{form}` 模块的 `module.json5`,确保 mobile 和 desktop form 都被正确注入(`--app` 模式多次 build 时每次 form 切换重跑 build.rs)。 + +#### Scenario: mobile form 注入 +- **WHEN** `OHOS_DEVICE_TYPE=mobile` +- **THEN** `entry_mobile/src/main/module.json5` SHALL 被注入 deep-link skills + +#### Scenario: desktop form 注入 +- **WHEN** `OHOS_DEVICE_TYPE=desktop` +- **THEN** `entry_desktop/src/main/module.json5` SHALL 被注入 deep-link skills diff --git a/openspec/changes/archive/2026-07-09-p2-deep-link/tasks.md b/openspec/changes/archive/2026-07-09-p2-deep-link/tasks.md new file mode 100644 index 000000000000..69601b64acac --- /dev/null +++ b/openspec/changes/archive/2026-07-09-p2-deep-link/tasks.md @@ -0,0 +1,18 @@ +## 1. tauri-plugin 新增 update_ohos_module_json 注入 API + +- [x] 1.1 `tauri/crates/tauri-plugin/Cargo.toml` 加 `json5 = { version = "0.4", optional = true }`(在 `[dependencies]`,并在 `build` feature 加 `"dep:json5"`)— **实现调整**:json5 作为 optional dep 在 build feature 启用(非 [build-dependencies]),因 `update_ohos_module_json` 在 build 模块 +- [x] 1.2 `tauri/crates/tauri-plugin/src/build/mobile.rs` 新增 `pub fn update_ohos_module_json(skills: serde_json::Value) -> Result<()>`:读 `TAURI_OHOS_PROJECT_PATH` 自门控;定位 `entry_{OHOS_DEVICE_TYPE}/src/main/module.json5`;`json5::from_str` parse → `module.abilities[0].skills` 移除含 `ohos.want.action.viewData` 的旧 skill(幂等)→ 追加新 skill → `serde_json::to_string_pretty` serialize 写回 — **实现调整**:用 serde_json serialize(标准 JSON 是合法 JSON5),简化未用 plugins.rs 的 serialize_json5 + +## 2. deep-link build.rs OHOS 分支 + +- [x] 2.1 `plugins-workspace/plugins/deep-link/build.rs` 新增 `fn ohos_skill(domain: &AssociatedDomain) -> serde_json::Value`:映射 `scheme`→`uris[].scheme`(多 scheme 多 uris 对象)、`host`→`uris[].host`、`path`→`uris[].path`、`path_pattern`→`uris[].pathRegex`、`path_prefix`→`uris[].pathStartWith`、`path_suffix`丢弃+`cargo:warning`、`app_link`→`domainVerify`;固定 `entities:["entity.system.browsable"]`、`actions:["ohos.want.action.viewData"]` +- [x] 2.2 `build.rs` 在 iOS 分支后新增 OHOS 分支:检查 `TAURI_OHOS_PROJECT_PATH` 存在,读 `config.mobile`,`config.mobile.iter().map(ohos_skill).collect()` 生成 skills 数组,调 `tauri_plugin::mobile::update_ohos_module_json` — **补充**:`plugins-workspace/Cargo.toml` 的 `[patch.crates-io]` tauri-plugin 从 git ohdev 改为 `path = "../tauri/crates/tauri-plugin"` + `cargo update -p tauri-plugin`,让 deep-link 用本地 tauri-plugin(含 update_ohos_module_json) + +## 3. 验证 + +- [ ] 3.1 OHOS 构建后检查 `entry_mobile/src/main/module.json5`:`abilities[0].skills` 含 deep-link skill — **待 OHOS 构建环境** +- [ ] 3.2 重复构建两次后检查 skills 数组不累积 — **待 OHOS 构建环境** +- [ ] 3.3 检查 home 入口 skill 保留不变 — **待 OHOS 构建环境** +- [x] 3.4 非 OHOS 构建(desktop)确认 `update_ohos_module_json` no-op:desktop `cargo check -p tauri-plugin-deep-link` 通过(44.52s),函数内 `TAURI_OHOS_PROJECT_PATH` 未设时 `return Ok(())` +- [ ] 3.5 设备端验证:`hdc shell aa start -a ohos.want.action.viewData -d myapp://path` 唤起 app — **待设备** +- [ ] 3.6 desktop form 构建后检查 `entry_desktop/src/main/module.json5` — **待 OHOS 构建环境** diff --git a/openspec/changes/archive/2026-07-09-p3-deep-link/.openspec.yaml b/openspec/changes/archive/2026-07-09-p3-deep-link/.openspec.yaml new file mode 100644 index 000000000000..43e65ca6e667 --- /dev/null +++ b/openspec/changes/archive/2026-07-09-p3-deep-link/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-03 diff --git a/openspec/changes/archive/2026-07-09-p3-deep-link/README.md b/openspec/changes/archive/2026-07-09-p3-deep-link/README.md new file mode 100644 index 000000000000..4b38339d37ae --- /dev/null +++ b/openspec/changes/archive/2026-07-09-p3-deep-link/README.md @@ -0,0 +1,3 @@ +# p3-deep-link + +Phase 3: deep-link OHOS 测试与文档 diff --git a/openspec/changes/archive/2026-07-09-p3-deep-link/design.md b/openspec/changes/archive/2026-07-09-p3-deep-link/design.md new file mode 100644 index 000000000000..3f2fe0dc5a19 --- /dev/null +++ b/openspec/changes/archive/2026-07-09-p3-deep-link/design.md @@ -0,0 +1,76 @@ +## Context + +Phase 1/2 实现了 deep-link 的 OHOS 功能。Phase 3 补充测试与文档。 + +**测试基础设施**(`frontend-api-testing` skill): +- 分类:`auto`(可自动断言)/ `side-effect`(有副作用可程序验证)/ `manual`(需人工确认) +- 位置:`examples/api/src/lib/tests/plugins.ts`(动态 import 防加载失败) +- 约束:5 秒超时(`test-runner.ts:30`);OHOS 不支持的 plugin 用 cfg 排除但测试保留 +- 最佳参考:`global-shortcut` 的 `register+isRegistered` 模式(`plugins.ts:674-702`)、`notification` 的 try/catch 容错(`plugins.ts:496-499`) + +**deep-link 现状**:完全不在 api demo(无依赖/注册/权限/测试);`examples/app` 无 OHOS 配置;README 无 OHOS 章节。 + +**OHOS 测试约束**(`ohos-rust-ut` skill):不能用 mock runtime(desktop 专用),只能测纯函数;设备端 `--test-threads=1`。 + +**前置依赖**:Phase 3 在 Phase 1/2 完成后进行,deep-link 已能 OHOS 编译,`getCurrent`/`isRegistered`/`register`/`onOpenUrl` 均有 OHOS 实现可测。 + +## Goals / Non-Goals + +**Goals:** +- 4 个 auto 测试用例(可自动断言) +- 3 个 manual 测试用例(人工确认清单) +- api demo 接入 deep-link(4 步配置) +- `examples/app` OHOS 化 +- README OHOS 章节 + +**Non-Goals:** +- 完整 e2e 自动化(外部链接唤起需 manual,无法自动化) +- side-effect 用例(deep-link 无可程序验证的副作用场景——register 是 no-op,onOpenUrl 触发需外部唤起) + +## Decisions + +### D1: 测试分类——4 auto + 3 manual +**选择**: +- auto:`getCurrent()`(非链接启动返回 null/空数组)、`isRegistered(scheme)`(返回 false)、`register(scheme)`+`unregister(scheme)`(no-op 不抛错)、`onOpenUrl` 注册返回 UnlistenFn(`typeof === 'function'`) +- manual:`onOpenUrl` 事件实际触发(需外部链接唤起)、`getCurrent()` 经链接启动(需外部唤起)、外部链接唤起 app(跨 app 行为) + +**理由**:auto 用例基于 Phase 1 的 no-op 语义(`isRegistered`→`Ok(false)`、`register`/`unregister`→`Ok(())`)和纯注册行为(`onOpenUrl` 返回 UnlistenFn),可自动断言。manual 用例需外部链接唤起,autotest 无法触发。无 side-effect 用例——deep-link 的 register 是 no-op 无副作用,onOpenUrl 触发需外部唤起不可程序验证。 + +### D2: 测试位置——plugins.ts 动态 import +**选择**:测试写 `examples/api/src/lib/tests/plugins.ts`,用动态 `import('@tauri-apps/plugin-deep-link')` 防加载失败影响其他测试。 + +**理由**:`frontend-api-testing` skill 规定 plugin 测试用动态 import(`SKILL.md:81-91`)。参考 `notification`/`global-shortcut` 的模式。 + +### D3: api demo 接入 deep-link(4 步) +**选择**: +1. `examples/api/src-tauri/Cargo.toml` 加 `tauri-plugin-deep-link` 依赖 +2. `examples/api/package.json` 加 `@tauri-apps/plugin-deep-link` 依赖 +3. `examples/api/src-tauri/src/lib.rs` 注册 `.plugin(tauri_plugin_deep_link::init())` +4. `examples/api/src-tauri/capabilities/run-app.json` 加 `deep-link:default` 权限 + +**理由**:`frontend-api-testing` skill 的 4 步接入流程(`test-template.md:139-172`)。Phase 3 在 Phase 1/2 完成后,deep-link 已能 OHOS 编译,**无需 `cfg(not(ohos))` 排除**。 + +### D4: examples/app OHOS 化 +**选择**: +- `tauri.conf.json` 加 OHOS deep-link 配置段(mobile domains,对标现有 mobile/desktop 段) +- `Cargo.toml` 的 desktop feature(`x11`/`common-controls-v6`)加 `not(target_env="ohos")` 隔离 +- `lib.rs` 的 `register_all`(`:37-38`)加 `not(target_env="ohos")` 排除(OHOS 不支持运行时注册) + +**理由**:`examples/app` 当前仅 desktop 配置(`tauri.conf.json:30-46`)。OHOS 化让 example app 可在 OHOS 设备演示 deep-link。 + +### D5: README OHOS 章节 +**选择**:`README.md` 平台表(`:5-11`)加 OHOS 行;Configuration 段(`:103-121`)补 OHOS 配置说明(`mobile` domains → module.json5 skills)。 + +**理由**:对标其他已适配插件的 README。让用户了解 OHOS 配置方式。 + +### D6: Rust UT(可选) +**选择**:若 Phase 1/2 产出可测纯函数(如 Phase 2 的 `ohos_skill` 字段映射逻辑),按 `ohos-rust-ut` skill 提取为纯函数,设备端 `cargo test --target aarch64-unknown-linux-ohos --no-run` → `hdc file send` → `hdc shell ... --test-threads=1`。 + +**理由**:`ohos-rust-ut` skill 约束:OHOS 不能用 mock runtime,只能测纯函数。`ohos_skill` 映射逻辑是纯函数,可测。但依赖 Phase 2 实现是否提取为可测函数,故标可选。 + +## Risks / Trade-offs + +- **[测试依赖 Phase 1/2]** → D3 明确 Phase 3 在 Phase 1/2 完成后进行;若 Phase 1/2 未完成,auto 用例会失败(`getCurrent`/`isRegistered` 走错误路径)。 +- **[manual 测试无法自动化]** → D1 manual 用例写为 `wrapManual()` 清单(`SKILL.md:101-134`),console-log 自动捕获,hdc 拉取。 +- **[api demo 接入回归]** → D3 4 步配置需确保不破坏现有 api demo 构建;cfg 隔离 desktop feature。 +- **[Rust UT 可选]** → D6 依赖 Phase 2 实现,若 `ohos_skill` 未提取为纯函数则跳过。 diff --git a/openspec/changes/archive/2026-07-09-p3-deep-link/proposal.md b/openspec/changes/archive/2026-07-09-p3-deep-link/proposal.md new file mode 100644 index 000000000000..a8e472f885ab --- /dev/null +++ b/openspec/changes/archive/2026-07-09-p3-deep-link/proposal.md @@ -0,0 +1,27 @@ +## Why + +Phase 1/2 实现了 deep-link 的 OHOS 功能(运行中事件接入 + 首启动 `get_current` + scheme 注册声明),但缺少前端测试用例和文档。deep-link 当前**完全不在 api demo**(`examples/api` 的 `package.json`/`Cargo.toml`/`lib.rs`/`capabilities` 均无 deep-link),README 无 OHOS 章节(`README.md:5-11` 平台表无 OHOS)。Phase 3 补充测试与文档,确保 deep-link 在 OHOS 上的行为可验证、可维护,对标其他已适配插件(notification/global-shortcut)的测试覆盖。 + +## What Changes + +- **前端测试用例**(`examples/api/src/lib/tests/plugins.ts`):4 个 auto(`getCurrent`/`isRegistered`/`register`+`unregister`/`onOpenUrl` 注册返回 UnlistenFn)+ 3 个 manual(`onOpenUrl` 事件触发/`getCurrent` 首启动/外部链接唤起)。 +- **api demo 接入 deep-link**:4 步(`Cargo.toml` 依赖/`package.json` 依赖/`lib.rs` 注册/`capabilities` `deep-link:default`)。Phase 3 在 Phase 1/2 完成后进行,deep-link 已能 OHOS 编译,无需 cfg 排除。 +- **`examples/app` OHOS 化**:`tauri.conf.json` 加 OHOS deep-link 配置段、`Cargo.toml` cfg 隔离 desktop feature、`lib.rs` OHOS 注册。 +- **README 补充 OHOS 章节**:平台表加 OHOS 行 + Configuration 段补 OHOS 配置说明。 +- **Rust UT(可选)**:若 Phase 1/2 产出可测纯函数(如 scheme 映射),按 `ohos-rust-ut` skill 设备端 `--test-threads=1`。 + +## Capabilities + +### New Capabilities +- `ohos-deep-link-testing`: deep-link OHOS 前端测试用例(auto/manual 分类)+ api demo 接入 + `examples/app` OHOS 化 + README 文档。 + +### Modified Capabilities + + +## Impact + +- **代码-测试**:`tauri/examples/api/src/lib/tests/plugins.ts`(新增 deep-link 测试用例) +- **代码-api demo 配置**:`tauri/examples/api/src-tauri/Cargo.toml`、`tauri/examples/api/package.json`、`tauri/examples/api/src-tauri/src/lib.rs`、`tauri/examples/api/src-tauri/capabilities/run-app.json`— 4 文件 +- **代码-examples/app**:`plugins-workspace/plugins/deep-link/examples/app/src-tauri/tauri.conf.json`、`src-tauri/Cargo.toml`、`src-tauri/src/lib.rs`— 3 文件 +- **文档**:`plugins-workspace/plugins/deep-link/README.md` +- **后续**:无(Phase 3 为最后一个 Phase) diff --git a/openspec/changes/archive/2026-07-09-p3-deep-link/specs/ohos-deep-link-testing/spec.md b/openspec/changes/archive/2026-07-09-p3-deep-link/specs/ohos-deep-link-testing/spec.md new file mode 100644 index 000000000000..b5e6c75bd6aa --- /dev/null +++ b/openspec/changes/archive/2026-07-09-p3-deep-link/specs/ohos-deep-link-testing/spec.md @@ -0,0 +1,73 @@ +# ohos-deep-link-testing Specification + +## Purpose +deep-link OHOS 前端测试用例(auto/manual 分类)+ api demo 接入 + examples/app OHOS 化 + README 文档规范。确保 deep-link 在 OHOS 上的行为可验证、可维护。 + +## ADDED Requirements + +### Requirement: auto 测试用例可自动断言 +`plugins.ts` SHALL 包含以下 auto 测试用例(可自动断言,5 秒超时):`getCurrent()`(非链接启动返回 `null` 或空数组)、`isRegistered(scheme)`(返回 `false`)、`register(scheme)`+`unregister(scheme)`(no-op 不抛错)、`onOpenUrl` 注册返回 `UnlistenFn`(`typeof === 'function'`)。用例 SHALL 用动态 `import('@tauri-apps/plugin-deep-link')` 加载。 + +#### Scenario: getCurrent 非链接启动 +- **WHEN** app 正常启动(非 deep-link 触发),调用 `getCurrent()` +- **THEN** SHALL 返回 `null` 或空数组,auto 断言 `result === null || Array.isArray(result)` + +#### Scenario: isRegistered 返回 false +- **WHEN** 调用 `isRegistered("myapp")` +- **THEN** SHALL 返回 `false`(OHOS no-op 语义),auto 断言 `result === false` + +#### Scenario: register/unregister no-op 不抛错 +- **WHEN** 调用 `register("myapp")` 后 `unregister("myapp")` +- **THEN** SHALL 不抛错(no-op 返回 null),auto 断言无 throw + +#### Scenario: onOpenUrl 注册返回 UnlistenFn +- **WHEN** 调用 `onOpenUrl(() => {})` 注册回调 +- **THEN** SHALL 返回 `UnlistenFn`,auto 断言 `typeof unlisten === 'function'` + +### Requirement: manual 测试用例人工确认 +`plugins.ts` SHALL 包含以下 manual 测试用例(用 `wrapManual()` 包装,需人工确认):`onOpenUrl` 事件实际触发(需外部链接唤起)、`getCurrent()` 经链接启动(需外部唤起 app)、外部链接唤起 app(跨 app 行为)。 + +#### Scenario: onOpenUrl 事件触发 +- **WHEN** 注册 `onOpenUrl` 回调后,人工用 `hdc shell aa start -d myapp://path` 唤起 +- **THEN** 回调 SHALL 被调用,urls 包含 `["myapp://path"]`,人工确认 + +#### Scenario: getCurrent 经链接启动 +- **WHEN** app 未运行,人工用 `myapp://path` 链接拉起,调用 `getCurrent()` +- **THEN** SHALL 返回 `["myapp://path"]`,人工确认 + +#### Scenario: 外部链接唤起 app +- **WHEN** 人工从浏览器/其他 app 点击 `myapp://path` 链接 +- **THEN** app SHALL 被唤起到前台,人工确认 + +### Requirement: api demo 接入 deep-link +api demo(`examples/api`)SHALL 接入 deep-link 插件:`src-tauri/Cargo.toml` 加 `tauri-plugin-deep-link` 依赖;`package.json` 加 `@tauri-apps/plugin-deep-link` 依赖;`src-tauri/src/lib.rs` 注册 `.plugin(tauri_plugin_deep_link::init())`;`capabilities/run-app.json` 加 `deep-link:default` 权限。Phase 3 在 Phase 1/2 完成后进行,无需 `cfg(not(ohos))` 排除。 + +#### Scenario: api demo OHOS 构建含 deep-link +- **WHEN** api demo 在 OHOS target 构建 +- **THEN** deep-link 插件 SHALL 被注册,前端 `import('@tauri-apps/plugin-deep-link')` SHALL 成功加载 + +#### Scenario: capabilities 含 deep-link 权限 +- **WHEN** 检查 `run-app.json` +- **THEN** SHALL 含 `deep-link:default` 权限 + +### Requirement: examples/app OHOS 化 +`examples/app` SHALL 支持 OHOS:`tauri.conf.json` 加 OHOS deep-link 配置段(mobile domains);`Cargo.toml` 的 desktop feature(`x11`/`common-controls-v6`)加 `not(target_env="ohos")` 隔离;`lib.rs` 的 `register_all` 加 `not(target_env="ohos")` 排除。 + +#### Scenario: examples/app OHOS 构建通过 +- **WHEN** `examples/app` 在 OHOS target 构建 +- **THEN** SHALL 编译成功,desktop feature 不误入 OHOS + +#### Scenario: tauri.conf.json 含 OHOS 配置 +- **WHEN** 检查 `examples/app/src-tauri/tauri.conf.json` +- **THEN** SHALL 含 deep-link 的 mobile domains 配置(用于 Phase 2 module.json5 skills 注入) + +### Requirement: README 补充 OHOS 章节 +`README.md` 平台表 SHALL 加 OHOS 行(`openharmony = { level = "partial" }`);Configuration 段 SHALL 补 OHOS 配置说明(`mobile` domains → module.json5 skills 声明,scheme 注册为构建时静态声明,非运行时动态注册)。 + +#### Scenario: README 平台表含 OHOS +- **WHEN** 检查 `README.md` 平台表 +- **THEN** SHALL 含 OHOS 行,标注支持等级与限制 + +#### Scenario: README Configuration 段含 OHOS 说明 +- **WHEN** 检查 Configuration 段 +- **THEN** SHALL 说明 OHOS scheme 注册为构建时 module.json5 skills 声明(非运行时 register) diff --git a/openspec/changes/archive/2026-07-09-p3-deep-link/tasks.md b/openspec/changes/archive/2026-07-09-p3-deep-link/tasks.md new file mode 100644 index 000000000000..6c71e599452f --- /dev/null +++ b/openspec/changes/archive/2026-07-09-p3-deep-link/tasks.md @@ -0,0 +1,34 @@ +## 1. 前端测试用例(plugins.ts) + +- [x] 1.1 `tauri/examples/api/src/lib/tests/plugins.ts` 新增 deep-link 测试块(动态 `import`),4 个 auto 用例:`getCurrent`/`isRegistered`(false)/`register+unregister`(不抛错)/`onOpenUrl` 注册返回 UnlistenFn。参考 global-shortcut 模式 +- [x] 1.2 新增 3 个 manual 用例:`onOpenUrl` 事件触发/`getCurrent` 冷启动/外部链接唤起 — **实现调整**:用 `category: 'manual'`(与现有 dialog manual 用例一致),未用 `wrapManual()` + +## 2. api demo 接入 deep-link(4 步) + +- [x] 2.1 `tauri/examples/api/src-tauri/Cargo.toml` 加 `tauri-plugin-deep-link = { path = "../../../../plugins-workspace/plugins/deep-link" }` +- [x] 2.2 `tauri/examples/api/package.json` 加 `"@tauri-apps/plugin-deep-link": "file:..."` +- [x] 2.3 `tauri/examples/api/src-tauri/src/lib.rs` 注册 `.plugin(tauri_plugin_deep_link::init())`(desktop 块 + OHOS 块各一处) +- [x] 2.4 `tauri/examples/api/src-tauri/capabilities/run-app.json` 加 `"deep-link:default"` + +## 3. examples/app OHOS 化 + +- [x] 3.1 `tauri.conf.json` — **无需改**:mobile domains 配置已存在(`fabianlars.de`/`tauri.app`/`taurideeplink`),OHOS 复用 mobile 配置(Phase 2 build.rs 读 `config.mobile` 生成 skills) +- [x] 3.2 `examples/app/src-tauri/Cargo.toml` desktop feature 隔离:`tauri={features=["wry"]}` + `[target.'cfg(not(target_env="ohos"))'.dependencies] tauri={features=["common-controls-v6","x11"]}` +- [x] 3.3 `examples/app/src-tauri/src/lib.rs` `register_all` cfg 加 `not(target_env="ohos")`:`#[cfg(any(all(target_os="linux", not(target_env="ohos")), all(debug_assertions, windows)))]` + +## 4. README 补充 OHOS 章节 + +- [x] 4.1 平台表加 `| OpenHarmony | ✓ |` +- [x] 4.2 Configuration 段加 OpenHarmony 说明(mobile→module.json5 skills 静态声明、register no-op、getCurrent 首启动、onOpenUrl onNewWant) + +## 5. Rust UT(可选) + +- [ ] 5.1 跳过 — `ohos_skill` 在 build.rs 内,提取为可测纯函数需重构 build.rs 结构,留作后续优化 + +## 6. 验证 + +- [ ] 6.1 api demo OHOS 构建:deep-link 注册 + 前端 import 加载 — **待 OHOS 构建环境** +- [ ] 6.2 Run All(auto):4 个 auto 用例通过 — **待设备** +- [ ] 6.3 manual 用例:`hdc shell aa start` 唤起 — **待设备** +- [ ] 6.4 examples/app OHOS 构建通过 — **待 OHOS 构建环境** +- [x] 6.5 README 平台表 + Configuration 段含 OHOS 说明 diff --git a/openspec/changes/deep-link-plan.md b/openspec/changes/deep-link-plan.md new file mode 100644 index 000000000000..d4560ac2c4d4 --- /dev/null +++ b/openspec/changes/deep-link-plan.md @@ -0,0 +1,48 @@ +# Deep-Link 适配计划 + +**创建时间**:2026-07-03 +**功能描述**:tauri-plugin-deep-link OHOS 适配 — 接收外部 URI scheme 唤起、`get_current`、`register`/`unregister`/`is_registered`,完整对标 iOS 行为 +**判断依据**:涉及 4 个代码层(deep-link 插件 / openharmony-ability / tauri-plugin+cli / tao+tauri 核心),预估 10-13 个文件 + +## Phase 列表 + +| Phase | 名称 | openspec change | 状态 | 涉及层 | 预估文件 | 验证方式 | +|-------|------|----------------|------|--------|---------|---------| +| 1 | 编译打通 + 运行中事件 + 首启动 get_current + register no-op | p1-deep-link | ✓ 设计完成 | deep-link 插件 + openharmony-ability | 7-8 | cargo check(ohos) + 设备端 onNewWant/冷启动验证 | +| 2 | scheme 注册声明(构建时注入) | p2-deep-link | ✓ 设计完成 | deep-link + tauri-plugin/cli | 3-4 | 构建产物 `module.json5` 含 `uris/skills` + 外部链接唤起 app | +| 3 | 测试与文档 | p3-deep-link | ✓ 设计完成 | examples + 前端测试 | 2-3 | auto/side-effect/manual 用例通过 | + +> 原拆分为 4-Phase。后因首启动 `get_current` 提前到 Phase 1,原 Phase 3(首启动+命令语义)内容并入 Phase 1,改为 3-Phase。 + +## Phase 详细说明 + +### Phase 1: 编译打通 + 运行中事件 + 首启动 get_current + register no-op +- **目标**:deep-link 在 OHOS `cargo check` 通过;接入现成 `RunEvent::Opened` 链路实现"运行中收到链接"emit `deep-link://new-url`;通过 `openharmony-ability` 新增 `take_initial_want_uri` getter 实现首启动 `get_current`;`register`/`unregister` no-op、`is_registered` 返回 `Ok(false)` +- **关键发现**:`onNewWant → RunEvent::Opened` 链路已就绪(tao mod.rs:595、tauri-runtime-wry lib.rs:4737、app.rs:2675);冷启动 `onCreate` 未提取 `want.uri`(NativeAbility.ets:80),需 openharmony-ability 补 getter(复刻 `take_want_parameters` 模式,pull 模型,无新 Event) +- **文件列表**: + - deep-link 插件:`Cargo.toml`、`src/lib.rs`、`src/commands.rs`(3 文件;`build.rs` 经审计无需结构性改动,见 p1 design D7) + - openharmony-ability:`crates/ability/src/app.rs`(INITIAL_WANT_URI+store+take)、`crates/ability/src/lifecycle.rs`(onAbilityCreateWithWant 闭包)、`native_ability/src/main/ets/ability/type.ets`(字段)、`native_ability/src/main/ets/ability/NativeAbility.ets`(onCreate 调用)— 4 文件 +- **依赖**:无(复用 tao/tauri 已就绪的 `RunEvent::Opened` 链路 + openharmony-ability 新增 getter) + +### Phase 2: scheme 注册声明(构建时注入) +- **目标**:实现 `module.json5` 的 `skills/uris` 声明,让系统能识别并路由 deep link 到 app;提供构建时自动注入机制 +- **关键缺口**:当前工程 `module.json5` 仅 home 入口 skills,无 `uris/scheme`;`tauri-plugin` 无 OHOS module.json5 注入 API(仅有 `update_android_manifest`/`update_entitlements`) +- **文件列表**: + - `plugins-workspace/plugins/deep-link/build.rs` — 新增 `#[cfg(target_env="ohos")]` 分支,读 `config.mobile` 生成 skills(`entity.system.browsable` + `ohos.want.action.viewData` + `uris:[{scheme,host}]`) + - `tauri-plugin/src/mobile.rs` — 新增 `update_ohos_module_json` 注入 API + - `tauri-cli` 模板 `module.json5` — 增加 skills 模板钩子(若需要) +- **依赖**:Phase 1 完成 + +### Phase 3: 测试与文档 +- **目标**:前端 API 测试(auto/side-effect/manual)+ examples + README 文档 +- **文件列表**: + - `plugins-workspace/plugins/deep-link/examples`(OHOS 用例) + - 前端测试用例(core.ts/plugins.ts,auto/side-effect/manual 分类) + - README 更新 +- **依赖**:Phase 2 完成 + +## 状态说明 +- `○ 待开始` — 未开始设计 +- `● 进行中` — 正在设计或实现 +- `✓ 设计完成` — 设计文档已生成并通过审计 +- `✓ 已归档` — 已完成实现、测试并归档 diff --git a/openspec/specs/ohos-deep-link-event/spec.md b/openspec/specs/ohos-deep-link-event/spec.md new file mode 100644 index 000000000000..7b3f88eb9056 --- /dev/null +++ b/openspec/specs/ohos-deep-link-event/spec.md @@ -0,0 +1,98 @@ +# ohos-deep-link-event Specification + +## Purpose +TBD - created by archiving change p1-deep-link. Update Purpose after archive. +## Requirements +### Requirement: deep-link crate 在 OHOS target 编译通过且不影响其他平台 +`tauri-plugin-deep-link` SHALL 在 OHOS target(`target_env="ohos"`)下编译成功。所有 OHOS 代码 SHALL 通过 `cfg(target_env="ohos")` 隔离。Linux 专属依赖(`rust-ini`)SHALL 加 `not(target_env="ohos")` 排除,不得引入 OHOS。Windows/macOS/Linux/iOS/Android 的现有代码路径 SHALL 保持不变。 + +#### Scenario: OHOS target 编译成功 +- **WHEN** 使用 OHOS target 编译 `tauri-plugin-deep-link` crate +- **THEN** SHALL 编译成功,`init_deep_link` 返回有效的 `DeepLink`,无"函数无返回值"或"Linux 分支误命中"错误 + +#### Scenario: 非 OHOS target 不受影响 +- **WHEN** 使用 Windows/macOS/Linux/iOS/Android target 编译 +- **THEN** 现有平台实现 SHALL 不受任何影响,行为与改动前一致 + +### Requirement: 运行中收到外部链接触发 deep-link 事件 +当 app 已在运行,OHOS 通过 `onNewWant` 投递携带有效 `want.uri` 的 Want 时,经 `Event::NewWant` → tao `Event::Opened{urls}` → `RunEvent::Opened{urls}` 链路,deep-link 插件的 `on_event` 闭包 SHALL emit `deep-link://new-url` 事件(payload 为 `Vec`),并更新内部 `current` 状态。 + +#### Scenario: OHOS 单 URL 场景 +- **WHEN** app 运行中,OHOS 调用 `onNewWant(want)` 且 `want.uri` 为 `"myapp://path"` +- **THEN** tao 将单个 `want.uri` 解析为 `vec!["myapp://path"]`,插件 SHALL emit `deep-link://new-url`,payload 为 `["myapp://path"]`,`current` 更新为该 URL + +#### Scenario: 多 URL 场景(仅 macOS/iOS) +- **WHEN** `RunEvent::Opened { urls }` 中 `urls` 含多个有效 URL(macOS/iOS 系统可能传多 URL) +- **THEN** 插件 SHALL 将完整 `Vec` 作为 payload emit,`current` 更新为该完整列表 +- **NOTE** OHOS 的 tao 实现将单个 `want.uri` 解析为单元素 `Vec`(`tao platform_impl/ohos/mod.rs:595-609`),不产生多 URL + +### Requirement: 空 URI 的再启动不触发 deep-link 事件 +OHOS 的 `onNewWant` 在 singleton 模式下每次"再启动"都触发,即使无 URI 也 emit 空 `Vec`(`tao platform_impl/ohos/mod.rs:596`)。deep-link 插件 SHALL 过滤 `urls.is_empty()`,不得在无链接的再启动时 emit `deep-link://new-url`,避免误触发前端监听器。 + +#### Scenario: onNewWant 空 URI +- **WHEN** app 运行中,OHOS 调用 `onNewWant(want)` 且 `want.uri` 为空字符串 +- **THEN** `RunEvent::Opened { urls: vec![] }` 被投递,但插件 SHALL 不 emit `deep-link://new-url`,`current` SHALL 保持不变 + +### Requirement: register/unregister 在 OHOS 返回 no-op,is_registered 返回 Ok(false) +OHOS 上运行时动态注册 scheme 不被支持(scheme 声明由 Phase 2 的 module.json5 skills 处理)。`register`/`unregister` SHALL 在 OHOS 独立 `#[cfg(target_env="ohos")]` 分支返回 `Ok(())`(no-op);`is_registered` SHALL 返回 `Ok(false)`(OHOS 无运行时注册状态)。`register`/`unregister`/`is_registered` 的 `#[cfg(target_os="linux")]` 分支 SHALL 修改为 `#[cfg(all(target_os="linux", not(target_env="ohos")))]` 避免与 ohos 独立分支冲突(E0592);fallback 分支 `#[cfg(not(any(windows, target_os="linux")))]` 不变(macOS/iOS 仍命中返回 UnsupportedPlatform)。 + +#### Scenario: 调用 register 返回 no-op +- **WHEN** 在 OHOS 上调用 `deep_link.register("myapp")` +- **THEN** SHALL 返回 `Ok(())`,不执行任何注册操作 + +#### Scenario: 调用 unregister 返回 no-op +- **WHEN** 在 OHOS 上调用 `deep_link.unregister("myapp")` +- **THEN** SHALL 返回 `Ok(())` + +#### Scenario: 调用 is_registered 返回 false +- **WHEN** 在 OHOS 上调用 `deep_link.is_registered("myapp")` +- **THEN** SHALL 返回 `Ok(false)` + +#### Scenario: Linux 分支不误命中 OHOS +- **WHEN** 在 OHOS 上调用 `register`/`unregister`/`is_registered` +- **THEN** SHALL 不执行 `xdg-mime`/`update-desktop-database` 等 Linux 桌面命令,不读写 `mimeapps.list` + +### Requirement: 首启动 get_current 经 take_initial_want_uri 注入 current +冷启动由链接拉起时,`NativeAbility.onCreate` 提取 `want.uri` 经 `onAbilityCreateWithWant` 闭包存储到 `INITIAL_WANT_URI`(openharmony-ability 新增,复刻 `take_want_parameters` 模式,pull 模型,无新 Event 变体);deep-link 的 `init_deep_link` OHOS 分支在返回前调 `openharmony_ability::take_initial_want_uri()`,将首启动 uri 解析为 `Url` 存入 `current`。`get_current` SHALL 返回 `current`(首启动值由 init 注入,运行中值由 `on_event` 更新)。 + +#### Scenario: 冷启动由链接拉起 +- **WHEN** app 未运行,由 `"myapp://path"` 链接拉起,`onCreate` 的 `want.uri="myapp://path"`,插件初始化后调用 `get_current` +- **THEN** SHALL 返回 `Ok(Some(vec!["myapp://path"]))` + +#### Scenario: 冷启动非链接拉起 +- **WHEN** app 冷启动但 `want.uri` 为空,插件初始化后调用 `get_current` +- **THEN** SHALL 返回 `Ok(None)` + +#### Scenario: 运行中收到链接后 get_current +- **WHEN** 已通过 `onNewWant` 收到 `"myapp://path"` 并触发 `RunEvent::Opened` 更新 `current`,调用 `get_current` +- **THEN** SHALL 返回 `Ok(Some(vec!["myapp://path"]))` + +### Requirement: on_open_url 监听 API 在 OHOS 行为一致 +deep-link 插件的 `on_open_url` 方法(`lib.rs:515`)SHALL 在 OHOS 上监听 `deep-link://new-url` 事件,收到时回调 `OpenUrlEvent{urls}`,返回 `EventId` 供 `unlisten` 使用。行为 SHALL 与 macOS/iOS 一致。 + +#### Scenario: 注册监听后收到链接 +- **WHEN** 前端调用 `on_open_url` 注册回调,app 运行中收到 `onNewWant` 携带 `"myapp://path"` +- **THEN** 回调 SHALL 被调用,`OpenUrlEvent.urls` 包含 `["myapp://path"]`,返回有效 `EventId` + +#### Scenario: unlisten 取消监听 +- **WHEN** 用返回的 `EventId` 调用 `Listener::unlisten` +- **THEN** 后续 `deep-link://new-url` 事件 SHALL 不再触发该回调 + +### Requirement: want.parameters 不影响 deep-link 事件 +OHOS 的 `want.parameters` 通过 `openharmony_ability::take_want_parameters()` 独立读取(`ohos-want-parameters` spec),**不随** `RunEvent::Opened{urls}` 传递。deep-link 插件 SHALL 只消费 `urls`,不读取 `want.parameters`。`take_initial_want_uri` 只存储 `want.uri`,不存储 parameters。 + +#### Scenario: onNewWant 携带 parameters 不影响 deep-link +- **WHEN** `onNewWant(want)` 携带 `want.uri="myapp://path"` 且 `want.parameters={"source":"widget"}` +- **THEN** deep-link 插件 emit 的 `deep-link://new-url` payload SHALL 仅含 `["myapp://path"]`,不包含 parameters 信息 + +### Requirement: scheme 匹配由系统 module.json5 skills 决定(Phase 2 范围) +OHOS 上 URI scheme 的匹配过滤由系统 `module.json5` 的 `skills/uris` 声明决定(Phase 2 实现)。Phase 1 范围内,deep-link 插件的 `on_event` SHALL 不对 `urls` 做二次 scheme 过滤——收到的 `urls` 均为系统 skills 匹配后路由到 app 的结果。 + +#### Scenario: Phase 1 不做 scheme 二次过滤 +- **WHEN** `RunEvent::Opened { urls }` 投递到 `on_event`(`urls` 已由系统 skills 匹配) +- **THEN** 插件 SHALL 直接 emit `urls`,不做 scheme 匹配过滤 + +#### Scenario: 未配置 skills 的 scheme 不唤起(Phase 2 范围) +- **WHEN** `module.json5` 未声明某 scheme 的 skills,外部链接使用该 scheme +- **THEN** 系统 SHALL 不路由到 app,`onNewWant` 不触发(此为 Phase 2 module.json5 skills 声明的职责,Phase 1 不处理) + diff --git a/openspec/specs/ohos-deep-link-scheme-registration/spec.md b/openspec/specs/ohos-deep-link-scheme-registration/spec.md new file mode 100644 index 000000000000..cff7174c07cc --- /dev/null +++ b/openspec/specs/ohos-deep-link-scheme-registration/spec.md @@ -0,0 +1,64 @@ +# ohos-deep-link-scheme-registration Specification + +## Purpose +TBD - created by archiving change p2-deep-link. Update Purpose after archive. +## Requirements +### Requirement: update_ohos_module_json 注入 API +`tauri-plugin` SHALL 提供 `mobile::update_ohos_module_json(skills: serde_json::Value)` 函数。该函数 SHALL 读 `TAURI_OHOS_PROJECT_PATH` 环境变量自门控(未设则 no-op 返回 `Ok(())`);定位 `{project_path}/entry_{OHOS_DEVICE_TYPE}/src/main/module.json5`;用 json5 parse → mutate → serialize 写回。skill 对象 SHALL 追加到 `module.abilities[0].skills` 数组。 + +#### Scenario: OHOS 构建时注入 skills +- **WHEN** OHOS 构建,`TAURI_OHOS_PROJECT_PATH` 已设,deep-link `config.mobile` 含 `AssociatedDomain{scheme:["myapp"]}` +- **THEN** `entry_mobile/src/main/module.json5` 的 `abilities[0].skills` SHALL 追加含 `uris:[{scheme:"myapp"}]` 的独立 skill 对象 + +#### Scenario: 非 OHOS 构建 no-op +- **WHEN** `TAURI_OHOS_PROJECT_PATH` 未设置(非 OHOS 构建) +- **THEN** `update_ohos_module_json` SHALL no-op,不修改任何文件,返回 `Ok(())` + +### Requirement: 幂等性——重复构建不累积 +重复构建时,`update_ohos_module_json` SHALL 先移除 `abilities[0].skills` 中已有的 deep-link skill(按 `actions` 含 `ohos.want.action.viewData` 且 `entities` 含 `entity.system.browsable` 签名匹配),再按 config 重新注入,不得累积重复 skill 对象。 + +#### Scenario: 重复构建不累积 +- **WHEN** 连续两次 OHOS 构建,`config.mobile` 不变 +- **THEN** `module.json5` 的 `skills` 数组 SHALL 只含一份 deep-link skill 对象,不重复 + +#### Scenario: 配置变更后重新注入 +- **WHEN** 第一次构建注入 `scheme:["myapp"]`,第二次构建 `config.mobile` 改为 `scheme:["myapp2"]` +- **THEN** 第二次构建后 SHALL 只含 `scheme:"myapp2"` 的 skill,旧的 `scheme:"myapp"` 被移除 + +### Requirement: home 入口 skill 不被破坏 +注入 SHALL 追加独立 skill 对象到 `abilities[0].skills` 末尾,不修改现有 home 入口 skill(`entities:["entity.system.home"]`、`actions:["action.system.home"]`)。 + +#### Scenario: home skill 保留 +- **WHEN** 注入 deep-link skills +- **THEN** home 入口 skill SHALL 保持不变(`entities:["entity.system.home"]`、`actions:["action.system.home"]`),deep-link skill 为独立新增对象 + +### Requirement: AssociatedDomain→OHOS skill 字段映射 +deep-link `build.rs` SHALL 把 `config.mobile` 的每个 `AssociatedDomain` 映射为一个 OHOS skill 对象:`scheme`→`uris[].scheme`(多 scheme 生成多个 uris 对象)、`host`→`uris[].host`、`path`→`uris[].path`、`path_pattern`→`uris[].pathRegex`、`path_prefix`→`uris[].pathStartWith`、`path_suffix`丢弃(OHOS 无对应)、`app_link=true`→`domainVerify:true`;固定 `entities:["entity.system.browsable"]`、`actions:["ohos.want.action.viewData"]`。 + +#### Scenario: 自定义 scheme 映射 +- **WHEN** `config.mobile` 含 `AssociatedDomain{scheme:["myapp"], host:None}` +- **THEN** 生成 skill `{entities:["entity.system.browsable"], actions:["ohos.want.action.viewData"], uris:[{scheme:"myapp"}], domainVerify:false}` + +#### Scenario: App Link(https)映射 +- **WHEN** `config.mobile` 含 `AssociatedDomain{scheme:["https"], host:"example.com", app_link:true}` +- **THEN** 生成 skill `{entities:["entity.system.browsable"], actions:["ohos.want.action.viewData"], uris:[{scheme:"https", host:"example.com"}], domainVerify:true}` + +#### Scenario: path 映射名称差异 +- **WHEN** `config.mobile` 含 `AssociatedDomain{scheme:["myapp"], path_pattern:["^/d+$"], path_prefix:["/app"]}` +- **THEN** uris 含 `pathRegex:"^/d+$"` 和 `pathStartWith:"/app"`(非 Android 的 pathPattern/pathPrefix) + +#### Scenario: 多 scheme 生成多 uris 对象 +- **WHEN** `config.mobile` 含 `AssociatedDomain{scheme:["myapp","myapp2"]}` +- **THEN** skill 的 `uris` 数组 SHALL 含两个对象 `{scheme:"myapp"}` 和 `{scheme:"myapp2"}` + +### Requirement: 多 form(mobile/desktop)覆盖 +注入 SHALL 根据 `OHOS_DEVICE_TYPE` 环境变量定位 `entry_{form}` 模块的 `module.json5`,确保 mobile 和 desktop form 都被正确注入(`--app` 模式多次 build 时每次 form 切换重跑 build.rs)。 + +#### Scenario: mobile form 注入 +- **WHEN** `OHOS_DEVICE_TYPE=mobile` +- **THEN** `entry_mobile/src/main/module.json5` SHALL 被注入 deep-link skills + +#### Scenario: desktop form 注入 +- **WHEN** `OHOS_DEVICE_TYPE=desktop` +- **THEN** `entry_desktop/src/main/module.json5` SHALL 被注入 deep-link skills + diff --git a/openspec/specs/ohos-deep-link-testing/spec.md b/openspec/specs/ohos-deep-link-testing/spec.md new file mode 100644 index 000000000000..6422db01da66 --- /dev/null +++ b/openspec/specs/ohos-deep-link-testing/spec.md @@ -0,0 +1,72 @@ +# ohos-deep-link-testing Specification + +## Purpose +TBD - created by archiving change p3-deep-link. Update Purpose after archive. +## Requirements +### Requirement: auto 测试用例可自动断言 +`plugins.ts` SHALL 包含以下 auto 测试用例(可自动断言,5 秒超时):`getCurrent()`(非链接启动返回 `null` 或空数组)、`isRegistered(scheme)`(返回 `false`)、`register(scheme)`+`unregister(scheme)`(no-op 不抛错)、`onOpenUrl` 注册返回 `UnlistenFn`(`typeof === 'function'`)。用例 SHALL 用动态 `import('@tauri-apps/plugin-deep-link')` 加载。 + +#### Scenario: getCurrent 非链接启动 +- **WHEN** app 正常启动(非 deep-link 触发),调用 `getCurrent()` +- **THEN** SHALL 返回 `null` 或空数组,auto 断言 `result === null || Array.isArray(result)` + +#### Scenario: isRegistered 返回 false +- **WHEN** 调用 `isRegistered("myapp")` +- **THEN** SHALL 返回 `false`(OHOS no-op 语义),auto 断言 `result === false` + +#### Scenario: register/unregister no-op 不抛错 +- **WHEN** 调用 `register("myapp")` 后 `unregister("myapp")` +- **THEN** SHALL 不抛错(no-op 返回 null),auto 断言无 throw + +#### Scenario: onOpenUrl 注册返回 UnlistenFn +- **WHEN** 调用 `onOpenUrl(() => {})` 注册回调 +- **THEN** SHALL 返回 `UnlistenFn`,auto 断言 `typeof unlisten === 'function'` + +### Requirement: manual 测试用例人工确认 +`plugins.ts` SHALL 包含以下 manual 测试用例(用 `wrapManual()` 包装,需人工确认):`onOpenUrl` 事件实际触发(需外部链接唤起)、`getCurrent()` 经链接启动(需外部唤起 app)、外部链接唤起 app(跨 app 行为)。 + +#### Scenario: onOpenUrl 事件触发 +- **WHEN** 注册 `onOpenUrl` 回调后,人工用 `hdc shell aa start -d myapp://path` 唤起 +- **THEN** 回调 SHALL 被调用,urls 包含 `["myapp://path"]`,人工确认 + +#### Scenario: getCurrent 经链接启动 +- **WHEN** app 未运行,人工用 `myapp://path` 链接拉起,调用 `getCurrent()` +- **THEN** SHALL 返回 `["myapp://path"]`,人工确认 + +#### Scenario: 外部链接唤起 app +- **WHEN** 人工从浏览器/其他 app 点击 `myapp://path` 链接 +- **THEN** app SHALL 被唤起到前台,人工确认 + +### Requirement: api demo 接入 deep-link +api demo(`examples/api`)SHALL 接入 deep-link 插件:`src-tauri/Cargo.toml` 加 `tauri-plugin-deep-link` 依赖;`package.json` 加 `@tauri-apps/plugin-deep-link` 依赖;`src-tauri/src/lib.rs` 注册 `.plugin(tauri_plugin_deep_link::init())`;`capabilities/run-app.json` 加 `deep-link:default` 权限。Phase 3 在 Phase 1/2 完成后进行,无需 `cfg(not(ohos))` 排除。 + +#### Scenario: api demo OHOS 构建含 deep-link +- **WHEN** api demo 在 OHOS target 构建 +- **THEN** deep-link 插件 SHALL 被注册,前端 `import('@tauri-apps/plugin-deep-link')` SHALL 成功加载 + +#### Scenario: capabilities 含 deep-link 权限 +- **WHEN** 检查 `run-app.json` +- **THEN** SHALL 含 `deep-link:default` 权限 + +### Requirement: examples/app OHOS 化 +`examples/app` SHALL 支持 OHOS:`tauri.conf.json` 加 OHOS deep-link 配置段(mobile domains);`Cargo.toml` 的 desktop feature(`x11`/`common-controls-v6`)加 `not(target_env="ohos")` 隔离;`lib.rs` 的 `register_all` 加 `not(target_env="ohos")` 排除。 + +#### Scenario: examples/app OHOS 构建通过 +- **WHEN** `examples/app` 在 OHOS target 构建 +- **THEN** SHALL 编译成功,desktop feature 不误入 OHOS + +#### Scenario: tauri.conf.json 含 OHOS 配置 +- **WHEN** 检查 `examples/app/src-tauri/tauri.conf.json` +- **THEN** SHALL 含 deep-link 的 mobile domains 配置(用于 Phase 2 module.json5 skills 注入) + +### Requirement: README 补充 OHOS 章节 +`README.md` 平台表 SHALL 加 OHOS 行(`openharmony = { level = "partial" }`);Configuration 段 SHALL 补 OHOS 配置说明(`mobile` domains → module.json5 skills 声明,scheme 注册为构建时静态声明,非运行时动态注册)。 + +#### Scenario: README 平台表含 OHOS +- **WHEN** 检查 `README.md` 平台表 +- **THEN** SHALL 含 OHOS 行,标注支持等级与限制 + +#### Scenario: README Configuration 段含 OHOS 说明 +- **WHEN** 检查 Configuration 段 +- **THEN** SHALL 说明 OHOS scheme 注册为构建时 module.json5 skills 声明(非运行时 register) +