收口原生角标边界

将 Expo 应用角标设置收口到 badge 模块

将 Tauri 任务栏角标设置收口到 badge 模块

同步原生壳结构门禁和架构文档清单
This commit is contained in:
2026-06-19 21:45:04 +08:00
parent af6aea3de2
commit b92b9bbc29
11 changed files with 182 additions and 105 deletions
+33 -1
View File
@@ -46,6 +46,14 @@ const app = fs.readFileSync(appPath, '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 desktopHostBridgeBadgePath = new URL(
'../src-tauri/src/host_bridge/badge.rs',
import.meta.url,
);
const desktopHostBridgeBadgeSource = fs.readFileSync(
desktopHostBridgeBadgePath,
'utf8',
);
const desktopHostBridgeCapabilitiesPath = new URL(
'../src-tauri/src/host_bridge/capabilities.rs',
import.meta.url,
@@ -193,6 +201,7 @@ const expectedDesktopRustRootEntries = [
'file:main.rs',
];
const expectedDesktopHostBridgeRustFiles = [
'badge.rs',
'capabilities.rs',
'clipboard.rs',
'dispatch.rs',
@@ -1283,7 +1292,7 @@ const desktopHostBridgePayloadLimits = {
'DESKTOP_NETWORK_CHECK_TIMEOUT_MS',
),
HOST_BRIDGE_BADGE_COUNT_MAX: extractRustNumberConst(
rustHostSource,
desktopHostBridgeBadgeSource,
'BADGE_COUNT_MAX',
),
HOST_BRIDGE_APP_TITLE_MAX_LENGTH: extractRustNumberConst(
@@ -1803,6 +1812,7 @@ const expectedRustRootEntries = [
'file:main.rs',
];
const expectedRustHostBridgeFiles = [
'badge.rs',
'capabilities.rs',
'clipboard.rs',
'dispatch.rs',
@@ -2048,6 +2058,28 @@ assertSameList(
if (nativeAppHostBridgeSource.includes("'host_bridge_request'")) {
throw new Error('H5 native app HostBridge must use HOST_BRIDGE_TAURI_COMMAND');
}
if (
!desktopHostBridgeDispatchSource.includes(
'set_desktop_app_badge_count(&app, &request)',
) ||
desktopHostBridgeDispatchSource.includes('set_badge_count') ||
desktopHostBridgeDispatchSource.includes('BADGE_COUNT_MAX')
) {
throw new Error('desktop shell badge HostBridge method must delegate to badge module');
}
for (const snippet of [
'BADGE_COUNT_MAX',
'fn badge_count_payload',
'set_desktop_app_badge_count',
'"count must be an integer between 0 and 99999"',
]) {
if (!desktopHostBridgeBadgeSource.includes(snippet)) {
throw new Error(`desktop shell badge module is missing ${snippet}`);
}
}
if (!desktopHostBridgeBadgeSource.match(/window\s*\.\s*set_badge_count\s*\(\s*count\s*\)/)) {
throw new Error('desktop shell badge module must call window.set_badge_count(count)');
}
if (
!desktopHostBridgeDispatchSource.includes(
'show_desktop_local_notification(&app, &request)',
@@ -0,0 +1,78 @@
use crate::host_bridge::protocol::{failed, HostBridgeRequest, HostBridgeResponse};
use serde_json::Value;
use tauri::Manager;
const BADGE_COUNT_MAX: i64 = 99999;
fn badge_count_payload(request: &HostBridgeRequest) -> Result<Option<i64>, HostBridgeResponse> {
let count = request
.payload
.as_ref()
.and_then(|value| value.get("count"))
.and_then(Value::as_i64)
.ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"count must be an integer between 0 and 99999",
)
})?;
if !(0..=BADGE_COUNT_MAX).contains(&count) {
return Err(failed(
request.id.clone(),
"invalid_request",
"count must be an integer between 0 and 99999",
));
}
Ok(if count == 0 { None } else { Some(count) })
}
pub(crate) fn set_desktop_app_badge_count(
app: &tauri::AppHandle,
request: &HostBridgeRequest,
) -> Result<(), HostBridgeResponse> {
let count = badge_count_payload(request)?;
let Some(window) = app.get_webview_window("main") else {
return Err(failed(request.id.clone(), "host_error", "main window not found"));
};
window
.set_badge_count(count)
.map_err(|error| failed(request.id.clone(), "host_error", error.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host_bridge::protocol::request;
use serde_json::json;
#[test]
fn badge_count_payload_accepts_clear_and_positive_counts() {
let mut clear = request("app.setBadgeCount");
clear.payload = Some(json!({ "count": 0 }));
assert_eq!(badge_count_payload(&clear).expect("clear badge"), None);
let mut count = request("app.setBadgeCount");
count.payload = Some(json!({ "count": 12 }));
assert_eq!(badge_count_payload(&count).expect("badge count"), Some(12));
}
#[test]
fn badge_count_payload_rejects_invalid_counts() {
for count in [json!(-1), json!(1.5), json!(100000), json!("1")] {
let mut invalid = request("app.setBadgeCount");
invalid.payload = Some(json!({ "count": count }));
let response = badge_count_payload(&invalid).expect_err("invalid count");
let error = response.error.expect("error");
assert_eq!(error.code, "invalid_request");
assert_eq!(
error.message,
"count must be an integer between 0 and 99999"
);
}
}
}
@@ -1,3 +1,4 @@
use crate::host_bridge::badge::set_desktop_app_badge_count;
use crate::host_bridge::capabilities::capabilities;
use crate::host_bridge::clipboard::{read_desktop_clipboard_text, write_desktop_clipboard_text};
use crate::host_bridge::files::{
@@ -15,11 +16,10 @@ use crate::shell::webview::{
color_scheme_from_theme, desktop_platform, normalize_external_url, normalize_native_page_url,
open_normalized_desktop_external_url, resolve_desktop_network_status,
};
use serde_json::{json, Value};
use serde_json::json;
use tauri::Manager;
use tauri_plugin_dialog::DialogExt;
const BADGE_COUNT_MAX: i64 = 99999;
const WINDOW_TITLE_MAX_LENGTH: usize = 80;
fn normalize_window_title(raw_title: &str) -> Option<String> {
@@ -31,31 +31,6 @@ fn normalize_window_title(raw_title: &str) -> Option<String> {
Some(title.chars().take(WINDOW_TITLE_MAX_LENGTH).collect())
}
fn badge_count_payload(request: &HostBridgeRequest) -> Result<Option<i64>, HostBridgeResponse> {
let count = request
.payload
.as_ref()
.and_then(|value| value.get("count"))
.and_then(Value::as_i64)
.ok_or_else(|| {
failed(
request.id.clone(),
"invalid_request",
"count must be an integer between 0 and 99999",
)
})?;
if !(0..=BADGE_COUNT_MAX).contains(&count) {
return Err(failed(
request.id.clone(),
"invalid_request",
"count must be an integer between 0 and 99999",
));
}
Ok(if count == 0 { None } else { Some(count) })
}
fn desktop_runtime() -> HostBridgeRuntime {
HostBridgeRuntime {
shell: "tauri_desktop",
@@ -385,20 +360,10 @@ pub(super) async fn execute_host_bridge_request(
None => failed(request.id, "host_error", "main window not found"),
}
}
"app.setBadgeCount" => {
let count = match badge_count_payload(&request) {
Ok(count) => count,
Err(response) => return response,
};
match app.get_webview_window("main") {
Some(window) => match window.set_badge_count(count) {
Ok(()) => ok(request.id, json!(true)),
Err(error) => failed(request.id, "host_error", error.to_string()),
},
None => failed(request.id, "host_error", "main window not found"),
}
}
"app.setBadgeCount" => match set_desktop_app_badge_count(&app, &request) {
Ok(()) => ok(request.id, json!(true)),
Err(response) => response,
},
"network.status" => {
let network_status =
tauri::async_runtime::spawn_blocking(resolve_desktop_network_status).await;
@@ -582,30 +547,4 @@ mod tests {
);
}
#[test]
fn badge_count_payload_accepts_clear_and_positive_counts() {
let mut clear = request("app.setBadgeCount");
clear.payload = Some(json!({ "count": 0 }));
assert_eq!(badge_count_payload(&clear).expect("clear badge"), None);
let mut count = request("app.setBadgeCount");
count.payload = Some(json!({ "count": 12 }));
assert_eq!(badge_count_payload(&count).expect("badge count"), Some(12));
}
#[test]
fn badge_count_payload_rejects_invalid_counts() {
for count in [json!(-1), json!(1.5), json!(100000), json!("1")] {
let mut invalid = request("app.setBadgeCount");
invalid.payload = Some(json!({ "count": count }));
let response = badge_count_payload(&invalid).expect_err("invalid count");
let error = response.error.expect("error");
assert_eq!(error.code, "invalid_request");
assert_eq!(
error.message,
"count must be an integer between 0 and 99999"
);
}
}
}
@@ -1,4 +1,5 @@
pub(crate) mod capabilities;
mod badge;
mod clipboard;
mod dispatch;
pub(crate) mod files;
@@ -12,6 +12,8 @@ const qrScannerOverlayPath = new URL('../src/shell/QrScannerOverlay.tsx', import
const qrScannerOverlaySource = fs.readFileSync(qrScannerOverlayPath, 'utf8');
const bridgePath = new URL('../src/host-bridge/bridge.ts', import.meta.url);
const bridgeSource = fs.readFileSync(bridgePath, 'utf8');
const badgePath = new URL('../src/host-bridge/badge.ts', import.meta.url);
const badgeSource = fs.readFileSync(badgePath, 'utf8');
const clipboardPath = new URL('../src/host-bridge/clipboard.ts', import.meta.url);
const clipboardSource = fs.readFileSync(clipboardPath, 'utf8');
const dispatchPath = new URL('../src/host-bridge/dispatch.ts', import.meta.url);
@@ -75,6 +77,7 @@ const productionSourceRoots = [
const productionFileExtensions = new Set(['.json', '.mjs', '.ts', '.tsx']);
const requiredMobileShellSourceModules = [
'env.d.ts',
'host-bridge/badge.ts',
'host-bridge/bridge.ts',
'host-bridge/capabilities.ts',
'host-bridge/clipboard.ts',
@@ -1813,6 +1816,24 @@ if (!iosMobileCapabilitySet.has('app.setBadgeCount')) {
if (mobileCapabilitySet.has('app.setBadgeCount')) {
throw new Error('Android mobile shell base capabilities must not include app.setBadgeCount');
}
if (
!dispatchSource.includes('setMobileAppBadgeCount(request.payload)') ||
dispatchSource.includes('PushNotificationIOS') ||
dispatchSource.includes('normalizeHostBridgeBadgeCount') ||
dispatchSource.includes('HOST_BRIDGE_BADGE_COUNT_MAX')
) {
throw new Error('mobile shell badge HostBridge method must delegate to badge module');
}
for (const snippet of [
'HOST_BRIDGE_BADGE_COUNT_MAX',
'normalizeHostBridgeBadgeCount',
'PushNotificationIOS.setApplicationIconBadgeNumber(count)',
'app badge count is only supported on iOS mobile shell',
]) {
if (!badgeSource.includes(snippet)) {
throw new Error(`mobile shell badge module is missing ${snippet}`);
}
}
if (!dispatchSource.includes('Appearance.getColorScheme()')) {
throw new Error('mobile shell HostBridge must read the native color scheme');
@@ -0,0 +1,30 @@
import { Platform, PushNotificationIOS } from 'react-native';
import {
HOST_BRIDGE_BADGE_COUNT_MAX,
type HostBridgeError,
normalizeHostBridgeBadgeCount,
type SetBadgeCountPayload,
} from '../../../../packages/shared/src/contracts/hostBridge';
import { invalidRequest } from './protocol';
export function setMobileAppBadgeCount(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 ${HOST_BRIDGE_BADGE_COUNT_MAX}`,
);
}
PushNotificationIOS.setApplicationIconBadgeNumber(count);
return true;
}
+3 -30
View File
@@ -1,24 +1,17 @@
import * as Linking from 'expo-linking';
import {
Appearance,
Platform,
PushNotificationIOS,
} from 'react-native';
import { Appearance, Platform } from 'react-native';
import {
type ClipboardWriteTextPayload,
type HapticsImpactPayload,
HOST_BRIDGE_BADGE_COUNT_MAX,
HOST_BRIDGE_VERSION,
type HostBridgeError,
type HostBridgeRequest,
type NavigateNativePagePayload,
normalizeHostBridgeBadgeCount,
normalizeHostBridgeColorScheme,
normalizeHostBridgeExternalUrlPayload,
normalizeHostBridgeLocalNotification,
type OpenExternalUrlPayload,
type SetBadgeCountPayload,
} from '../../../../packages/shared/src/contracts/hostBridge';
import {
openMobileShellExternalNavigation,
@@ -53,6 +46,7 @@ import {
writeMobileClipboardText,
} from './clipboard';
import { runMobileHapticsImpact } from './haptics';
import { setMobileAppBadgeCount } from './badge';
let currentShareTarget: unknown = null;
let navigation: MobileHostBridgeNavigation | null = null;
@@ -105,27 +99,6 @@ async function runHaptics(payload: unknown) {
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 ${HOST_BRIDGE_BADGE_COUNT_MAX}`,
);
}
PushNotificationIOS.setApplicationIconBadgeNumber(count);
return true;
}
async function showLocalNotification(payload: unknown) {
const notification = normalizeHostBridgeLocalNotification(payload);
if (!notification) {
@@ -230,7 +203,7 @@ export async function dispatchMobileHostBridgeRequest(
case 'notification.showLocal':
return ok(request, await showLocalNotification(request.payload));
case 'app.setBadgeCount':
return ok(request, setBadgeCount(request.payload));
return ok(request, setMobileAppBadgeCount(request.payload));
case 'share.open':
return ok(request, await openShare(request.payload, currentShareTarget));
case 'share.setTarget':
@@ -45,6 +45,7 @@
- 2026-06-18 分享卡图片导出:新增 `file.exportImage` HostBridge capabilityH5 分享卡下载在 native app 中优先把 canvas 生成的 base64 图片交给宿主导出;Expo 壳写缓存图片后交给系统分享 / 保存面板,Tauri 壳通过系统保存对话框写入图片字节。该能力只接受 `image/png` / `image/jpeg` / `image/webp`、单次 5 MiB 内图片数据,成功只返回文件名和字节数,不暴露本机绝对路径;宿主未声明时保留浏览器下载。
- 2026-06-18 应用角标能力:新增 `app.setBadgeCount` HostBridge capabilityH5 只传 `0` 到共享契约 `HOST_BRIDGE_BADGE_COUNT_MAX` 之间的整数并在宿主未声明时静默 fallback;Expo 壳只在 iOS 声明并通过 React Native `PushNotificationIOS` 设置应用图标角标,Android 不声明、不伪造成功;Tauri 壳通过主窗口 `set_badge_count` 设置任务栏角标,底层平台不支持时返回真实错误。
- 2026-06-18 草稿生成未读角标:平台壳层把“可见作品架里未读的草稿生成完成更新”同步到 `app.setBadgeCount`;同一草稿的 work/profile/session 等多个恢复 ID 只计 1,已读、失败、生成中和不可见草稿不计入。该角标只消费已有 HostBridge 能力,宿主不支持或设置失败不影响 H5 红点、作品架或后端状态。
- 2026-06-19 原生壳角标边界:Expo `app.setBadgeCount` 的 iOS 平台判定、共享上限校验和 `PushNotificationIOS.setApplicationIconBadgeNumber(...)` 调用统一收口在 `apps/mobile-shell/src/host-bridge/badge.ts`Tauri `app.setBadgeCount` 的 payload 校验、清除语义和主窗口 `set_badge_count` 调用统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/badge.rs`。两端 `dispatch` 只负责委托对应 badge 模块和映射响应,配置检查会拒绝分发层直接导入角标底层 API 或重声明数量边界。
- 2026-06-18 宿主外观只读查询:新增 `appearance.getColorScheme` HostBridge capabilityExpo 壳通过 React Native `Appearance.getColorScheme()` 读取系统配色,Tauri 壳通过主窗口 `theme()` 读取窗口主题;该能力只返回 `light` / `dark` / `unknown`,不设置 H5 主题、不覆盖系统主题,也不作为强制 UI 样式入口。
- 2026-06-18 原生壳生命周期事件:新增 `app.lifecycle` HostBridge capabilityExpo 壳通过 React Native `AppState` 派发 `active` / `inactive` / `background`Tauri 壳通过主窗口 focus / blur、托盘隐藏 / 恢复和页面加载重放派发统一状态;桌面隐藏到托盘或最小化都归一为 `background``hidden``minimized``focused``blurred` 只进入 `nativeState` 便于排障,不扩展共享 `state`。两端都声明 `host.events` 表示事件通过 HostBridge message 注入,但不把它作为 request method,也不开放 Tauri event 插件或 React Native 私有事件 API。H5 只通过 `subscribeHostAppLifecycle()` 订阅统一状态,后续游戏循环、音频和轮询暂停 / 恢复不得直接依赖 Expo / Tauri 平台细节。
- 2026-06-18 原生壳网络状态:新增 `network.status``network.statusChanged` HostBridge capabilityExpo 壳通过 `expo-network` 查询和订阅真实系统网络状态,Tauri 壳从 `WEB_APP_ORIGIN` 解析主站 host / port 后做短超时 TCP 可达性查询,并通过 WebView `online` / `offline` 注入变化事件;H5 统一使用 `getHostNetworkStatus()` / `subscribeHostNetworkStatusChange()`,不得直接读取 Expo / Tauri 私有网络 API。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
View File
@@ -249,6 +249,7 @@ const expectedWechatPageFilesByRoute = {
'wechat-pay': ['index.js', 'index.json', 'index.wxml', 'index.wxss'],
};
const expectedMobileHostBridgeFiles = [
'badge.ts',
'bridge.test.ts',
'bridge.ts',
'capabilities.ts',
@@ -292,6 +293,7 @@ const expectedMobileShellFiles = [
'webViewPolicy.ts',
];
const expectedDesktopHostBridgeRustFiles = [
'badge.rs',
'capabilities.rs',
'clipboard.rs',
'dispatch.rs',