接入原生壳本地通知能力

新增 notification.showLocal HostBridge 契约和 H5 facade

移动端通过 expo-notifications 发送即时本地通知

桌面端通过 Tauri notification 插件发送系统通知

更新壳能力检查、测试、方案文档和共享决策记录
This commit is contained in:
2026-06-18 04:24:55 +08:00
parent f34f98c1a0
commit bbfe4b7181
19 changed files with 685 additions and 7 deletions

View File

@@ -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) {

View File

@@ -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",
]

View File

@@ -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"

View File

@@ -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");

View File

@@ -6,7 +6,7 @@
"build": {
"beforeDevCommand": "npm --prefix ../.. run dev:web",
"beforeBuildCommand": "npm --prefix ../.. run build:raw && npm run typecheck",
"devUrl": "http://127.0.0.1:3000/?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,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,

View File

@@ -18,6 +18,12 @@
"cameraPermission": false,
"microphonePermission": false
}
],
[
"expo-notifications",
{
"enableBackgroundRemoteNotifications": false
}
]
],
"ios": {

View File

@@ -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",

View File

@@ -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}`);

View File

@@ -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', {

View File

@@ -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':

View File

@@ -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 capabilityH5 只能传必填 `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固定玩法在各宿主中读取同一作品数据和运行态 snapshotAI sandbox 无法直接调用 HostBridgeTauri release 不允许任意远端页面调用桌面命令。
- 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md``docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`

View File

@@ -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 导航状态变化实时注入 H5WebView 自身拦截到外域导航时只会把 `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 导航状态变化实时注入 H5WebView 自身拦截到外域导航时只会把 `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 3Tauri 桌面壳 MVP
@@ -260,7 +261,7 @@ GameBridge 禁止:
- 实现 runtime、openExternalUrl、clipboard、share fallback、窗口标题同步。
- 验证 macOS / Windows / Linux 至少一条本地 smoke。
当前状态:已新增 `apps/desktop-shell/`Tauri dev 直接加载本地主站 Viterelease 打包根 `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 直接加载本地主站 Viterelease 打包根 `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宿主能力扩展

View File

@@ -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 HapticsH5 运行时点击反馈在 `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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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);

View File

@@ -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;
};

View File

@@ -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 () => {

View File

@@ -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')) {