接入原生壳本地通知能力
新增 notification.showLocal HostBridge 契约和 H5 facade 移动端通过 expo-notifications 发送即时本地通知 桌面端通过 Tauri notification 插件发送系统通知 更新壳能力检查、测试、方案文档和共享决策记录
This commit is contained in:
@@ -147,7 +147,10 @@ const requiredMainSnippets = [
|
||||
'"file.exportImage"',
|
||||
'"file.importImage"',
|
||||
'"file.imageDropped"',
|
||||
'"notification.showLocal"',
|
||||
'tauri_plugin_dialog::init()',
|
||||
'tauri_plugin_notification::init()',
|
||||
'tauri_plugin_notification::NotificationExt',
|
||||
'"copied_to_clipboard"',
|
||||
'"file export cancelled"',
|
||||
'"file import cancelled"',
|
||||
@@ -163,6 +166,7 @@ const requiredMainSnippets = [
|
||||
'resolve_desktop_network_status',
|
||||
'network.statusChanged',
|
||||
'file.imageDropped',
|
||||
'app.notification().builder()',
|
||||
];
|
||||
|
||||
for (const permission of requiredPermissions) {
|
||||
|
||||
113
apps/desktop-shell/src-tauri/Cargo.lock
generated
113
apps/desktop-shell/src-tauri/Cargo.lock
generated
@@ -63,7 +63,7 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -1284,6 +1284,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-clipboard-manager",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
]
|
||||
|
||||
@@ -2067,6 +2068,20 @@ version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
|
||||
[[package]]
|
||||
name = "mac-notification-sys"
|
||||
version = "0.6.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"time",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.38.0"
|
||||
@@ -2190,6 +2205,20 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-rust"
|
||||
version = "4.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891"
|
||||
dependencies = [
|
||||
"futures-lite",
|
||||
"log",
|
||||
"mac-notification-sys",
|
||||
"serde",
|
||||
"tauri-winrt-notification",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -2629,7 +2658,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.14.0",
|
||||
"quick-xml",
|
||||
"quick-xml 0.39.4",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -2689,6 +2718,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@@ -2779,6 +2817,15 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
@@ -2809,6 +2856,35 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3682,6 +3758,25 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-notification"
|
||||
version = "2.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
|
||||
dependencies = [
|
||||
"log",
|
||||
"notify-rust",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.4"
|
||||
@@ -3804,6 +3899,18 @@ dependencies = [
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-winrt-notification"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
|
||||
dependencies = [
|
||||
"quick-xml 0.37.5",
|
||||
"thiserror 2.0.18",
|
||||
"windows",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -4563,7 +4670,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quick-xml 0.39.4",
|
||||
"quote",
|
||||
]
|
||||
|
||||
|
||||
@@ -14,4 +14,5 @@ serde_json = "1"
|
||||
tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-clipboard-manager = "2.3.2"
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-notification = "2.3.3"
|
||||
tauri-plugin-opener = "2.5.4"
|
||||
|
||||
@@ -14,6 +14,7 @@ use tauri::WebviewWindow;
|
||||
use tauri::WindowEvent;
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge";
|
||||
@@ -27,6 +28,8 @@ const EXPORT_FILE_NAME_FALLBACK: &str = "genarrative-export.txt";
|
||||
const EXPORT_FILE_NAME_MAX_LENGTH: usize = 120;
|
||||
const BADGE_COUNT_MAX: i64 = 99999;
|
||||
const DESKTOP_NETWORK_CHECK_TIMEOUT_MS: u64 = 1200;
|
||||
const LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: usize = 80;
|
||||
const LOCAL_NOTIFICATION_BODY_MAX_LENGTH: usize = 240;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -102,6 +105,7 @@ fn capabilities() -> Vec<&'static str> {
|
||||
"file.exportImage",
|
||||
"file.importImage",
|
||||
"file.imageDropped",
|
||||
"notification.showLocal",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -244,6 +248,56 @@ fn badge_count_payload(request: &HostBridgeRequest) -> Result<Option<i64>, HostB
|
||||
Ok(if count == 0 { None } else { Some(count) })
|
||||
}
|
||||
|
||||
fn normalize_plain_text(
|
||||
value: Option<&str>,
|
||||
max_length: usize,
|
||||
required: bool,
|
||||
) -> Option<Option<String>> {
|
||||
let Some(value) = value else {
|
||||
return if required { None } else { Some(None) };
|
||||
};
|
||||
if value.chars().any(char::is_control) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let text = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if text.is_empty() {
|
||||
return if required { None } else { Some(None) };
|
||||
}
|
||||
|
||||
Some(Some(text.chars().take(max_length).collect()))
|
||||
}
|
||||
|
||||
fn local_notification_payload(
|
||||
request: &HostBridgeRequest,
|
||||
) -> Result<(String, Option<String>), HostBridgeResponse> {
|
||||
let payload = request.payload.as_ref().ok_or_else(|| {
|
||||
failed(
|
||||
request.id.clone(),
|
||||
"invalid_request",
|
||||
"title is required",
|
||||
)
|
||||
})?;
|
||||
let title = match normalize_plain_text(
|
||||
payload.get("title").and_then(Value::as_str),
|
||||
LOCAL_NOTIFICATION_TITLE_MAX_LENGTH,
|
||||
true,
|
||||
) {
|
||||
Some(Some(title)) => title,
|
||||
_ => return Err(failed(request.id.clone(), "invalid_request", "title is required")),
|
||||
};
|
||||
let body = match normalize_plain_text(
|
||||
payload.get("body").and_then(Value::as_str),
|
||||
LOCAL_NOTIFICATION_BODY_MAX_LENGTH,
|
||||
false,
|
||||
) {
|
||||
Some(body) => body,
|
||||
None => return Err(failed(request.id.clone(), "invalid_request", "body is invalid")),
|
||||
};
|
||||
|
||||
Ok((title, body))
|
||||
}
|
||||
|
||||
fn normalize_export_file_name(raw_file_name: &str) -> String {
|
||||
let mut file_name = String::new();
|
||||
let mut last_was_space = false;
|
||||
@@ -938,6 +992,21 @@ async fn host_bridge_request(
|
||||
Err(error) => failed(request.id, "host_error", error.to_string()),
|
||||
}
|
||||
}
|
||||
"notification.showLocal" => {
|
||||
let (title, body) = match local_notification_payload(&request) {
|
||||
Ok(payload) => payload,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let mut notification = app.notification().builder().title(title);
|
||||
if let Some(body) = body {
|
||||
notification = notification.body(body);
|
||||
}
|
||||
|
||||
match notification.show() {
|
||||
Ok(()) => ok(request.id, json!(true)),
|
||||
Err(error) => failed(request.id, "host_error", error.to_string()),
|
||||
}
|
||||
}
|
||||
"share.setTarget" => {
|
||||
let target = request
|
||||
.payload
|
||||
@@ -983,6 +1052,7 @@ fn main() {
|
||||
.manage(DesktopShareState::default())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(|app| {
|
||||
let window_config = app.config().app.windows.get(0).cloned();
|
||||
@@ -1076,6 +1146,10 @@ mod tests {
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("file.imageDropped")));
|
||||
assert!(result["capabilities"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("notification.showLocal")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1112,6 +1186,41 @@ mod tests {
|
||||
assert_eq!(error.message, "text is required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_notification_payload_is_normalized() {
|
||||
let mut request = request("notification.showLocal");
|
||||
request.payload = Some(json!({
|
||||
"title": " 生成完成 ",
|
||||
"body": " 作品已准备好 可以试玩 "
|
||||
}));
|
||||
|
||||
let (title, body) = local_notification_payload(&request).expect("payload");
|
||||
|
||||
assert_eq!(title, "生成完成");
|
||||
assert_eq!(body.as_deref(), Some("作品已准备好 可以试玩"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_notification_payload_rejects_empty_and_control_text() {
|
||||
let mut empty = request("notification.showLocal");
|
||||
empty.payload = Some(json!({
|
||||
"title": " "
|
||||
}));
|
||||
|
||||
let response = local_notification_payload(&empty).expect_err("empty title");
|
||||
|
||||
assert_eq!(response.error.expect("error").code, "invalid_request");
|
||||
|
||||
let mut control = request("notification.showLocal");
|
||||
control.payload = Some(json!({
|
||||
"title": "生成\n完成"
|
||||
}));
|
||||
|
||||
let response = local_notification_payload(&control).expect_err("control title");
|
||||
|
||||
assert_eq!(response.error.expect("error").code, "invalid_request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_scheme_maps_window_theme() {
|
||||
assert_eq!(color_scheme_from_theme(Theme::Light), "light");
|
||||
|
||||
@@ -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,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,file.exportText,file.exportImage,file.importImage,file.imageDropped",
|
||||
"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,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,file.exportText,file.exportImage,file.importImage,file.imageDropped,notification.showLocal",
|
||||
"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,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,file.exportText,file.exportImage,file.importImage,file.imageDropped",
|
||||
"url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,file.exportText,file.exportImage,file.importImage,file.imageDropped,notification.showLocal",
|
||||
"title": "Genarrative",
|
||||
"width": 1280,
|
||||
"height": 820,
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
"cameraPermission": false,
|
||||
"microphonePermission": false
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"enableBackgroundRemoteNotifications": false
|
||||
}
|
||||
]
|
||||
],
|
||||
"ios": {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"expo-image-picker": "^56.0.18",
|
||||
"expo-linking": "^56.0.14",
|
||||
"expo-network": "^56.0.5",
|
||||
"expo-notifications": "^56.0.18",
|
||||
"expo-sharing": "^56.0.18",
|
||||
"expo-status-bar": "^56.0.4",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@@ -133,6 +133,7 @@ for (const dependency of [
|
||||
'expo-file-system',
|
||||
'expo-image-picker',
|
||||
'expo-network',
|
||||
'expo-notifications',
|
||||
'expo-sharing',
|
||||
]) {
|
||||
if (!packageConfig.dependencies?.[dependency]) {
|
||||
@@ -160,12 +161,31 @@ if (Array.isArray(imagePickerPlugin)) {
|
||||
}
|
||||
}
|
||||
|
||||
const notificationsPlugin = appConfig.plugins?.find((plugin) =>
|
||||
Array.isArray(plugin) ? plugin[0] === 'expo-notifications' : plugin === 'expo-notifications',
|
||||
);
|
||||
if (!notificationsPlugin) {
|
||||
throw new Error('mobile shell notifications plugin is missing');
|
||||
}
|
||||
|
||||
if (Array.isArray(notificationsPlugin)) {
|
||||
const pluginOptions = notificationsPlugin[1] ?? {};
|
||||
if (pluginOptions.enableBackgroundRemoteNotifications !== false) {
|
||||
throw new Error('mobile shell must not enable background remote notifications');
|
||||
}
|
||||
}
|
||||
|
||||
for (const snippet of [
|
||||
'file.exportText',
|
||||
'file.exportImage',
|
||||
'file.importImage',
|
||||
'notification.showLocal',
|
||||
'network.status',
|
||||
'getMobileNetworkStatus',
|
||||
'Notifications.scheduleNotificationAsync',
|
||||
'Notifications.setNotificationChannelAsync',
|
||||
'Notifications.getPermissionsAsync',
|
||||
'Notifications.requestPermissionsAsync',
|
||||
'Sharing.shareAsync',
|
||||
'ImagePicker.launchImageLibraryAsync',
|
||||
'ImagePicker.requestMediaLibraryPermissionsAsync',
|
||||
@@ -202,6 +222,7 @@ for (const capability of [
|
||||
'file.exportImage',
|
||||
'file.importImage',
|
||||
'haptics.impact',
|
||||
'notification.showLocal',
|
||||
]) {
|
||||
if (!mobileCapabilitySet.has(capability)) {
|
||||
throw new Error(`mobile shell capabilities missing ${capability}`);
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as Haptics from 'expo-haptics';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as Linking from 'expo-linking';
|
||||
import * as Network from 'expo-network';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import {
|
||||
Appearance,
|
||||
@@ -24,6 +25,23 @@ import {
|
||||
resetMobileHostBridgeForTest,
|
||||
} from './mobileHostBridge';
|
||||
|
||||
type NotificationPermissionStatus =
|
||||
Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>;
|
||||
|
||||
const GRANTED_NOTIFICATION_PERMISSION = {
|
||||
status: 'granted',
|
||||
granted: true,
|
||||
canAskAgain: true,
|
||||
expires: 'never',
|
||||
} as NotificationPermissionStatus;
|
||||
|
||||
const DENIED_NOTIFICATION_PERMISSION = {
|
||||
status: 'denied',
|
||||
granted: false,
|
||||
canAskAgain: false,
|
||||
expires: 'never',
|
||||
} as NotificationPermissionStatus;
|
||||
|
||||
vi.mock('expo-clipboard', () => ({
|
||||
setStringAsync: vi.fn(),
|
||||
}));
|
||||
@@ -94,6 +112,20 @@ vi.mock('expo-network', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('expo-notifications', () => ({
|
||||
AndroidImportance: {
|
||||
DEFAULT: 'default',
|
||||
},
|
||||
IosAuthorizationStatus: {
|
||||
PROVISIONAL: 'provisional',
|
||||
},
|
||||
getPermissionsAsync: vi.fn(),
|
||||
requestPermissionsAsync: vi.fn(),
|
||||
scheduleNotificationAsync: vi.fn(),
|
||||
setNotificationChannelAsync: vi.fn(),
|
||||
setNotificationHandler: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('expo-sharing', () => ({
|
||||
isAvailableAsync: vi.fn(async () => true),
|
||||
shareAsync: vi.fn(),
|
||||
@@ -185,6 +217,20 @@ afterEach(() => {
|
||||
isConnected: true,
|
||||
isInternetReachable: true,
|
||||
});
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockReset();
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
|
||||
GRANTED_NOTIFICATION_PERMISSION,
|
||||
);
|
||||
vi.mocked(Notifications.requestPermissionsAsync).mockReset();
|
||||
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
|
||||
GRANTED_NOTIFICATION_PERMISSION,
|
||||
);
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockReset();
|
||||
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue(
|
||||
'notification-1',
|
||||
);
|
||||
vi.mocked(Notifications.setNotificationChannelAsync).mockReset();
|
||||
vi.mocked(Notifications.setNotificationChannelAsync).mockResolvedValue(null);
|
||||
vi.mocked(PushNotificationIOS.setApplicationIconBadgeNumber).mockReset();
|
||||
setPlatformOS('ios');
|
||||
vi.mocked(Sharing.isAvailableAsync).mockReset();
|
||||
@@ -225,6 +271,7 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
'network.statusChanged',
|
||||
'navigation.canGoBack',
|
||||
'app.setBadgeCount',
|
||||
'notification.showLocal',
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -366,6 +413,79 @@ describe('handleMobileHostBridgeMessage', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('notification.showLocal 调起 Expo 本地通知', async () => {
|
||||
const response = await send(
|
||||
request('notification.showLocal', {
|
||||
title: ' 生成完成 ',
|
||||
body: ' 作品已准备好 可以试玩 ',
|
||||
}),
|
||||
);
|
||||
|
||||
expectOk(response);
|
||||
expect(Notifications.getPermissionsAsync).toHaveBeenCalled();
|
||||
expect(Notifications.requestPermissionsAsync).not.toHaveBeenCalled();
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith({
|
||||
content: {
|
||||
title: '生成完成',
|
||||
body: '作品已准备好 可以试玩',
|
||||
},
|
||||
trigger: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('notification.showLocal 在 Android 使用固定通知 channel', async () => {
|
||||
setPlatformOS('android');
|
||||
|
||||
const response = await send(
|
||||
request('notification.showLocal', {
|
||||
title: '生成完成',
|
||||
}),
|
||||
);
|
||||
|
||||
expectOk(response);
|
||||
expect(Notifications.setNotificationChannelAsync).toHaveBeenCalledWith(
|
||||
'genarrative-local',
|
||||
{
|
||||
name: 'Genarrative',
|
||||
importance: Notifications.AndroidImportance.DEFAULT,
|
||||
},
|
||||
);
|
||||
expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledWith({
|
||||
content: {
|
||||
title: '生成完成',
|
||||
},
|
||||
trigger: {
|
||||
channelId: 'genarrative-local',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('notification.showLocal 拒绝权限和非法 payload', async () => {
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue(
|
||||
DENIED_NOTIFICATION_PERMISSION,
|
||||
);
|
||||
vi.mocked(Notifications.requestPermissionsAsync).mockResolvedValue(
|
||||
DENIED_NOTIFICATION_PERMISSION,
|
||||
);
|
||||
|
||||
const denied = await send(
|
||||
request('notification.showLocal', {
|
||||
title: '生成完成',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(expectFailed(denied).error.code).toBe('host_error');
|
||||
expect(Notifications.scheduleNotificationAsync).not.toHaveBeenCalled();
|
||||
|
||||
const invalid = await send(
|
||||
request('notification.showLocal', {
|
||||
title: '生成\n完成',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(expectFailed(invalid).error.code).toBe('invalid_request');
|
||||
});
|
||||
|
||||
test('app.setBadgeCount 在 iOS 调起系统角标能力', async () => {
|
||||
const response = await send(
|
||||
request('app.setBadgeCount', {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { File, Paths } from 'expo-file-system';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as Linking from 'expo-linking';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import {
|
||||
Appearance,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
normalizeHostBridgeColorScheme,
|
||||
normalizeHostBridgeExportFileName,
|
||||
normalizeHostBridgeExternalUrl,
|
||||
normalizeHostBridgeLocalNotification,
|
||||
type OpenExternalUrlPayload,
|
||||
type SetBadgeCountPayload,
|
||||
type ShareOpenPayload,
|
||||
@@ -43,12 +45,22 @@ const WEB_APP_ORIGIN = 'https://app.genarrative.world';
|
||||
const EXPORT_TEXT_MAX_BYTES = 5 * 1024 * 1024;
|
||||
const EXPORT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
const IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const LOCAL_NOTIFICATION_CHANNEL_ID = 'genarrative-local';
|
||||
const HOST_BRIDGE_IMAGE_MIME_TYPES = new Set<HostBridgeImageMimeType>([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: false,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
});
|
||||
|
||||
export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
|
||||
'host.getRuntime',
|
||||
'appearance.getColorScheme',
|
||||
@@ -66,6 +78,7 @@ export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
|
||||
'file.exportImage',
|
||||
'file.importImage',
|
||||
'haptics.impact',
|
||||
'notification.showLocal',
|
||||
];
|
||||
|
||||
export const IOS_MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
|
||||
@@ -412,6 +425,63 @@ function setBadgeCount(payload: unknown) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasNotificationPermission(
|
||||
permission: Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>,
|
||||
) {
|
||||
return (
|
||||
permission.granted ||
|
||||
permission.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureNotificationPermission() {
|
||||
const currentPermission = await Notifications.getPermissionsAsync();
|
||||
if (hasNotificationPermission(currentPermission)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedPermission = await Notifications.requestPermissionsAsync({
|
||||
ios: {
|
||||
allowAlert: true,
|
||||
allowBadge: false,
|
||||
allowSound: false,
|
||||
},
|
||||
});
|
||||
if (!hasNotificationPermission(requestedPermission)) {
|
||||
throw {
|
||||
code: 'host_error',
|
||||
message: 'notification permission denied',
|
||||
} satisfies HostBridgeError;
|
||||
}
|
||||
}
|
||||
|
||||
async function showLocalNotification(payload: unknown) {
|
||||
const notification = normalizeHostBridgeLocalNotification(payload);
|
||||
if (!notification) {
|
||||
throw invalidRequest('title is required');
|
||||
}
|
||||
|
||||
await ensureNotificationPermission();
|
||||
if (Platform.OS === 'android') {
|
||||
await Notifications.setNotificationChannelAsync(
|
||||
LOCAL_NOTIFICATION_CHANNEL_ID,
|
||||
{
|
||||
name: 'Genarrative',
|
||||
importance: Notifications.AndroidImportance.DEFAULT,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
content: notification,
|
||||
trigger:
|
||||
Platform.OS === 'android'
|
||||
? { channelId: LOCAL_NOTIFICATION_CHANNEL_ID }
|
||||
: null,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function getColorScheme() {
|
||||
return {
|
||||
colorScheme: normalizeHostBridgeColorScheme(Appearance.getColorScheme()),
|
||||
@@ -544,6 +614,8 @@ async function handleRequest(request: HostBridgeRequest) {
|
||||
return ok(request, await importImageFile());
|
||||
case 'haptics.impact':
|
||||
return ok(request, await runHaptics(request.payload));
|
||||
case 'notification.showLocal':
|
||||
return ok(request, await showLocalNotification(request.payload));
|
||||
case 'app.setBadgeCount':
|
||||
return ok(request, setBadgeCount(request.payload));
|
||||
case 'share.open':
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
- 2026-06-18 桌面图片拖入接入主图槽位:`CreativeImageInputPanel` 在桌面壳声明 `file.imageDropped` 时订阅宿主拖入事件,只在拖入坐标命中当前主图卡片且未被上层元素遮挡时消费事件,避免窗口级拖入被多个创作面板同时接收;成功后仍转换为现有 `File` 上传回调。
|
||||
- 2026-06-18 H5 背景音乐接入宿主生命周期:`useBackgroundMusic` 通过 `useHostLifecycleActive()` 消费 `subscribeHostAppLifecycle()` 的归一结果,宿主进入后台、inactive 或桌面窗口失焦时降低音量并暂停音频循环,同时 `suspend` WebAudio context;回到 `active + focused` 且用户原本开启音乐时再恢复播放,不改变用户音量设置。
|
||||
- 2026-06-18 固定玩法音频接入宿主生命周期:前端新增 `useHostLifecycleActive()` 统一消费 `subscribeHostAppLifecycle()`,`useBackgroundMusic`、拼图运行态和抓大鹅运行态都只依赖该归一状态判断音频可播放性;宿主 inactive、background 或窗口失焦时暂停 `<audio>` / WebAudio,回到 `active + focused` 后仅在运行态仍在播放、音源存在且用户音乐音量大于 0 时恢复,不改变用户音量设置。
|
||||
- 2026-06-18 本地通知能力:新增 `notification.showLocal` HostBridge capability,H5 只能传必填 `title` 和可选 `body`,共享契约负责修剪、折叠普通空白、限制长度并拒绝控制字符;Expo 壳通过 `expo-notifications` 请求系统通知权限、创建 Android 本地 channel 并发送即时本地通知,Tauri 壳通过 Rust 侧 `tauri-plugin-notification` 发送系统通知且不开放插件 JS guest API。该能力不包含远程推送、token 注册、定时提醒或后台远程通知,权限拒绝、系统失败或宿主未声明时由 H5 视作失败并继续主流程。
|
||||
- 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。
|
||||
- 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。
|
||||
- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。
|
||||
|
||||
@@ -129,6 +129,7 @@ type HostBridgeEvent = {
|
||||
| `file.importImage` | 导入用户选择的图片文件 | 支持系统相册选择图片 | 支持系统选择图片 |
|
||||
| `file.imageDropped` | 通知 H5 桌面拖入图片 | 不声明 | 支持主窗口拖拽图片事件 |
|
||||
| `haptics.impact` | 轻量触感反馈 | 支持 | 不声明 |
|
||||
| `notification.showLocal` | 发送即时本地系统通知 | 支持 Expo Notifications | 支持 Rust 侧 Tauri notification |
|
||||
|
||||
每个 method 都必须有明确 payload schema、超时、错误码和能力开关;H5 看到不支持时回退到现有浏览器路径。
|
||||
|
||||
@@ -249,7 +250,7 @@ GameBridge 禁止:
|
||||
- iOS / Android 深链打开作品详情、创作页和邀请码。
|
||||
- 登录和支付先 fallback 到 H5;只把能力边界跑通。
|
||||
|
||||
当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。移动壳使用真实品牌图标资产,已接入 `genarrative://` scheme、iOS associated domain 和 Android app link filter,启动和运行时 deep link 只会映射到同源 H5 路径并继续附加 HostBridge 上下文,外域和危险协议回退到默认主站入口。首轮真实能力包括 `host.getRuntime`、`appearance.getColorScheme`、`host.events`、`app.lifecycle`、`network.status`、`network.statusChanged`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.openExternalUrl`、`clipboard.writeText`、`file.exportText`、`file.exportImage`、`file.importImage`、`haptics.impact` 和 Android 返回键回退;其中 `appearance.getColorScheme` 只读系统配色偏好,不强改 H5 或系统主题;`app.lifecycle` 通过 React Native `AppState` 注入 `active` / `inactive` / `background` 统一状态,供 H5 游戏循环、音频和轮询做真实暂停 / 恢复判断,H5 的 `useHostLifecycleActive()` 会把该事件归一成运行态可播放状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `<audio>` 背景音乐都按该状态在宿主进后台时暂停、回到前台且原播放条件仍满足时恢复;`network.status` / `network.statusChanged` 通过 `expo-network` 查询并订阅真实系统网络状态,供 H5 游戏运行态和生成页识别离线 / 弱网回退;iOS 额外声明 `app.setBadgeCount`,通过 React Native `PushNotificationIOS` 设置应用图标角标,Android 不声明该能力。H5 会解析并过滤 `hostCapabilities`,也会在主 App 启动时通过真实 `host.getRuntime` 回读并缓存能力,只对声明或回读到的能力展示入口或调用宿主能力;其中 `share.setTarget` / `share.open` 会解析统一分享目标里的 `title`、`message`、`url`、`work`、`path` 或 `targetPath` 并调用 React Native 系统分享面板;发布分享弹窗只有在宿主声明 `share.open` 时才提供“系统分享”动作,失败时保留复制链接回退路径;`navigation.openNativePage` 在 Expo 壳内只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的登录、支付或其它原生页面,`navigation.canGoBack` 由 WebView 导航状态变化实时注入 H5,WebView 自身拦截到外域导航时只会把 `http:`、`https:`、`mailto:`、`tel:` 交给系统,危险协议直接阻断;`app.openExternalUrl` 也只允许同一协议白名单,ICP备案号和资产调试原图等 H5 外链入口在 `native_app` 中优先通过该能力离开 WebView 并交给系统浏览器;`clipboard.writeText` 由 H5 复制服务优先调用并写入系统剪贴板;`file.exportText` 通过 Expo 文件系统写入缓存文本文件,再交给系统分享 / 保存面板,文件名必须清洗,单次文本不超过 5 MiB,成功只返回文件名和字节数;`file.exportImage` 通过 Expo 文件系统写入缓存图片,再交给系统分享 / 保存面板,H5 只传允许 MIME 的 base64 图片数据,单次不超过 5 MiB,分享卡下载会优先走该能力;`file.importImage` 通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,只接受 `image/png` / `image/jpeg` / `image/webp`、单次不超过 10 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不把设备本地 URI 暴露给 H5,用户取消返回 `cancelled` 并由 H5 视作无选择;通用创作图片输入面板 `CreativeImageInputPanel` 在原生壳声明 `file.importImage` 时会优先调用该宿主能力,并把导入结果转换为现有 `File` 上传回调,拼图、拼消消、敲木鱼等复用该面板的主图和描述参考图选择无需新增玩法分叉;`haptics.impact` 通过 Expo Haptics 承接运行时轻触反馈,H5 在宿主不支持时回退到浏览器 vibration。登录和支付尚未接入渠道 SDK / 原生页面时明确返回 unsupported,让 H5 fallback 承接。
|
||||
当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。移动壳使用真实品牌图标资产,已接入 `genarrative://` scheme、iOS associated domain 和 Android app link filter,启动和运行时 deep link 只会映射到同源 H5 路径并继续附加 HostBridge 上下文,外域和危险协议回退到默认主站入口。首轮真实能力包括 `host.getRuntime`、`appearance.getColorScheme`、`host.events`、`app.lifecycle`、`network.status`、`network.statusChanged`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.openExternalUrl`、`clipboard.writeText`、`file.exportText`、`file.exportImage`、`file.importImage`、`haptics.impact`、`notification.showLocal` 和 Android 返回键回退;其中 `appearance.getColorScheme` 只读系统配色偏好,不强改 H5 或系统主题;`app.lifecycle` 通过 React Native `AppState` 注入 `active` / `inactive` / `background` 统一状态,供 H5 游戏循环、音频和轮询做真实暂停 / 恢复判断,H5 的 `useHostLifecycleActive()` 会把该事件归一成运行态可播放状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `<audio>` 背景音乐都按该状态在宿主进后台时暂停、回到前台且原播放条件仍满足时恢复;`network.status` / `network.statusChanged` 通过 `expo-network` 查询并订阅真实系统网络状态,供 H5 游戏运行态和生成页识别离线 / 弱网回退;iOS 额外声明 `app.setBadgeCount`,通过 React Native `PushNotificationIOS` 设置应用图标角标,Android 不声明该能力。H5 会解析并过滤 `hostCapabilities`,也会在主 App 启动时通过真实 `host.getRuntime` 回读并缓存能力,只对声明或回读到的能力展示入口或调用宿主能力;其中 `share.setTarget` / `share.open` 会解析统一分享目标里的 `title`、`message`、`url`、`work`、`path` 或 `targetPath` 并调用 React Native 系统分享面板;发布分享弹窗只有在宿主声明 `share.open` 时才提供“系统分享”动作,失败时保留复制链接回退路径;`navigation.openNativePage` 在 Expo 壳内只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的登录、支付或其它原生页面,`navigation.canGoBack` 由 WebView 导航状态变化实时注入 H5,WebView 自身拦截到外域导航时只会把 `http:`、`https:`、`mailto:`、`tel:` 交给系统,危险协议直接阻断;`app.openExternalUrl` 也只允许同一协议白名单,ICP备案号和资产调试原图等 H5 外链入口在 `native_app` 中优先通过该能力离开 WebView 并交给系统浏览器;`clipboard.writeText` 由 H5 复制服务优先调用并写入系统剪贴板;`file.exportText` 通过 Expo 文件系统写入缓存文本文件,再交给系统分享 / 保存面板,文件名必须清洗,单次文本不超过 5 MiB,成功只返回文件名和字节数;`file.exportImage` 通过 Expo 文件系统写入缓存图片,再交给系统分享 / 保存面板,H5 只传允许 MIME 的 base64 图片数据,单次不超过 5 MiB,分享卡下载会优先走该能力;`file.importImage` 通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,只接受 `image/png` / `image/jpeg` / `image/webp`、单次不超过 10 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不把设备本地 URI 暴露给 H5,用户取消返回 `cancelled` 并由 H5 视作无选择;通用创作图片输入面板 `CreativeImageInputPanel` 在原生壳声明 `file.importImage` 时会优先调用该宿主能力,并把导入结果转换为现有 `File` 上传回调,拼图、拼消消、敲木鱼等复用该面板的主图和描述参考图选择无需新增玩法分叉;`haptics.impact` 通过 Expo Haptics 承接运行时轻触反馈,H5 在宿主不支持时回退到浏览器 vibration;`notification.showLocal` 通过 `expo-notifications` 请求系统通知权限并调度即时本地通知,Android 只使用固定本地 channel,不启用后台远程通知、远程推送 token 或定时提醒。登录和支付尚未接入渠道 SDK / 原生页面时明确返回 unsupported,让 H5 fallback 承接。
|
||||
|
||||
### Phase 3:Tauri 桌面壳 MVP
|
||||
|
||||
@@ -260,7 +261,7 @@ GameBridge 禁止:
|
||||
- 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。
|
||||
- 验证 macOS / Windows / Linux 至少一条本地 smoke。
|
||||
|
||||
当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`appearance.getColorScheme` 由 Rust 内部读取主窗口 `theme()` 并返回 `light` / `dark` / `unknown`,不设置或覆盖系统主题;`app.lifecycle` 由主窗口 focus / blur 事件注入 `active` / `inactive` 统一状态,不开放 Tauri event 插件给前端,H5 通过 `useHostLifecycleActive()` 统一归一窗口焦点状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `<audio>` 背景音乐都会在窗口失焦时暂停、恢复焦点且原播放条件仍满足时恢复;`network.status` 由 Rust 对 `app.genarrative.world:443` 做短超时 TCP 可达性查询,`network.statusChanged` 由主 WebView 的 `online` / `offline` 事件注入,不开放任意网络探测给 H5;`app.openExternalUrl` 由 Rust 内部通过 opener 插件执行且只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议,ICP备案号和资产调试原图等 H5 外链入口在 `native_app` 中优先通过该能力离开主窗口并交给系统浏览器;`navigation.openNativePage` 只接受 `https://app.genarrative.world` 同源 H5 route 并在主窗口内受控跳转,`clipboard.writeText` 由 Rust 内部通过 clipboard-manager 插件写入系统剪贴板并由 H5 复制服务优先调用,`app.setTitle` 通过 Tauri 主窗口 API 同步窗口标题并拒绝空标题 / 控制字符,`app.setBadgeCount` 通过主窗口 `set_badge_count` 设置任务栏角标,数量只接受 `0-99999` 整数且 `0` 表示清除;H5 主站会按当前平台阶段先更新 `document.title`,再通过 `app.setTitle` 把同一标题同步给 Tauri 窗口,Expo 移动壳不声明该能力时静默忽略;不把 opener、clipboard 或 dialog 插件命令直接暴露给前端。当前真实能力为 `host.getRuntime`、`appearance.getColorScheme`、`app.lifecycle`、`network.status`、`network.statusChanged`、`share.setTarget`、`share.open`、`navigation.openNativePage`、`app.openExternalUrl`、`app.setTitle`、`app.setBadgeCount`、`clipboard.writeText`、`file.exportText`、`file.exportImage`、`file.importImage` 和 `file.imageDropped`;H5 会在主 App 启动时通过真实 `host.getRuntime` 回读并缓存这些能力,即使入口 URL 缺少 `hostCapabilities`,也只按宿主真实回包开启能力入口;其中 `share.open` 会把直接传入的分享 payload 或 `share.setTarget` 缓存的作品目标整理成非空分享文本并写入系统剪贴板,返回 `copied_to_clipboard`,发布分享弹窗在桌面壳中也通过该能力提供“系统分享”动作;`file.exportText` 通过系统保存对话框让用户选择本地路径,清洗文件名、限制单次文本导出不超过 5 MiB,写入成功后只返回文件名和字节数,不把本机绝对路径暴露给 H5,用户取消返回 `cancelled`;`file.exportImage` 同样通过系统保存对话框写入 H5 生成的图片字节,只接受 `image/png` / `image/jpeg` / `image/webp` base64 数据,单次不超过 5 MiB,分享卡下载会优先走该能力,用户取消返回 `cancelled`;`file.importImage` 通过系统选择框读取用户选择的图片,`file.imageDropped` 通过主窗口拖拽事件读取用户拖入的图片,二者都只接受 `image/png` / `image/jpeg` / `image/webp`、单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容和字节数,不把本机绝对路径暴露给 H5,也不开放通用文件系统;通用创作图片输入面板 `CreativeImageInputPanel` 在桌面壳声明 `file.importImage` 时会优先打开系统图片选择框,在同时声明 `file.imageDropped` 时会按宿主拖入坐标把图片交给命中的主图槽位,并把结果转换为现有 `File` 上传回调,普通浏览器、小程序和未声明能力的壳继续保留原文件输入路径。原生系统分享面板、登录和支付未接入真实插件 / 渠道前不声明支持,不返回 mock 成功。
|
||||
当前状态:已新增 `apps/desktop-shell/`,Tauri dev 直接加载本地主站 Vite,release 打包根 `dist` 主站资产。Rust 侧只把 `host_bridge_request` command 授给主窗口,`appearance.getColorScheme` 由 Rust 内部读取主窗口 `theme()` 并返回 `light` / `dark` / `unknown`,不设置或覆盖系统主题;`app.lifecycle` 由主窗口 focus / blur 事件注入 `active` / `inactive` 统一状态,不开放 Tauri event 插件给前端,H5 通过 `useHostLifecycleActive()` 统一归一窗口焦点状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `<audio>` 背景音乐都会在窗口失焦时暂停、恢复焦点且原播放条件仍满足时恢复;`network.status` 由 Rust 对 `app.genarrative.world:443` 做短超时 TCP 可达性查询,`network.statusChanged` 由主 WebView 的 `online` / `offline` 事件注入,不开放任意网络探测给 H5;`app.openExternalUrl` 由 Rust 内部通过 opener 插件执行且只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议,ICP备案号和资产调试原图等 H5 外链入口在 `native_app` 中优先通过该能力离开主窗口并交给系统浏览器;`navigation.openNativePage` 只接受 `https://app.genarrative.world` 同源 H5 route 并在主窗口内受控跳转,`clipboard.writeText` 由 Rust 内部通过 clipboard-manager 插件写入系统剪贴板并由 H5 复制服务优先调用,`app.setTitle` 通过 Tauri 主窗口 API 同步窗口标题并拒绝空标题 / 控制字符,`app.setBadgeCount` 通过主窗口 `set_badge_count` 设置任务栏角标,数量只接受 `0-99999` 整数且 `0` 表示清除;H5 主站会按当前平台阶段先更新 `document.title`,再通过 `app.setTitle` 把同一标题同步给 Tauri 窗口,Expo 移动壳不声明该能力时静默忽略;不把 opener、clipboard、dialog 或 notification 插件命令直接暴露给前端。当前真实能力为 `host.getRuntime`、`appearance.getColorScheme`、`app.lifecycle`、`network.status`、`network.statusChanged`、`share.setTarget`、`share.open`、`navigation.openNativePage`、`app.openExternalUrl`、`app.setTitle`、`app.setBadgeCount`、`clipboard.writeText`、`file.exportText`、`file.exportImage`、`file.importImage`、`file.imageDropped` 和 `notification.showLocal`;H5 会在主 App 启动时通过真实 `host.getRuntime` 回读并缓存这些能力,即使入口 URL 缺少 `hostCapabilities`,也只按宿主真实回包开启能力入口;其中 `share.open` 会把直接传入的分享 payload 或 `share.setTarget` 缓存的作品目标整理成非空分享文本并写入系统剪贴板,返回 `copied_to_clipboard`,发布分享弹窗在桌面壳中也通过该能力提供“系统分享”动作;`file.exportText` 通过系统保存对话框让用户选择本地路径,清洗文件名、限制单次文本导出不超过 5 MiB,写入成功后只返回文件名和字节数,不把本机绝对路径暴露给 H5,用户取消返回 `cancelled`;`file.exportImage` 同样通过系统保存对话框写入 H5 生成的图片字节,只接受 `image/png` / `image/jpeg` / `image/webp` base64 数据,单次不超过 5 MiB,分享卡下载会优先走该能力,用户取消返回 `cancelled`;`file.importImage` 通过系统选择框读取用户选择的图片,`file.imageDropped` 通过主窗口拖拽事件读取用户拖入的图片,二者都只接受 `image/png` / `image/jpeg` / `image/webp`、单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容和字节数,不把本机绝对路径暴露给 H5,也不开放通用文件系统;`notification.showLocal` 由 Rust 内部通过 `tauri-plugin-notification` 发送即时系统通知,只接受清洗后的标题和正文,不开放插件 JS guest API、远程推送、定时提醒或通知 token;通用创作图片输入面板 `CreativeImageInputPanel` 在桌面壳声明 `file.importImage` 时会优先打开系统图片选择框,在同时声明 `file.imageDropped` 时会按宿主拖入坐标把图片交给命中的主图槽位,并把结果转换为现有 `File` 上传回调,普通浏览器、小程序和未声明能力的壳继续保留原文件输入路径。原生系统分享面板、登录和支付未接入真实插件 / 渠道前不声明支持,不返回 mock 成功。
|
||||
|
||||
### Phase 4:宿主能力扩展
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ AI H5 sandbox
|
||||
- `openHostShareGrid()`:微信小程序九宫格切图页。
|
||||
- `writeHostClipboardText()`:原生 App 宿主的受控剪贴板入口。H5 复制服务在 `native_app` 中优先通过 `clipboard.writeText` 写入 Expo / Tauri 系统剪贴板;宿主不可用、拒绝或返回 unsupported 时继续回退到浏览器 Clipboard API 和 legacy selection copy。
|
||||
- `requestHostHapticsImpact()`:原生 App 宿主的受控触觉反馈入口。Expo 移动壳通过 `haptics.impact` 调用 Expo Haptics;H5 运行时点击反馈在 `native_app` 中优先请求宿主触觉,宿主不可用、拒绝或返回 unsupported 时继续回退到浏览器 `navigator.vibrate`。
|
||||
- `showHostLocalNotification()`:原生 App 宿主的受控即时本地通知入口。H5 只能传必填 `title` 和可选 `body`,两者都会去除首尾空白、折叠普通空白、限制长度并拒绝控制字符;Expo 移动壳通过 `expo-notifications` 请求通知权限、创建 Android 本地通知 channel 并立刻调度本地通知,Tauri 桌面壳通过 Rust 侧 `tauri-plugin-notification` 发送系统通知。该能力不包含远程推送、token 注册、定时提醒、后台远程通知或任意通知插件透传,宿主未声明、权限拒绝或系统失败时由 H5 视作失败并继续主流程。
|
||||
- `setHostAppTitle()`:原生 App 宿主的受控窗口标题入口。H5 主站会按当前平台阶段先同步 `document.title`,再通过 `app.setTitle` 请求宿主窗口标题同步;Tauri 桌面壳支持该能力,Expo 移动壳不声明时静默忽略。
|
||||
- `setHostAppBadgeCount()`:原生 App 宿主的受控应用角标入口。H5 只传 `0-99999` 的整数,`0` 表示清除角标;Expo 移动壳只在 iOS 声明 `app.setBadgeCount` 并通过 React Native `PushNotificationIOS` 设置应用图标角标,Android 不声明该能力;Tauri 桌面壳通过主窗口 `set_badge_count` 设置任务栏角标,底层平台不支持时返回明确错误,由 H5 视作失败并继续主流程。
|
||||
- `openHostExternalUrl()`:原生 App 宿主的受控外链入口。H5 中需要离开主站的外链在 `native_app` 下先通过 `app.openExternalUrl` 请求宿主系统浏览器打开;只允许 `http:`、`https:`、`mailto:`、`tel:`,相对路径会先归一化到当前站点绝对 URL。宿主不可用或拒绝时回退浏览器外链行为,普通浏览器和小程序保持原有 `<a>` 语义。
|
||||
|
||||
57
package-lock.json
generated
57
package-lock.json
generated
@@ -22,6 +22,7 @@
|
||||
"expo-image-picker": "^56.0.18",
|
||||
"expo-linking": "^56.0.14",
|
||||
"expo-network": "^56.0.5",
|
||||
"expo-notifications": "^56.0.18",
|
||||
"expo-sharing": "^56.0.18",
|
||||
"expo-status-bar": "^56.0.4",
|
||||
"lucide-react": "^0.546.0",
|
||||
@@ -4981,6 +4982,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/badgin": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
|
||||
"integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -6317,6 +6324,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-application": {
|
||||
"version": "56.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-56.0.3.tgz",
|
||||
"integrity": "sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-asset": {
|
||||
"version": "56.0.17",
|
||||
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.17.tgz",
|
||||
@@ -6477,6 +6493,24 @@
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-notifications": {
|
||||
"version": "56.0.18",
|
||||
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.18.tgz",
|
||||
"integrity": "sha512-HHnrwyCLC5srFojcHYS2KskbNroy9o2fwPKdyhjrdjjrBu4sNRKm4LepcuZjDy98cZKEm89WIPW8O45vut8Rgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/image-utils": "^0.10.1",
|
||||
"abort-controller": "^3.0.0",
|
||||
"badgin": "^1.1.5",
|
||||
"expo-application": "~56.0.3",
|
||||
"expo-constants": "~56.0.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-server": {
|
||||
"version": "56.0.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-56.0.5.tgz",
|
||||
@@ -16350,6 +16384,11 @@
|
||||
"debug": "^4.3.4"
|
||||
}
|
||||
},
|
||||
"badgin": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
|
||||
"integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw=="
|
||||
},
|
||||
"balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -17271,6 +17310,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"expo-application": {
|
||||
"version": "56.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-56.0.3.tgz",
|
||||
"integrity": "sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==",
|
||||
"requires": {}
|
||||
},
|
||||
"expo-asset": {
|
||||
"version": "56.0.17",
|
||||
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.17.tgz",
|
||||
@@ -17373,6 +17418,18 @@
|
||||
"integrity": "sha512-zmuyO95jayDY9jyUfOAlNp9XXJrJaAOkBXXLy0TS/nh2kppj7CHirRPkQ/tf0rsxhIL3AEd9nsRTiPtNsGT9Lw==",
|
||||
"requires": {}
|
||||
},
|
||||
"expo-notifications": {
|
||||
"version": "56.0.18",
|
||||
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.18.tgz",
|
||||
"integrity": "sha512-HHnrwyCLC5srFojcHYS2KskbNroy9o2fwPKdyhjrdjjrBu4sNRKm4LepcuZjDy98cZKEm89WIPW8O45vut8Rgw==",
|
||||
"requires": {
|
||||
"@expo/image-utils": "^0.10.1",
|
||||
"abort-controller": "^3.0.0",
|
||||
"badgin": "^1.1.5",
|
||||
"expo-application": "~56.0.3",
|
||||
"expo-constants": "~56.0.18"
|
||||
}
|
||||
},
|
||||
"expo-server": {
|
||||
"version": "56.0.5",
|
||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-56.0.5.tgz",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"expo-image-picker": "^56.0.18",
|
||||
"expo-linking": "^56.0.14",
|
||||
"expo-network": "^56.0.5",
|
||||
"expo-notifications": "^56.0.18",
|
||||
"expo-sharing": "^56.0.18",
|
||||
"expo-status-bar": "^56.0.4",
|
||||
"lucide-react": "^0.546.0",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
normalizeHostBridgeExportFileName,
|
||||
normalizeHostBridgeExternalUrl,
|
||||
normalizeHostBridgeLifecycleState,
|
||||
normalizeHostBridgeLocalNotification,
|
||||
} from './hostBridge';
|
||||
|
||||
describe('HostBridge shared contract helpers', () => {
|
||||
@@ -57,11 +58,52 @@ describe('HostBridge shared contract helpers', () => {
|
||||
expect(isHostBridgeCapability('file.importImage')).toBe(true);
|
||||
expect(isHostBridgeCapability('file.imageDropped')).toBe(true);
|
||||
expect(isHostBridgeCapability('app.setBadgeCount')).toBe(true);
|
||||
expect(isHostBridgeCapability('notification.showLocal')).toBe(true);
|
||||
expect(isHostBridgeCapability('navigation.canGoBack')).toBe(true);
|
||||
expect(isHostBridgeCapability('unknown.capability')).toBe(false);
|
||||
expect(isHostBridgeCapability(null)).toBe(false);
|
||||
});
|
||||
|
||||
test('归一化宿主本地通知内容', () => {
|
||||
expect(
|
||||
normalizeHostBridgeLocalNotification({
|
||||
title: ' 生成完成 ',
|
||||
body: ' 作品已准备好 可以试玩 ',
|
||||
}),
|
||||
).toEqual({
|
||||
title: '生成完成',
|
||||
body: '作品已准备好 可以试玩',
|
||||
});
|
||||
expect(
|
||||
normalizeHostBridgeLocalNotification({
|
||||
title: '生成完成',
|
||||
body: '',
|
||||
}),
|
||||
).toEqual({
|
||||
title: '生成完成',
|
||||
});
|
||||
expect(
|
||||
normalizeHostBridgeLocalNotification({
|
||||
title: 'a'.repeat(90),
|
||||
body: 'b'.repeat(250),
|
||||
}),
|
||||
).toEqual({
|
||||
title: 'a'.repeat(80),
|
||||
body: 'b'.repeat(240),
|
||||
});
|
||||
expect(normalizeHostBridgeLocalNotification({ title: '' })).toBeNull();
|
||||
expect(
|
||||
normalizeHostBridgeLocalNotification({ title: '生成\n完成' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
normalizeHostBridgeLocalNotification({
|
||||
title: '生成完成',
|
||||
body: '坏\u0001内容',
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(normalizeHostBridgeLocalNotification(null)).toBeNull();
|
||||
});
|
||||
|
||||
test('归一化宿主角标数量', () => {
|
||||
expect(normalizeHostBridgeBadgeCount(0)).toBe(0);
|
||||
expect(normalizeHostBridgeBadgeCount(12)).toBe(12);
|
||||
|
||||
@@ -30,6 +30,7 @@ export const HOST_BRIDGE_METHODS = [
|
||||
'file.exportImage',
|
||||
'file.importImage',
|
||||
'haptics.impact',
|
||||
'notification.showLocal',
|
||||
] as const;
|
||||
|
||||
export type HostBridgeMethod = (typeof HOST_BRIDGE_METHODS)[number];
|
||||
@@ -304,6 +305,64 @@ export type HapticsImpactPayload = {
|
||||
style?: 'light' | 'medium' | 'heavy';
|
||||
};
|
||||
|
||||
export type LocalNotificationPayload = {
|
||||
title: string;
|
||||
body?: string;
|
||||
};
|
||||
|
||||
export const HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH = 80;
|
||||
export const HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH = 240;
|
||||
|
||||
function normalizeHostBridgePlainText(
|
||||
value: unknown,
|
||||
maxLength: number,
|
||||
required: boolean,
|
||||
) {
|
||||
if (typeof value !== 'string') {
|
||||
return required ? null : undefined;
|
||||
}
|
||||
|
||||
if (hasHostBridgeControlCharacter(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = value.trim().replace(/\s+/g, ' ');
|
||||
if (!text) {
|
||||
return required ? null : undefined;
|
||||
}
|
||||
|
||||
return text.slice(0, maxLength);
|
||||
}
|
||||
|
||||
export function normalizeHostBridgeLocalNotification(
|
||||
payload: unknown,
|
||||
): LocalNotificationPayload | null {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = payload as Partial<LocalNotificationPayload>;
|
||||
const title = normalizeHostBridgePlainText(
|
||||
candidate.title,
|
||||
HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH,
|
||||
true,
|
||||
);
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = normalizeHostBridgePlainText(
|
||||
candidate.body,
|
||||
HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH,
|
||||
false,
|
||||
);
|
||||
if (body === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return body ? { title, body } : { title };
|
||||
}
|
||||
|
||||
export type ShareSetTargetPayload = {
|
||||
target: unknown;
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
setHostAppBadgeCount,
|
||||
setHostAppTitle,
|
||||
setHostShareTarget,
|
||||
showHostLocalNotification,
|
||||
subscribeHostAppLifecycle,
|
||||
subscribeHostImageDrop,
|
||||
subscribeHostNetworkStatusChange,
|
||||
@@ -623,6 +624,7 @@ describe('hostBridge', () => {
|
||||
'file.exportImage',
|
||||
'file.importImage',
|
||||
'file.imageDropped',
|
||||
'notification.showLocal',
|
||||
]),
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
@@ -686,6 +688,12 @@ describe('hostBridge', () => {
|
||||
mimeType: 'image/png',
|
||||
bytes: 5,
|
||||
});
|
||||
await expect(
|
||||
showHostLocalNotification({
|
||||
title: ' 生成完成 ',
|
||||
body: ' 作品已准备好 可以试玩 ',
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
||||
request: expect.objectContaining({
|
||||
@@ -784,6 +792,15 @@ describe('hostBridge', () => {
|
||||
timeoutMs: 30000,
|
||||
}),
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
||||
request: expect.objectContaining({
|
||||
method: 'notification.showLocal',
|
||||
payload: {
|
||||
title: '生成完成',
|
||||
body: '作品已准备好 可以试玩',
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test('原生 App 宿主不支持能力时回退到 H5 路径', async () => {
|
||||
@@ -850,6 +867,11 @@ describe('hostBridge', () => {
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(importHostImageFile()).resolves.toBe(false);
|
||||
await expect(
|
||||
showHostLocalNotification({
|
||||
title: '生成完成',
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('普通浏览器不处理宿主文件导出', async () => {
|
||||
@@ -868,6 +890,33 @@ describe('hostBridge', () => {
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(importHostImageFile()).resolves.toBe(false);
|
||||
await expect(
|
||||
showHostLocalNotification({
|
||||
title: '生成完成',
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('原生 App 宿主拒绝非法本地通知 payload', async () => {
|
||||
const invoke = vi.fn();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
nativeAppPath(['notification.showLocal']),
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: asTauriInvoke(invoke),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
showHostLocalNotification({
|
||||
title: '生成\n完成',
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('原生 App 宿主通过 HostBridge 导出文本文件', async () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
HostBridgeImageMimeType,
|
||||
HostBridgeMethod,
|
||||
HostBridgeRuntimeResult,
|
||||
LocalNotificationPayload,
|
||||
NetworkStatusResult,
|
||||
OpenExternalUrlPayload,
|
||||
SetBadgeCountPayload,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
normalizeHostBridgeConnectionType,
|
||||
normalizeHostBridgeExternalUrl,
|
||||
normalizeHostBridgeLifecycleState,
|
||||
normalizeHostBridgeLocalNotification,
|
||||
} from '../../../packages/shared/src/contracts/hostBridge';
|
||||
import type {
|
||||
WechatMiniProgramPayParams,
|
||||
@@ -96,6 +98,8 @@ export type HostClipboardWriteTextRequest = {
|
||||
|
||||
export type HostHapticsImpactRequest = HapticsImpactPayload;
|
||||
|
||||
export type HostLocalNotificationRequest = LocalNotificationPayload;
|
||||
|
||||
export type HostAppTitleRequest = {
|
||||
title: string;
|
||||
};
|
||||
@@ -818,6 +822,28 @@ export async function requestHostHapticsImpact(
|
||||
}
|
||||
}
|
||||
|
||||
export async function showHostLocalNotification(
|
||||
params: HostLocalNotificationRequest,
|
||||
) {
|
||||
if (!canUseNativeHostCapability('notification.showLocal')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const notification = normalizeHostBridgeLocalNotification(params);
|
||||
if (!notification) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestNativeHostBoolean(
|
||||
'notification.showLocal',
|
||||
notification,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setHostAppTitle({ title }: HostAppTitleRequest) {
|
||||
const normalizedTitle = title.trim();
|
||||
if (!normalizedTitle || !canUseNativeHostCapability('app.setTitle')) {
|
||||
|
||||
Reference in New Issue
Block a user