接入桌面壳窗口标题同步

HostBridge 契约新增 app.setTitle 方法和标题 payload

Tauri 桌面壳通过主窗口 API 同步非空窗口标题

桌面壳能力清单和配置守卫声明 app.setTitle

补充标题校验测试并更新宿主壳方案和团队共享决策记录
This commit is contained in:
2026-06-17 22:36:52 +08:00
parent 61d910400e
commit a87f3dcc82
6 changed files with 64 additions and 5 deletions

View File

@@ -34,7 +34,7 @@ const requiredUrlParts = [
'hostShell=tauri_desktop',
'hostPlatform=unknown',
'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) {
@@ -55,8 +55,10 @@ const requiredMainSnippets = [
'tauri_plugin_clipboard_manager::init()',
'"share.open"',
'"share.setTarget"',
'"app.setTitle"',
'"clipboard.writeText"',
'"copied_to_clipboard"',
'set_title',
];
for (const permission of requiredPermissions) {

View File

@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::Mutex;
use tauri::Manager;
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_opener::OpenerExt;
@@ -71,6 +72,7 @@ fn capabilities() -> Vec<&'static str> {
"share.open",
"share.setTarget",
"app.openExternalUrl",
"app.setTitle",
"clipboard.writeText",
]
}
@@ -157,6 +159,15 @@ fn normalize_external_url(raw_url: &str) -> Option<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> {
value
.get(field)
@@ -296,6 +307,23 @@ fn host_bridge_request(
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" => {
let target = request
.payload
@@ -381,6 +409,10 @@ mod tests {
.as_array()
.unwrap()
.contains(&json!("share.setTarget")));
assert!(result["capabilities"]
.as_array()
.unwrap()
.contains(&json!("app.setTitle")));
}
#[test]
@@ -433,6 +465,25 @@ mod tests {
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]
fn share_text_uses_direct_share_payload() {
let state = DesktopShareState::default();

View File

@@ -6,7 +6,7 @@
"build": {
"beforeDevCommand": "npm --prefix ../.. run dev:web",
"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"
},
"app": {
@@ -14,7 +14,7 @@
{
"create": false,
"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",
"width": 1280,
"height": 820,

View File

@@ -20,7 +20,7 @@
- 背景:后续需要移动端 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 契约和测试。
- 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 边界。
- 验证方式普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5固定玩法在各宿主中读取同一作品数据和运行态 snapshotAI sandbox 无法直接调用 HostBridgeTauri release 不允许任意远端页面调用桌面命令。
- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md``docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`

View File

@@ -117,6 +117,7 @@ type HostBridgeEvent = {
| `share.open` | 打开分享动作 | 支持系统分享面板 | 复制分享文本到剪贴板 |
| `navigation.openNativePage` | 打开受控宿主页 | 支持同源 H5 route | 支持设置 / 关于等 |
| `app.openExternalUrl` | 用系统浏览器打开外链 | 支持白名单协议 | 支持白名单协议 |
| `app.setTitle` | 同步宿主窗口标题 | 不声明 | 支持 |
| `clipboard.writeText` | 写剪贴板 | 可选 | 可选 |
| `haptics.impact` | 轻量触感反馈 | 可选 | 不支持 |
@@ -249,7 +250,7 @@ GameBridge 禁止:
- 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。
- 验证 macOS / Windows / Linux 至少一条本地 smoke。
当前状态:已新增 `apps/desktop-shell/`Tauri dev 直接加载本地主站 Viterelease 打包根 `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 直接加载本地主站 Viterelease 打包根 `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宿主能力扩展

View File

@@ -21,6 +21,7 @@ export type HostBridgeMethod =
| 'share.open'
| 'navigation.openNativePage'
| 'app.openExternalUrl'
| 'app.setTitle'
| 'clipboard.writeText'
| 'haptics.impact';
@@ -87,6 +88,10 @@ export type OpenExternalUrlPayload = {
url: string;
};
export type SetTitlePayload = {
title: string;
};
export const HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS = [
'http:',
'https:',