统一三端宿主桥接层结构
拆分移动壳能力清单与分发模块 拆分桌面壳能力清单与分发模块 补充微信壳协议与分发薄索引 同步三端桥接层门禁和文档
This commit is contained in:
@@ -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};
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 policy;Tauri 桌面壳拆成 `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 policy;Tauri 桌面壳拆成 `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 policy;Tauri 桌面壳使用 `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 policy。Tauri 桌面壳使用 `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 policy;Tauri 桌面壳使用 `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 policy。Tauri 桌面壳使用 `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` 会检查这些目录清单。
|
||||
|
||||
## 首批能力
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user