统一三端宿主桥接层结构

拆分移动壳能力清单与分发模块

拆分桌面壳能力清单与分发模块

补充微信壳协议与分发薄索引

同步三端桥接层门禁和文档
This commit is contained in:
2026-06-18 18:41:34 +08:00
parent ce27a0c35d
commit d71fde8b9a
18 changed files with 1297 additions and 1108 deletions
+24 -2
View File
@@ -37,6 +37,22 @@ const nativeAppHostBridgeSource = fs.readFileSync(nativeAppHostBridgePath, 'utf8
const mainPath = new URL('../src-tauri/src/main.rs', import.meta.url);
const main = fs.readFileSync(mainPath, 'utf8');
const rustSourceDir = new URL('../src-tauri/src/', import.meta.url);
const desktopHostBridgeCapabilitiesPath = new URL(
'../src-tauri/src/host_bridge/capabilities.rs',
import.meta.url,
);
const desktopHostBridgeCapabilitiesSource = fs.readFileSync(
desktopHostBridgeCapabilitiesPath,
'utf8',
);
const desktopHostBridgeDispatchPath = new URL(
'../src-tauri/src/host_bridge/dispatch.rs',
import.meta.url,
);
const desktopHostBridgeDispatchSource = fs.readFileSync(
desktopHostBridgeDispatchPath,
'utf8',
);
const productionSourceRoots = [
new URL('../package.json', import.meta.url),
new URL('../src-tauri/Cargo.toml', import.meta.url),
@@ -814,8 +830,12 @@ const sharedMethods = extractStringArrayExport(
'HOST_BRIDGE_METHODS',
);
const desktopMethods = extractRustStringArrayConst(rustHostSource, 'HOST_BRIDGE_METHODS');
const desktopCapabilities = extractDesktopCapabilities(rustHostSource);
const desktopHandledMethods = extractDesktopHandledMethods(rustHostSource);
const desktopCapabilities = extractDesktopCapabilities(
desktopHostBridgeCapabilitiesSource,
);
const desktopHandledMethods = extractDesktopHandledMethods(
desktopHostBridgeDispatchSource,
);
const sdkBackedCapabilities = ['auth.requestLogin', 'payment.request'];
assertSameList(desktopMethods, sharedMethods, 'desktop shell HostBridge method whitelist');
const unknownHandledDesktopMethods = desktopHandledMethods.filter(
@@ -1041,6 +1061,8 @@ const sharedTauriCommand = extractTsStringConst(
);
const allowedTauriCommands = [sharedTauriCommand];
const requiredRustHostModules = [
'host_bridge/capabilities.rs',
'host_bridge/dispatch.rs',
'host_bridge/files.rs',
'host_bridge/mod.rs',
'host_bridge/protocol.rs',
@@ -0,0 +1,66 @@
pub(crate) fn capabilities() -> Vec<&'static str> {
vec![
"host.getRuntime",
"appearance.getColorScheme",
"host.events",
"app.lifecycle",
"share.open",
"share.setTarget",
"navigation.openNativePage",
"app.reloadWebView",
"app.openExternalUrl",
"app.setTitle",
"app.setBadgeCount",
"network.status",
"network.statusChanged",
"clipboard.writeText",
"clipboard.readText",
"file.exportText",
"file.importText",
"file.exportImage",
"file.importImage",
"file.importAudio",
"file.exportAudio",
"file.imageDropped",
"notification.showLocal",
]
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
#[test]
fn runtime_capability_list_stays_ordered() {
assert_eq!(
capabilities(),
vec![
"host.getRuntime",
"appearance.getColorScheme",
"host.events",
"app.lifecycle",
"share.open",
"share.setTarget",
"navigation.openNativePage",
"app.reloadWebView",
"app.openExternalUrl",
"app.setTitle",
"app.setBadgeCount",
"network.status",
"network.statusChanged",
"clipboard.writeText",
"clipboard.readText",
"file.exportText",
"file.importText",
"file.exportImage",
"file.importImage",
"file.importAudio",
"file.exportAudio",
"file.imageDropped",
"notification.showLocal",
]
);
assert!(Value::from(capabilities()).as_array().is_some());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -141,34 +141,6 @@ impl HostBridgeReplayState {
}
}
pub(crate) fn capabilities() -> Vec<&'static str> {
vec![
"host.getRuntime",
"appearance.getColorScheme",
"host.events",
"app.lifecycle",
"share.open",
"share.setTarget",
"navigation.openNativePage",
"app.reloadWebView",
"app.openExternalUrl",
"app.setTitle",
"app.setBadgeCount",
"network.status",
"network.statusChanged",
"clipboard.writeText",
"clipboard.readText",
"file.exportText",
"file.importText",
"file.exportImage",
"file.importImage",
"file.importAudio",
"file.exportAudio",
"file.imageDropped",
"notification.showLocal",
]
}
pub(crate) fn ok(id: String, result: Value) -> HostBridgeResponse {
HostBridgeResponse {
bridge: HOST_BRIDGE_PROTOCOL,
@@ -281,7 +253,7 @@ pub(crate) fn request(method: &str) -> HostBridgeRequest {
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{json, Value};
use serde_json::json;
#[test]
fn invalid_envelope_is_rejected() {
@@ -363,37 +335,4 @@ mod tests {
assert_eq!(error.code, "invalid_request");
assert_eq!(error.message, "text is required");
}
#[test]
fn runtime_capability_list_stays_ordered() {
assert_eq!(
capabilities(),
vec![
"host.getRuntime",
"appearance.getColorScheme",
"host.events",
"app.lifecycle",
"share.open",
"share.setTarget",
"navigation.openNativePage",
"app.reloadWebView",
"app.openExternalUrl",
"app.setTitle",
"app.setBadgeCount",
"network.status",
"network.statusChanged",
"clipboard.writeText",
"clipboard.readText",
"file.exportText",
"file.importText",
"file.exportImage",
"file.importImage",
"file.importAudio",
"file.exportAudio",
"file.imageDropped",
"notification.showLocal",
]
);
assert!(Value::from(capabilities()).as_array().is_some());
}
}
@@ -1,4 +1,5 @@
use crate::host_bridge::protocol::{capabilities, HOST_BRIDGE_VERSION};
use crate::host_bridge::capabilities::capabilities;
use crate::host_bridge::protocol::HOST_BRIDGE_VERSION;
use crate::shell::tray::show_main_window;
use crate::shell::webview::{desktop_platform, WEB_APP_ORIGIN};
use tauri::{Manager, Url, WebviewWindow};
+8 -6
View File
@@ -10,6 +10,8 @@ const shellAppPath = new URL('../src/shell/ShellApp.tsx', import.meta.url);
const shellAppSource = fs.readFileSync(shellAppPath, 'utf8');
const bridgePath = new URL('../src/host-bridge/bridge.ts', import.meta.url);
const bridgeSource = fs.readFileSync(bridgePath, 'utf8');
const dispatchPath = new URL('../src/host-bridge/dispatch.ts', import.meta.url);
const dispatchSource = fs.readFileSync(dispatchPath, 'utf8');
const bridgeDirPath = new URL('../src/host-bridge/', import.meta.url);
const bridgeSourceFiles = fs
.readdirSync(bridgeDirPath, { withFileTypes: true })
@@ -161,7 +163,7 @@ function extractStringConstExport(source, exportName) {
function extractMobileBridgeHandledMethods(source) {
const match = source.match(
/async function handleRequest\(request: HostBridgeRequest\)[^{]*\{[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/,
/async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/,
);
if (!match) {
throw new Error('unable to read mobile shell HostBridge handler methods');
@@ -456,7 +458,7 @@ const sharedMethods = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_METHODS',
);
const handledMobileMethods = extractMobileBridgeHandledMethods(bridgeSource);
const handledMobileMethods = extractMobileBridgeHandledMethods(dispatchSource);
const mobileCapabilities = extractStringArrayExport(
hostBridgeSource,
'MOBILE_HOST_CAPABILITIES',
@@ -874,7 +876,7 @@ for (const forbiddenNotificationSnippet of [
if (
!/trigger:\s*Platform\.OS === 'android'\s*\?\s*\{\s*channelId: LOCAL_NOTIFICATION_CHANNEL_ID\s*\}\s*:\s*null/.test(
bridgeSource,
dispatchSource,
)
) {
throw new Error('mobile shell local notifications must stay immediate and channel-only');
@@ -936,13 +938,13 @@ if (!shellAppSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) {
throw new Error('mobile shell URL must use the shared mobile shell host version');
}
if (!bridgeSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) {
if (!dispatchSource.includes('hostVersion: MOBILE_SHELL_HOST_VERSION')) {
throw new Error('mobile shell runtime response must use the shared mobile shell host version');
}
if (
shellAppSource.includes("hostVersion: '0.1.0'") ||
bridgeSource.includes("hostVersion: '0.1.0'")
hostBridgeSource.includes("hostVersion: '0.1.0'")
) {
throw new Error('mobile shell HostBridge version must not be duplicated in app or bridge source');
}
@@ -995,6 +997,6 @@ if (mobileCapabilitySet.has('app.setBadgeCount')) {
throw new Error('Android mobile shell base capabilities must not include app.setBadgeCount');
}
if (!bridgeSource.includes('Appearance.getColorScheme()')) {
if (!dispatchSource.includes('Appearance.getColorScheme()')) {
throw new Error('mobile shell HostBridge must read the native color scheme');
}
+7 -291
View File
@@ -1,317 +1,34 @@
import * as Clipboard from 'expo-clipboard';
import * as Haptics from 'expo-haptics';
import * as Linking from 'expo-linking';
import * as Notifications from 'expo-notifications';
import {
Appearance,
Platform,
PushNotificationIOS,
} from 'react-native';
import {
type ClipboardReadTextResult,
type ClipboardWriteTextPayload,
type HapticsImpactPayload,
HOST_BRIDGE_VERSION,
type HostBridgeError,
type HostBridgeRequest,
type HostBridgeResponse,
type NavigateNativePagePayload,
normalizeHostBridgeBadgeCount,
normalizeHostBridgeClipboardText,
normalizeHostBridgeColorScheme,
normalizeHostBridgeExternalUrl,
normalizeHostBridgeHapticsImpactStyle,
normalizeHostBridgeLocalNotification,
normalizeHostBridgeRequestId,
type OpenExternalUrlPayload,
type SetBadgeCountPayload,
} from '../../../../packages/shared/src/contracts/hostBridge';
import { resolveMobileShellWebViewUrl } from '../shell/navigation';
import { getMobileNetworkStatus } from '../shell/network';
import { MOBILE_SHELL_HOST_VERSION } from '../shell/runtime';
import {
captureImageFile,
exportAudioFile,
exportImageFile,
exportTextFile,
importAudioFile,
importImageFile,
importTextFile,
} from './files';
dispatchMobileHostBridgeRequest,
resetMobileHostBridgeDispatchForTest,
} from './dispatch';
import {
HOST_BRIDGE_RESPONSE_CACHE_MAX,
type MobileHostBridgeNavigation,
failure,
invalidRequest,
isHostBridgeRequest,
normalizeMobileHostBridgeError,
ok,
parseRequest,
resolveMobileHostCapabilities,
unsupported,
} from './protocol';
import { openShare } from './share';
export {
IOS_MOBILE_HOST_CAPABILITIES,
MOBILE_HOST_CAPABILITIES,
resolveMobileHostCapabilities,
} from './protocol';
} from './capabilities';
export { configureMobileHostBridgeNavigation } from './dispatch';
const LOCAL_NOTIFICATION_CHANNEL_ID = 'genarrative-local';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
let currentShareTarget: unknown = null;
let navigation: MobileHostBridgeNavigation | null = null;
const completedHostBridgeResponses = new Map<string, HostBridgeResponse>();
const inFlightHostBridgeResponses = new Map<
string,
Promise<HostBridgeResponse>
>();
export function configureMobileHostBridgeNavigation(
nextNavigation: MobileHostBridgeNavigation | null,
) {
navigation = nextNavigation;
}
async function openExternalUrl(payload: unknown) {
const url = normalizeHostBridgeExternalUrl(
(payload as OpenExternalUrlPayload | undefined)?.url,
);
if (!url) {
throw invalidRequest('url must use an allowed external protocol');
}
await Linking.openURL(url);
return true;
}
async function writeClipboard(payload: unknown) {
const text = (payload as ClipboardWriteTextPayload | undefined)?.text;
if (typeof text !== 'string') {
throw invalidRequest('text is required');
}
await Clipboard.setStringAsync(text);
return true;
}
async function readClipboard(): Promise<ClipboardReadTextResult> {
const result = normalizeHostBridgeClipboardText(
await Clipboard.getStringAsync(),
);
if (!result) {
throw {
code: 'host_error',
message: 'clipboard text unavailable',
} satisfies HostBridgeError;
}
return result;
}
async function runHaptics(payload: unknown) {
const style = normalizeHostBridgeHapticsImpactStyle(
(payload as HapticsImpactPayload | undefined)?.style,
);
if (!style) {
throw invalidRequest('haptics impact style must be light, medium, or heavy');
}
const impactStyle =
style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: style === 'medium'
? Haptics.ImpactFeedbackStyle.Medium
: Haptics.ImpactFeedbackStyle.Light;
await Haptics.impactAsync(impactStyle);
return true;
}
function setBadgeCount(payload: unknown) {
if (Platform.OS !== 'ios') {
throw {
code: 'unsupported_capability',
message: 'app badge count is only supported on iOS mobile shell',
} satisfies HostBridgeError;
}
const count = normalizeHostBridgeBadgeCount(
(payload as SetBadgeCountPayload | undefined)?.count,
);
if (count === null) {
throw invalidRequest('count must be an integer between 0 and 99999');
}
PushNotificationIOS.setApplicationIconBadgeNumber(count);
return true;
}
function hasNotificationPermission(
permission: Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>,
) {
return (
permission.granted ||
permission.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL
);
}
async function ensureNotificationPermission() {
const currentPermission = await Notifications.getPermissionsAsync();
if (hasNotificationPermission(currentPermission)) {
return;
}
const requestedPermission = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: false,
allowSound: false,
},
});
if (!hasNotificationPermission(requestedPermission)) {
throw {
code: 'host_error',
message: 'notification permission denied',
} satisfies HostBridgeError;
}
}
async function showLocalNotification(payload: unknown) {
const notification = normalizeHostBridgeLocalNotification(payload);
if (!notification) {
throw invalidRequest('title is required');
}
await ensureNotificationPermission();
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync(
LOCAL_NOTIFICATION_CHANNEL_ID,
{
name: 'Genarrative',
importance: Notifications.AndroidImportance.DEFAULT,
},
);
}
await Notifications.scheduleNotificationAsync({
content: notification,
trigger:
Platform.OS === 'android'
? { channelId: LOCAL_NOTIFICATION_CHANNEL_ID }
: null,
});
return true;
}
function getColorScheme() {
return {
colorScheme: normalizeHostBridgeColorScheme(Appearance.getColorScheme()),
};
}
function openNativePage(payload: unknown) {
if (!navigation) {
throw unsupported('navigation.openNativePage');
}
const url = (payload as NavigateNativePagePayload | undefined)?.url;
if (typeof url !== 'string') {
throw invalidRequest('url is required');
}
const webViewUrl = resolveMobileShellWebViewUrl(
url,
navigation.allowedOrigin,
);
if (!webViewUrl) {
throw invalidRequest('url must be an allowed same-origin web path');
}
navigation.openWebViewUrl(webViewUrl);
return true;
}
function reloadWebView() {
if (!navigation) {
throw unsupported('app.reloadWebView');
}
navigation.reloadWebView();
return true;
}
async function handleRequest(request: HostBridgeRequest) {
switch (request.method) {
case 'host.getRuntime':
return ok(request, {
shell: 'expo_mobile',
platform: Platform.OS === 'ios' ? 'ios' : 'android',
hostVersion: MOBILE_SHELL_HOST_VERSION,
bridgeVersion: HOST_BRIDGE_VERSION,
capabilities: resolveMobileHostCapabilities(),
});
case 'appearance.getColorScheme':
return ok(request, getColorScheme());
case 'app.openExternalUrl':
return ok(request, await openExternalUrl(request.payload));
case 'app.reloadWebView':
return ok(request, reloadWebView());
case 'network.status':
return ok(request, await getMobileNetworkStatus());
case 'clipboard.writeText':
return ok(request, await writeClipboard(request.payload));
case 'clipboard.readText':
return ok(request, await readClipboard());
case 'file.exportText':
return ok(request, await exportTextFile(request.payload));
case 'file.importText':
return ok(request, await importTextFile());
case 'file.exportImage':
return ok(request, await exportImageFile(request.payload));
case 'file.importImage':
return ok(request, await importImageFile());
case 'file.captureImage':
return ok(request, await captureImageFile());
case 'file.importAudio':
return ok(request, await importAudioFile());
case 'file.exportAudio':
return ok(request, await exportAudioFile(request.payload));
case 'haptics.impact':
return ok(request, await runHaptics(request.payload));
case 'notification.showLocal':
return ok(request, await showLocalNotification(request.payload));
case 'app.setBadgeCount':
return ok(request, setBadgeCount(request.payload));
case 'share.open':
return ok(request, await openShare(request.payload, currentShareTarget));
case 'share.setTarget':
currentShareTarget =
request.payload && typeof request.payload === 'object'
? (request.payload as { target?: unknown }).target
: null;
return ok(request, true);
case 'navigation.openNativePage':
return ok(request, openNativePage(request.payload));
case 'auth.requestLogin':
case 'payment.request':
return failure(request, unsupported(request.method));
default:
return failure(request, unsupported(request.method));
}
}
function rememberHostBridgeResponse(response: HostBridgeResponse) {
completedHostBridgeResponses.set(response.id, response);
if (completedHostBridgeResponses.size > HOST_BRIDGE_RESPONSE_CACHE_MAX) {
@@ -335,7 +52,7 @@ async function resolveMobileHostBridgeResponse(request: HostBridgeRequest) {
return await inFlightResponse;
}
const responsePromise = handleRequest(request)
const responsePromise = dispatchMobileHostBridgeRequest(request)
.catch((error: unknown) =>
failure(request, normalizeMobileHostBridgeError(error)),
)
@@ -369,8 +86,7 @@ export async function handleMobileHostBridgeMessage(
}
export function resetMobileHostBridgeForTest() {
currentShareTarget = null;
navigation = null;
resetMobileHostBridgeDispatchForTest();
completedHostBridgeResponses.clear();
inFlightHostBridgeResponses.clear();
}
@@ -0,0 +1,40 @@
import { Platform } from 'react-native';
import type { HostBridgeCapability } from '../../../../packages/shared/src/contracts/hostBridge';
export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
'host.getRuntime',
'appearance.getColorScheme',
'host.events',
'app.lifecycle',
'share.open',
'share.setTarget',
'navigation.openNativePage',
'navigation.canGoBack',
'app.reloadWebView',
'app.openExternalUrl',
'network.status',
'network.statusChanged',
'clipboard.writeText',
'clipboard.readText',
'file.exportText',
'file.importText',
'file.exportImage',
'file.importImage',
'file.captureImage',
'file.importAudio',
'file.exportAudio',
'haptics.impact',
'notification.showLocal',
];
export const IOS_MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
...MOBILE_HOST_CAPABILITIES,
'app.setBadgeCount',
];
export function resolveMobileHostCapabilities(platform = Platform.OS) {
return platform === 'ios'
? IOS_MOBILE_HOST_CAPABILITIES
: MOBILE_HOST_CAPABILITIES;
}
@@ -0,0 +1,303 @@
import * as Clipboard from 'expo-clipboard';
import * as Haptics from 'expo-haptics';
import * as Linking from 'expo-linking';
import * as Notifications from 'expo-notifications';
import {
Appearance,
Platform,
PushNotificationIOS,
} from 'react-native';
import {
type ClipboardReadTextResult,
type ClipboardWriteTextPayload,
type HapticsImpactPayload,
HOST_BRIDGE_VERSION,
type HostBridgeError,
type HostBridgeRequest,
type NavigateNativePagePayload,
normalizeHostBridgeBadgeCount,
normalizeHostBridgeClipboardText,
normalizeHostBridgeColorScheme,
normalizeHostBridgeExternalUrl,
normalizeHostBridgeHapticsImpactStyle,
normalizeHostBridgeLocalNotification,
type OpenExternalUrlPayload,
type SetBadgeCountPayload,
} from '../../../../packages/shared/src/contracts/hostBridge';
import { resolveMobileShellWebViewUrl } from '../shell/navigation';
import { getMobileNetworkStatus } from '../shell/network';
import { MOBILE_SHELL_HOST_VERSION } from '../shell/runtime';
import { resolveMobileHostCapabilities } from './capabilities';
import {
captureImageFile,
exportAudioFile,
exportImageFile,
exportTextFile,
importAudioFile,
importImageFile,
importTextFile,
} from './files';
import {
type MobileHostBridgeNavigation,
failure,
invalidRequest,
ok,
unsupported,
} from './protocol';
import { openShare } from './share';
const LOCAL_NOTIFICATION_CHANNEL_ID = 'genarrative-local';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
let currentShareTarget: unknown = null;
let navigation: MobileHostBridgeNavigation | null = null;
export function configureMobileHostBridgeNavigation(
nextNavigation: MobileHostBridgeNavigation | null,
) {
navigation = nextNavigation;
}
async function openExternalUrl(payload: unknown) {
const url = normalizeHostBridgeExternalUrl(
(payload as OpenExternalUrlPayload | undefined)?.url,
);
if (!url) {
throw invalidRequest('url must use an allowed external protocol');
}
await Linking.openURL(url);
return true;
}
async function writeClipboard(payload: unknown) {
const text = (payload as ClipboardWriteTextPayload | undefined)?.text;
if (typeof text !== 'string') {
throw invalidRequest('text is required');
}
await Clipboard.setStringAsync(text);
return true;
}
async function readClipboard(): Promise<ClipboardReadTextResult> {
const result = normalizeHostBridgeClipboardText(
await Clipboard.getStringAsync(),
);
if (!result) {
throw {
code: 'host_error',
message: 'clipboard text unavailable',
} satisfies HostBridgeError;
}
return result;
}
async function runHaptics(payload: unknown) {
const style = normalizeHostBridgeHapticsImpactStyle(
(payload as HapticsImpactPayload | undefined)?.style,
);
if (!style) {
throw invalidRequest('haptics impact style must be light, medium, or heavy');
}
const impactStyle =
style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: style === 'medium'
? Haptics.ImpactFeedbackStyle.Medium
: Haptics.ImpactFeedbackStyle.Light;
await Haptics.impactAsync(impactStyle);
return true;
}
function setBadgeCount(payload: unknown) {
if (Platform.OS !== 'ios') {
throw {
code: 'unsupported_capability',
message: 'app badge count is only supported on iOS mobile shell',
} satisfies HostBridgeError;
}
const count = normalizeHostBridgeBadgeCount(
(payload as SetBadgeCountPayload | undefined)?.count,
);
if (count === null) {
throw invalidRequest('count must be an integer between 0 and 99999');
}
PushNotificationIOS.setApplicationIconBadgeNumber(count);
return true;
}
function hasNotificationPermission(
permission: Awaited<ReturnType<typeof Notifications.getPermissionsAsync>>,
) {
return (
permission.granted ||
permission.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL
);
}
async function ensureNotificationPermission() {
const currentPermission = await Notifications.getPermissionsAsync();
if (hasNotificationPermission(currentPermission)) {
return;
}
const requestedPermission = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: false,
allowSound: false,
},
});
if (!hasNotificationPermission(requestedPermission)) {
throw {
code: 'host_error',
message: 'notification permission denied',
} satisfies HostBridgeError;
}
}
async function showLocalNotification(payload: unknown) {
const notification = normalizeHostBridgeLocalNotification(payload);
if (!notification) {
throw invalidRequest('title is required');
}
await ensureNotificationPermission();
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync(
LOCAL_NOTIFICATION_CHANNEL_ID,
{
name: 'Genarrative',
importance: Notifications.AndroidImportance.DEFAULT,
},
);
}
await Notifications.scheduleNotificationAsync({
content: notification,
trigger:
Platform.OS === 'android'
? { channelId: LOCAL_NOTIFICATION_CHANNEL_ID }
: null,
});
return true;
}
function getColorScheme() {
return {
colorScheme: normalizeHostBridgeColorScheme(Appearance.getColorScheme()),
};
}
function openNativePage(payload: unknown) {
if (!navigation) {
throw unsupported('navigation.openNativePage');
}
const url = (payload as NavigateNativePagePayload | undefined)?.url;
if (typeof url !== 'string') {
throw invalidRequest('url is required');
}
const webViewUrl = resolveMobileShellWebViewUrl(
url,
navigation.allowedOrigin,
);
if (!webViewUrl) {
throw invalidRequest('url must be an allowed same-origin web path');
}
navigation.openWebViewUrl(webViewUrl);
return true;
}
function reloadWebView() {
if (!navigation) {
throw unsupported('app.reloadWebView');
}
navigation.reloadWebView();
return true;
}
export async function dispatchMobileHostBridgeRequest(
request: HostBridgeRequest,
) {
switch (request.method) {
case 'host.getRuntime':
return ok(request, {
shell: 'expo_mobile',
platform: Platform.OS === 'ios' ? 'ios' : 'android',
hostVersion: MOBILE_SHELL_HOST_VERSION,
bridgeVersion: HOST_BRIDGE_VERSION,
capabilities: resolveMobileHostCapabilities(),
});
case 'appearance.getColorScheme':
return ok(request, getColorScheme());
case 'app.openExternalUrl':
return ok(request, await openExternalUrl(request.payload));
case 'app.reloadWebView':
return ok(request, reloadWebView());
case 'network.status':
return ok(request, await getMobileNetworkStatus());
case 'clipboard.writeText':
return ok(request, await writeClipboard(request.payload));
case 'clipboard.readText':
return ok(request, await readClipboard());
case 'file.exportText':
return ok(request, await exportTextFile(request.payload));
case 'file.importText':
return ok(request, await importTextFile());
case 'file.exportImage':
return ok(request, await exportImageFile(request.payload));
case 'file.importImage':
return ok(request, await importImageFile());
case 'file.captureImage':
return ok(request, await captureImageFile());
case 'file.importAudio':
return ok(request, await importAudioFile());
case 'file.exportAudio':
return ok(request, await exportAudioFile(request.payload));
case 'haptics.impact':
return ok(request, await runHaptics(request.payload));
case 'notification.showLocal':
return ok(request, await showLocalNotification(request.payload));
case 'app.setBadgeCount':
return ok(request, setBadgeCount(request.payload));
case 'share.open':
return ok(request, await openShare(request.payload, currentShareTarget));
case 'share.setTarget':
currentShareTarget =
request.payload && typeof request.payload === 'object'
? (request.payload as { target?: unknown }).target
: null;
return ok(request, true);
case 'navigation.openNativePage':
return ok(request, openNativePage(request.payload));
case 'auth.requestLogin':
case 'payment.request':
return failure(request, unsupported(request.method));
default:
return failure(request, unsupported(request.method));
}
}
export function resetMobileHostBridgeDispatchForTest() {
currentShareTarget = null;
navigation = null;
}
@@ -1,9 +1,6 @@
import { Platform } from 'react-native';
import {
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_VERSION,
type HostBridgeCapability,
type HostBridgeError,
type HostBridgeMethod,
type HostBridgeRequest,
@@ -14,43 +11,6 @@ import {
export const HOST_BRIDGE_RESPONSE_CACHE_MAX = 128;
export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
'host.getRuntime',
'appearance.getColorScheme',
'host.events',
'app.lifecycle',
'share.open',
'share.setTarget',
'navigation.openNativePage',
'navigation.canGoBack',
'app.reloadWebView',
'app.openExternalUrl',
'network.status',
'network.statusChanged',
'clipboard.writeText',
'clipboard.readText',
'file.exportText',
'file.importText',
'file.exportImage',
'file.importImage',
'file.captureImage',
'file.importAudio',
'file.exportAudio',
'haptics.impact',
'notification.showLocal',
];
export const IOS_MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
...MOBILE_HOST_CAPABILITIES,
'app.setBadgeCount',
];
export function resolveMobileHostCapabilities(platform = Platform.OS) {
return platform === 'ios'
? IOS_MOBILE_HOST_CAPABILITIES
: MOBILE_HOST_CAPABILITIES;
}
export type MobileHostBridgeNavigation = {
allowedOrigin: string;
openWebViewUrl: (url: string) => void;
@@ -2475,6 +2475,6 @@
## 2026-06-18 三端宿主桥接层文件结构对齐
- 背景:微信小程序壳、Expo 移动壳和 Tauri 桌面壳都在承接宿主能力;如果微信页面继续散落 `index.shared.js`,桌面端继续把桥接分发堆在 `main.rs`,后续新增登录、支付、文件、通知或 sandbox 转发能力时会很难跨端对照 owner。
- 决策:三端桥接层按职责对齐。微信小程序页面路由不改,但可测试桥接逻辑统一放到 `miniprogram/host-bridge/webView.js``payment.js``shareGrid.js``subscribeMessage.js`,页面目录只保留生命周期和装配;Expo 移动壳拆成 `apps/mobile-shell/src/host-bridge/protocol.ts``files.ts``share.ts` 和 facade `bridge.ts`,分别负责协议 / 能力清单 / request 校验 / replay 基础、文件能力、分享能力和 method 分发,`App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx``apps/mobile-shell/src/shell/*.ts(x)` 负责 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policyTauri 桌面壳拆成 `apps/desktop-shell/src-tauri/src/host_bridge/*.rs``apps/desktop-shell/src-tauri/src/shell/*.rs`,其中 `runtime.rs``url.rs``navigation.rs``network.rs``lifecycle.rs``file_drop.rs``events.rs``deep_link.rs``tray.rs``webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘和 WebView 门面,`main.rs` 只做 builder、plugin、窗口和状态装配。`scripts/check-native-shells.mjs` 锁定三端桥接层目录清单,并拒绝移动根入口直接引用 HostBridge。
- 决策:三端桥接层按职责对齐,但保留各宿主真实边界。微信小程序页面路由不改,`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol``webView``payment``shareGrid``subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别放在 `webView.js``payment.js``shareGrid.js``subscribeMessage.js`,页面目录只保留生命周期和装配,不把微信小程序硬改成 Expo / Tauri 的 request 总线Expo 移动壳拆成 `apps/mobile-shell/src/host-bridge/protocol.ts``capabilities.ts``dispatch.ts``files.ts``share.ts` 和 facade `bridge.ts`,分别负责 envelope / request 校验 / ok-failure 响应 / replay 基础类型、能力清单与 iOS 差异、method 分发、文件能力、分享能力和 WebView message 入口 / request id replay 编排;`App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx``apps/mobile-shell/src/shell/*.ts(x)` 负责 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policyTauri 桌面壳拆成 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs``capabilities.rs``dispatch.rs``files.rs``share.rs` 和 command facade `mod.rs`,分别负责 envelope / method 白名单 / request 校验 / replay 状态、能力清单、method 分发、文件能力、分享能力和 `host_bridge_request` command / replay 编排;`apps/desktop-shell/src-tauri/src/shell/*.rs` 承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘和 WebView 门面,`main.rs` 只做 builder、plugin、窗口和状态装配。`scripts/check-native-shells.mjs` 锁定三端桥接层目录清单,并拒绝移动根入口直接引用 HostBridge。
- 影响范围:`miniprogram/host-bridge/``miniprogram/pages/*/index.js``apps/mobile-shell/src/``apps/desktop-shell/src-tauri/src/``scripts/check-native-shells.mjs`、宿主壳方案文档。
- 验证方式:`npm run test -- miniprogram/host-bridge/webView.test.js miniprogram/host-bridge/payment.test.js miniprogram/host-bridge/shareGrid.test.js miniprogram/host-bridge/subscribeMessage.test.js miniprogram/pages/web-view/index.style.test.js``npm run check:native-shells``npm run typecheck``npm run check:encoding``git diff --check`
@@ -64,7 +64,7 @@ src/
已落地:`packages/shared/src/contracts/hostBridge.ts` 保存消息 envelope、method、payload 和错误码,H5、Expo 壳与 Tauri 壳共享同一份协议类型。
三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*`协议归一、支付 / 订阅 / 分享结果编解码统一放在 `miniprogram/host-bridge/webView.js``payment.js``shareGrid.js``subscribeMessage.js`Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/webView.js``payment.js``shareGrid.js``subscribeMessage.js`,页面入口只做 `Page(createWechat...Page())` 装配Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、能力清单、request 校验和 replay 基础`files.ts` / `share.ts` 分别承接文件和分享能力,`bridge.ts` 只作为对外 facade 与 method 分发入口,`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policyTauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs``files.rs``share.rs` `mod.rs` 承接协议、文件、分享和分发,`apps/desktop-shell/src-tauri/src/shell/runtime.rs``url.rs``navigation.rs``network.rs``lifecycle.rs``file_drop.rs``events.rs``deep_link.rs``tray.rs``webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘和 WebView 门面,`main.rs` 只保留 Tauri builder / plugin / window 装配。
三端宿主桥接层按职责对齐命名:微信小程序页面路由仍保留在 `miniprogram/pages/*``miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol``webView``payment``shareGrid``subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别在 `webView.js``payment.js``shareGrid.js``subscribeMessage.js`不把微信小程序硬改成 Expo / Tauri 的 request 总线;Page 生命周期、`wx.*` 容器调用、WebView 容器行为和页面工厂统一放在 `miniprogram/shell/webView.js``payment.js``shareGrid.js``subscribeMessage.js`,页面入口只做 `Page(createWechat...Page())` 装配Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 承接能力清单与 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用`files.ts` / `share.ts` 分别承接文件和分享能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policyTauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`capabilities.rs` 承接能力清单,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` / `share.rs` 分别承接文件和分享能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs``url.rs``navigation.rs``network.rs``lifecycle.rs``file_drop.rs``events.rs``deep_link.rs``tray.rs``webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘和 WebView 门面,`main.rs` 只保留 Tauri builder / plugin / window 装配。
## HostBridge 消息协议
@@ -37,7 +37,7 @@ AI H5 sandbox
-> parent HostBridge adapter
```
桥接层文件结构按宿主统一为“协议 / 分发 / 宿主容器行为”类职责。微信小程序的协议与可测试桥接函数统一放在 `miniprogram/host-bridge/webView.js``payment.js``shareGrid.js``subscribeMessage.js``miniprogram/shell/webView.js``payment.js``shareGrid.js``subscribeMessage.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为、支付页和订阅页装配,页面目录只保留 `Page(createWechat...Page())` 装配Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、能力清单、request 校验和 replay 基础`files.ts` / `share.ts` 分别承接文件和分享能力,`bridge.ts` 只作为对外 facade 与 method 分发入口,`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policyTauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs``files.rs``share.rs` `mod.rs` 承接协议、文件、分享和分发,`apps/desktop-shell/src-tauri/src/shell/runtime.rs``url.rs``navigation.rs``network.rs``lifecycle.rs``file_drop.rs``events.rs``deep_link.rs``tray.rs``webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘和 WebView 门面,`main.rs` 只保留 Tauri builder / plugin / window 装配。`npm run check:native-shells` 会检查这些目录清单。
桥接层文件结构按宿主统一为“协议 / 能力清单 / 分发 / 宿主容器行为”类职责。微信小程序不硬套 Expo / Tauri 的 request 总线:`miniprogram/host-bridge/protocol.js` 只沉淀微信壳能力、页面 URL、结果 hash / storage key 和分享消息类型等常量,`dispatch.js` 只作为 `protocol``webView``payment``shareGrid``subscribeMessage` 的薄索引,真实协议归一、支付 / 订阅 / 分享结果编解码仍分别放在 `webView.js``payment.js``shareGrid.js``subscribeMessage.js``miniprogram/shell/webView.js``payment.js``shareGrid.js``subscribeMessage.js` 承接 Page 生命周期、`wx.*` 容器调用、WebView 容器行为、支付页和订阅页装配,页面目录只保留 `Page(createWechat...Page())` 装配Expo 移动壳使用 `apps/mobile-shell/src/host-bridge/protocol.ts` 承接 envelope、request 校验、ok / failure 响应和 replay 基础类型,`capabilities.ts` 承接能力清单与 iOS 差异能力,`dispatch.ts` 承接 method 分发和宿主能力调用`files.ts` / `share.ts` 分别承接文件和分享能力,`bridge.ts` 只作为 WebView message 入口、request id replay 编排和对外 facade`apps/mobile-shell/App.tsx` 只装配 `apps/mobile-shell/src/shell/ShellApp.tsx`,由 `apps/mobile-shell/src/shell/*.ts(x)` 承接 WebView 容器、URL、导航、网络、生命周期、安全区和 WebView policyTauri 桌面壳使用 `apps/desktop-shell/src-tauri/src/host_bridge/protocol.rs` 承接 envelope、method 白名单、request 校验和 replay 状态,`capabilities.rs` 承接能力清单,`dispatch.rs` 承接 method 分发和宿主能力调用,`files.rs` / `share.rs` 分别承接文件和分享能力,`mod.rs` 只保留模块声明、必要 re-export、`host_bridge_request` command facade 和 replay 编排;`apps/desktop-shell/src-tauri/src/shell/runtime.rs``url.rs``navigation.rs``network.rs``lifecycle.rs``file_drop.rs``events.rs``deep_link.rs``tray.rs``webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘和 WebView 门面,`main.rs` 只保留 Tauri builder / plugin / window 装配。`npm run check:native-shells` 会检查这些目录清单。
## 首批能力
+13
View File
@@ -0,0 +1,13 @@
const payment = require('./payment');
const protocol = require('./protocol');
const shareGrid = require('./shareGrid');
const subscribeMessage = require('./subscribeMessage');
const webView = require('./webView');
module.exports = {
payment,
protocol,
shareGrid,
subscribeMessage,
webView,
};
+33
View File
@@ -0,0 +1,33 @@
const WECHAT_HOST_CAPABILITIES = [
'auth.requestLogin',
'payment.request',
'share.setTarget',
'share.open',
];
const WECHAT_WEB_VIEW_PAGE_URL = '/pages/web-view/index';
const WECHAT_AUTH_PAGE_URL =
`${WECHAT_WEB_VIEW_PAGE_URL}?authAction=login&returnTo=previous`;
const WECHAT_PAY_PAGE_URL = '/pages/wechat-pay/index';
const WECHAT_SHARE_GRID_PAGE_URL = '/pages/share-grid/index';
const WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL = '/pages/subscribe-message/index';
const WECHAT_PAY_RESULT_HASH_KEY = 'wx_pay_result';
const WECHAT_SUBSCRIBE_RESULT_HASH_KEY = 'wx_subscribe_result';
const WECHAT_PAY_RESULT_STORAGE_KEY = 'genarrative:wechat-pay-result';
const WECHAT_SUBSCRIBE_RESULT_STORAGE_KEY =
'genarrative:wechat-subscribe-result';
const WECHAT_SHARE_TARGET_MESSAGE_TYPE = 'genarrative:share-target';
module.exports = {
WECHAT_AUTH_PAGE_URL,
WECHAT_HOST_CAPABILITIES,
WECHAT_PAY_PAGE_URL,
WECHAT_PAY_RESULT_HASH_KEY,
WECHAT_PAY_RESULT_STORAGE_KEY,
WECHAT_SHARE_GRID_PAGE_URL,
WECHAT_SHARE_TARGET_MESSAGE_TYPE,
WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL,
WECHAT_SUBSCRIBE_RESULT_HASH_KEY,
WECHAT_SUBSCRIBE_RESULT_STORAGE_KEY,
WECHAT_WEB_VIEW_PAGE_URL,
};
+80
View File
@@ -0,0 +1,80 @@
import path from 'node:path';
import { describe, expect, test } from 'vitest';
import { loadCommonJsModule } from '../test-utils/loadCommonJsModule.js';
const protocolPath = path.resolve(
process.cwd(),
'miniprogram/host-bridge/protocol.js',
);
const dispatchPath = path.resolve(
process.cwd(),
'miniprogram/host-bridge/dispatch.js',
);
const paymentPath = path.resolve(
process.cwd(),
'miniprogram/host-bridge/payment.js',
);
const shareGridPath = path.resolve(
process.cwd(),
'miniprogram/host-bridge/shareGrid.js',
);
const subscribeMessagePath = path.resolve(
process.cwd(),
'miniprogram/host-bridge/subscribeMessage.js',
);
const webViewPath = path.resolve(
process.cwd(),
'miniprogram/host-bridge/webView.js',
);
describe('wechat mini program host bridge protocol index', () => {
test('keeps native page routes and result keys centralized', () => {
const protocol = loadCommonJsModule(protocolPath);
expect(protocol.WECHAT_HOST_CAPABILITIES).toEqual([
'auth.requestLogin',
'payment.request',
'share.setTarget',
'share.open',
]);
expect(protocol.WECHAT_AUTH_PAGE_URL).toBe(
'/pages/web-view/index?authAction=login&returnTo=previous',
);
expect(protocol.WECHAT_PAY_PAGE_URL).toBe('/pages/wechat-pay/index');
expect(protocol.WECHAT_SHARE_GRID_PAGE_URL).toBe('/pages/share-grid/index');
expect(protocol.WECHAT_SUBSCRIBE_MESSAGE_PAGE_URL).toBe(
'/pages/subscribe-message/index',
);
expect(protocol.WECHAT_PAY_RESULT_HASH_KEY).toBe('wx_pay_result');
expect(protocol.WECHAT_SUBSCRIBE_RESULT_HASH_KEY).toBe(
'wx_subscribe_result',
);
});
test('dispatch index exposes current bridge ability modules', () => {
const payment = loadCommonJsModule(paymentPath);
const protocol = loadCommonJsModule(protocolPath);
const shareGrid = loadCommonJsModule(shareGridPath);
const subscribeMessage = loadCommonJsModule(subscribeMessagePath);
const webView = loadCommonJsModule(webViewPath);
const dispatch = loadCommonJsModule(dispatchPath, {
'./payment': payment,
'./protocol': protocol,
'./shareGrid': shareGrid,
'./subscribeMessage': subscribeMessage,
'./webView': webView,
});
expect(dispatch.protocol.WECHAT_PAY_PAGE_URL).toBe('/pages/wechat-pay/index');
expect(typeof dispatch.webView.resolveWebViewUrlFromRuntimeConfig).toBe(
'function',
);
expect(typeof dispatch.payment.requestWechatPayment).toBe('function');
expect(typeof dispatch.shareGrid.buildShareGridTilePlan).toBe('function');
expect(typeof dispatch.subscribeMessage.buildSubscribeResultValue).toBe(
'function',
);
});
});
+7
View File
@@ -12,8 +12,11 @@ const productionShellRoots = [
'miniprogram',
];
const expectedWechatHostBridgeFiles = [
'dispatch.js',
'payment.js',
'payment.test.js',
'protocol.js',
'protocol.test.js',
'shareGrid.js',
'shareGrid.test.js',
'subscribeMessage.js',
@@ -34,6 +37,8 @@ const expectedWechatShellFiles = [
const expectedMobileHostBridgeFiles = [
'bridge.test.ts',
'bridge.ts',
'capabilities.ts',
'dispatch.ts',
'files.ts',
'protocol.ts',
'share.ts',
@@ -57,6 +62,8 @@ const expectedMobileShellFiles = [
'webViewPolicy.ts',
];
const expectedDesktopHostBridgeRustFiles = [
'capabilities.rs',
'dispatch.rs',
'files.rs',
'mod.rs',
'protocol.rs',