修正原生壳公开主站配置

将原生壳公开主站统一为 www.genarrative.world

调整 Tauri release 直接加载线上主站并移除 frontendDist 打包

补齐创作入口配置开发代理与回归测试

同步原生壳方案文档和共享记忆
This commit is contained in:
2026-06-22 15:07:02 +08:00
parent 58ede89341
commit 8bd8363ffa
31 changed files with 205 additions and 183 deletions
+10 -10
View File
@@ -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/') {
+1 -1
View File
@@ -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);
@@ -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",
@@ -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"
);
}
@@ -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)",
] {
@@ -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
);
}
@@ -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");
+3 -4
View File
@@ -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,
+2 -2
View File
@@ -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": [
+6 -6
View File
@@ -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}`);
@@ -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',
});
});
@@ -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');
@@ -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, {
+13 -13
View File
@@ -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(
@@ -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',
});
});
+11 -11
View File
@@ -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',
},
};
+15 -15
View File
@@ -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,
);
+11 -11
View File
@@ -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: '重试',
+10 -10
View File
@@ -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);
+6 -6
View File
@@ -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,
@@ -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);
});
@@ -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 资产调试原图入口已优先走宿主系统浏览器,普通浏览器和小程序保留原 `<a>` 行为,宿主不可用或拒绝时回退浏览器外链。
- 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` schemeRust 层只接受 `genarrative://open/...``genarrative://app/...``genarrative://<path>``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` schemeRust 层只接受 `genarrative://open/...``genarrative://app/...``genarrative://<path>``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、遥测日志、自动更新或渠道分发 SDKSentry、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 的下载事件默认拒绝网页自动下载和 `<a download>` 落盘,桌面文件保存只能通过 `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`
File diff suppressed because one or more lines are too long
@@ -72,7 +72,7 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗
- `requestHostLogin()`:微信小程序跳转原生登录页;浏览器返回 `false`,由 H5 登录弹窗承接。
- `requestHostPayment()`:微信小程序支付跳转原生支付页;其它渠道返回 `false`,继续走 H5 / Native 二维码。
- `setHostShareTarget()`:把当前公开作品分享目标同步给宿主。
- `openHostShare()`:原生 App 宿主的受控分享入口。发布分享弹窗只在 `hostCapabilities` 声明 `share.open` 时展示宿主分享动作,通过 `share.open` 把当前作品标题、作品号和公开 URL 交给宿主;Expo 移动壳打开系统分享面板,H5 展示“系统分享 / 已打开 / 分享失败”;Tauri 桌面壳把分享文本写入系统剪贴板,H5 展示“复制分享文案 / 已复制 / 复制失败”,不得把桌面剪贴板动作包装成系统分享面板。H5 facade 和 Expo 移动壳都通过共享契约 `normalizeHostBridgeShareOpenPayload()``url``href``path``targetPath``work` 归一到 `https://app.genarrative.world` 同源公开 URL,Tauri 桌面壳用 Rust 镜像同一规则;外域、协议相对 URL、危险协议和无法归一的显式分享目标必须返回 `invalid_request`,且不得回退到之前缓存的 `share.setTarget` 目标;宿主不可用或返回 unsupported 时显示失败并保留复制链接路径。
- `openHostShare()`:原生 App 宿主的受控分享入口。发布分享弹窗只在 `hostCapabilities` 声明 `share.open` 时展示宿主分享动作,通过 `share.open` 把当前作品标题、作品号和公开 URL 交给宿主;Expo 移动壳打开系统分享面板,H5 展示“系统分享 / 已打开 / 分享失败”;Tauri 桌面壳把分享文本写入系统剪贴板,H5 展示“复制分享文案 / 已复制 / 复制失败”,不得把桌面剪贴板动作包装成系统分享面板。H5 facade 和 Expo 移动壳都通过共享契约 `normalizeHostBridgeShareOpenPayload()``url``href``path``targetPath``work` 归一到 `https://www.genarrative.world` 同源公开 URL,Tauri 桌面壳用 Rust 镜像同一规则;外域、协议相对 URL、危险协议和无法归一的显式分享目标必须返回 `invalid_request`,且不得回退到之前缓存的 `share.setTarget` 目标;宿主不可用或返回 unsupported 时显示失败并保留复制链接路径。
- `openHostShareGrid()`:微信小程序九宫格切图页。
- `writeHostClipboardText()`:原生 App 宿主的受控剪贴板入口。H5 复制服务在 `native_app` 中优先通过 `clipboard.writeText` 写入 Expo / Tauri 系统剪贴板;H5 facade 发起请求前先按共享契约 `HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH` 归一化 payload,减少 WebView bridge 承载超长文本;两端壳写入前也必须执行同一上限截断,不能让 H5 透传超长剪贴板内容;宿主不可用、拒绝或返回 unsupported 时继续回退到浏览器 Clipboard API 和 legacy selection copy。
- `readHostClipboardText()`:原生 App 宿主的受控剪贴板读取入口。H5 只能读取纯文本结果,宿主返回内容会按 HostBridge 契约限制到 100000 字符;Expo 移动壳通过 `expo-clipboard` 读取系统剪贴板文本,Tauri 桌面壳通过 Rust 侧 `clipboard-manager` 读取系统剪贴板文本。该能力不读取图片、HTML、文件列表或剪贴板监听事件,不把 Tauri / Expo 剪贴板插件 API 直接暴露给 H5;宿主未声明或读取失败时由 H5 视作失败并保留原流程。个人中心的邀请码和兑换码弹窗只在宿主声明 `clipboard.readText` 时显示“粘贴”,读取到的纯文本只填入现有输入框,不自动提交、不代表兑换成功。
@@ -83,7 +83,7 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗
- `setHostAppBadgeCount()`:原生 App 宿主的受控应用角标入口。H5 只传 `0` 到共享契约 `HOST_BRIDGE_BADGE_COUNT_MAX` 之间的整数,`0` 表示清除角标;Expo 移动壳只在 iOS 声明 `app.setBadgeCount`,并通过 Expo Notifications 查询 / 请求 `allowBadge` 权限,再以 `Notifications.setBadgeCountAsync(count)` 的返回值作为成功依据;权限拒绝、权限状态不可读、系统返回 `false` 或原生 API 异常都必须返回明确失败,Android 不声明该能力,且请求实际到达 Android 壳时必须返回 `unsupported_capability`,不得返回成功;Tauri 桌面壳通过主窗口 `set_badge_count` 设置任务栏角标,底层平台不支持时返回明确错误,由 H5 视作失败并继续主流程。当前 H5 只把“可见作品架里未读的草稿生成完成更新”同步为角标数,同一个草稿有多个恢复 ID 时只计 1,已读、失败、生成中和不可见草稿不计入;宿主不支持或设置失败不改变 H5 红点、作品架或后端状态。平台壳同步层必须通过真实 `host_bridge_request` transport 测到 `app.setBadgeCount` 请求,保证未读计数模型和原生壳消费链路同时被门禁覆盖。
- `reloadHostWebView()`:原生 App 宿主的受控 WebView 刷新入口。H5 只能请求刷新当前承载主站的宿主 WebView;Expo 移动壳调用当前 `react-native-webview``reload()`Tauri 桌面壳调用主 `WebviewWindow.reload()`。该能力不接受 payload,不开放任意 URL 导航、脚本执行、Tauri guest API 或 RN WebView ref;成功只表示宿主已发起刷新,刷新后当前 H5 上下文会卸载。`AuthGate` 在登录态从未登录变为已登录、或从已登录变为未登录时优先调用该能力刷新当前容器;宿主未声明、返回失败或不可用时再回退浏览器 `window.location.reload()`
- `openHostExternalUrl()`:原生 App 宿主的受控外链入口。H5 中需要离开主站的外链在 `native_app` 下先通过 `app.openExternalUrl` 请求宿主系统浏览器打开;只允许 `http:``https:``mailto:``tel:`,相对路径会先归一化到当前站点绝对 URL,再通过共享契约 `normalizeHostBridgeExternalUrlPayload()` 清洗为 `{ url }` 载荷。Expo 移动壳消费该共享 payload normalizerTauri 桌面壳在 Rust 侧用 URL parser 镜像同一协议清单。宿主不可用或拒绝时回退浏览器外链行为,普通浏览器和小程序保持原有 `<a>` 语义。H5 支付链接和微信 OAuth 登录授权 URL 也走该入口:原生壳未声明真实 `payment.request` / `auth.requestLogin` 前,微信 H5 支付 URL 和后端返回的微信登录授权 URL 优先交给宿主系统浏览器,宿主未处理时才回退当前 WebView 跳转;不得把 H5 支付或网页登录伪装成已完成的原生支付 / 原生登录。
- `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录和内置独立 H5 体验入口等 adapter 复用。Expo 移动壳首版只接受同源 H5 route 并切换 WebView URLTauri 桌面壳同样只接受 `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 URLTauri 桌面壳同样只接受 `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 DocumentPickerTauri 桌面壳通过系统文件选择框读取用户选择的文档副本;两端都只接受 `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 契约。
@@ -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(

Some files were not shown because too many files have changed in this diff Show More