diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 0744dbb1..d3862de4 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -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) { diff --git a/apps/desktop-shell/src-tauri/Cargo.lock b/apps/desktop-shell/src-tauri/Cargo.lock index b5ba963e..83d90b98 100644 --- a/apps/desktop-shell/src-tauri/Cargo.lock +++ b/apps/desktop-shell/src-tauri/Cargo.lock @@ -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", ] diff --git a/apps/desktop-shell/src-tauri/Cargo.toml b/apps/desktop-shell/src-tauri/Cargo.toml index e5daeac8..6b3b74a4 100644 --- a/apps/desktop-shell/src-tauri/Cargo.toml +++ b/apps/desktop-shell/src-tauri/Cargo.toml @@ -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" diff --git a/apps/desktop-shell/src-tauri/src/main.rs b/apps/desktop-shell/src-tauri/src/main.rs index 68c19094..4638c90f 100644 --- a/apps/desktop-shell/src-tauri/src/main.rs +++ b/apps/desktop-shell/src-tauri/src/main.rs @@ -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, HostB Ok(if count == 0 { None } else { Some(count) }) } +fn normalize_plain_text( + value: Option<&str>, + max_length: usize, + required: bool, +) -> Option> { + 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::>().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), 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"); diff --git a/apps/desktop-shell/src-tauri/tauri.conf.json b/apps/desktop-shell/src-tauri/tauri.conf.json index 0f975f99..7331bc9a 100644 --- a/apps/desktop-shell/src-tauri/tauri.conf.json +++ b/apps/desktop-shell/src-tauri/tauri.conf.json @@ -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, diff --git a/apps/mobile-shell/app.json b/apps/mobile-shell/app.json index 03cbf2a7..c7723e6f 100644 --- a/apps/mobile-shell/app.json +++ b/apps/mobile-shell/app.json @@ -18,6 +18,12 @@ "cameraPermission": false, "microphonePermission": false } + ], + [ + "expo-notifications", + { + "enableBackgroundRemoteNotifications": false + } ] ], "ios": { diff --git a/apps/mobile-shell/package.json b/apps/mobile-shell/package.json index af9e8d43..a2aeddba 100644 --- a/apps/mobile-shell/package.json +++ b/apps/mobile-shell/package.json @@ -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", diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 9923232b..3bc1ca0d 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -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}`); diff --git a/apps/mobile-shell/src/mobileHostBridge.test.ts b/apps/mobile-shell/src/mobileHostBridge.test.ts index e8e12267..aa611ff8 100644 --- a/apps/mobile-shell/src/mobileHostBridge.test.ts +++ b/apps/mobile-shell/src/mobileHostBridge.test.ts @@ -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>; + +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', { diff --git a/apps/mobile-shell/src/mobileHostBridge.ts b/apps/mobile-shell/src/mobileHostBridge.ts index be5c95f3..a013a4b1 100644 --- a/apps/mobile-shell/src/mobileHostBridge.ts +++ b/apps/mobile-shell/src/mobileHostBridge.ts @@ -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([ '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>, +) { + 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': diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 10a1d44d..4f981858 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 或窗口失焦时暂停 `