桌面 GUI 外链修复 + 主题顺序调整(含结构扁平化进行中的工作区基线)

- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化)
- 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转
- 设置→外观 主题顺序调整为 深色/浅色/跟随系统
- release 二进制重编并验证(浏览器实测关于页链接可开文档页)
- 附带工作区中未提交的扁平化重构现状(另一会话进行中)
- homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
This commit is contained in:
logan committed 2026-10-16 03:03:36 +08:00
1 parent 6452026fd6
commit 2bb4ef7a95
299 files changed
+6059 -3322

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
use crate::{
error::AppError,
models::{HttpResult, HttpSpec, WsSpec},
services::{
http,
websocket::{self, WsState},
},
};
use tauri::{AppHandle, Emitter, State};
#[tauri::command]
pub async fn http_send(spec: HttpSpec) -> Result<HttpResult, AppError> {
http::send(spec).await
}
#[tauri::command]
pub async fn ws_connect(
app: AppHandle,
state: State<'_, WsState>,
spec: WsSpec,
session_id: String,
) -> Result<String, AppError> {
if uuid::Uuid::parse_str(&session_id).is_err() {
return Err(AppError::Validation("Invalid session ID".into()));
}
websocket::connect(&state, spec, session_id, move |event| {
let _ = app.emit("ws-event", event);
})
.await
}
#[tauri::command]
pub async fn ws_send(
state: State<'_, WsState>,
session_id: String,
data: String,
) -> Result<(), AppError> {
websocket::send(&state, session_id, data).await
}
#[tauri::command]
pub async fn ws_close(state: State<'_, WsState>, session_id: String) -> Result<(), AppError> {
websocket::close(&state, session_id).await
}
+35
View File
@@ -0,0 +1,35 @@
use serde::Serialize;
#[derive(Debug, Serialize)]
#[serde(tag = "code", content = "message", rename_all = "SCREAMING_SNAKE_CASE")]
pub enum AppError {
Validation(String),
Network(String),
Timeout(String),
NotFound(String),
Internal(String),
}
impl From<reqwest::Error> for AppError {
fn from(error: reqwest::Error) -> Self {
if error.is_timeout() {
Self::Timeout(error.to_string())
} else if error.is_builder() {
Self::Validation(error.to_string())
} else {
Self::Network(error.to_string())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ipc_error_has_stable_code_and_message() {
assert_eq!(
serde_json::to_value(AppError::Validation("bad URL".into())).unwrap(),
serde_json::json!({"code": "VALIDATION", "message": "bad URL"})
);
}
}
+18
View File
@@ -0,0 +1,18 @@
mod commands;
mod error;
mod models;
mod services;
use commands::*;
use services::websocket::WsState;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(WsState::default())
.invoke_handler(tauri::generate_handler![
http_send, ws_connect, ws_send, ws_close
])
.run(tauri::generate_context!())
.expect("error while running OpsxAPI desktop");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
opsxapi_lib::run();
}
+64
View File
@@ -0,0 +1,64 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BodySpec {
pub mode: String,
pub raw: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpSpec {
pub method: String,
pub url: String,
#[serde(default)]
pub headers: HashMap<String, String>,
pub query: Option<HashMap<String, String>>,
pub body: Option<BodySpec>,
pub timeout_ms: Option<u64>,
pub proxy: Option<ProxySettings>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpResult {
pub status: u16,
pub headers: HashMap<String, String>,
pub body: String,
pub time_ms: u128,
pub size: usize,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WsSpec {
pub url: String,
pub protocols: Option<Vec<String>>,
#[serde(default)]
pub headers: HashMap<String, String>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WsEvent {
pub session_id: String,
pub kind: String,
pub data: String,
}
#[derive(Debug, Deserialize)]
pub struct ProxySettings {
pub mode: String,
pub http: String,
pub https: String,
pub bypass: String,
pub auth: Option<ProxyAuth>,
}
#[derive(Debug, Deserialize)]
pub struct ProxyAuth {
pub username: String,
pub password: String,
}
+53
View File
@@ -0,0 +1,53 @@
use super::validation::validate_http_url;
use crate::{
error::AppError,
models::{HttpResult, HttpSpec},
};
use std::time::{Duration, Instant};
pub async fn send(spec: HttpSpec) -> Result<HttpResult, AppError> {
let mut url = validate_http_url(&spec.url)?;
if let Some(query) = &spec.query {
let mut pairs = url.query_pairs_mut();
for (key, value) in query {
pairs.append_pair(key, value);
}
}
let method = reqwest::Method::from_bytes(spec.method.as_bytes())
.map_err(|e| AppError::Validation(format!("invalid method: {e}")))?;
let timeout = Duration::from_millis(spec.timeout_ms.unwrap_or(30_000));
let builder = super::proxy::configure(
reqwest::Client::builder().timeout(timeout),
spec.proxy.as_ref(),
&url,
)?;
let client = builder.build().map_err(AppError::from)?;
let mut request = client.request(method, url);
for (key, value) in &spec.headers {
request = request.header(key, value);
}
if let Some(body) = spec.body {
if body.mode != "none" {
request = request.body(body.raw.unwrap_or_default());
}
}
let started = Instant::now();
let response = request.send().await.map_err(AppError::from)?;
let status = response.status().as_u16();
let headers = response
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
let bytes = response.bytes().await.map_err(AppError::from)?;
let size = bytes.len();
let body = String::from_utf8_lossy(&bytes).into_owned();
Ok(HttpResult {
status,
headers,
body,
time_ms: started.elapsed().as_millis(),
size,
})
}
+4
View File
@@ -0,0 +1,4 @@
pub mod http;
pub mod proxy;
mod validation;
pub mod websocket;
+127
View File
@@ -0,0 +1,127 @@
use crate::{error::AppError, models::ProxySettings};
fn bypasses(url: &url::Url, rules: &str) -> bool {
let host = url
.host_str()
.unwrap_or("")
.trim_matches(['[', ']'])
.to_ascii_lowercase();
rules
.split(',')
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty())
.any(|rule| {
if rule == "*" || rule == host {
return true;
}
let authority = format!(
"{}:{}",
url.host_str().unwrap_or(""),
url.port_or_known_default().unwrap_or(0)
);
if rule == authority {
return true;
}
rule.strip_prefix("*.")
.map(|domain| host.ends_with(&format!(".{domain}")))
.unwrap_or(false)
})
}
fn proxy_url(address: &str) -> Result<Option<url::Url>, AppError> {
let address = address.trim();
if address.is_empty() {
return Ok(None);
}
let address = if address.contains("://") {
address.to_string()
} else {
format!("http://{address}")
};
let parsed =
url::Url::parse(&address).map_err(|_| AppError::Validation("代理地址无效".into()))?;
if !matches!(parsed.scheme(), "http" | "https")
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.path() != "/"
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return Err(AppError::Validation(
"代理地址仅支持 HTTP/HTTPS 主机与端口".into(),
));
}
Ok(Some(parsed))
}
pub fn configure(
builder: reqwest::ClientBuilder,
settings: Option<&ProxySettings>,
_url: &url::Url,
) -> Result<reqwest::ClientBuilder, AppError> {
let Some(settings) = settings else {
return Ok(builder);
};
match settings.mode.as_str() {
"automatic" => Ok(builder),
"disabled" => Ok(builder.no_proxy()),
"custom" => {
let http = proxy_url(&settings.http)?;
let https = proxy_url(&settings.https)?;
let bypass = settings.bypass.clone();
// Evaluate each destination, including redirects, against the bypass rules.
let mut proxy = reqwest::Proxy::custom(move |url| {
if bypasses(url, &bypass) {
return None;
}
if url.scheme() == "https" {
https.clone()
} else {
http.clone()
}
});
if let Some(auth) = &settings.auth {
proxy = proxy.basic_auth(&auth.username, &auth.password);
}
Ok(builder.no_proxy().proxy(proxy))
}
_ => Err(AppError::Validation("未知代理模式".into())),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_proxy_urls() {
assert!(proxy_url("localhost:7890").unwrap().is_some());
assert!(proxy_url("").unwrap().is_none());
assert!(proxy_url("socks5://localhost:7890").is_err());
assert!(proxy_url("http://user:secret@localhost:7890").is_err());
assert!(proxy_url("http://localhost/path").is_err());
}
#[test]
fn bypass_matches_hosts_without_matching_lookalikes() {
assert!(bypasses(
&url::Url::parse("http://localhost:8787").unwrap(),
"localhost"
));
assert!(bypasses(
&url::Url::parse("https://api.internal.test").unwrap(),
"*.internal.test"
));
assert!(!bypasses(
&url::Url::parse("https://evilinternal.test").unwrap(),
"*.internal.test"
));
assert!(bypasses(
&url::Url::parse("http://[::1]:8787").unwrap(),
"::1"
));
assert!(!bypasses(
&url::Url::parse("http://localhost:8787").unwrap(),
"localhost:9000"
));
}
}
+75
View File
@@ -0,0 +1,75 @@
use crate::error::AppError;
pub(super) fn validate_http_url(raw: &str) -> Result<url::Url, AppError> {
let parsed =
url::Url::parse(raw).map_err(|e| AppError::Validation(format!("invalid URL: {e}")))?;
match parsed.scheme() {
"http" | "https" => {}
_ => {
return Err(AppError::Validation(
"only http/https schemes are allowed".into(),
))
}
}
match parsed
.host_str()
.unwrap_or("")
.to_ascii_lowercase()
.as_str()
{
"169.254.169.254" | "metadata.google.internal" => {
Err(AppError::Validation("blocked metadata host".into()))
}
_ => Ok(parsed),
}
}
pub(super) fn validate_ws_url(raw: &str) -> Result<String, AppError> {
let mut parsed =
url::Url::parse(raw).map_err(|e| AppError::Validation(format!("invalid URL: {e}")))?;
match parsed.scheme() {
"ws" | "wss" => {}
"http" => {
parsed
.set_scheme("ws")
.map_err(|_| AppError::Validation("cannot set ws scheme".into()))?;
}
"https" => {
parsed
.set_scheme("wss")
.map_err(|_| AppError::Validation("cannot set wss scheme".into()))?;
}
_ => {
return Err(AppError::Validation(
"only ws/wss schemes are allowed".into(),
))
}
}
match parsed
.host_str()
.unwrap_or("")
.to_ascii_lowercase()
.as_str()
{
"169.254.169.254" | "metadata.google.internal" => {
return Err(AppError::Validation("blocked metadata host".into()))
}
_ => {}
}
Ok(parsed.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_protocols_and_metadata_hosts() {
assert!(validate_http_url("file:///tmp/test").is_err());
assert!(validate_http_url("http://169.254.169.254/latest").is_err());
assert!(validate_ws_url("ws://metadata.google.internal/").is_err());
assert_eq!(
validate_ws_url("https://example.com/socket").unwrap(),
"wss://example.com/socket"
);
assert!(validate_http_url("http://localhost:8787").is_ok());
}
}
+245
View File
@@ -0,0 +1,245 @@
use super::validation::validate_ws_url;
use crate::{
error::AppError,
models::{WsEvent, WsSpec},
};
use futures_util::{SinkExt, StreamExt};
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::{client::IntoClientRequest, Message};
enum WsCmd {
Send(String),
Close,
}
struct WsHandle {
tx: mpsc::UnboundedSender<WsCmd>,
}
#[derive(Default, Clone)]
pub struct WsState {
sessions: Arc<Mutex<HashMap<String, WsHandle>>>,
}
pub async fn connect(
state: &WsState,
spec: WsSpec,
session_id: String,
event_sink: impl Fn(WsEvent) + Send + Sync + 'static,
) -> Result<String, AppError> {
let url = validate_ws_url(&spec.url)?;
let mut request = url
.into_client_request()
.map_err(|e| AppError::Validation(e.to_string()))?;
for (k, v) in &spec.headers {
request.headers_mut().insert(
http::header::HeaderName::from_bytes(k.as_bytes())
.map_err(|e| AppError::Validation(e.to_string()))?,
v.parse().map_err(|e: http::header::InvalidHeaderValue| {
AppError::Validation(e.to_string())
})?,
);
}
if let Some(protocols) = &spec.protocols {
let joined = protocols
.iter()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(", ");
if !joined.is_empty() {
request.headers_mut().insert(
"Sec-WebSocket-Protocol",
joined
.parse()
.map_err(|e: http::header::InvalidHeaderValue| {
AppError::Validation(e.to_string())
})?,
);
}
}
let (tx, mut rx) = mpsc::unbounded_channel::<WsCmd>();
{
let mut map = state
.sessions
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
map.insert(session_id.clone(), WsHandle { tx });
}
let sid = session_id.clone();
let sessions = state.sessions.clone();
tokio::spawn(async move {
let emit = |kind: &str, data: String| {
event_sink(WsEvent {
session_id: sid.clone(),
kind: kind.to_string(),
data,
});
};
let connection = tokio::select! {
result = tokio::time::timeout(std::time::Duration::from_secs(30), tokio_tungstenite::connect_async(request)) => {
result.map_err(|_| "WebSocket connection timed out".to_string()).and_then(|r| r.map_err(|e| e.to_string()))
}
_ = rx.recv() => {
emit("status", "closed".into());
if let Ok(mut map) = sessions.lock() { map.remove(&sid); }
return;
}
};
match connection {
Ok((ws, _)) => {
emit("status", "open".into());
let (mut write, mut read) = ws.split();
loop {
tokio::select! {
cmd = rx.recv() => {
match cmd {
Some(WsCmd::Send(data)) => {
if write.send(Message::Text(data.into())).await.is_err() {
emit("status", "error".into());
break;
}
}
Some(WsCmd::Close) | None => {
let _ = write.close().await;
break;
}
}
}
msg = read.next() => {
match msg {
Some(Ok(Message::Text(t))) => emit("message", t.to_string()),
Some(Ok(Message::Binary(b))) => {
emit("message", format!("[binary {} bytes]", b.len()))
}
Some(Ok(Message::Close(_))) | None => break,
Some(Ok(_)) => {}
Some(Err(e)) => {
emit("message", format!("[ws error] {e}"));
emit("status", "error".into());
break;
}
}
}
}
}
emit("status", "closed".into());
}
Err(e) => {
emit("message", format!("[ws_connect error] {e}"));
emit("status", "error".into());
emit("status", "closed".into());
}
}
if let Ok(mut map) = sessions.lock() {
map.remove(&sid);
}
});
Ok(session_id)
}
pub async fn send(state: &WsState, session_id: String, data: String) -> Result<(), AppError> {
let map = state
.sessions
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
let handle = map
.get(&session_id)
.ok_or_else(|| AppError::NotFound(format!("unknown ws session {session_id}")))?;
handle
.tx
.send(WsCmd::Send(data))
.map_err(|_| AppError::Network("ws session closed".into()))?;
Ok(())
}
pub async fn close(state: &WsState, session_id: String) -> Result<(), AppError> {
let mut map = state
.sessions
.lock()
.map_err(|e| AppError::Internal(e.to_string()))?;
if let Some(handle) = map.remove(&session_id) {
let _ = handle.tx.send(WsCmd::Close);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn send_and_close_use_shared_session_state() {
let state = WsState::default();
let (tx, mut rx) = mpsc::unbounded_channel();
state
.sessions
.lock()
.unwrap()
.insert("test".into(), WsHandle { tx });
let clone = state.clone();
send(&clone, "test".into(), "hello".into()).await.unwrap();
assert!(matches!(rx.recv().await, Some(WsCmd::Send(data)) if data == "hello"));
close(&state, "test".into()).await.unwrap();
assert!(matches!(rx.recv().await, Some(WsCmd::Close)));
assert!(clone.sessions.lock().unwrap().is_empty());
close(&clone, "test".into()).await.unwrap();
assert!(matches!(
send(&clone, "test".into(), "data".into()).await,
Err(AppError::NotFound(_))
));
}
#[tokio::test]
#[ignore = "requires permission to bind a localhost port"]
async fn connects_sends_and_reclaims_session() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap();
let msg = socket.next().await.unwrap().unwrap();
socket.send(msg).await.unwrap();
socket.close(None).await.unwrap();
});
let state = WsState::default();
let (tx, mut rx) = mpsc::unbounded_channel();
connect(
&state,
WsSpec {
url: format!("ws://{addr}"),
protocols: None,
headers: HashMap::new(),
},
"test".into(),
move |event| {
let _ = tx.send(event);
},
)
.await
.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(5), async {
assert_eq!(rx.recv().await.unwrap().data, "open");
send(&state, "test".into(), "hello".into()).await.unwrap();
assert_eq!(rx.recv().await.unwrap().data, "hello");
assert_eq!(rx.recv().await.unwrap().data, "closed");
server.await.unwrap();
tokio::task::yield_now().await;
assert!(state.sessions.lock().unwrap().is_empty());
close(&state, "test".into()).await.unwrap();
assert!(matches!(
send(&state, "missing".into(), "data".into()).await,
Err(AppError::NotFound(_))
));
})
.await
.unwrap();
}
}