接入原生壳本地通知能力
新增 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) {
|
||||
|
||||
Generated
+110
-3
@@ -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`。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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>` 语义。
|
||||
|
||||
Generated
+57
@@ -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