From 8bd8363ffabfa4fb81ecb2ff0b0827cde927b7d5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 22 Jun 2026 15:07:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=8E=9F=E7=94=9F=E5=A3=B3?= =?UTF-8?q?=E5=85=AC=E5=BC=80=E4=B8=BB=E7=AB=99=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将原生壳公开主站统一为 www.genarrative.world 调整 Tauri release 直接加载线上主站并移除 frontendDist 打包 补齐创作入口配置开发代理与回归测试 同步原生壳方案文档和共享记忆 --- apps/desktop-shell/scripts/check-config.mjs | 20 +++++------ apps/desktop-shell/src-tauri/src/app.rs | 2 +- .../src-tauri/src/host_bridge/navigation.rs | 4 +-- .../src-tauri/src/host_bridge/share.rs | 10 +++--- .../src-tauri/src/shell/deep_link.rs | 4 +-- .../src-tauri/src/shell/navigation.rs | 12 +++---- apps/desktop-shell/src-tauri/src/shell/url.rs | 4 +-- apps/desktop-shell/src-tauri/tauri.conf.json | 7 ++-- apps/mobile-shell/app.json | 4 +-- apps/mobile-shell/scripts/check-config.mjs | 12 +++---- .../src/host-bridge/bridge.test.ts | 36 +++++++++---------- .../src/host-bridge/navigation.test.ts | 4 +-- .../src/host-bridge/scanner.test.ts | 4 +-- .../src/host-bridge/share.test.ts | 26 +++++++------- .../src/shell/QrScannerOverlay.test.tsx | 4 +-- apps/mobile-shell/src/shell/ShellApp.test.tsx | 22 ++++++------ apps/mobile-shell/src/shell/deepLink.test.ts | 30 ++++++++-------- .../src/shell/loadFailure.test.ts | 22 ++++++------ .../mobile-shell/src/shell/navigation.test.ts | 20 +++++------ apps/mobile-shell/src/shell/url.test.ts | 12 +++---- .../src/shell/webViewPolicy.test.ts | 14 ++++---- .../shared-memory/decision-log.md | 24 ++++++------- ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 26 +++++++------- ...前端架构】宿主壳能力统一协议-2026-06-17.md | 4 +-- .../shared/src/contracts/hostBridge.test.ts | 10 +++--- packages/shared/src/contracts/hostBridge.ts | 4 +-- .../PlatformProfileQrScannerModal.test.tsx | 4 +-- .../RpgEntryHomeView.recharge.test.tsx | 4 +-- src/config/viteProxyConfig.test.ts | 18 ++++++++++ src/services/host-bridge/hostBridge.test.ts | 16 ++++----- vite.config.ts | 5 +++ 31 files changed, 205 insertions(+), 183 deletions(-) diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 899f369e7..70aac0f05 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1553,6 +1553,10 @@ const sharedPublicWebOrigin = extractTsStringConst( sharedContractSource, 'HOST_BRIDGE_PUBLIC_WEB_ORIGIN', ); +const sharedPublicWebUrl = extractTsStringConst( + sharedContractSource, + 'HOST_BRIDGE_PUBLIC_WEB_URL', +); const sharedNativeAppQueryKeys = extractStringArrayExport( sharedContractSource, 'HOST_BRIDGE_NATIVE_APP_QUERY_KEYS', @@ -1816,7 +1820,7 @@ for (const expectedNetworkSnippet of [ } } -if (desktopShellNetworkSource.includes('"app.genarrative.world"')) { +if (desktopShellNetworkSource.includes('"www.genarrative.world"')) { throw new Error('desktop shell network probe must not duplicate the public web host'); } for (const blockedDesktopNetworkEventSnippet of [ @@ -2340,11 +2344,11 @@ if ( throw new Error('desktop shell clipboard module must hide native clipboard errors'); } -if (config.build?.frontendDist !== '../../../dist') { - throw new Error('desktop shell must package the root H5 dist'); +if (Object.hasOwn(config.build ?? {}, 'frontendDist')) { + throw new Error('desktop shell release must load the shared public H5 URL without packaging root dist'); } -if (config.build?.beforeBuildCommand !== 'npm --prefix ../.. run build:raw && npm run typecheck') { +if (config.build?.beforeBuildCommand !== 'npm run typecheck') { throw new Error('desktop shell build command must run from apps/desktop-shell'); } @@ -2374,12 +2378,8 @@ if (mainWindow.label !== 'main') { throw new Error('desktop shell main window label must be main'); } -if (String(mainWindow.url ?? '').startsWith('http')) { - throw new Error('desktop shell release window must load packaged H5 assets'); -} - -if (String(mainWindow.url ?? '') !== 'index.html') { - throw new Error('desktop shell release window must enter through packaged index.html'); +if (String(mainWindow.url ?? '') !== sharedPublicWebUrl) { + throw new Error('desktop shell release window must load the shared public H5 URL'); } if (String(config.build?.devUrl ?? '') !== 'http://127.0.0.1:3000/') { diff --git a/apps/desktop-shell/src-tauri/src/app.rs b/apps/desktop-shell/src-tauri/src/app.rs index 5ebd709cc..35d23b7fc 100644 --- a/apps/desktop-shell/src-tauri/src/app.rs +++ b/apps/desktop-shell/src-tauri/src/app.rs @@ -232,7 +232,7 @@ mod tests { Some("https://example.com/share".to_string()) ); - let same_origin_url = tauri::Url::parse("https://app.genarrative.world/works/detail") + let same_origin_url = tauri::Url::parse("https://www.genarrative.world/works/detail") .expect("same-origin desktop new window url"); assert_eq!(desktop_new_window_external_url(&same_origin_url), None); diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs b/apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs index 0943481de..c3972e506 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs @@ -159,7 +159,7 @@ mod tests { assert_eq!( route.origin().ascii_serialization(), - "https://app.genarrative.world" + "https://www.genarrative.world" ); assert_eq!(route.path(), "/works/detail"); assert!(route @@ -170,7 +170,7 @@ mod tests { "https://example.com/works", "//example.com/works", "javascript:alert(1)", - "https://app.genarrative.world/\nnext", + "https://www.genarrative.world/\nnext", ] { let response = desktop_native_page_url_from_request(&request_with_url( "navigation.openNativePage", diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/share.rs b/apps/desktop-shell/src-tauri/src/host_bridge/share.rs index cb33a0f28..dfc2133d0 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/share.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/share.rs @@ -218,14 +218,14 @@ mod tests { open.payload = Some(json!({ "title": "测试作品", "message": "来玩这个作品", - "url": "https://app.genarrative.world/works/detail?work=PZ-1" + "url": "https://www.genarrative.world/works/detail?work=PZ-1" })); let text = share_text_from_request(&open, &state).expect("share text"); assert_eq!( text, - "测试作品\n来玩这个作品\nhttps://app.genarrative.world/works/detail?work=PZ-1" + "测试作品\n来玩这个作品\nhttps://www.genarrative.world/works/detail?work=PZ-1" ); } @@ -242,7 +242,7 @@ mod tests { assert_eq!( path_text, - "测试作品\nhttps://app.genarrative.world/works/detail?work=PZ-1" + "测试作品\nhttps://www.genarrative.world/works/detail?work=PZ-1" ); let mut work = request("share.open"); @@ -255,7 +255,7 @@ mod tests { assert_eq!( work_text, - "测试作品\nhttps://app.genarrative.world/works/detail?work=PZ+1%2F%E4%BA%8C" + "测试作品\nhttps://www.genarrative.world/works/detail?work=PZ+1%2F%E4%BA%8C" ); } @@ -325,7 +325,7 @@ mod tests { assert_eq!( text, - "测试作品\nhttps://app.genarrative.world/works/detail?work=PZ-1" + "测试作品\nhttps://www.genarrative.world/works/detail?work=PZ-1" ); } diff --git a/apps/desktop-shell/src-tauri/src/shell/deep_link.rs b/apps/desktop-shell/src-tauri/src/shell/deep_link.rs index 28711ac20..ebc4e81f4 100644 --- a/apps/desktop-shell/src-tauri/src/shell/deep_link.rs +++ b/apps/desktop-shell/src-tauri/src/shell/deep_link.rs @@ -185,7 +185,7 @@ mod tests { #[test] fn same_origin_https_link_keeps_path_and_restores_desktop_host_context() { let url = - normalized("https://app.genarrative.world/creation/puzzle?clientRuntime=browser#draft"); + normalized("https://www.genarrative.world/creation/puzzle?clientRuntime=browser#draft"); assert_eq!(url.path(), "/creation/puzzle"); assert_eq!( @@ -208,7 +208,7 @@ mod tests { fn external_and_unsafe_links_are_rejected() { for raw_url in [ "https://example.com/works/detail?work=PZ-1", - "http://app.genarrative.world/works/detail?work=PZ-1", + "http://www.genarrative.world/works/detail?work=PZ-1", "mailto:hi@example.com", "javascript:alert(1)", ] { diff --git a/apps/desktop-shell/src-tauri/src/shell/navigation.rs b/apps/desktop-shell/src-tauri/src/shell/navigation.rs index b1b0ffb66..cc50e4cb1 100644 --- a/apps/desktop-shell/src-tauri/src/shell/navigation.rs +++ b/apps/desktop-shell/src-tauri/src/shell/navigation.rs @@ -263,7 +263,7 @@ mod tests { None ); - let same_origin_url = Url::parse("https://app.genarrative.world/works/detail?work=PZ-1") + let same_origin_url = Url::parse("https://www.genarrative.world/works/detail?work=PZ-1") .expect("same-origin url"); assert!(should_allow_desktop_webview_navigation(&same_origin_url)); assert_eq!(desktop_external_navigation_url(&same_origin_url), None); @@ -305,7 +305,7 @@ mod tests { #[test] fn desktop_webview_downloads_are_blocked_by_default() { let mut destination = std::env::temp_dir().join("genarrative-webview-download.txt"); - let url = Url::parse("https://app.genarrative.world/download.txt").expect("download url"); + let url = Url::parse("https://www.genarrative.world/download.txt").expect("download url"); let requested = DownloadEvent::Requested { url: url.clone(), destination: &mut destination, @@ -325,7 +325,7 @@ mod tests { #[test] fn desktop_webview_download_handler_logs_blocked_requests() { let mut destination = std::env::temp_dir().join("genarrative-webview-download.txt"); - let url = Url::parse("https://app.genarrative.world/download.txt").expect("download url"); + let url = Url::parse("https://www.genarrative.world/download.txt").expect("download url"); let requested = DownloadEvent::Requested { url: url.clone(), destination: &mut destination, @@ -338,7 +338,7 @@ mod tests { #[test] fn desktop_webview_download_blocked_logs_stable_label_only() { let url = Url::parse( - "https://app.genarrative.world/download.txt?token=private&work=PZ-1", + "https://www.genarrative.world/download.txt?token=private&work=PZ-1", ) .expect("download url"); @@ -383,7 +383,7 @@ mod tests { .query_pairs() .any(|(key, value)| key == "clientType" && value == "native_app")); assert!( - normalize_native_page_url("https://app.genarrative.world/works/detail?work=PZ-1") + normalize_native_page_url("https://www.genarrative.world/works/detail?work=PZ-1") .expect("absolute same-origin route") .query_pairs() .any(|(key, value)| key == "hostShell" && value == "tauri_desktop") @@ -396,7 +396,7 @@ mod tests { assert_eq!(normalize_native_page_url("//example.com/works"), None); assert_eq!(normalize_native_page_url("javascript:alert(1)"), None); assert_eq!( - normalize_native_page_url("https://app.genarrative.world/\nnext"), + normalize_native_page_url("https://www.genarrative.world/\nnext"), None ); } diff --git a/apps/desktop-shell/src-tauri/src/shell/url.rs b/apps/desktop-shell/src-tauri/src/shell/url.rs index c223323a3..8c2daadbf 100644 --- a/apps/desktop-shell/src-tauri/src/shell/url.rs +++ b/apps/desktop-shell/src-tauri/src/shell/url.rs @@ -4,7 +4,7 @@ use crate::shell::runtime::desktop_platform; use std::path::PathBuf; use tauri::{Url, WebviewUrl}; -pub(crate) const WEB_APP_ORIGIN: &str = "https://app.genarrative.world"; +pub(crate) const WEB_APP_ORIGIN: &str = "https://www.genarrative.world"; const HOST_CONTEXT_QUERY_KEYS: [&str; 7] = [ "clientRuntime", "clientType", @@ -140,7 +140,7 @@ mod tests { fn desktop_h5_url_with_host_context_rewrites_runtime_query_once() { let platform = desktop_platform(); let url = Url::parse( - "https://app.genarrative.world/creation/puzzle?work=PZ-1&clientRuntime=browser&hostCapabilities=old#draft", + "https://www.genarrative.world/creation/puzzle?work=PZ-1&clientRuntime=browser&hostCapabilities=old#draft", ) .expect("desktop H5 url"); let url = desktop_h5_url_with_host_context(url).expect("context url"); diff --git a/apps/desktop-shell/src-tauri/tauri.conf.json b/apps/desktop-shell/src-tauri/tauri.conf.json index 6c30d818e..ccc60c66b 100644 --- a/apps/desktop-shell/src-tauri/tauri.conf.json +++ b/apps/desktop-shell/src-tauri/tauri.conf.json @@ -5,16 +5,15 @@ "identifier": "world.genarrative.desktop", "build": { "beforeDevCommand": "npm --prefix ../.. run dev:web -- --web-port 3000 --strict-web-port", - "beforeBuildCommand": "npm --prefix ../.. run build:raw && npm run typecheck", - "devUrl": "http://127.0.0.1:3000/", - "frontendDist": "../../../dist" + "beforeBuildCommand": "npm run typecheck", + "devUrl": "http://127.0.0.1:3000/" }, "app": { "windows": [ { "create": false, "label": "main", - "url": "index.html", + "url": "https://www.genarrative.world/", "title": "Genarrative", "width": 1280, "height": 820, diff --git a/apps/mobile-shell/app.json b/apps/mobile-shell/app.json index d6f1b2631..b666f4948 100644 --- a/apps/mobile-shell/app.json +++ b/apps/mobile-shell/app.json @@ -54,7 +54,7 @@ } }, "associatedDomains": [ - "applinks:app.genarrative.world" + "applinks:www.genarrative.world" ], "privacyManifests": { "NSPrivacyCollectedDataTypes": [], @@ -121,7 +121,7 @@ "data": [ { "scheme": "https", - "host": "app.genarrative.world" + "host": "www.genarrative.world" } ], "category": [ diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index d600bc5d3..19ce5f33e 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -1836,9 +1836,9 @@ for (const snippet of [ "url: 'about:blank'", "url: 'javascript:alert(1)'", "url: '/favicon.ico'", - "url: 'https://app.genarrative.world/assets/main.js'", - "https://app.genarrative.world/creation/puzzle?sessionId=private#recover", - "url: 'https://app.genarrative.world/works/detail'", + "url: 'https://www.genarrative.world/assets/main.js'", + "https://www.genarrative.world/creation/puzzle?sessionId=private#recover", + "url: 'https://www.genarrative.world/works/detail'", ]) { if (!loadFailureTestSource.includes(snippet)) { throw new Error(`mobile shell load failure tests missing ${snippet}`); @@ -1998,7 +1998,7 @@ for (const snippet of [ 'does not fall back to cached target when explicit payload is unsafe', 'rejects empty share requests before opening native share sheet', "message: 'share unavailable'", - 'https://app.genarrative.world/works/detail?work=PZ-00000001', + 'https://www.genarrative.world/works/detail?work=PZ-00000001', 'javascript:alert(1)', 'expect(Share.share).not.toHaveBeenCalled()', ]) { @@ -2007,7 +2007,7 @@ for (const snippet of [ } } -if (shareSource.includes("const WEB_APP_ORIGIN = 'https://app.genarrative.world'")) { +if (shareSource.includes("const WEB_APP_ORIGIN = 'https://www.genarrative.world'")) { throw new Error('mobile shell share URL policy must reuse the shared web origin'); } @@ -2820,7 +2820,7 @@ for (const snippet of [ "'mobile QR scanner permission request failed'", "barcodeTypes: ['qr']", "type: 'qr'", - "value: 'https://app.genarrative.world/works/detail?work=PZ-1'", + "value: 'https://www.genarrative.world/works/detail?work=PZ-1'", ]) { if (!qrScannerOverlayTestSource.includes(snippet)) { throw new Error(`mobile shell QR scanner overlay test missing ${snippet}`); diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts index 9cbbd853f..53726666f 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.test.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -454,7 +454,7 @@ describe('handleMobileHostBridgeMessage', () => { test('navigation.openNativePage 把同源路径切到移动壳 WebView', async () => { const openWebViewUrl = vi.fn(); configureMobileHostBridgeNavigation({ - allowedOrigin: 'https://app.genarrative.world', + allowedOrigin: 'https://www.genarrative.world', urlOptions: TEST_MOBILE_URL_OPTIONS, openWebViewUrl, reloadWebView: vi.fn(), @@ -468,7 +468,7 @@ describe('handleMobileHostBridgeMessage', () => { expect(expectOk(response).result).toBe(true); const openedUrl = new URL(openWebViewUrl.mock.calls[0]?.[0] as string); - expect(openedUrl.origin).toBe('https://app.genarrative.world'); + expect(openedUrl.origin).toBe('https://www.genarrative.world'); expect(openedUrl.pathname).toBe('/works/detail'); expect(openedUrl.searchParams.get('work')).toBe('PZ-1'); expect(openedUrl.searchParams.get('clientRuntime')).toBe('native_app'); @@ -483,7 +483,7 @@ describe('handleMobileHostBridgeMessage', () => { test('navigation.openNativePage 拒绝外域目标', async () => { configureMobileHostBridgeNavigation({ - allowedOrigin: 'https://app.genarrative.world', + allowedOrigin: 'https://www.genarrative.world', urlOptions: TEST_MOBILE_URL_OPTIONS, openWebViewUrl: vi.fn(), reloadWebView: vi.fn(), @@ -552,7 +552,7 @@ describe('handleMobileHostBridgeMessage', () => { test('app.reloadWebView 刷新移动壳当前 WebView', async () => { const reloadWebView = vi.fn(); configureMobileHostBridgeNavigation({ - allowedOrigin: 'https://app.genarrative.world', + allowedOrigin: 'https://www.genarrative.world', urlOptions: TEST_MOBILE_URL_OPTIONS, openWebViewUrl: vi.fn(), reloadWebView, @@ -676,7 +676,7 @@ describe('handleMobileHostBridgeMessage', () => { test('重复 HostBridge request id 回放首次结果且不重复触发系统动作', async () => { const duplicateRequest = request('share.open', { title: '测试作品', - url: 'https://app.genarrative.world/works/detail?work=PZ-1', + url: 'https://www.genarrative.world/works/detail?work=PZ-1', }); const [firstResponse, secondResponse] = await Promise.all([ @@ -1005,7 +1005,7 @@ describe('handleMobileHostBridgeMessage', () => { request('share.open', { title: '测试作品', message: '来玩这个作品', - url: 'https://app.genarrative.world/works/detail?work=PZ-1', + url: 'https://www.genarrative.world/works/detail?work=PZ-1', }), ); @@ -1013,8 +1013,8 @@ describe('handleMobileHostBridgeMessage', () => { expect(Share.share).toHaveBeenCalledWith({ title: '测试作品', message: - '来玩这个作品\nhttps://app.genarrative.world/works/detail?work=PZ-1', - url: 'https://app.genarrative.world/works/detail?work=PZ-1', + '来玩这个作品\nhttps://www.genarrative.world/works/detail?work=PZ-1', + url: 'https://www.genarrative.world/works/detail?work=PZ-1', }); }); @@ -1040,8 +1040,8 @@ describe('handleMobileHostBridgeMessage', () => { expect(Share.share).toHaveBeenCalledWith({ title: '暖灯猫街', message: - '来玩这个作品\nhttps://app.genarrative.world/works/detail?work=PZ-00000001', - url: 'https://app.genarrative.world/works/detail?work=PZ-00000001', + '来玩这个作品\nhttps://www.genarrative.world/works/detail?work=PZ-00000001', + url: 'https://www.genarrative.world/works/detail?work=PZ-00000001', }); }); @@ -1082,8 +1082,8 @@ describe('handleMobileHostBridgeMessage', () => { expectOk(await send(request('share.open'))); expect(Share.share).toHaveBeenCalledWith({ title: '暖灯猫街', - message: 'https://app.genarrative.world/works/detail?work=PZ-00000001', - url: 'https://app.genarrative.world/works/detail?work=PZ-00000001', + message: 'https://www.genarrative.world/works/detail?work=PZ-00000001', + url: 'https://www.genarrative.world/works/detail?work=PZ-00000001', }); }); @@ -1098,15 +1098,15 @@ describe('handleMobileHostBridgeMessage', () => { expectOk(response); expect(Share.share).toHaveBeenCalledWith({ title: '测试作品', - message: 'https://app.genarrative.world/works/detail?work=PZ-2#play', - url: 'https://app.genarrative.world/works/detail?work=PZ-2#play', + message: 'https://www.genarrative.world/works/detail?work=PZ-2#play', + url: 'https://www.genarrative.world/works/detail?work=PZ-2#play', }); }); test.each([ 'https://example.com/works/detail?work=PZ-1', '//example.com/works/detail?work=PZ-1', - '//app.genarrative.world/works/detail?work=PZ-1', + '//www.genarrative.world/works/detail?work=PZ-1', 'javascript:alert(1)', ])('share.open 拒绝非公开主站 URL:%s', async (url) => { const response = await send( @@ -1164,7 +1164,7 @@ describe('handleMobileHostBridgeMessage', () => { const response = await send( request('share.open', { title: '测试作品', - targetPath: '//app.genarrative.world/works/detail?work=PZ-1', + targetPath: '//www.genarrative.world/works/detail?work=PZ-1', }), ); @@ -1895,12 +1895,12 @@ describe('handleMobileHostBridgeMessage', () => { test('scanner.scanQrCode 等待扫码 overlay 返回真实结果', async () => { const pendingResponse = send(request('scanner.scanQrCode')); - expect(completeQrCodeScan(' https://app.genarrative.world/w/PZ-1 ')).toBe( + expect(completeQrCodeScan(' https://www.genarrative.world/w/PZ-1 ')).toBe( true, ); expect(expectOk(await pendingResponse).result).toEqual({ - value: 'https://app.genarrative.world/w/PZ-1', + value: 'https://www.genarrative.world/w/PZ-1', format: 'qr_code', }); }); diff --git a/apps/mobile-shell/src/host-bridge/navigation.test.ts b/apps/mobile-shell/src/host-bridge/navigation.test.ts index 4099b681e..a102c876b 100644 --- a/apps/mobile-shell/src/host-bridge/navigation.test.ts +++ b/apps/mobile-shell/src/host-bridge/navigation.test.ts @@ -30,7 +30,7 @@ function request(method: HostBridgeRequest['method'], payload?: unknown) { function navigation(): MobileHostBridgeNavigation { return { - allowedOrigin: 'https://app.genarrative.world', + allowedOrigin: 'https://www.genarrative.world', urlOptions: { platform: 'ios', hostVersion: '0.1.0', @@ -171,7 +171,7 @@ describe('mobile HostBridge navigation helpers', () => { throw new Error('navigation.openNativePage did not open a WebView URL'); } const target = new URL(targetUrl); - expect(target.origin).toBe('https://app.genarrative.world'); + expect(target.origin).toBe('https://www.genarrative.world'); expect(target.pathname).toBe('/works/detail'); expect(target.searchParams.get('work')).toBe('PZ-1'); expect(target.hash).toBe('#play'); diff --git a/apps/mobile-shell/src/host-bridge/scanner.test.ts b/apps/mobile-shell/src/host-bridge/scanner.test.ts index 787c3a6df..ed5573aed 100644 --- a/apps/mobile-shell/src/host-bridge/scanner.test.ts +++ b/apps/mobile-shell/src/host-bridge/scanner.test.ts @@ -44,10 +44,10 @@ describe('mobile QR scanner helpers', () => { requestKey: 1, }); - expect(completeQrCodeScan(' https://app.genarrative.world/w/PZ-1 ')) + expect(completeQrCodeScan(' https://www.genarrative.world/w/PZ-1 ')) .toBe(true); await expect(pendingScan).resolves.toEqual({ - value: 'https://app.genarrative.world/w/PZ-1', + value: 'https://www.genarrative.world/w/PZ-1', format: 'qr_code', }); expect(listener).toHaveBeenNthCalledWith(3, { diff --git a/apps/mobile-shell/src/host-bridge/share.test.ts b/apps/mobile-shell/src/host-bridge/share.test.ts index 8f9dceeb8..dc5e90151 100644 --- a/apps/mobile-shell/src/host-bridge/share.test.ts +++ b/apps/mobile-shell/src/host-bridge/share.test.ts @@ -43,7 +43,7 @@ describe('mobile share helpers', () => { request('share.open', { title: '测试作品', message: '来玩这个作品', - url: 'https://app.genarrative.world/works/detail?work=PZ-1', + url: 'https://www.genarrative.world/works/detail?work=PZ-1', }), ); @@ -57,8 +57,8 @@ describe('mobile share helpers', () => { expect(Share.share).toHaveBeenCalledWith({ title: '测试作品', message: - '来玩这个作品\nhttps://app.genarrative.world/works/detail?work=PZ-1', - url: 'https://app.genarrative.world/works/detail?work=PZ-1', + '来玩这个作品\nhttps://www.genarrative.world/works/detail?work=PZ-1', + url: 'https://www.genarrative.world/works/detail?work=PZ-1', }); }); @@ -91,8 +91,8 @@ describe('mobile share helpers', () => { expect(Share.share).toHaveBeenCalledWith({ title: '暖灯猫街', message: - '来玩这个作品\nhttps://app.genarrative.world/works/detail?work=PZ-00000001', - url: 'https://app.genarrative.world/works/detail?work=PZ-00000001', + '来玩这个作品\nhttps://www.genarrative.world/works/detail?work=PZ-00000001', + url: 'https://www.genarrative.world/works/detail?work=PZ-00000001', }); }); @@ -118,8 +118,8 @@ describe('mobile share helpers', () => { expect(Share.share).toHaveBeenCalledWith({ title: '暖灯猫街', message: - '来玩这个作品\nhttps://app.genarrative.world/works/detail?work=PZ-00000001', - url: 'https://app.genarrative.world/works/detail?work=PZ-00000001', + '来玩这个作品\nhttps://www.genarrative.world/works/detail?work=PZ-00000001', + url: 'https://www.genarrative.world/works/detail?work=PZ-00000001', }); }); @@ -162,8 +162,8 @@ describe('mobile share helpers', () => { }); expect(Share.share).toHaveBeenCalledWith({ title: '暖灯猫街', - message: 'https://app.genarrative.world/works/detail?work=PZ-00000001', - url: 'https://app.genarrative.world/works/detail?work=PZ-00000001', + message: 'https://www.genarrative.world/works/detail?work=PZ-00000001', + url: 'https://www.genarrative.world/works/detail?work=PZ-00000001', }); }); @@ -182,8 +182,8 @@ describe('mobile share helpers', () => { expect(Share.share).toHaveBeenCalledWith({ title: '测试作品', - message: 'https://app.genarrative.world/works/detail?work=PZ-2#play', - url: 'https://app.genarrative.world/works/detail?work=PZ-2#play', + message: 'https://www.genarrative.world/works/detail?work=PZ-2#play', + url: 'https://www.genarrative.world/works/detail?work=PZ-2#play', }); }); @@ -196,7 +196,7 @@ describe('mobile share helpers', () => { openShare( request('share.open', { title: '测试作品', - url: 'https://app.genarrative.world/works/detail?work=PZ-1', + url: 'https://www.genarrative.world/works/detail?work=PZ-1', }), ), ).rejects.toMatchObject({ @@ -211,7 +211,7 @@ describe('mobile share helpers', () => { test.each([ 'https://example.com/works/detail?work=PZ-1', '//example.com/works/detail?work=PZ-1', - '//app.genarrative.world/works/detail?work=PZ-1', + '//www.genarrative.world/works/detail?work=PZ-1', 'javascript:alert(1)', ])('rejects unsafe explicit share URLs: %s', async (url) => { await expect( diff --git a/apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx b/apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx index 32ae825be..bf326eb78 100644 --- a/apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx +++ b/apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx @@ -111,12 +111,12 @@ describe('QrScannerOverlay', () => { }; cameraProps.onBarcodeScanned?.({ type: 'qr', - data: ' https://app.genarrative.world/works/detail?work=PZ-1 ', + data: ' https://www.genarrative.world/works/detail?work=PZ-1 ', }); await expect(scanPromise).resolves.toEqual({ format: 'qr_code', - value: 'https://app.genarrative.world/works/detail?work=PZ-1', + value: 'https://www.genarrative.world/works/detail?work=PZ-1', }); }); diff --git a/apps/mobile-shell/src/shell/ShellApp.test.tsx b/apps/mobile-shell/src/shell/ShellApp.test.tsx index 0c2a993e3..8cd52d326 100644 --- a/apps/mobile-shell/src/shell/ShellApp.test.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.test.tsx @@ -300,7 +300,7 @@ describe('ShellApp QR scanner HostBridge flow', () => { }; const sourceUrl = new URL(webViewProps.source?.uri ?? ''); - expect(sourceUrl.origin).toBe('https://app.genarrative.world'); + expect(sourceUrl.origin).toBe('https://www.genarrative.world'); expect(sourceUrl.searchParams.get('clientRuntime')).toBe('native_app'); expect(sourceUrl.searchParams.get('hostShell')).toBe('expo_mobile'); }); @@ -335,12 +335,12 @@ describe('ShellApp QR scanner HostBridge flow', () => { source?: { uri?: string }; }; const webViewUrl = webViewProps.source?.uri; - expect(webViewUrl).toContain('https://app.genarrative.world'); + expect(webViewUrl).toContain('https://www.genarrative.world'); webViewProps.onMessage?.({ nativeEvent: { data: JSON.stringify(buildRequest()), - url: webViewUrl ?? 'https://app.genarrative.world/', + url: webViewUrl ?? 'https://www.genarrative.world/', }, }); @@ -353,7 +353,7 @@ describe('ShellApp QR scanner HostBridge flow', () => { }; cameraViewProps.onBarcodeScanned?.({ type: 'qr', - data: ' https://app.genarrative.world/works/detail?work=PZ-1 ', + data: ' https://www.genarrative.world/works/detail?work=PZ-1 ', }); await waitFor(() => { @@ -377,7 +377,7 @@ describe('ShellApp QR scanner HostBridge flow', () => { ok: true, result: { format: 'qr_code', - value: 'https://app.genarrative.world/works/detail?work=PZ-1', + value: 'https://www.genarrative.world/works/detail?work=PZ-1', }, }); }); @@ -395,7 +395,7 @@ describe('ShellApp QR scanner HostBridge flow', () => { }) => void; source?: { uri?: string }; }; - const webViewUrl = webViewProps.source?.uri ?? 'https://app.genarrative.world/'; + const webViewUrl = webViewProps.source?.uri ?? 'https://www.genarrative.world/'; const injectionError = new Error('response injection failed'); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); shellHarness.injectJavaScriptError.current = injectionError; @@ -446,7 +446,7 @@ describe('ShellApp QR scanner HostBridge flow', () => { }) => void; source?: { uri?: string }; }; - const webViewUrl = webViewProps.source?.uri ?? 'https://app.genarrative.world/'; + const webViewUrl = webViewProps.source?.uri ?? 'https://www.genarrative.world/'; webViewProps.onMessage?.({ nativeEvent: { @@ -487,7 +487,7 @@ describe('ShellApp HostBridge event injection', () => { originWhitelist?: string[]; source?: { uri?: string }; }; - const webViewUrl = webViewProps.source?.uri ?? 'https://app.genarrative.world/'; + const webViewUrl = webViewProps.source?.uri ?? 'https://www.genarrative.world/'; expect(webViewProps.originWhitelist).toEqual([new URL(webViewUrl).origin]); }); @@ -583,7 +583,7 @@ describe('ShellApp HostBridge event injection', () => { onNavigationStateChange?: (event: { canGoBack: boolean }) => void; source?: { uri?: string }; }; - const webViewUrl = webViewProps.source?.uri ?? 'https://app.genarrative.world/'; + const webViewUrl = webViewProps.source?.uri ?? 'https://www.genarrative.world/'; shellHarness.injectedScripts.length = 0; webViewProps.onNavigationStateChange?.({ canGoBack: true }); @@ -626,7 +626,7 @@ describe('ShellApp HostBridge event injection', () => { }) => void; source?: { uri?: string }; }; - const webViewUrl = webViewProps.source?.uri ?? 'https://app.genarrative.world/'; + const webViewUrl = webViewProps.source?.uri ?? 'https://www.genarrative.world/'; const networkError = new Error('network replay failed'); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); vi.mocked(Network.getNetworkStateAsync).mockRejectedValueOnce(networkError); @@ -689,7 +689,7 @@ describe('ShellApp HostBridge event injection', () => { }; const downloadEvent = { nativeEvent: { - downloadUrl: 'blob:https://app.genarrative.world/download-id', + downloadUrl: 'blob:https://www.genarrative.world/download-id', }, }; diff --git a/apps/mobile-shell/src/shell/deepLink.test.ts b/apps/mobile-shell/src/shell/deepLink.test.ts index aa4a70634..82bab421b 100644 --- a/apps/mobile-shell/src/shell/deepLink.test.ts +++ b/apps/mobile-shell/src/shell/deepLink.test.ts @@ -16,12 +16,12 @@ describe('buildMobileShellUrlFromDeepLink', () => { const url = new URL( buildMobileShellUrlFromDeepLink( 'genarrative://open/works/detail?work=PZ-1', - 'https://app.genarrative.world/', + 'https://www.genarrative.world/', options, ), ); - expect(url.origin).toBe('https://app.genarrative.world'); + expect(url.origin).toBe('https://www.genarrative.world'); expect(url.pathname).toBe('/works/detail'); expect(url.searchParams.get('work')).toBe('PZ-1'); expect(url.searchParams.get('clientRuntime')).toBe('native_app'); @@ -31,8 +31,8 @@ describe('buildMobileShellUrlFromDeepLink', () => { test('把同源 universal link 映射成带宿主上下文的 H5 URL', () => { const url = new URL( buildMobileShellUrlFromDeepLink( - 'https://app.genarrative.world/creation/puzzle#draft', - 'https://app.genarrative.world/', + 'https://www.genarrative.world/creation/puzzle#draft', + 'https://www.genarrative.world/', options, ), ); @@ -46,21 +46,21 @@ describe('buildMobileShellUrlFromDeepLink', () => { const external = new URL( buildMobileShellUrlFromDeepLink( 'https://example.com/works/detail?work=PZ-1', - 'https://app.genarrative.world/', + 'https://www.genarrative.world/', options, ), ); const unsafe = new URL( buildMobileShellUrlFromDeepLink( 'javascript:alert(1)', - 'https://app.genarrative.world/', + 'https://www.genarrative.world/', options, ), ); - expect(external.origin).toBe('https://app.genarrative.world'); + expect(external.origin).toBe('https://www.genarrative.world'); expect(external.pathname).toBe('/'); - expect(unsafe.origin).toBe('https://app.genarrative.world'); + expect(unsafe.origin).toBe('https://www.genarrative.world'); expect(unsafe.pathname).toBe('/'); }); @@ -68,12 +68,12 @@ describe('buildMobileShellUrlFromDeepLink', () => { const url = new URL( buildMobileShellUrlFromDeepLink( '//example.com/works/detail?work=PZ-1', - 'https://app.genarrative.world/', + 'https://www.genarrative.world/', options, ), ); - expect(url.origin).toBe('https://app.genarrative.world'); + expect(url.origin).toBe('https://www.genarrative.world'); expect(url.pathname).toBe('/'); }); @@ -86,7 +86,7 @@ describe('buildMobileShellUrlFromDeepLink', () => { ), ); - expect(url.origin).toBe('https://app.genarrative.world'); + expect(url.origin).toBe('https://www.genarrative.world'); expect(url.pathname).toBe('/works/detail'); expect(url.searchParams.get('work')).toBe('PZ-1'); expect(url.searchParams.get('clientRuntime')).toBe('native_app'); @@ -111,7 +111,7 @@ describe('buildMobileShellUrlFromDeepLink', () => { ), ); - expect(productionUrl.origin).toBe('https://app.genarrative.world'); + expect(productionUrl.origin).toBe('https://www.genarrative.world'); expect(productionUrl.pathname).toBe('/'); expect(developmentUrl.origin).toBe('http://127.0.0.1:3000'); expect(developmentUrl.pathname).toBe('/works/detail'); @@ -122,17 +122,17 @@ describe('buildMobileShellUrlFromDeepLink', () => { test('返回 deep link 解析状态用于壳层记录拒绝路径', () => { const mapped = resolveMobileShellUrlFromDeepLink( 'genarrative://open/works/detail?work=PZ-1', - 'https://app.genarrative.world/', + 'https://www.genarrative.world/', options, ); const rejected = resolveMobileShellUrlFromDeepLink( 'https://example.com/works/detail?work=PZ-1', - 'https://app.genarrative.world/', + 'https://www.genarrative.world/', options, ); const empty = resolveMobileShellUrlFromDeepLink( null, - 'https://app.genarrative.world/', + 'https://www.genarrative.world/', options, ); diff --git a/apps/mobile-shell/src/shell/loadFailure.test.ts b/apps/mobile-shell/src/shell/loadFailure.test.ts index 0ce69edb8..3db25e246 100644 --- a/apps/mobile-shell/src/shell/loadFailure.test.ts +++ b/apps/mobile-shell/src/shell/loadFailure.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'vitest'; import { normalizeMobileShellLoadFailure } from './loadFailure'; -const allowedOrigin = 'https://app.genarrative.world'; +const allowedOrigin = 'https://www.genarrative.world'; describe('loadFailure', () => { test('归一化同源 HTTP 加载失败', () => { @@ -10,7 +10,7 @@ describe('loadFailure', () => { normalizeMobileShellLoadFailure( { type: 'http', - url: 'https://app.genarrative.world/creation/puzzle?sessionId=private#recover', + url: 'https://www.genarrative.world/creation/puzzle?sessionId=private#recover', statusCode: 503, description: 'Service Unavailable', }, @@ -18,7 +18,7 @@ describe('loadFailure', () => { ), ).toEqual({ type: 'http', - url: 'https://app.genarrative.world/creation/puzzle', + url: 'https://www.genarrative.world/creation/puzzle', title: '加载失败 503', detail: '服务器暂时没有返回可用页面', retryLabel: '重试', @@ -38,7 +38,7 @@ describe('loadFailure', () => { ), ).toEqual({ type: 'native', - url: 'https://app.genarrative.world/works/detail', + url: 'https://www.genarrative.world/works/detail', title: '网络不可用', detail: '当前页面没有加载成功', retryLabel: '重试', @@ -93,22 +93,22 @@ describe('loadFailure', () => { normalizeMobileShellLoadFailure( { type: 'http', - url: 'https://app.genarrative.world/assets/main.js', + url: 'https://www.genarrative.world/assets/main.js', statusCode: 404, }, allowedOrigin, - 'https://app.genarrative.world/creation/puzzle', + 'https://www.genarrative.world/creation/puzzle', ), ).toBeNull(); expect( normalizeMobileShellLoadFailure( { type: 'http', - url: 'https://app.genarrative.world/creation/puzzle', + url: 'https://www.genarrative.world/creation/puzzle', statusCode: 503, }, allowedOrigin, - 'https://app.genarrative.world/creation/puzzle', + 'https://www.genarrative.world/creation/puzzle', )?.title, ).toBe('加载失败 503'); }); @@ -118,15 +118,15 @@ describe('loadFailure', () => { normalizeMobileShellLoadFailure( { type: 'process', - url: 'https://app.genarrative.world/works/detail?work=WF-1&token=private#runtime', + url: 'https://www.genarrative.world/works/detail?work=WF-1&token=private#runtime', description: 'WebView renderer terminated repeatedly', }, allowedOrigin, - 'https://app.genarrative.world/works/detail?work=WF-1&token=private#runtime', + 'https://www.genarrative.world/works/detail?work=WF-1&token=private#runtime', ), ).toEqual({ type: 'process', - url: 'https://app.genarrative.world/works/detail', + url: 'https://www.genarrative.world/works/detail', title: '页面已停止', detail: '当前页面连续恢复失败', retryLabel: '重试', diff --git a/apps/mobile-shell/src/shell/navigation.test.ts b/apps/mobile-shell/src/shell/navigation.test.ts index 81613cef4..a866717ef 100644 --- a/apps/mobile-shell/src/shell/navigation.test.ts +++ b/apps/mobile-shell/src/shell/navigation.test.ts @@ -12,11 +12,11 @@ import { describe('shouldOpenInMobileShellWebView', () => { test('只允许主站同源页面留在移动壳 WebView 内', () => { - const allowedOrigin = 'https://app.genarrative.world'; + const allowedOrigin = 'https://www.genarrative.world'; expect( shouldOpenInMobileShellWebView( - 'https://app.genarrative.world/works/detail?work=PZ-1', + 'https://www.genarrative.world/works/detail?work=PZ-1', allowedOrigin, ), ).toBe(true); @@ -25,7 +25,7 @@ describe('shouldOpenInMobileShellWebView', () => { ).toBe(true); expect( shouldOpenInMobileShellWebView( - 'http://app.genarrative.world/works/detail?work=PZ-1', + 'http://www.genarrative.world/works/detail?work=PZ-1', allowedOrigin, ), ).toBe(false); @@ -35,7 +35,7 @@ describe('shouldOpenInMobileShellWebView', () => { }); test('外链和非网页协议必须离开带 HostBridge 的 WebView', () => { - const allowedOrigin = 'https://app.genarrative.world'; + const allowedOrigin = 'https://www.genarrative.world'; expect( shouldOpenInMobileShellWebView('https://example.com/', allowedOrigin), @@ -126,17 +126,17 @@ describe('shouldOpenInMobileShellWebView', () => { }); test('HostBridge 主动导航只解析同源网页目标', () => { - const allowedOrigin = 'https://app.genarrative.world'; + const allowedOrigin = 'https://www.genarrative.world'; expect( resolveMobileShellWebViewUrl('/works/detail?work=PZ-1', allowedOrigin), - ).toBe('https://app.genarrative.world/works/detail?work=PZ-1'); + ).toBe('https://www.genarrative.world/works/detail?work=PZ-1'); expect( resolveMobileShellWebViewUrl( - 'https://app.genarrative.world/creation/puzzle#draft', + 'https://www.genarrative.world/creation/puzzle#draft', allowedOrigin, ), - ).toBe('https://app.genarrative.world/creation/puzzle#draft'); + ).toBe('https://www.genarrative.world/creation/puzzle#draft'); expect( resolveMobileShellWebViewUrl('https://example.com/', allowedOrigin), ).toBeNull(); @@ -146,11 +146,11 @@ describe('shouldOpenInMobileShellWebView', () => { }); test('HostBridge 消息只接受同源主站页面', () => { - const allowedOrigin = 'https://app.genarrative.world'; + const allowedOrigin = 'https://www.genarrative.world'; expect( shouldAcceptMobileShellHostBridgeMessage( - 'https://app.genarrative.world/creation/puzzle', + 'https://www.genarrative.world/creation/puzzle', allowedOrigin, ), ).toBe(true); diff --git a/apps/mobile-shell/src/shell/url.test.ts b/apps/mobile-shell/src/shell/url.test.ts index abbeef3bd..8b5eb8077 100644 --- a/apps/mobile-shell/src/shell/url.test.ts +++ b/apps/mobile-shell/src/shell/url.test.ts @@ -20,7 +20,7 @@ describe('buildMobileShellUrl', () => { test('为 H5 附加原生移动壳上下文', () => { const url = new URL( - buildMobileShellUrl('https://app.genarrative.world/works/detail?work=PZ-1', { + buildMobileShellUrl('https://www.genarrative.world/works/detail?work=PZ-1', { platform: 'ios', hostVersion: '0.1.0', capabilities: ['host.getRuntime', 'share.open'], @@ -43,7 +43,7 @@ describe('buildMobileShellUrl', () => { test('附加宿主上下文前会清理旧移动壳 query', () => { const rawUrl = - 'https://app.genarrative.world/works/detail?clientRuntime=browser&hostShell=old_shell&hostCapabilities=old&work=PZ-1'; + 'https://www.genarrative.world/works/detail?clientRuntime=browser&hostShell=old_shell&hostCapabilities=old&work=PZ-1'; const builtUrl = buildMobileShellUrl(rawUrl, { platform: 'android', hostVersion: '0.1.0', @@ -67,14 +67,14 @@ describe('buildMobileShellUrl', () => { test('支持按平台注入不同能力清单', () => { const iosUrl = new URL( - buildMobileShellUrl('https://app.genarrative.world/', { + buildMobileShellUrl('https://www.genarrative.world/', { platform: 'ios', hostVersion: '0.1.0', capabilities: ['host.getRuntime', 'app.setBadgeCount'], }), ); const androidUrl = new URL( - buildMobileShellUrl('https://app.genarrative.world/', { + buildMobileShellUrl('https://www.genarrative.world/', { platform: 'android', hostVersion: '0.1.0', capabilities: ['host.getRuntime'], @@ -91,9 +91,9 @@ describe('buildMobileShellUrl', () => { test('移动壳基准 URL 默认只接受生产主站', () => { expect( - resolveMobileShellBaseWebUrl('https://app.genarrative.world/path'), + resolveMobileShellBaseWebUrl('https://www.genarrative.world/path'), ).toBe( - 'https://app.genarrative.world/path', + 'https://www.genarrative.world/path', ); expect(resolveMobileShellBaseWebUrl('http://127.0.0.1:3000/')).toBe( DEFAULT_MOBILE_SHELL_WEB_URL, diff --git a/apps/mobile-shell/src/shell/webViewPolicy.test.ts b/apps/mobile-shell/src/shell/webViewPolicy.test.ts index 97afc33fb..79df70b50 100644 --- a/apps/mobile-shell/src/shell/webViewPolicy.test.ts +++ b/apps/mobile-shell/src/shell/webViewPolicy.test.ts @@ -91,7 +91,7 @@ describe('BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', () => { }); test('阻断危险下载协议的窗口打开', () => { - const opened = window.open('blob:https://app.genarrative.world/file-id'); + const opened = window.open('blob:https://www.genarrative.world/file-id'); expect(opened).toBeNull(); }); @@ -231,7 +231,7 @@ describe('TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT', () => { }; window.eval(MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT); - const opened = window.open('blob:https://app.genarrative.world/file-id'); + const opened = window.open('blob:https://www.genarrative.world/file-id'); window.history.pushState(null, '', '/creation/puzzle'); expect(opened).toBeNull(); @@ -248,7 +248,7 @@ describe('shouldBlockMobileWebViewDownloadUrl', () => { test('识别不能进入移动壳 WebView 的下载协议', () => { expect( shouldBlockMobileWebViewDownloadUrl( - 'blob:https://app.genarrative.world/file-id', + 'blob:https://www.genarrative.world/file-id', ), ).toBe(true); expect(shouldBlockMobileWebViewDownloadUrl('data:text/plain,hello')).toBe( @@ -259,7 +259,7 @@ describe('shouldBlockMobileWebViewDownloadUrl', () => { ); expect( shouldBlockMobileWebViewDownloadUrl( - 'filesystem:https://app.genarrative.world/temporary/export.png', + 'filesystem:https://www.genarrative.world/temporary/export.png', ), ).toBe(true); }); @@ -267,7 +267,7 @@ describe('shouldBlockMobileWebViewDownloadUrl', () => { test('放行普通网页和系统外链协议', () => { expect( shouldBlockMobileWebViewDownloadUrl( - 'https://app.genarrative.world/works/detail?work=PZ-1', + 'https://www.genarrative.world/works/detail?work=PZ-1', ), ).toBe(false); expect(shouldBlockMobileWebViewDownloadUrl('/creation/puzzle')).toBe(false); @@ -282,12 +282,12 @@ describe('shouldBlockMobileWebViewNavigationRequest', () => { test('在 WebView 导航前拦截下载协议', () => { expect( shouldBlockMobileWebViewNavigationRequest({ - url: 'blob:https://app.genarrative.world/file-id', + url: 'blob:https://www.genarrative.world/file-id', }), ).toBe(true); expect( shouldBlockMobileWebViewNavigationRequest({ - url: 'https://app.genarrative.world/creation/puzzle', + url: 'https://www.genarrative.world/creation/puzzle', }), ).toBe(false); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e9b9184ae..7dca58295 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -92,15 +92,15 @@ - 背景:后续需要移动端 App 和桌面端 App,但现有主站、固定玩法 runtime、小程序壳和未来 AI H5 sandbox 已经以 H5 为主线;如果移动端重写 React Native UI、桌面端重写 Rust/Tauri UI,会形成玩法、登录、支付、分享和运行态的多套实现。 - 决策:移动端原生壳采用 `Expo + React Native`,桌面端壳采用 `Tauri`。两者都只作为 `native_app` 宿主壳和 HostBridge adapter,不重写现有 React H5 主站,不把固定内置玩法迁到 React Native / Rust UI,也不让 AI 生成 H5 游戏直接访问完整 HostBridge。Expo 壳通过 `react-native-webview` 承接 H5 与 native 通信,Tauri 壳通过受控 command 和 capabilities 承接桌面能力;新增能力必须先进入 HostBridge 契约和测试。 -- 2026-06-17 首轮落地:新增 `packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/mobile-shell/` 和 `apps/desktop-shell/`。壳只声明并实现真实可用能力;移动壳使用真实品牌图标资产并支持 `genarrative://`、iOS associated domain、Android app link 到同源 H5 路径,`navigation.openNativePage` 只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的原生页面,且通过 `host.events` 注入 `navigation.canGoBack` 返回栈状态事件,`share.setTarget` / `share.open` 解析统一分享目标并调用 React Native 系统分享面板,发布分享弹窗在 Expo 移动壳中通过 `share.open` 提供“系统分享”动作,失败时保留复制链接回退路径;`file.exportText` 写入 Expo 缓存文本文件后交给系统分享 / 保存面板,成功只返回文件名和字节数,`haptics.impact` 通过 Expo Haptics 承接 H5 运行时点击反馈;`app.openExternalUrl` 在 Expo 与 Tauri 两端都只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议;H5 复制服务在 native_app 中优先通过 `clipboard.writeText` 写入 Expo / Tauri 系统剪贴板,失败后再回退浏览器复制路径;H5 运行时反馈在 native_app 中优先通过 `haptics.impact` 请求真实移动端触觉,宿主不可用或 unsupported 时回退浏览器 `navigator.vibrate`;H5 主站按当前平台阶段同步 `document.title` 并通过 `app.setTitle` 请求宿主窗口标题,Tauri 壳通过主窗口 API 同步非空窗口标题,Expo 移动壳不声明该能力时静默忽略;桌面壳已通过 Tauri clipboard-manager 接入 `clipboard.writeText`,将 `navigation.openNativePage` 实现为 `https://app.genarrative.world` 同源 H5 route 的主窗口受控跳转,并将 `share.setTarget` / `share.open` 实现为复制非空分享文本到系统剪贴板,H5 发布分享弹窗在 Tauri 桌面壳中展示“复制分享文案 / 已复制 / 复制失败”;桌面 `file.exportText` 通过 Tauri dialog 插件打开系统保存对话框并由 Rust 写入文本文件,但不把 dialog / fs 插件 command 直接暴露给 H5,成功只返回文件名和字节数,用户取消返回 `cancelled`;登录、支付、原生系统分享面板等未接入真实 SDK / 插件前必须返回 unsupported 并让 H5 fallback,生产代码禁止 mock 成功。 +- 2026-06-17 首轮落地:新增 `packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/mobile-shell/` 和 `apps/desktop-shell/`。壳只声明并实现真实可用能力;移动壳使用真实品牌图标资产并支持 `genarrative://`、iOS associated domain、Android app link 到同源 H5 路径,`navigation.openNativePage` 只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的原生页面,且通过 `host.events` 注入 `navigation.canGoBack` 返回栈状态事件,`share.setTarget` / `share.open` 解析统一分享目标并调用 React Native 系统分享面板,发布分享弹窗在 Expo 移动壳中通过 `share.open` 提供“系统分享”动作,失败时保留复制链接回退路径;`file.exportText` 写入 Expo 缓存文本文件后交给系统分享 / 保存面板,成功只返回文件名和字节数,`haptics.impact` 通过 Expo Haptics 承接 H5 运行时点击反馈;`app.openExternalUrl` 在 Expo 与 Tauri 两端都只允许 `http:`、`https:`、`mailto:`、`tel:` 外链协议;H5 复制服务在 native_app 中优先通过 `clipboard.writeText` 写入 Expo / Tauri 系统剪贴板,失败后再回退浏览器复制路径;H5 运行时反馈在 native_app 中优先通过 `haptics.impact` 请求真实移动端触觉,宿主不可用或 unsupported 时回退浏览器 `navigator.vibrate`;H5 主站按当前平台阶段同步 `document.title` 并通过 `app.setTitle` 请求宿主窗口标题,Tauri 壳通过主窗口 API 同步非空窗口标题,Expo 移动壳不声明该能力时静默忽略;桌面壳已通过 Tauri clipboard-manager 接入 `clipboard.writeText`,将 `navigation.openNativePage` 实现为 `https://www.genarrative.world` 同源 H5 route 的主窗口受控跳转,并将 `share.setTarget` / `share.open` 实现为复制非空分享文本到系统剪贴板,H5 发布分享弹窗在 Tauri 桌面壳中展示“复制分享文案 / 已复制 / 复制失败”;桌面 `file.exportText` 通过 Tauri dialog 插件打开系统保存对话框并由 Rust 写入文本文件,但不把 dialog / fs 插件 command 直接暴露给 H5,成功只返回文件名和字节数,用户取消返回 `cancelled`;登录、支付、原生系统分享面板等未接入真实 SDK / 插件前必须返回 unsupported 并让 H5 fallback,生产代码禁止 mock 成功。 - 2026-06-18 外链接入:H5 新增 `openHostExternalUrl()` facade,`native_app` 下会把外链归一化为允许协议的绝对 URL 后请求 `app.openExternalUrl`;ICP备案号和 RPG 资产调试原图入口已优先走宿主系统浏览器,普通浏览器和小程序保留原 `` 行为,宿主不可用或拒绝时回退浏览器外链。 - 2026-06-18 外链协议白名单门禁:`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS` 是 `app.openExternalUrl` 唯一协议来源,当前只允许 `http:`、`https:`、`mailto:`、`tel:`;Expo 直接复用共享归一化逻辑,Tauri Rust 侧必须用 URL parser 镜像同一清单,根级 `npm run check:native-shells` 会拒绝共享契约与桌面壳协议清单漂移。 - 2026-06-18 移动壳 WebView 导航收紧:Expo WebView 自身拦截外域导航时复用 HostBridge 外链协议白名单,只把 `http:`、`https:`、`mailto:`、`tel:` 交给 `Linking.openURL`,`javascript:`、`file:`、相对异常路径等危险目标直接阻断,避免离开同源主站后仍保留完整 HostBridge。 - 2026-06-19 移动壳 WebView 外链协议共源:`apps/mobile-shell/src/shell/navigation.ts` 的 WebView 外链离壳判断必须调用共享 `normalizeHostBridgeExternalUrl`,不得在 shell 层另写协议判断;`apps/mobile-shell/scripts/check-config.mjs` 会拒绝重新硬编码 `mailto:` / `tel:` / `javascript:` 等协议分支,`navigation.test.ts` 用 `HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS` 反查当前允许协议。 - 2026-06-19 移动壳 WebView 外链打开收口:Expo WebView 外链拦截统一调用 `openMobileShellExternalNavigation(Linking, request.url)`,该 helper 先复用共享外链协议 normalizer,再调用 `canOpenURL` 确认系统可处理,最后才 `openURL`;系统不能打开或 URL 被拒绝时只阻断留壳,不伪造成功也不把危险协议交给系统。`ShellApp` 不再内联 `Linking.canOpenURL` / `Linking.openURL` Promise 链,移动壳配置检查和 `navigation.test.ts` 会覆盖该顺序。 - 2026-06-19 移动壳外链打开 helper 共用:Expo WebView 外域拦截和 HostBridge `app.openExternalUrl` 都必须复用 `openMobileShellExternalNavigation` 执行系统外链打开动作;HostBridge 分支仍先调用 `normalizeHostBridgeExternalUrlPayload` 保留 payload 错误语义,且 `apps/mobile-shell/src/host-bridge/navigation.ts` 自己承接 `expo-linking` 系统 API 调用,不再让 `dispatch.ts` 直接导入 `Linking` 或维护 `Linking.canOpenURL` / `Linking.openURL` 顺序。移动壳配置检查会拒绝 `app.openExternalUrl` 绕开该 helper 或分发层重新导入 `expo-linking`,避免两条离壳路径漂移。 -- 2026-06-19 移动壳系统分享 URL 边界:Expo `share.open` 调用 React Native 系统分享面板前,只允许把 `url`、`href`、`path`、`targetPath` 和 `work` 归一为 `https://app.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。分享实现复用移动壳入口 URL 的生产主站 origin,配置检查会拒绝重新声明同值 origin 或移除协议相对 URL 拦截。 -- 2026-06-19 桌面壳系统分享 URL 边界:Tauri `share.open` 写入系统剪贴板前同样只允许把 `url`、`href`、`path`、`targetPath` 和 `work` 归一为 `https://app.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。桌面壳配置检查会拒绝移除同源分享 URL 归一和协议相对 URL 拦截。 +- 2026-06-19 移动壳系统分享 URL 边界:Expo `share.open` 调用 React Native 系统分享面板前,只允许把 `url`、`href`、`path`、`targetPath` 和 `work` 归一为 `https://www.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。分享实现复用移动壳入口 URL 的生产主站 origin,配置检查会拒绝重新声明同值 origin 或移除协议相对 URL 拦截。 +- 2026-06-19 桌面壳系统分享 URL 边界:Tauri `share.open` 写入系统剪贴板前同样只允许把 `url`、`href`、`path`、`targetPath` 和 `work` 归一为 `https://www.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。桌面壳配置检查会拒绝移除同源分享 URL 归一和协议相对 URL 拦截。 - 2026-06-19 原生壳分享桥接边界:Expo `share.setTarget` / `share.open` 的缓存目标、分享 payload 归一、系统分享调用和 HostBridge 响应统一收口在 `apps/mobile-shell/src/host-bridge/share.ts`;Tauri `share.setTarget` / `share.open` 的缓存目标、分享文本生成、剪贴板 fallback 写入和 HostBridge 响应统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/share.rs`。两端 `dispatch` 只负责委托对应 share 模块,配置检查会拒绝分发层直接持有分享状态、生成分享文本、写入分享剪贴板结果或包装分享成功响应。 - 2026-06-19 桌面壳窗口标题桥接边界:Tauri `app.setTitle` 的 payload 校验、非空 / 控制字符拒绝、80 字符截断和主窗口 `set_title` 调用统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/title.rs`;`dispatch.rs` 只负责委托 `set_desktop_host_bridge_window_title(...)`。桌面壳配置检查和根级结构门禁会覆盖 `title.rs` 文件清单、共享标题长度镜像和 dispatch 委托关系。 - 2026-06-19 桌面壳文件桥接执行边界:Tauri `file.exportText` / `file.importText` / `file.importDocument` / `file.exportImage` / `file.importImage` / `file.importAudio` / `file.exportAudio` 的系统文件对话框过滤器、用户取消语义、路径转换、异步读写编排和 HostBridge 响应统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/files.rs`;MIME、大小、base64、文件名清洗、本地副本读写和 HostBridge payload 组装统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs`;`dispatch.rs` 只负责按 method 委托 `export_desktop_host_bridge_*_file(...)` / `import_desktop_host_bridge_*_file(...)`。桌面壳配置检查会拒绝分发层直接调用 `.dialog()`、`blocking_save_file` / `blocking_pick_file`、文件 payload helper 或落盘 helper,避免文件访问边界重新散落。 @@ -165,13 +165,13 @@ - 2026-06-18 移动壳方向策略:Expo 壳 `orientation` 固定为 `default`,不锁竖屏或横屏;后续固定玩法和 AI H5 sandbox 的方向需求由设备方向、H5 响应式布局和玩法自身画布适配承接,壳层只负责安全区、WebView 容器和 HostBridge。移动壳配置检查和 Expo public config smoke 会拒绝重新锁定 portrait / landscape。 - 2026-06-18 移动壳键盘布局:Expo Android 壳 `softwareKeyboardLayoutMode` 固定为 `resize`,让系统键盘打开时真实调整 WebView 可视高度;H5 继续使用已有 viewport / 输入法聚焦适配承接创作表单、聊天输入和玩法输入框,壳层不新增键盘遮挡补偿 UI、不伪造键盘状态。移动壳配置检查和 Expo public config smoke 会拒绝该字段缺失或漂移。 - 2026-06-18 移动壳媒体策略:Expo WebView 允许内联媒体播放和用户触发的全屏视频,但保留 `mediaPlaybackRequiresUserAction`,不允许无手势自动播放;固定玩法和 AI H5 sandbox 的音频仍由 H5 用户开关、运行态状态和宿主生命周期控制,壳层不注入额外播放器或假播放状态。移动壳配置检查会拒绝 WebView 媒体策略漂移。 -- 2026-06-18 移动壳启动 URL 归一:Expo 壳的 `EXPO_PUBLIC_GENARRATIVE_WEB_URL` 和 deep link 基准地址只接受生产主站 `https://app.genarrative.world`,以及本机开发联调 `http://127.0.0.1`、`http://localhost`、`http://[::1]`;空值、相对路径、外域、`file:`、`javascript:` 等非法配置回退到默认 H5 地址后再附加 `native_app` 宿主上下文;deep link 仍只映射归一后基准 origin 的 H5 路径,禁止把外域或危险协议页面装进带完整 HostBridge 的 WebView。 +- 2026-06-18 移动壳启动 URL 归一:Expo 壳的 `EXPO_PUBLIC_GENARRATIVE_WEB_URL` 和 deep link 基准地址只接受生产主站 `https://www.genarrative.world`,以及本机开发联调 `http://127.0.0.1`、`http://localhost`、`http://[::1]`;空值、相对路径、外域、`file:`、`javascript:` 等非法配置回退到默认 H5 地址后再附加 `native_app` 宿主上下文;deep link 仍只映射归一后基准 origin 的 H5 路径,禁止把外域或危险协议页面装进带完整 HostBridge 的 WebView。 - 2026-06-18 移动壳主动导航上下文:Expo 壳的 `navigation.openNativePage` 与 deep link 都必须复用 `buildMobileShellUrl(...)` 补写 `native_app`、`expo_mobile`、真实平台、版本和 capability 清单;受控导航只接受当前允许 origin 的同源 H5 URL。移动壳配置检查会拒绝主动导航或 deep link 绕过该宿主上下文构造入口。 - 2026-06-20 移动壳导航单测边界:`apps/mobile-shell/src/host-bridge/navigation.test.ts` 直接覆盖 `app.openExternalUrl` 的共享外链 helper 调用、危险 URL 拒绝、系统不能打开时的 `host_error`,以及 `navigation.openNativePage` 的同源 H5 跳转、宿主上下文补写、缺失 navigation adapter 的 unsupported 语义和 `app.reloadWebView` adapter 调用;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免宿主导航边界只靠 WebView shell 测试或完整 bridge 流程间接覆盖。 - 2026-06-18 移动壳协议常量来源:Expo 壳的 HostBridge 事件注入、入口 URL `bridgeVersion`、`host.getRuntime` 回包和 Expo public config smoke 必须使用 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_PROTOCOL` / `HOST_BRIDGE_VERSION`,不得在壳层重新写死协议名或版本字面量;配置检查会拒绝这些常量漂移。 -- 2026-06-19 公开 Web origin 单一来源:原生壳允许加载 / 分享 / 跳转的公开 H5 主站 origin 以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_PUBLIC_WEB_ORIGIN` / `HOST_BRIDGE_PUBLIC_WEB_URL` 为源;Expo 移动壳只能通过 `DEFAULT_MOBILE_SHELL_WEB_URL` / `ALLOWED_PRODUCTION_WEB_ORIGIN` 语义别名引用共享常量,Tauri 桌面壳 `WEB_APP_ORIGIN` 作为 Rust 运行时镜像常量必须由 `apps/desktop-shell/scripts/check-config.mjs` 反查同一共享值。两端不得在分享、WebView policy、启动 URL 或桌面导航里另行复刻 `https://app.genarrative.world` 作为独立真相。 +- 2026-06-19 公开 Web origin 单一来源:原生壳允许加载 / 分享 / 跳转的公开 H5 主站 origin 以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_PUBLIC_WEB_ORIGIN` / `HOST_BRIDGE_PUBLIC_WEB_URL` 为源;Expo 移动壳只能通过 `DEFAULT_MOBILE_SHELL_WEB_URL` / `ALLOWED_PRODUCTION_WEB_ORIGIN` 语义别名引用共享常量,Tauri 桌面壳 `WEB_APP_ORIGIN` 作为 Rust 运行时镜像常量必须由 `apps/desktop-shell/scripts/check-config.mjs` 反查同一共享值。两端不得在分享、WebView policy、启动 URL 或桌面导航里另行复刻 `https://www.genarrative.world` 作为独立真相。 - 2026-06-18 桌面壳协议常量来源:Tauri Rust 侧 `host_bridge/protocol.rs` 的 `HOST_BRIDGE_PROTOCOL` / `HOST_BRIDGE_VERSION`、桌面入口 URL `bridgeVersion`、HostBridge event 注入和 runtime 回包必须与 `packages/shared/src/contracts/hostBridge.ts` 保持一致;桌面配置检查会反查共享契约并拒绝协议名或协议版本漂移。`tauri.conf.json` 只保留基础入口,`shell/url.rs` 统一补写桌面宿主上下文和真实 capability 清单,配置检查会拒绝把 `hostCapabilities` 等宿主 query 长串重新写回 Tauri 配置。 -- 2026-06-18 移动壳默认入口:Expo 壳默认 H5 地址固定为 `https://app.genarrative.world/`,开发联调本机 Vite 必须显式设置 `EXPO_PUBLIC_GENARRATIVE_WEB_URL=http://127.0.0.1:3000/`、`http://localhost:3000/` 或 `http://[::1]:3000/`;生产包不得在未配置环境变量时加载设备本机 localhost,也不得通过环境变量把第三方外域 H5 放入带完整 HostBridge 的 WebView。 +- 2026-06-18 移动壳默认入口:Expo 壳默认 H5 地址固定为 `https://www.genarrative.world/`,开发联调本机 Vite 必须显式设置 `EXPO_PUBLIC_GENARRATIVE_WEB_URL=http://127.0.0.1:3000/`、`http://localhost:3000/` 或 `http://[::1]:3000/`;生产包不得在未配置环境变量时加载设备本机 localhost,也不得通过环境变量把第三方外域 H5 放入带完整 HostBridge 的 WebView。 - 2026-06-18 移动壳安装包身份:Expo 移动壳的 iOS bundle identifier 与 Android package 统一固定为 `world.genarrative.mobile`,应用版本固定为 `0.1.0`,iOS `buildNumber` 从字符串 `"1"` 起步,Android `versionCode` 从整数 `1` 起步;后续分发安装包时递增构建号 / versionCode,产品版本号按发布节奏调整。移动壳配置检查会校验 `app.json` 与 `package.json` 版本一致,并拒绝缺失或漂移的包标识,当前不写入假商店元数据、假更新端点或占位渠道 SDK 配置。 - 2026-06-19 移动壳 HostBridge 版本运行时来源:Expo 移动壳的 H5 入口 query 和 `host.getRuntime` 回包都读取 `MOBILE_SHELL_HOST_VERSION`,该值必须从移动壳 `app.json` 的 Expo `version` 配置解析,异常配置只回退到与 `app.json` / `package.json` 一致的受检 fallback;配置检查会拒绝 `App.tsx`、`bridge.ts` 或 `runtime.ts` 重新散落硬编码版本,避免安装包版本升级时 H5 首屏上下文与 runtime 回读分叉。 - 2026-06-19 移动壳 runtime 桥接边界:Expo `host.getRuntime` 的平台归一、hostVersion、bridgeVersion、capability 清单组装和 HostBridge 成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/runtime.ts`;`dispatch.ts` 只负责把 `host.getRuntime` 委托给 `getMobileHostBridgeRuntimeResponse(...)`。移动壳配置检查会拒绝分发层重新读取 `MOBILE_SHELL_HOST_VERSION`、`HOST_BRIDGE_VERSION`、`resolveMobileHostCapabilities(...)`、`Platform.OS` 或包装 runtime 成功响应,避免入口 URL、runtime 回包和能力清单继续分叉。 @@ -194,13 +194,13 @@ - 2026-06-19 文档文件导入能力:新增 `file.importDocument` HostBridge capability,作为创作 Agent 工作台优先导入路径;Expo 壳通过 DocumentPicker、Tauri 壳通过系统文件选择框读取文本类文档或 DOCX 副本。两端只接受文本 MIME / DOCX MIME 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备 URI、本机绝对路径,也不开放通用文件系统;H5 把返回内容转换成浏览器 `File` 后继续走后端 `/api/runtime/creation-agent/document-inputs/parse`,不在前端解析 DOCX。旧壳只声明 `file.importText` 时继续使用文本导入兜底。Expo 文档导入的 DocumentPicker 调用、大小校验、base64 读取和 HostBridge 成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/files.ts`,`dispatch.ts` 只委托文件模块。 - 2026-06-18 Tauri 系统托盘:桌面壳启用真实 OS 托盘并复用品牌图标,托盘菜单只执行显示主窗口、刷新主窗口和退出应用,左键点击托盘图标恢复并聚焦主窗口;该能力归桌面壳自身,不进入 HostBridge capability,不向 H5 暴露托盘、菜单、shell 或任意窗口控制 API。托盘注册成功时主窗口关闭按钮只隐藏到托盘,必须通过托盘“退出”结束应用;托盘注册失败不得阻断主窗口启动,也不得拦截关闭,避免窗口消失后无法恢复。`check:native-shells` 和 Tauri cargo test 覆盖托盘配置、菜单动作映射和关闭策略。 - 2026-06-18 Tauri 单实例:桌面壳启用 `tauri-plugin-single-instance` 并要求该插件最先注册;重复启动 App 时第二实例退出,只唤醒、取消最小化并聚焦已有主窗口,不把第二实例 argv / cwd 作为事件透传给 H5。Windows / Linux 的二次实例深链只通过 single-instance 的 `deep-link` feature 交给 Tauri deep-link 插件,再由 `shell/deep_link.rs` 做受控 URL 归一。 -- 2026-06-18 Tauri 桌面深链:桌面壳启用 `tauri-plugin-deep-link`,但不安装 JS guest 包、不把 deep-link command 加入主窗口 capability,也不新增 HostBridge capability。Tauri 配置只注册 `genarrative` scheme;Rust 层只接受 `genarrative://open/...`、`genarrative://app/...`、`genarrative://` 和 `https://app.genarrative.world/...`,统一跳转到同源 H5 并补写 `native_app`、`tauri_desktop`、当前平台、版本和真实 capability 清单;外域、明文协议和危险协议不进入主 WebView。 -- 2026-06-18 桌面壳安装包身份:Tauri 桌面壳的产品名固定为 `Genarrative`,应用 identifier 固定为 `world.genarrative.desktop`,Tauri 配置、`apps/desktop-shell/package.json` 与 Cargo package 版本统一为 `0.1.0`;Release 主窗口只加载打包的 `index.html` 和根 `dist` H5 资产,dev URL 只指向本机 Vite 调试入口。桌面壳 CSP 保持 `script-src 'self'`,不得加入 `unsafe-eval`、`tauri:` 或 `file:`,也不得在没有真实端点、签名密钥和发布流程前配置 updater;检查脚本会拒绝包身份、版本、CSP 或 updater 约束漂移。 +- 2026-06-18 Tauri 桌面深链:桌面壳启用 `tauri-plugin-deep-link`,但不安装 JS guest 包、不把 deep-link command 加入主窗口 capability,也不新增 HostBridge capability。Tauri 配置只注册 `genarrative` scheme;Rust 层只接受 `genarrative://open/...`、`genarrative://app/...`、`genarrative://` 和 `https://www.genarrative.world/...`,统一跳转到同源 H5 并补写 `native_app`、`tauri_desktop`、当前平台、版本和真实 capability 清单;外域、明文协议和危险协议不进入主 WebView。 +- 2026-06-18 桌面壳安装包身份:Tauri 桌面壳的产品名固定为 `Genarrative`,应用 identifier 固定为 `world.genarrative.desktop`,Tauri 配置、`apps/desktop-shell/package.json` 与 Cargo package 版本统一为 `0.1.0`;Release 主窗口只加载共享公开主站 `https://www.genarrative.world/`,不得配置 `frontendDist` 打包根 H5 资产;dev URL 只指向本机 Vite 调试入口。桌面壳 CSP 保持 `script-src 'self'`,不得加入 `unsafe-eval`、`tauri:` 或 `file:`,也不得在没有真实端点、签名密钥和发布流程前配置 updater;检查脚本会拒绝包身份、版本、CSP 或 updater 约束漂移。 - 2026-06-18 桌面壳观测与渠道 SDK 边界:Tauri 桌面壳默认不接入崩溃上报、analytics、遥测日志、自动更新或渠道分发 SDK;Sentry、Datadog、PostHog、Segment、Amplitude、Bugsnag、OpenTelemetry、Tauri log / updater 等 Node / Cargo 依赖、`package-lock.json` / `Cargo.lock` 解析包和 Rust 初始化片段都会被配置检查拒绝。后续只有在真实端点、采集字段、用户授权、隐私披露、签名和发布流程确定后,才能按单项能力更新方案并接入。 - 2026-06-18 桌面壳 HostBridge 版本边界:Tauri release / dev 入口 URL 的 `hostVersion` 由 Rust `shell/url.rs` 从 Cargo package 版本统一补写;`host.getRuntime` 回包继续使用 `env!("CARGO_PKG_VERSION")`,配置检查会确认 `tauri.conf.json`、`apps/desktop-shell/package.json` 和 Cargo package 版本一致,并拒绝在 Tauri 配置里手写入口 query 版本。 - 2026-06-18 桌面壳运行时平台 query:Tauri 静态配置不再写入 `hostPlatform` 或其它宿主上下文 query;Rust `setup` 手动创建主窗口前必须把基础入口改写为当前 `macos` / `windows` / `linux` 平台和完整宿主上下文,保证 H5 首屏 query 与 `host.getRuntime` 回读的平台一致。第二实例参数、外部 deep link 或 H5 自报值不得覆盖该字段;桌面壳测试和配置检查会拒绝绕过该归一流程。 - 2026-06-20 桌面入口 URL 宿主上下文清洗:`desktop_entry_url_with_host_context(...)` 对 dev URL 和打包入口补写宿主上下文前必须先移除旧 `clientRuntime`、`hostShell`、`hostCapabilities` 等宿主 query,再追加当前 Tauri 壳真实上下文;Rust 单测和桌面配置检查反查旧 query 不会在首屏入口中重复或覆盖当前壳身份。 -- 2026-06-18 桌面壳顶层导航边界:Tauri 主 WebView 只允许打包资产 URL 和 `https://app.genarrative.world` 同源 H5 route 留在主窗口;外域 `http:` / `https:`、`mailto:`、`tel:` 导航与 `window.open` 请求交给系统 opener 后拒绝 WebView 留壳;`javascript:`、`file:` 等危险协议直接拒绝。该规则不进入 HostBridge capability,不开放 opener JS guest API,配置检查和 cargo test 覆盖导航策略。 +- 2026-06-18 桌面壳顶层导航边界:Tauri 主 WebView 只允许打包资产 URL 和 `https://www.genarrative.world` 同源 H5 route 留在主窗口;外域 `http:` / `https:`、`mailto:`、`tel:` 导航与 `window.open` 请求交给系统 opener 后拒绝 WebView 留壳;`javascript:`、`file:` 等危险协议直接拒绝。该规则不进入 HostBridge capability,不开放 opener JS guest API,配置检查和 cargo test 覆盖导航策略。 - 2026-06-18 桌面壳默认下载边界:Tauri 主 WebView 的下载事件默认拒绝网页自动下载和 `` 落盘,桌面文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等已声明 HostBridge method 进入 Rust 侧系统保存对话框,并继续执行 MIME、大小、文件名清洗和用户确认。该规则不进入 HostBridge capability,配置检查和 cargo test 覆盖下载拒绝策略。 - 2026-06-18 桌面壳文件 bytes 校验:Tauri 图片 / 音频导入导出不得只信扩展名或 H5 声明 MIME;Rust 侧必须识别 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM bytes 头部,要求导入文件扩展名对应 MIME 与真实 bytes 匹配,导出 payload 的 `mimeType` 与 `base64Data` 解码 bytes 匹配。不匹配返回 `invalid_request`,继续不暴露本机绝对路径或通用文件系统能力。配置检查和 cargo test 覆盖该边界。 - 2026-06-18 移动壳文件 bytes 校验:Expo 图片 / 音频导入导出不得只信系统 picker 返回 MIME、文件扩展名或 H5 声明 MIME;移动壳必须识别 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM base64 bytes 头部,要求导入 MIME 归一结果与真实 bytes 匹配,导出 payload 的 `mimeType` 与 `base64Data` 解码 bytes 匹配。不匹配返回 `invalid_request`,不会写入缓存文件、调起系统分享或把内容回传给 H5。移动图片导出还必须按 MIME 给系统分享 / 保存面板补齐 `.png` / `.jpg` / `.webp` 文件名扩展,避免缓存文件名与真实图片类型漂移。配置检查和移动壳测试覆盖该边界。 @@ -2624,14 +2624,14 @@ ## 2026-06-18 移动壳系统深链声明收口 - 背景:移动壳已经通过运行时归一限制 deep link 只能进入同源 H5 路径,但 iOS associated domains 和 Android intent filter 也属于安装包级接管范围;如果后续只做“包含主站”校验,安装包可能额外接管外域、明文协议或更宽路径。 -- 决策:Expo 源配置和 Expo CLI public config 都必须把 iOS `associatedDomains` 固定为唯一 `applinks:app.genarrative.world`;Android `intentFilters` 固定为唯一 `VIEW` / `autoVerify=true` 的 App Link 过滤器,category 只能是 `BROWSABLE` 和 `DEFAULT`,data 只能包含 `scheme=https` 与 `host=app.genarrative.world`,不得声明额外 domain、protocol、pathPattern 或其它接管范围。配置检查从共享 `HOST_BRIDGE_PUBLIC_WEB_ORIGIN` 解析 expected host 后反查这些平台 manifest 字段,避免移动壳脚本把主站域名维护成第二来源;运行时 deep link 继续只映射同源路径并附加 HostBridge 上下文。 +- 决策:Expo 源配置和 Expo CLI public config 都必须把 iOS `associatedDomains` 固定为唯一 `applinks:www.genarrative.world`;Android `intentFilters` 固定为唯一 `VIEW` / `autoVerify=true` 的 App Link 过滤器,category 只能是 `BROWSABLE` 和 `DEFAULT`,data 只能包含 `scheme=https` 与 `host=www.genarrative.world`,不得声明额外 domain、protocol、pathPattern 或其它接管范围。配置检查从共享 `HOST_BRIDGE_PUBLIC_WEB_ORIGIN` 解析 expected host 后反查这些平台 manifest 字段,避免移动壳脚本把主站域名维护成第二来源;运行时 deep link 继续只映射同源路径并附加 HostBridge 上下文。 - 影响范围:`apps/mobile-shell/app.json`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/mobile-shell/scripts/check-expo-config.mjs`、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 ## 2026-06-18 原生壳命令入口收口 - 背景:Expo / Tauri 壳的源码、权限和构建配置已经进入门禁,但 package scripts 仍属于真实开发和验收入口;如果后续把根级或壳级 dev / build / typecheck / test 命令改成临时快捷命令,就可能绕过 Expo public config、Metro export、Tauri dev、Tauri release build smoke 或根 H5 构建。 -- 决策:移动壳 `apps/mobile-shell/package.json` 的 `dev`、`android`、`ios`、`test`、`config:smoke`、`export:smoke`、`typecheck` 和根 `mobile-shell:*` 入口必须保持指向真实 Expo / RN / Vitest / Expo config / Metro export / 配置检查流程;桌面壳 `apps/desktop-shell/package.json` 的 `dev`、`build`、`typecheck`、根 `desktop-shell:*` 入口,以及 Tauri `beforeDevCommand` / `beforeBuildCommand` 必须保持指向真实 Tauri dev / build、根 H5 `dev:web` / `build:raw` 和桌面壳配置检查流程。两端配置检查负责拒绝命令入口漂移。 +- 决策:移动壳 `apps/mobile-shell/package.json` 的 `dev`、`android`、`ios`、`test`、`config:smoke`、`export:smoke`、`typecheck` 和根 `mobile-shell:*` 入口必须保持指向真实 Expo / RN / Vitest / Expo config / Metro export / 配置检查流程;桌面壳 `apps/desktop-shell/package.json` 的 `dev`、`build`、`typecheck`、根 `desktop-shell:*` 入口,以及 Tauri `beforeDevCommand` / `beforeBuildCommand` 必须保持指向真实 Tauri dev / build、根 H5 `dev:web` 和桌面壳配置检查流程。两端配置检查负责拒绝命令入口漂移。 - 影响范围:根 `package.json`、`apps/mobile-shell/package.json`、`apps/desktop-shell/package.json`、`apps/mobile-shell/scripts/check-config.mjs`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 @@ -2749,7 +2749,7 @@ ## 2026-06-18 桌面壳主动导航保留宿主上下文 - 背景:Tauri 桌面壳 release / dev 入口和 deep link 都会给 H5 追加 `native_app`、`tauri_desktop`、当前平台、版本和 capability query;但 H5 通过 HostBridge 调用 `navigation.openNativePage` 主动跳转同源 route 时,如果只把裸同源 URL 交给主窗口,目标页可能按普通浏览器运行态启动。 -- 决策:`navigation.openNativePage` 仍只接受 `https://app.genarrative.world` 同源 H5 route,不新增真实原生页面、不放宽外域导航;通过校验后的目标 URL 必须复用 `desktop_h5_url_with_host_context(...)`,与桌面 deep link 一样重写宿主上下文 query,确保主动导航后的页面继续带 `clientRuntime=native_app`、`hostShell=tauri_desktop`、当前平台、宿主版本和真实 capability 清单。 +- 决策:`navigation.openNativePage` 仍只接受 `https://www.genarrative.world` 同源 H5 route,不新增真实原生页面、不放宽外域导航;通过校验后的目标 URL 必须复用 `desktop_h5_url_with_host_context(...)`,与桌面 deep link 一样重写宿主上下文 query,确保主动导航后的页面继续带 `clientRuntime=native_app`、`hostShell=tauri_desktop`、当前平台、宿主版本和真实 capability 清单。 - 影响范围:`apps/desktop-shell/src-tauri/src/shell/url.rs`、`apps/desktop-shell/src-tauri/src/shell/navigation.rs`、`apps/desktop-shell/src-tauri/src/shell/deep_link.rs`、`apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run desktop-shell:typecheck`、`npm run desktop-shell:test`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 3bbb52897..fac2bc0e8 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -202,7 +202,7 @@ Tauri 壳同样只负责桌面宿主能力,不承接玩法业务。 推荐能力: -- Release 包默认打包当前 H5 `dist`,保证桌面包版本可复现。 +- Release 包默认加载共享公开主站 `https://www.genarrative.world/`,不再打包根 H5 `dist`,避免包内 `tauri.localhost` 与线上 API 来源分裂。 - Dev 模式允许加载本地 Vite URL,方便调试。 - H5 通过 `nativeAppHostBridge` 超时封装调用 `window.__TAURI__.core.invoke('host_bridge_request', request)`,不直接调用其它 Tauri command。 - Rust 侧只暴露一个受控 `host_bridge_request` command,再在 Rust 内部按 method 白名单分发。 @@ -215,7 +215,7 @@ Tauri 壳同样只负责桌面宿主能力,不承接玩法业务。 - 主 WebView 显式关闭 DevTools,Cargo 不启用 Tauri `devtools` feature;本地调试通过普通浏览器和 Vite 完成,不把可分发桌面壳变成调试容器。 - 崩溃上报、前端 analytics、桌面遥测日志、自动更新和渠道分发 SDK 都必须等真实端点、采集字段、用户同意、隐私策略、签名和发布流程确定后逐项接入;当前桌面壳不安装 Sentry、Datadog、PostHog、Segment、Amplitude、Bugsnag、OpenTelemetry、Tauri log / updater 等相关依赖。 - 桌面壳和根 H5 包不安装 `@tauri-apps/api` 或 `@tauri-apps/plugin-*` JS guest 包;生产 H5 只通过 Tauri 注入的 `window.__TAURI__.core.invoke('host_bridge_request', request)` 进入 HostBridge。opener、clipboard、dialog、notification 等能力只保留 Rust Cargo 插件,由 Rust 内部分发并受 capability 白名单约束。 -- 桌面深链只作为宿主启动 / 唤醒入口处理,不进入 HostBridge capability,也不把 deep-link 插件 command 授权给 H5。Tauri 只注册 `genarrative` scheme,并接受同源 `https://app.genarrative.world` URL;壳层会把目标路径归一为带 `native_app`、`tauri_desktop` 和真实 capability 清单的同源 H5 URL,外域、明文协议和危险协议直接丢弃。`navigation.openNativePage` 的同源主动跳转也必须复用同一宿主上下文补写逻辑,避免新页面按普通浏览器运行态启动。 +- 桌面深链只作为宿主启动 / 唤醒入口处理,不进入 HostBridge capability,也不把 deep-link 插件 command 授权给 H5。Tauri 只注册 `genarrative` scheme,并接受同源 `https://www.genarrative.world` URL;壳层会把目标路径归一为带 `native_app`、`tauri_desktop` 和真实 capability 清单的同源 H5 URL,外域、明文协议和危险协议直接丢弃。`navigation.openNativePage` 的同源主动跳转也必须复用同一宿主上下文补写逻辑,避免新页面按普通浏览器运行态启动。 - 桌面窗口状态持久化属于宿主壳自有体验,不进入 HostBridge capability,也不开放窗口状态插件 command 给 H5。Tauri 壳只保存主窗口大小、位置和最大化状态,不保存可见性、全屏或装饰状态,避免托盘隐藏窗口后下次启动被恢复成隐藏状态;`shell/window_state.rs` 必须保留 Rust 单测证明这组 flags 边界。 - 桌面壳外链打开、WebView 新窗口外链接管、托盘关闭前生命周期注入和窗口隐藏都属于用户可见宿主动作;这些动作失败必须走统一桌面宿主事件日志,配置检查拒绝 `let _ = ...` 静默吞错。 - 桌面壳托盘注册失败只允许记录 `desktop tray registration failed` 固定标签,不把 Tauri tray 插件错误详情写入可分发桌面壳 stderr;托盘不可用时主窗口关闭仍按无托盘路径退出。 @@ -307,7 +307,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 上下文,外域和危险协议回退到默认主站入口并记录拒绝日志;系统初始 URL 读取失败也会记录错误且保留当前安全入口。首轮真实能力包括 `host.getRuntime`、`appearance.getColorScheme`、`host.events`、`app.lifecycle`、`network.status`、`network.statusChanged`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.reloadWebView`、`app.openExternalUrl`、`clipboard.writeText`、`clipboard.readText`、`file.exportText`、`file.importText`、`file.importDocument`、`file.exportImage`、`file.importImage`、`file.captureImage`、`scanner.scanQrCode`、`file.importAudio`、`file.exportAudio`、`haptics.impact`、`notification.showLocal` 和 Android 返回键回退;其中 `appearance.getColorScheme` 只读系统配色偏好,不强改 H5 或系统主题;`app.lifecycle` 通过 React Native `AppState` 注入 `active` / `inactive` / `background` 统一状态,供 H5 游戏循环、音频和轮询做真实暂停 / 恢复判断,H5 的 `useHostLifecycleActive()` 会把该事件归一成运行态可播放状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `` 语义。H5 支付链接和微信 OAuth 登录授权 URL 也走该入口:原生壳未声明真实 `payment.request` / `auth.requestLogin` 前,微信 H5 支付 URL 和后端返回的微信登录授权 URL 优先交给宿主系统浏览器,宿主未处理时才回退当前 WebView 跳转;不得把 H5 支付或网页登录伪装成已完成的原生支付 / 原生登录。 -- `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录和内置独立 H5 体验入口等 adapter 复用。Expo 移动壳首版只接受同源 H5 route 并切换 WebView URL;Tauri 桌面壳同样只接受 `https://app.genarrative.world` 同源 H5 route 并在主窗口内跳转。H5 facade 在 `native_app` 下发送 `navigation.openNativePage` 前先拒绝空值、控制字符、协议相对 URL、外域绝对 URL 和非 `http:` / `https:` 协议目标,避免把明显不安全的跳转请求交给原生壳;同源绝对 URL、`/path` 和保留给桌面壳兼容的相对 route 继续由宿主二次归一并补写宿主上下文。微信小程序分支仍按小程序页面 URL 语义走 `wx.miniProgram.navigateTo`,不套原生 App 同源 H5 预校验。平台首页的儿童动作热身 Demo 入口在 `native_app` 且宿主声明 `navigation.openNativePage` 时必须优先走该 facade 跳转 `/child-motion-demo`,普通浏览器、小程序和未声明能力的裁剪壳才回退浏览器跳转。真正原生页面、登录和支付能力必须等对应 SDK / 页面接入后再声明支持。 +- `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录和内置独立 H5 体验入口等 adapter 复用。Expo 移动壳首版只接受同源 H5 route 并切换 WebView URL;Tauri 桌面壳同样只接受 `https://www.genarrative.world` 同源 H5 route 并在主窗口内跳转。H5 facade 在 `native_app` 下发送 `navigation.openNativePage` 前先拒绝空值、控制字符、协议相对 URL、外域绝对 URL 和非 `http:` / `https:` 协议目标,避免把明显不安全的跳转请求交给原生壳;同源绝对 URL、`/path` 和保留给桌面壳兼容的相对 route 继续由宿主二次归一并补写宿主上下文。微信小程序分支仍按小程序页面 URL 语义走 `wx.miniProgram.navigateTo`,不套原生 App 同源 H5 预校验。平台首页的儿童动作热身 Demo 入口在 `native_app` 且宿主声明 `navigation.openNativePage` 时必须优先走该 facade 跳转 `/child-motion-demo`,普通浏览器、小程序和未声明能力的裁剪壳才回退浏览器跳转。真正原生页面、登录和支付能力必须等对应 SDK / 页面接入后再声明支持。 - `exportHostTextFile()`:原生 App 宿主的受控文本导出入口。H5 facade 发起请求前先通过共享契约 `normalizeHostBridgeExportTextPayload()` 预校验文件名、文本内容、可选 MIME 和 5 MiB 上限;Expo 移动壳通过 `file.exportText` 写入缓存文本文件并交给系统分享 / 保存面板;Tauri 桌面壳通过 `file.exportText` 打开系统保存对话框并写入用户选择的文件。文件名必须清洗,可选 MIME 只能来自共享契约 `HOST_BRIDGE_TEXT_MIME_TYPES`,未传时默认为 `text/plain`,非文本 MIME 必须拒绝,不能借文本导出通道伪装成图片、音频或二进制文件;Expo 与 Tauri 壳仍必须二次校验真实文本字节数和 MIME。成功只返回文件名和字节数,不把本机绝对路径暴露给 H5;系统分享不可用或用户取消时返回明确错误,由 H5 fallback 承接。创作 Agent 工作台在 `native_app` 且声明该能力时提供会话 Markdown 导出入口,导出内容只来自当前 H5 已持有的会话标题、摘要、进度、锚点、消息、流式回复和输入草稿,并在 H5 侧先按同一 5 MiB 上限做 UTF-8 byte 校验;普通浏览器、小程序和未声明能力的裁剪壳不展示该入口。 - `importHostTextFile()`:原生 App 宿主的受控文本导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统文档选择器,Tauri 桌面壳通过系统文件选择框读取用户选择的文本文件;两端都只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回通过共享 `normalizeHostBridgeImportFileName()` 清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取文本内容前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入;H5 facade 收到结果后继续通过共享契约 `normalizeHostBridgeImportTextResult()` 复核文件名、MIME、文本内容和字节数,非法或超界结果归为 `false`;用户取消时由 H5 facade 归为 `false`。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文本导入,并把结果转换成现有浏览器 `File` 后继续复用后端 `/api/runtime/creation-agent/document-inputs/parse` 解析链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。 - `importHostDocumentFile()`:原生 App 宿主的受控文档导入入口。Expo 移动壳通过 Expo DocumentPicker,Tauri 桌面壳通过系统文件选择框读取用户选择的文档副本;两端都只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json`、`application/vnd.openxmlformats-officedocument.wordprocessingml.document` 或对应 `.txt` / `.md` / `.markdown` / `.csv` / `.json` / `.docx` 扩展名,单次不超过 5 MiB。成功只返回通过共享 `normalizeHostBridgeImportFileName()` 清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI、本机绝对路径或通用文件系统能力;宿主必须在读取 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入;H5 facade 收到结果后继续通过共享契约 `normalizeHostBridgeImportDocumentResult()` 复核文件名、MIME、base64 和字节数。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文档导入,把返回 base64 转换成现有浏览器 `File` 后继续调用 `/api/runtime/creation-agent/document-inputs/parse`;旧壳只声明 `file.importText` 时才回退到文本导入,普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。该能力不在前端解析 DOCX,也不绕过后端文档解析、大小校验或错误口径;移动壳配置检查必须强制文档 MIME 清单、5 MiB 上限和导入文件名清洗函数来自共享 HostBridge 契约。 diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts index f885a571d..1b79fb6dc 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -59,8 +59,8 @@ import { describe('HostBridge shared contract helpers', () => { test('固定公开 H5 主站入口', () => { - expect(HOST_BRIDGE_PUBLIC_WEB_ORIGIN).toBe('https://app.genarrative.world'); - expect(HOST_BRIDGE_PUBLIC_WEB_URL).toBe('https://app.genarrative.world/'); + expect(HOST_BRIDGE_PUBLIC_WEB_ORIGIN).toBe('https://www.genarrative.world'); + expect(HOST_BRIDGE_PUBLIC_WEB_URL).toBe('https://www.genarrative.world/'); }); test('固定 Tauri 只暴露唯一 HostBridge command', () => { @@ -568,7 +568,7 @@ describe('HostBridge shared contract helpers', () => { payload: { title: '暖灯猫街', message: '来玩', - url: 'https://app.genarrative.world/works/detail?work=PZ-1', + url: 'https://www.genarrative.world/works/detail?work=PZ-1', }, }); expect( @@ -580,13 +580,13 @@ describe('HostBridge shared contract helpers', () => { status: 'valid', payload: { title: '作品', - url: 'https://app.genarrative.world/works/detail?work=PZ+1%2F%E4%BA%8C', + url: 'https://www.genarrative.world/works/detail?work=PZ+1%2F%E4%BA%8C', }, }); expect( normalizeHostBridgeShareOpenPayload({ title: '作品', - targetPath: '//app.genarrative.world/works/detail?work=PZ-1', + targetPath: '//www.genarrative.world/works/detail?work=PZ-1', }), ).toEqual({ status: 'invalid' }); expect( diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index c5586ffd9..e4ace5b92 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -1,8 +1,8 @@ export const HOST_BRIDGE_PROTOCOL = 'GenarrativeHostBridge'; export const HOST_BRIDGE_VERSION = 1; export const HOST_BRIDGE_TAURI_COMMAND = 'host_bridge_request'; -export const HOST_BRIDGE_PUBLIC_WEB_ORIGIN = 'https://app.genarrative.world'; -export const HOST_BRIDGE_PUBLIC_WEB_URL = 'https://app.genarrative.world/'; +export const HOST_BRIDGE_PUBLIC_WEB_ORIGIN = 'https://www.genarrative.world'; +export const HOST_BRIDGE_PUBLIC_WEB_URL = 'https://www.genarrative.world/'; export type HostShellKind = 'browser' | 'wechat_mini_program' | 'native_app'; diff --git a/src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx b/src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx index 0a0373640..2a5f3c7a5 100644 --- a/src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx +++ b/src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx @@ -120,7 +120,7 @@ describe('PlatformProfileQrScannerModal', () => { const getUserMedia = vi.fn(); const onResult = vi.fn(); vi.spyOn(hostBridgeServices, 'scanHostQrCode').mockResolvedValue({ - value: 'https://app.genarrative.world/works/detail?work=PZ-1', + value: 'https://www.genarrative.world/works/detail?work=PZ-1', format: 'qr_code', }); installMediaDevices(getUserMedia); @@ -140,7 +140,7 @@ describe('PlatformProfileQrScannerModal', () => { }); expect(onResult).toHaveBeenCalledWith( - 'https://app.genarrative.world/works/detail?work=PZ-1', + 'https://www.genarrative.world/works/detail?work=PZ-1', ); expect(getUserMedia).not.toHaveBeenCalled(); }); diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index f5d6fa9d2..a46b60374 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -3181,7 +3181,7 @@ test('profile scan action uses native QR scanner before browser camera', async ( const scanHostQrCodeSpy = vi .spyOn(hostBridgeServices, 'scanHostQrCode') .mockResolvedValue({ - value: 'https://app.genarrative.world/works/detail?work=PZ-1', + value: 'https://www.genarrative.world/works/detail?work=PZ-1', format: 'qr_code', }); @@ -3203,7 +3203,7 @@ test('profile scan action uses native QR scanner before browser camera', async ( expect(scanHostQrCodeSpy).toHaveBeenCalledTimes(1); expect(qrScannerDialog.textContent).toContain( - '已识别:https://app.genarrative.world/works/detail?work=PZ-1', + '已识别:https://www.genarrative.world/works/detail?work=PZ-1', ); expect(getUserMedia).not.toHaveBeenCalled(); expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled(); diff --git a/src/config/viteProxyConfig.test.ts b/src/config/viteProxyConfig.test.ts index 9ab97331a..3d367e130 100644 --- a/src/config/viteProxyConfig.test.ts +++ b/src/config/viteProxyConfig.test.ts @@ -22,6 +22,24 @@ describe('vite dev api proxy', () => { ); }); + it('forwards the public creation entry config route to the Rust API server', async () => { + const resolvedConfig = + typeof viteConfig === 'function' + ? await viteConfig({ command: 'serve', mode: 'test' }) + : viteConfig; + + // 中文注释:创作入口配置是底部加号入口的首屏事实源,漏配代理会回退 index.html。 + expect(resolvedConfig.server?.proxy).toEqual( + expect.objectContaining({ + '/api/creation-entry': expect.objectContaining({ + target: expect.any(String), + changeOrigin: true, + secure: false, + }), + }), + ); + }); + it('forwards the admin route to the admin dev server', async () => { const resolvedConfig = typeof viteConfig === 'function' diff --git a/src/services/host-bridge/hostBridge.test.ts b/src/services/host-bridge/hostBridge.test.ts index fff306421..2c195b2b8 100644 --- a/src/services/host-bridge/hostBridge.test.ts +++ b/src/services/host-bridge/hostBridge.test.ts @@ -1139,7 +1139,7 @@ describe('hostBridge', () => { } : request.method === 'scanner.scanQrCode' ? { - value: ' https://app.genarrative.world/works/detail?work=PZ-1 ', + value: ' https://www.genarrative.world/works/detail?work=PZ-1 ', format: 'qr_code', } : request.method === 'file.importImage' @@ -1246,7 +1246,7 @@ describe('hostBridge', () => { openHostShare({ title: '暖灯猫街', message: '邀请你来玩', - url: 'https://app.genarrative.world/works/detail?work=PZ-1', + url: 'https://www.genarrative.world/works/detail?work=PZ-1', }), ).resolves.toBe(true); await expect( @@ -1277,7 +1277,7 @@ describe('hostBridge', () => { bytes: 6, }); await expect(scanHostQrCode()).resolves.toEqual({ - value: 'https://app.genarrative.world/works/detail?work=PZ-1', + value: 'https://www.genarrative.world/works/detail?work=PZ-1', format: 'qr_code', }); await expect(importHostTextFile()).resolves.toEqual({ @@ -1410,7 +1410,7 @@ describe('hostBridge', () => { payload: { title: '暖灯猫街', message: '邀请你来玩', - url: 'https://app.genarrative.world/works/detail?work=PZ-1', + url: 'https://www.genarrative.world/works/detail?work=PZ-1', }, }), }); @@ -1505,13 +1505,13 @@ describe('hostBridge', () => { navigateHostNativePage('javascript:alert(1)'), ).resolves.toBe(false); await expect( - navigateHostNativePage('https://app.genarrative.world/\nnext'), + navigateHostNativePage('https://www.genarrative.world/\nnext'), ).resolves.toBe(false); expect(invoke).not.toHaveBeenCalled(); await expect( navigateHostNativePage( - 'https://app.genarrative.world/works/detail?work=PZ-1', + 'https://www.genarrative.world/works/detail?work=PZ-1', ), ).resolves.toBe(true); await expect( @@ -1522,7 +1522,7 @@ describe('hostBridge', () => { request: expect.objectContaining({ method: 'navigation.openNativePage', payload: { - url: 'https://app.genarrative.world/works/detail?work=PZ-1', + url: 'https://www.genarrative.world/works/detail?work=PZ-1', }, }), }); @@ -1705,7 +1705,7 @@ describe('hostBridge', () => { openHostShare({ title: '暖灯猫街', message: '邀请你来玩', - url: 'https://app.genarrative.world/works/detail?work=PZ-1', + url: 'https://www.genarrative.world/works/detail?work=PZ-1', }), ).resolves.toBe(false); await expect( diff --git a/vite.config.ts b/vite.config.ts index d11881062..afe9b24a4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -79,6 +79,11 @@ export default defineConfig(({mode}) => { changeOrigin: true, secure: false, }, + '/api/creation-entry': { + target: runtimeServerTarget, + changeOrigin: true, + secure: false, + }, '/api/profile': { target: runtimeServerTarget, changeOrigin: true,