接入桌面壳窗口标题同步
HostBridge 契约新增 app.setTitle 方法和标题 payload Tauri 桌面壳通过主窗口 API 同步非空窗口标题 桌面壳能力清单和配置守卫声明 app.setTitle 补充标题校验测试并更新宿主壳方案和团队共享决策记录
This commit is contained in:
@@ -34,7 +34,7 @@ const requiredUrlParts = [
|
|||||||
'hostShell=tauri_desktop',
|
'hostShell=tauri_desktop',
|
||||||
'hostPlatform=unknown',
|
'hostPlatform=unknown',
|
||||||
'bridgeVersion=1',
|
'bridgeVersion=1',
|
||||||
'hostCapabilities=host.getRuntime,share.open,share.setTarget,app.openExternalUrl,clipboard.writeText',
|
'hostCapabilities=host.getRuntime,share.open,share.setTarget,app.openExternalUrl,app.setTitle,clipboard.writeText',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const part of requiredUrlParts) {
|
for (const part of requiredUrlParts) {
|
||||||
@@ -55,8 +55,10 @@ const requiredMainSnippets = [
|
|||||||
'tauri_plugin_clipboard_manager::init()',
|
'tauri_plugin_clipboard_manager::init()',
|
||||||
'"share.open"',
|
'"share.open"',
|
||||||
'"share.setTarget"',
|
'"share.setTarget"',
|
||||||
|
'"app.setTitle"',
|
||||||
'"clipboard.writeText"',
|
'"clipboard.writeText"',
|
||||||
'"copied_to_clipboard"',
|
'"copied_to_clipboard"',
|
||||||
|
'set_title',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const permission of requiredPermissions) {
|
for (const permission of requiredPermissions) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use tauri::Manager;
|
||||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ fn capabilities() -> Vec<&'static str> {
|
|||||||
"share.open",
|
"share.open",
|
||||||
"share.setTarget",
|
"share.setTarget",
|
||||||
"app.openExternalUrl",
|
"app.openExternalUrl",
|
||||||
|
"app.setTitle",
|
||||||
"clipboard.writeText",
|
"clipboard.writeText",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -157,6 +159,15 @@ fn normalize_external_url(raw_url: &str) -> Option<String> {
|
|||||||
Some(url.to_string())
|
Some(url.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_window_title(raw_title: &str) -> Option<String> {
|
||||||
|
let title = raw_title.trim();
|
||||||
|
if title.is_empty() || title.chars().any(char::is_control) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(title.chars().take(80).collect())
|
||||||
|
}
|
||||||
|
|
||||||
fn payload_string<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
|
fn payload_string<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
|
||||||
value
|
value
|
||||||
.get(field)
|
.get(field)
|
||||||
@@ -296,6 +307,23 @@ fn host_bridge_request(
|
|||||||
Err(error) => failed(request.id, "host_error", error.to_string()),
|
Err(error) => failed(request.id, "host_error", error.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"app.setTitle" => {
|
||||||
|
let title = match required_string_payload(&request, "title")
|
||||||
|
.ok()
|
||||||
|
.and_then(normalize_window_title)
|
||||||
|
{
|
||||||
|
Some(title) => title,
|
||||||
|
None => return failed(request.id, "invalid_request", "title is required"),
|
||||||
|
};
|
||||||
|
|
||||||
|
match app.get_webview_window("main") {
|
||||||
|
Some(window) => match window.set_title(&title) {
|
||||||
|
Ok(()) => ok(request.id, json!(true)),
|
||||||
|
Err(error) => failed(request.id, "host_error", error.to_string()),
|
||||||
|
},
|
||||||
|
None => failed(request.id, "host_error", "main window not found"),
|
||||||
|
}
|
||||||
|
}
|
||||||
"share.setTarget" => {
|
"share.setTarget" => {
|
||||||
let target = request
|
let target = request
|
||||||
.payload
|
.payload
|
||||||
@@ -381,6 +409,10 @@ mod tests {
|
|||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.contains(&json!("share.setTarget")));
|
.contains(&json!("share.setTarget")));
|
||||||
|
assert!(result["capabilities"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.contains(&json!("app.setTitle")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -433,6 +465,25 @@ mod tests {
|
|||||||
assert_eq!(normalize_external_url("/relative/path"), None);
|
assert_eq!(normalize_external_url("/relative/path"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_title_normalization_requires_visible_text() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_window_title(" Genarrative "),
|
||||||
|
Some("Genarrative".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(normalize_window_title(""), None);
|
||||||
|
assert_eq!(normalize_window_title("Genarrative\nDev"), None);
|
||||||
|
|
||||||
|
let long_title = "甲".repeat(120);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_window_title(&long_title)
|
||||||
|
.expect("truncated title")
|
||||||
|
.chars()
|
||||||
|
.count(),
|
||||||
|
80
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn share_text_uses_direct_share_payload() {
|
fn share_text_uses_direct_share_payload() {
|
||||||
let state = DesktopShareState::default();
|
let state = DesktopShareState::default();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm --prefix ../.. run dev:web",
|
"beforeDevCommand": "npm --prefix ../.. run dev:web",
|
||||||
"beforeBuildCommand": "npm --prefix ../.. run build:raw && npm run typecheck",
|
"beforeBuildCommand": "npm --prefix ../.. run build:raw && npm run typecheck",
|
||||||
"devUrl": "http://127.0.0.1:3000/?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,share.open,share.setTarget,app.openExternalUrl,clipboard.writeText",
|
"devUrl": "http://127.0.0.1:3000/?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,share.open,share.setTarget,app.openExternalUrl,app.setTitle,clipboard.writeText",
|
||||||
"frontendDist": "../../../dist"
|
"frontendDist": "../../../dist"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
{
|
{
|
||||||
"create": false,
|
"create": false,
|
||||||
"label": "main",
|
"label": "main",
|
||||||
"url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,share.open,share.setTarget,app.openExternalUrl,clipboard.writeText",
|
"url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,share.open,share.setTarget,app.openExternalUrl,app.setTitle,clipboard.writeText",
|
||||||
"title": "Genarrative",
|
"title": "Genarrative",
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
"height": 820,
|
"height": 820,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
- 背景:后续需要移动端 App 和桌面端 App,但现有主站、固定玩法 runtime、小程序壳和未来 AI H5 sandbox 已经以 H5 为主线;如果移动端重写 React Native UI、桌面端重写 Rust/Tauri UI,会形成玩法、登录、支付、分享和运行态的多套实现。
|
- 背景:后续需要移动端 App 和桌面端 App,但现有主站、固定玩法 runtime、小程序壳和未来 AI H5 sandbox 已经以 H5 为主线;如果移动端重写 React Native UI、桌面端重写 Rust/Tauri UI,会形成玩法、登录、支付、分享和运行态的多套实现。
|
||||||
- 决策:移动端原生壳采用 `Expo + React Native`,桌面端壳采用 `Tauri`。两者都只作为 `native_app` 宿主壳和 HostBridge adapter,不重写现有 React H5 主站,不把固定内置玩法迁到 React Native / Rust UI,也不让 AI 生成 H5 游戏直接访问完整 HostBridge。Expo 壳通过 `react-native-webview` 承接 H5 与 native 通信,Tauri 壳通过受控 command 和 capabilities 承接桌面能力;新增能力必须先进入 HostBridge 契约和测试。
|
- 决策:移动端原生壳采用 `Expo + React Native`,桌面端壳采用 `Tauri`。两者都只作为 `native_app` 宿主壳和 HostBridge adapter,不重写现有 React H5 主站,不把固定内置玩法迁到 React Native / Rust UI,也不让 AI 生成 H5 游戏直接访问完整 HostBridge。Expo 壳通过 `react-native-webview` 承接 H5 与 native 通信,Tauri 壳通过受控 command 和 capabilities 承接桌面能力;新增能力必须先进入 HostBridge 契约和测试。
|
||||||
- 2026-06-17 首轮落地:新增 `packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/mobile-shell/` 和 `apps/desktop-shell/`。壳只声明并实现真实可用能力;移动壳使用真实品牌图标资产并支持 `genarrative://`、iOS associated domain、Android app link 到同源 H5 路径,`navigation.openNativePage` 只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的原生页面;`app.openExternalUrl` 在 Expo 与 Tauri 两端都只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议;桌面壳已通过 Tauri clipboard-manager 接入 `clipboard.writeText`,并将 `share.setTarget` / `share.open` 实现为复制非空分享文本到系统剪贴板;登录、支付、原生系统分享面板等未接入真实 SDK / 插件前必须返回 unsupported 并让 H5 fallback,生产代码禁止 mock 成功。
|
- 2026-06-17 首轮落地:新增 `packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/mobile-shell/` 和 `apps/desktop-shell/`。壳只声明并实现真实可用能力;移动壳使用真实品牌图标资产并支持 `genarrative://`、iOS associated domain、Android app link 到同源 H5 路径,`navigation.openNativePage` 只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的原生页面;`app.openExternalUrl` 在 Expo 与 Tauri 两端都只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议;桌面壳已通过 Tauri clipboard-manager 接入 `clipboard.writeText`,并将 `share.setTarget` / `share.open` 实现为复制非空分享文本到系统剪贴板,`app.setTitle` 通过主窗口 API 同步非空窗口标题;登录、支付、原生系统分享面板等未接入真实 SDK / 插件前必须返回 unsupported 并让 H5 fallback,生产代码禁止 mock 成功。
|
||||||
- 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。
|
- 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。
|
||||||
- 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。
|
- 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。
|
||||||
- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。
|
- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ type HostBridgeEvent = {
|
|||||||
| `share.open` | 打开分享动作 | 支持系统分享面板 | 复制分享文本到剪贴板 |
|
| `share.open` | 打开分享动作 | 支持系统分享面板 | 复制分享文本到剪贴板 |
|
||||||
| `navigation.openNativePage` | 打开受控宿主页 | 支持同源 H5 route | 支持设置 / 关于等 |
|
| `navigation.openNativePage` | 打开受控宿主页 | 支持同源 H5 route | 支持设置 / 关于等 |
|
||||||
| `app.openExternalUrl` | 用系统浏览器打开外链 | 支持白名单协议 | 支持白名单协议 |
|
| `app.openExternalUrl` | 用系统浏览器打开外链 | 支持白名单协议 | 支持白名单协议 |
|
||||||
|
| `app.setTitle` | 同步宿主窗口标题 | 不声明 | 支持 |
|
||||||
| `clipboard.writeText` | 写剪贴板 | 可选 | 可选 |
|
| `clipboard.writeText` | 写剪贴板 | 可选 | 可选 |
|
||||||
| `haptics.impact` | 轻量触感反馈 | 可选 | 不支持 |
|
| `haptics.impact` | 轻量触感反馈 | 可选 | 不支持 |
|
||||||
|
|
||||||
@@ -249,7 +250,7 @@ GameBridge 禁止:
|
|||||||
- 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。
|
- 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。
|
||||||
- 验证 macOS / Windows / Linux 至少一条本地 smoke。
|
- 验证 macOS / Windows / Linux 至少一条本地 smoke。
|
||||||
|
|
||||||
当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`app.openExternalUrl` 由 Rust 内部通过 opener 插件执行且只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议,`clipboard.writeText` 由 Rust 内部通过 clipboard-manager 插件写入系统剪贴板;不把 opener 或 clipboard 插件命令直接暴露给前端。当前真实能力为 `host.getRuntime`、`share.setTarget`、`share.open`、`app.openExternalUrl` 和 `clipboard.writeText`;其中 `share.open` 会把直接传入的分享 payload 或 `share.setTarget` 缓存的作品目标整理成非空分享文本并写入系统剪贴板,返回 `copied_to_clipboard`。原生系统分享面板、登录和支付未接入真实插件 / 渠道前不声明支持,不返回 mock 成功。
|
当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`app.openExternalUrl` 由 Rust 内部通过 opener 插件执行且只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议,`clipboard.writeText` 由 Rust 内部通过 clipboard-manager 插件写入系统剪贴板,`app.setTitle` 通过 Tauri 主窗口 API 同步窗口标题并拒绝空标题 / 控制字符;不把 opener 或 clipboard 插件命令直接暴露给前端。当前真实能力为 `host.getRuntime`、`share.setTarget`、`share.open`、`app.openExternalUrl`、`app.setTitle` 和 `clipboard.writeText`;其中 `share.open` 会把直接传入的分享 payload 或 `share.setTarget` 缓存的作品目标整理成非空分享文本并写入系统剪贴板,返回 `copied_to_clipboard`。原生系统分享面板、登录和支付未接入真实插件 / 渠道前不声明支持,不返回 mock 成功。
|
||||||
|
|
||||||
### Phase 4:宿主能力扩展
|
### Phase 4:宿主能力扩展
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type HostBridgeMethod =
|
|||||||
| 'share.open'
|
| 'share.open'
|
||||||
| 'navigation.openNativePage'
|
| 'navigation.openNativePage'
|
||||||
| 'app.openExternalUrl'
|
| 'app.openExternalUrl'
|
||||||
|
| 'app.setTitle'
|
||||||
| 'clipboard.writeText'
|
| 'clipboard.writeText'
|
||||||
| 'haptics.impact';
|
| 'haptics.impact';
|
||||||
|
|
||||||
@@ -87,6 +88,10 @@ export type OpenExternalUrlPayload = {
|
|||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SetTitlePayload = {
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS = [
|
export const HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS = [
|
||||||
'http:',
|
'http:',
|
||||||
'https:',
|
'https:',
|
||||||
|
|||||||
Reference in New Issue
Block a user