diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index d3862de4..aa3c8c46 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -143,6 +143,7 @@ const requiredMainSnippets = [ '"app.setTitle"', '"app.setBadgeCount"', '"clipboard.writeText"', + '"clipboard.readText"', '"file.exportText"', '"file.exportImage"', '"file.importImage"', @@ -159,6 +160,8 @@ const requiredMainSnippets = [ 'import_image_file_payload', 'set_title', 'set_badge_count', + 'read_text()', + 'normalize_clipboard_text', 'window.theme()', 'WindowEvent::Focused', 'WindowEvent::DragDrop', diff --git a/apps/desktop-shell/src-tauri/src/main.rs b/apps/desktop-shell/src-tauri/src/main.rs index 4638c90f..291b1220 100644 --- a/apps/desktop-shell/src-tauri/src/main.rs +++ b/apps/desktop-shell/src-tauri/src/main.rs @@ -30,6 +30,7 @@ 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; +const CLIPBOARD_TEXT_MAX_LENGTH: usize = 100000; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -101,6 +102,7 @@ fn capabilities() -> Vec<&'static str> { "network.status", "network.statusChanged", "clipboard.writeText", + "clipboard.readText", "file.exportText", "file.exportImage", "file.importImage", @@ -248,6 +250,10 @@ fn badge_count_payload(request: &HostBridgeRequest) -> Result, HostB Ok(if count == 0 { None } else { Some(count) }) } +fn normalize_clipboard_text(text: String) -> String { + text.chars().take(CLIPBOARD_TEXT_MAX_LENGTH).collect() +} + fn normalize_plain_text( value: Option<&str>, max_length: usize, @@ -859,6 +865,15 @@ async fn host_bridge_request( Err(error) => failed(request.id, "host_error", error.to_string()), } } + "clipboard.readText" => match app.clipboard().read_text() { + Ok(text) => ok( + request.id, + json!({ + "text": normalize_clipboard_text(text), + }), + ), + Err(error) => failed(request.id, "host_error", error.to_string()), + }, "file.exportText" => { let (file_name, content) = match export_text_payload(&request) { Ok(payload) => payload, @@ -1130,6 +1145,10 @@ mod tests { .as_array() .unwrap() .contains(&json!("app.setBadgeCount"))); + assert!(result["capabilities"] + .as_array() + .unwrap() + .contains(&json!("clipboard.readText"))); assert!(result["capabilities"] .as_array() .unwrap() @@ -1186,6 +1205,15 @@ mod tests { assert_eq!(error.message, "text is required"); } + #[test] + fn clipboard_text_is_truncated_to_contract_limit() { + assert_eq!(normalize_clipboard_text("作品号 PZ-1".to_string()), "作品号 PZ-1"); + assert_eq!( + normalize_clipboard_text("a".repeat(CLIPBOARD_TEXT_MAX_LENGTH + 10)).len(), + CLIPBOARD_TEXT_MAX_LENGTH + ); + } + #[test] fn local_notification_payload_is_normalized() { let mut request = request("notification.showLocal"); diff --git a/apps/desktop-shell/src-tauri/tauri.conf.json b/apps/desktop-shell/src-tauri/tauri.conf.json index 7331bc9a..d7637f85 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,notification.showLocal", + "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,clipboard.readText,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,notification.showLocal", + "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,clipboard.readText,file.exportText,file.exportImage,file.importImage,file.imageDropped,notification.showLocal", "title": "Genarrative", "width": 1280, "height": 820, diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 3bc1ca0d..8526c604 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -179,6 +179,7 @@ for (const snippet of [ 'file.exportText', 'file.exportImage', 'file.importImage', + 'clipboard.readText', 'notification.showLocal', 'network.status', 'getMobileNetworkStatus', @@ -187,9 +188,11 @@ for (const snippet of [ 'Notifications.getPermissionsAsync', 'Notifications.requestPermissionsAsync', 'Sharing.shareAsync', + 'Clipboard.getStringAsync', 'ImagePicker.launchImageLibraryAsync', 'ImagePicker.requestMediaLibraryPermissionsAsync', 'normalizeHostBridgeExportFileName', + 'normalizeHostBridgeClipboardText', 'base64Data', ]) { if (!bridgeSource.includes(snippet)) { @@ -218,6 +221,7 @@ for (const capability of [ 'navigation.canGoBack', 'app.openExternalUrl', 'clipboard.writeText', + 'clipboard.readText', 'file.exportText', 'file.exportImage', 'file.importImage', diff --git a/apps/mobile-shell/src/mobileHostBridge.test.ts b/apps/mobile-shell/src/mobileHostBridge.test.ts index aa611ff8..9c6932a6 100644 --- a/apps/mobile-shell/src/mobileHostBridge.test.ts +++ b/apps/mobile-shell/src/mobileHostBridge.test.ts @@ -1,3 +1,4 @@ +import * as Clipboard from 'expo-clipboard'; import * as Haptics from 'expo-haptics'; import * as ImagePicker from 'expo-image-picker'; import * as Linking from 'expo-linking'; @@ -43,6 +44,7 @@ const DENIED_NOTIFICATION_PERMISSION = { } as NotificationPermissionStatus; vi.mock('expo-clipboard', () => ({ + getStringAsync: vi.fn(), setStringAsync: vi.fn(), })); @@ -198,6 +200,9 @@ afterEach(() => { vi.mocked(Appearance.getColorScheme).mockReset(); vi.mocked(Appearance.getColorScheme).mockReturnValue('light'); vi.mocked(Haptics.impactAsync).mockReset(); + vi.mocked(Clipboard.getStringAsync).mockReset(); + vi.mocked(Clipboard.getStringAsync).mockResolvedValue('作品号 PZ-1'); + vi.mocked(Clipboard.setStringAsync).mockReset(); vi.mocked(ImagePicker.launchImageLibraryAsync).mockReset(); vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({ canceled: true, @@ -271,6 +276,7 @@ describe('handleMobileHostBridgeMessage', () => { 'network.statusChanged', 'navigation.canGoBack', 'app.setBadgeCount', + 'clipboard.readText', 'notification.showLocal', ]), ); @@ -400,6 +406,28 @@ describe('handleMobileHostBridgeMessage', () => { }); }); + test('clipboard.readText 读取 Expo 系统剪贴板文本', async () => { + vi.mocked(Clipboard.getStringAsync).mockResolvedValue('作品号 PZ-1'); + + const response = await send(request('clipboard.readText')); + + expect(expectOk(response).result).toEqual({ + text: '作品号 PZ-1', + }); + expect(Clipboard.getStringAsync).toHaveBeenCalled(); + }); + + test('clipboard.readText 读取失败时返回 host_error', async () => { + vi.mocked(Clipboard.getStringAsync).mockRejectedValue( + new Error('clipboard unavailable'), + ); + + const response = await send(request('clipboard.readText')); + + const failedResponse = expectFailed(response); + expect(failedResponse.error.code).toBe('host_error'); + }); + test('haptics.impact 调起 Expo 触觉反馈', async () => { const response = await send( request('haptics.impact', { diff --git a/apps/mobile-shell/src/mobileHostBridge.ts b/apps/mobile-shell/src/mobileHostBridge.ts index a013a4b1..e98d92c8 100644 --- a/apps/mobile-shell/src/mobileHostBridge.ts +++ b/apps/mobile-shell/src/mobileHostBridge.ts @@ -13,6 +13,7 @@ import { } from 'react-native'; import { + type ClipboardReadTextResult, type ClipboardWriteTextPayload, type FileExportImagePayload, type FileExportImageResult, @@ -30,6 +31,7 @@ import { type HostBridgeResponse, type NavigateNativePagePayload, normalizeHostBridgeBadgeCount, + normalizeHostBridgeClipboardText, normalizeHostBridgeColorScheme, normalizeHostBridgeExportFileName, normalizeHostBridgeExternalUrl, @@ -74,6 +76,7 @@ export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [ 'network.status', 'network.statusChanged', 'clipboard.writeText', + 'clipboard.readText', 'file.exportText', 'file.exportImage', 'file.importImage', @@ -229,6 +232,20 @@ async function writeClipboard(payload: unknown) { return true; } +async function readClipboard(): Promise { + const result = normalizeHostBridgeClipboardText( + await Clipboard.getStringAsync(), + ); + if (!result) { + throw { + code: 'host_error', + message: 'clipboard text unavailable', + } satisfies HostBridgeError; + } + + return result; +} + async function exportTextFile(payload: unknown): Promise { const exportPayload = payload as FileExportTextPayload | undefined; const content = exportPayload?.content; @@ -606,6 +623,8 @@ async function handleRequest(request: HostBridgeRequest) { return ok(request, await getMobileNetworkStatus()); case 'clipboard.writeText': return ok(request, await writeClipboard(request.payload)); + case 'clipboard.readText': + return ok(request, await readClipboard()); case 'file.exportText': return ok(request, await exportTextFile(request.payload)); case 'file.exportImage': diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4f981858..bf1999cc 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -39,6 +39,7 @@ - 2026-06-18 H5 背景音乐接入宿主生命周期:`useBackgroundMusic` 通过 `useHostLifecycleActive()` 消费 `subscribeHostAppLifecycle()` 的归一结果,宿主进入后台、inactive 或桌面窗口失焦时降低音量并暂停音频循环,同时 `suspend` WebAudio context;回到 `active + focused` 且用户原本开启音乐时再恢复播放,不改变用户音量设置。 - 2026-06-18 固定玩法音频接入宿主生命周期:前端新增 `useHostLifecycleActive()` 统一消费 `subscribeHostAppLifecycle()`,`useBackgroundMusic`、拼图运行态和抓大鹅运行态都只依赖该归一状态判断音频可播放性;宿主 inactive、background 或窗口失焦时暂停 `