接入原生壳网络状态能力

新增 network.status 与 network.statusChanged HostBridge 能力

Expo 壳通过 expo-network 查询并订阅真实网络状态

Tauri 壳通过主站可达性查询和 WebView online/offline 事件同步网络状态

更新壳能力检查、测试和架构文档
This commit is contained in:
2026-06-18 02:35:48 +08:00
parent 346368f0e7
commit 586e46fa63
19 changed files with 551 additions and 5 deletions

View File

@@ -2,8 +2,10 @@ use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::fs;
use std::net::{TcpStream, ToSocketAddrs};
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Duration;
use tauri::Manager;
use tauri::Theme;
use tauri::Url;
@@ -22,6 +24,7 @@ const EXPORT_IMAGE_MAX_BYTES: usize = 5 * 1024 * 1024;
const EXPORT_FILE_NAME_FALLBACK: &str = "genarrative-export.txt";
const EXPORT_FILE_NAME_MAX_LENGTH: usize = 120;
const BADGE_COUNT_MAX: i64 = 99999;
const DESKTOP_NETWORK_CHECK_TIMEOUT_MS: u64 = 1200;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -90,6 +93,8 @@ fn capabilities() -> Vec<&'static str> {
"app.openExternalUrl",
"app.setTitle",
"app.setBadgeCount",
"network.status",
"network.statusChanged",
"clipboard.writeText",
"file.exportText",
"file.exportImage",
@@ -441,6 +446,34 @@ fn emit_desktop_lifecycle_event(
window.eval(script)
}
fn desktop_network_status_payload(is_online: bool) -> Value {
json!({
"isConnected": is_online,
"isInternetReachable": is_online,
"connectionType": if is_online { "unknown" } else { "none" },
"nativeType": if is_online { "online" } else { "offline" },
})
}
fn resolve_desktop_network_status() -> Value {
let timeout = Duration::from_millis(DESKTOP_NETWORK_CHECK_TIMEOUT_MS);
let is_reachable = ("app.genarrative.world", 443)
.to_socket_addrs()
.map(|addresses| {
addresses.into_iter().any(|address| {
TcpStream::connect_timeout(&address, timeout)
.map(|stream| {
drop(stream);
true
})
.unwrap_or(false)
})
})
.unwrap_or(false);
desktop_network_status_payload(is_reachable)
}
fn register_desktop_lifecycle_events(window: &WebviewWindow) {
let lifecycle_window = window.clone();
window.on_window_event(move |event| {
@@ -455,6 +488,56 @@ fn register_desktop_lifecycle_events(window: &WebviewWindow) {
});
}
fn register_desktop_network_events(window: &WebviewWindow) -> tauri::Result<()> {
let online_script = host_bridge_event_script(
"network.statusChanged",
desktop_network_status_payload(true),
)
.map_err(tauri::Error::Json)?;
let offline_script = host_bridge_event_script(
"network.statusChanged",
desktop_network_status_payload(false),
)
.map_err(tauri::Error::Json)?;
let current_status_script = host_bridge_event_script(
"network.statusChanged",
json!({
"isConnected": "__GENARRATIVE_DESKTOP_ONLINE__",
"isInternetReachable": "__GENARRATIVE_DESKTOP_ONLINE__",
"connectionType": "__GENARRATIVE_DESKTOP_CONNECTION_TYPE__",
"nativeType": "__GENARRATIVE_DESKTOP_NATIVE_TYPE__",
}),
)
.map_err(tauri::Error::Json)?
.replace("\"__GENARRATIVE_DESKTOP_ONLINE__\"", "navigator.onLine")
.replace(
"\"__GENARRATIVE_DESKTOP_CONNECTION_TYPE__\"",
"(navigator.onLine ? 'unknown' : 'none')",
)
.replace(
"\"__GENARRATIVE_DESKTOP_NATIVE_TYPE__\"",
"(navigator.onLine ? 'online' : 'offline')",
);
let script = format!(
"(() => {{
if (window.__GENARRATIVE_DESKTOP_NETWORK_LISTENER_INSTALLED__) {{
return true;
}}
window.__GENARRATIVE_DESKTOP_NETWORK_LISTENER_INSTALLED__ = true;
const emitOnline = () => {{ {} }};
const emitOffline = () => {{ {} }};
window.addEventListener('online', emitOnline);
window.addEventListener('offline', emitOffline);
{}
return true;
}})();",
online_script, offline_script, current_status_script
);
window.eval(script)
}
fn payload_string<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
value
.get(field)
@@ -730,6 +813,14 @@ async fn host_bridge_request(
None => failed(request.id, "host_error", "main window not found"),
}
}
"network.status" => {
let network_status =
tauri::async_runtime::spawn_blocking(resolve_desktop_network_status).await;
match network_status {
Ok(status) => ok(request.id, status),
Err(error) => failed(request.id, "host_error", error.to_string()),
}
}
"share.setTarget" => {
let target = request
.payload
@@ -783,6 +874,7 @@ fn main() {
tauri::WebviewWindowBuilder::from_config(app.handle(), &config)?.build()?;
register_desktop_lifecycle_events(&window);
let _ = emit_desktop_lifecycle_event(&window, "active", true, "created");
let _ = register_desktop_network_events(&window);
}
Ok(())
})
@@ -822,6 +914,14 @@ mod tests {
.as_array()
.unwrap()
.contains(&json!("app.lifecycle")));
assert!(result["capabilities"]
.as_array()
.unwrap()
.contains(&json!("network.status")));
assert!(result["capabilities"]
.as_array()
.unwrap()
.contains(&json!("network.statusChanged")));
assert!(result["capabilities"]
.as_array()
.unwrap()
@@ -911,6 +1011,28 @@ mod tests {
assert!(script.contains("\\\"focused\\\":true"));
}
#[test]
fn desktop_network_status_payload_reports_reachability() {
assert_eq!(
desktop_network_status_payload(true),
json!({
"isConnected": true,
"isInternetReachable": true,
"connectionType": "unknown",
"nativeType": "online",
})
);
assert_eq!(
desktop_network_status_payload(false),
json!({
"isConnected": false,
"isInternetReachable": false,
"connectionType": "none",
"nativeType": "offline",
})
);
}
#[test]
fn external_url_normalization_allows_only_safe_protocols() {
assert_eq!(

View File

@@ -6,7 +6,7 @@
"build": {
"beforeDevCommand": "npm --prefix ../.. run dev:web",
"beforeBuildCommand": "npm --prefix ../.. run build:raw && npm run typecheck",
"devUrl": "http://127.0.0.1:3000/?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,clipboard.writeText,file.exportText,file.exportImage",
"devUrl": "http://127.0.0.1:3000/?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,file.exportText,file.exportImage",
"frontendDist": "../../../dist"
},
"app": {
@@ -14,7 +14,7 @@
{
"create": false,
"label": "main",
"url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,clipboard.writeText,file.exportText,file.exportImage",
"url": "index.html?clientRuntime=native_app&clientType=native_app&hostShell=tauri_desktop&hostPlatform=unknown&hostVersion=0.1.0&bridgeVersion=1&hostCapabilities=host.getRuntime,appearance.getColorScheme,app.lifecycle,share.open,share.setTarget,navigation.openNativePage,app.openExternalUrl,app.setTitle,app.setBadgeCount,network.status,network.statusChanged,clipboard.writeText,file.exportText,file.exportImage",
"title": "Genarrative",
"width": 1280,
"height": 820,