初始化web服务功能
This commit is contained in:
149
src-tauri/Cargo.lock
generated
149
src-tauri/Cargo.lock
generated
@@ -357,6 +357,61 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||
dependencies = [
|
||||
"axum-core",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"form_urlencoded",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -795,6 +850,7 @@ name = "codeg"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"agent-client-protocol-schema",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bzip2",
|
||||
"chrono",
|
||||
@@ -827,6 +883,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml 0.8.2",
|
||||
"tower-http",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
@@ -1154,6 +1211,12 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.10"
|
||||
@@ -2268,12 +2331,24 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
@@ -2288,6 +2363,7 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
@@ -3014,6 +3090,12 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
@@ -3054,6 +3136,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@@ -5247,6 +5339,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -6628,6 +6731,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -6751,6 +6866,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6761,14 +6877,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"iri-string",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6858,6 +6984,23 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -6922,6 +7065,12 @@ dependencies = [
|
||||
"unic-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
|
||||
@@ -29,7 +29,7 @@ dirs = "6"
|
||||
walkdir = "2"
|
||||
sacp = "11.0.0-alpha.1"
|
||||
sacp-tokio = "11.0.0-alpha.1"
|
||||
tokio = { version = "1", features = ["process", "io-util", "sync", "macros", "rt"] }
|
||||
tokio = { version = "1", features = ["process", "io-util", "sync", "macros", "rt", "net"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
futures = "0.3"
|
||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
||||
@@ -50,6 +50,8 @@ agent-client-protocol-schema = { version = "0.10", features = ["unstable_session
|
||||
kill_tree = { version = "0.2", features = ["tokio"] }
|
||||
which = "7"
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "cors"] }
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-window-state = "2"
|
||||
|
||||
@@ -24,7 +24,6 @@ use sacp::{
|
||||
on_receive_request, Agent, Client, ConnectionTo, Responder, SessionMessage, UntypedMessage,
|
||||
};
|
||||
use sacp_tokio::AcpAgent;
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::acp::error::AcpError;
|
||||
@@ -195,7 +194,8 @@ async fn build_agent(
|
||||
.flatten()
|
||||
.is_some();
|
||||
if !has_cached_binary {
|
||||
let _ = app_handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
app_handle,
|
||||
"acp://event",
|
||||
AcpEvent::StatusChanged {
|
||||
connection_id: connection_id.into(),
|
||||
@@ -237,7 +237,8 @@ pub async fn spawn_agent_connection(
|
||||
owner_window_label: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<AgentConnection, AcpError> {
|
||||
let _ = app_handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
&app_handle,
|
||||
"acp://event",
|
||||
AcpEvent::StatusChanged {
|
||||
connection_id: connection_id.clone(),
|
||||
@@ -263,7 +264,8 @@ pub async fn spawn_agent_connection(
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
&handle,
|
||||
"acp://event",
|
||||
AcpEvent::Error {
|
||||
connection_id: conn_id.clone(),
|
||||
@@ -272,7 +274,8 @@ pub async fn spawn_agent_connection(
|
||||
);
|
||||
}
|
||||
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
&handle,
|
||||
"acp://event",
|
||||
AcpEvent::StatusChanged {
|
||||
connection_id: conn_id,
|
||||
@@ -315,7 +318,8 @@ fn emit_session_modes(
|
||||
modes: &Option<SessionModeState>,
|
||||
) {
|
||||
if let Some(mode_state) = modes {
|
||||
let _ = app_handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
app_handle,
|
||||
"acp://event",
|
||||
AcpEvent::SessionModes {
|
||||
connection_id: connection_id.into(),
|
||||
@@ -416,7 +420,8 @@ fn emit_session_config_options_values(
|
||||
config_options: Vec<SessionConfigOption>,
|
||||
) {
|
||||
let mapped = map_session_config_options(&config_options);
|
||||
let _ = app_handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
app_handle,
|
||||
"acp://event",
|
||||
AcpEvent::SessionConfigOptions {
|
||||
connection_id: connection_id.into(),
|
||||
@@ -439,7 +444,8 @@ fn emit_session_config_options(
|
||||
}
|
||||
|
||||
fn emit_selectors_ready(connection_id: &str, app_handle: &tauri::AppHandle) {
|
||||
let _ = app_handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
app_handle,
|
||||
"acp://event",
|
||||
AcpEvent::SelectorsReady {
|
||||
connection_id: connection_id.into(),
|
||||
@@ -452,7 +458,8 @@ fn emit_prompt_capabilities(
|
||||
app_handle: &tauri::AppHandle,
|
||||
capabilities: &sacp::schema::PromptCapabilities,
|
||||
) {
|
||||
let _ = app_handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
app_handle,
|
||||
"acp://event",
|
||||
AcpEvent::PromptCapabilities {
|
||||
connection_id: connection_id.into(),
|
||||
@@ -627,7 +634,8 @@ async fn run_connection(
|
||||
);
|
||||
|
||||
// Emit fork support capability
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
&handle,
|
||||
"acp://event",
|
||||
AcpEvent::ForkSupported {
|
||||
connection_id: conn_id.clone(),
|
||||
@@ -636,7 +644,8 @@ async fn run_connection(
|
||||
);
|
||||
|
||||
// Emit connected status
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
&handle,
|
||||
"acp://event",
|
||||
AcpEvent::StatusChanged {
|
||||
connection_id: conn_id.clone(),
|
||||
@@ -689,7 +698,8 @@ async fn run_connection(
|
||||
eprintln!("[ACP] Drained {drained} historical replay notifications");
|
||||
}
|
||||
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
&handle,
|
||||
"acp://event",
|
||||
AcpEvent::SessionStarted {
|
||||
connection_id: conn_id.clone(),
|
||||
@@ -732,7 +742,8 @@ async fn run_connection(
|
||||
"[ACP] session/load failed ({}), falling back to session/new",
|
||||
e
|
||||
);
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
&handle,
|
||||
"acp://event",
|
||||
AcpEvent::Error {
|
||||
connection_id: conn_id.clone(),
|
||||
@@ -747,7 +758,8 @@ async fn run_connection(
|
||||
let initial_config_options = new_resp.config_options.clone();
|
||||
let mut session =
|
||||
cx.attach_session(new_resp, Default::default())?;
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
&handle,
|
||||
"acp://event",
|
||||
AcpEvent::SessionStarted {
|
||||
connection_id: conn_id.clone(),
|
||||
@@ -799,7 +811,8 @@ async fn run_connection(
|
||||
let sid = new_resp.session_id.0.to_string();
|
||||
let initial_config_options = new_resp.config_options.clone();
|
||||
let mut session = cx.attach_session(new_resp, Default::default())?;
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
&handle,
|
||||
"acp://event",
|
||||
AcpEvent::SessionStarted {
|
||||
connection_id: conn_id.clone(),
|
||||
@@ -870,7 +883,8 @@ async fn handle_permission_request(
|
||||
|
||||
perms.lock().await.insert(request_id.clone(), responder);
|
||||
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
handle,
|
||||
"acp://event",
|
||||
AcpEvent::PermissionRequest {
|
||||
connection_id: conn_id.into(),
|
||||
@@ -914,7 +928,8 @@ async fn set_session_mode(
|
||||
.block_task()
|
||||
.await?;
|
||||
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
handle,
|
||||
"acp://event",
|
||||
AcpEvent::ModeChanged {
|
||||
connection_id: conn_id.into(),
|
||||
@@ -1166,7 +1181,8 @@ fn emit_terminal_output_update(
|
||||
output: String,
|
||||
append: bool,
|
||||
) {
|
||||
let _ = app_handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
app_handle,
|
||||
"acp://event",
|
||||
AcpEvent::ToolCallUpdate {
|
||||
connection_id: connection_id.into(),
|
||||
@@ -1350,7 +1366,8 @@ async fn handle_fork_or_exit(
|
||||
.meta(fork_resp.meta);
|
||||
let mut session = cx.attach_session(new_resp, Default::default())?;
|
||||
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
handle,
|
||||
"acp://event",
|
||||
AcpEvent::SessionStarted {
|
||||
connection_id: conn_id.to_string(),
|
||||
@@ -1430,7 +1447,8 @@ async fn run_conversation_loop<'a>(
|
||||
Some(ConnectionCommand::Prompt { blocks }) => {
|
||||
let prompt_blocks = map_prompt_blocks(blocks);
|
||||
if prompt_blocks.is_empty() {
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
handle,
|
||||
"acp://event",
|
||||
AcpEvent::Error {
|
||||
connection_id: conn_id.into(),
|
||||
@@ -1440,7 +1458,8 @@ async fn run_conversation_loop<'a>(
|
||||
continue;
|
||||
}
|
||||
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
handle,
|
||||
"acp://event",
|
||||
AcpEvent::StatusChanged {
|
||||
connection_id: conn_id.into(),
|
||||
@@ -1532,7 +1551,8 @@ async fn run_conversation_loop<'a>(
|
||||
StopReason::Cancelled => "cancelled",
|
||||
_ => "unknown",
|
||||
};
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
handle,
|
||||
"acp://event",
|
||||
AcpEvent::TurnComplete {
|
||||
connection_id: conn_id.into(),
|
||||
@@ -1562,7 +1582,8 @@ async fn run_conversation_loop<'a>(
|
||||
StopReason::Cancelled => "cancelled",
|
||||
_ => "unknown",
|
||||
};
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
handle,
|
||||
"acp://event",
|
||||
AcpEvent::TurnComplete {
|
||||
connection_id: conn_id.into(),
|
||||
@@ -1599,7 +1620,8 @@ async fn run_conversation_loop<'a>(
|
||||
let req = SetSessionModeRequest::new(sid.clone(), mode_id.clone());
|
||||
match cx.send_request_to(Agent, req).block_task().await {
|
||||
Ok(_) => {
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
handle,
|
||||
"acp://event",
|
||||
AcpEvent::ModeChanged {
|
||||
connection_id: conn_id.into(),
|
||||
@@ -1608,7 +1630,8 @@ async fn run_conversation_loop<'a>(
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
handle,
|
||||
"acp://event",
|
||||
AcpEvent::Error {
|
||||
connection_id: conn_id.into(),
|
||||
@@ -1632,7 +1655,8 @@ async fn run_conversation_loop<'a>(
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
handle,
|
||||
"acp://event",
|
||||
AcpEvent::Error {
|
||||
connection_id: conn_id.into(),
|
||||
@@ -1665,7 +1689,8 @@ async fn run_conversation_loop<'a>(
|
||||
// transitions out of "prompting" and the user can
|
||||
// send new messages. Don't wait for the agent —
|
||||
// it may be slow to respond or not respond at all.
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
handle,
|
||||
"acp://event",
|
||||
AcpEvent::TurnComplete {
|
||||
connection_id: conn_id.into(),
|
||||
@@ -1715,7 +1740,8 @@ async fn run_conversation_loop<'a>(
|
||||
break;
|
||||
}
|
||||
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
handle,
|
||||
"acp://event",
|
||||
AcpEvent::StatusChanged {
|
||||
connection_id: conn_id.into(),
|
||||
@@ -1736,7 +1762,8 @@ async fn run_conversation_loop<'a>(
|
||||
}
|
||||
Some(ConnectionCommand::SetMode { mode_id }) => {
|
||||
if let Err(e) = set_session_mode(session, conn_id, handle, mode_id).await {
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
handle,
|
||||
"acp://event",
|
||||
AcpEvent::Error {
|
||||
connection_id: conn_id.into(),
|
||||
@@ -1754,7 +1781,8 @@ async fn run_conversation_loop<'a>(
|
||||
if let Err(e) =
|
||||
set_session_config_option(&cx, &sid, conn_id, handle, config_id, value_id).await
|
||||
{
|
||||
let _ = handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
handle,
|
||||
"acp://event",
|
||||
AcpEvent::Error {
|
||||
connection_id: conn_id.into(),
|
||||
@@ -1908,7 +1936,8 @@ fn emit_conversation_update(
|
||||
content: ContentBlock::Text(text),
|
||||
..
|
||||
}) => {
|
||||
let _ = app_handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
app_handle,
|
||||
"acp://event",
|
||||
AcpEvent::ContentDelta {
|
||||
connection_id: connection_id.into(),
|
||||
@@ -1923,7 +1952,8 @@ fn emit_conversation_update(
|
||||
content: ContentBlock::Text(text),
|
||||
..
|
||||
}) => {
|
||||
let _ = app_handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
app_handle,
|
||||
"acp://event",
|
||||
AcpEvent::Thinking {
|
||||
connection_id: connection_id.into(),
|
||||
@@ -1938,7 +1968,8 @@ fn emit_conversation_update(
|
||||
let content = serialize_tool_call_content(&tc.content);
|
||||
let raw_input = json_value_to_text(&tc.raw_input);
|
||||
let raw_output = json_value_to_text(&tc.raw_output);
|
||||
let _ = app_handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
app_handle,
|
||||
"acp://event",
|
||||
AcpEvent::ToolCall {
|
||||
connection_id: connection_id.into(),
|
||||
@@ -1960,7 +1991,8 @@ fn emit_conversation_update(
|
||||
.and_then(serialize_tool_call_content);
|
||||
let raw_input = json_value_to_text(&tcu.fields.raw_input);
|
||||
let raw_output = json_value_to_text(&tcu.fields.raw_output);
|
||||
let _ = app_handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
app_handle,
|
||||
"acp://event",
|
||||
AcpEvent::ToolCallUpdate {
|
||||
connection_id: connection_id.into(),
|
||||
@@ -1975,7 +2007,8 @@ fn emit_conversation_update(
|
||||
);
|
||||
}
|
||||
SessionUpdate::CurrentModeUpdate(update) => {
|
||||
let _ = app_handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
app_handle,
|
||||
"acp://event",
|
||||
AcpEvent::ModeChanged {
|
||||
connection_id: connection_id.into(),
|
||||
@@ -1984,7 +2017,8 @@ fn emit_conversation_update(
|
||||
);
|
||||
}
|
||||
SessionUpdate::Plan(plan) => {
|
||||
let _ = app_handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
app_handle,
|
||||
"acp://event",
|
||||
AcpEvent::PlanUpdate {
|
||||
connection_id: connection_id.into(),
|
||||
@@ -2011,7 +2045,8 @@ fn emit_conversation_update(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let _ = app_handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
app_handle,
|
||||
"acp://event",
|
||||
AcpEvent::AvailableCommands {
|
||||
connection_id: connection_id.into(),
|
||||
@@ -2020,7 +2055,8 @@ fn emit_conversation_update(
|
||||
);
|
||||
}
|
||||
SessionUpdate::UsageUpdate(update) => {
|
||||
let _ = app_handle.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
app_handle,
|
||||
"acp://event",
|
||||
AcpEvent::UsageUpdate {
|
||||
connection_id: connection_id.into(),
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{Emitter, State};
|
||||
use tauri::State;
|
||||
|
||||
use crate::acp::binary_cache;
|
||||
use crate::acp::error::AcpError;
|
||||
@@ -32,7 +32,8 @@ fn emit_acp_agents_updated(
|
||||
reason: &'static str,
|
||||
agent_type: Option<AgentType>,
|
||||
) {
|
||||
let _ = app.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
app,
|
||||
ACP_AGENTS_UPDATED_EVENT,
|
||||
AcpAgentsUpdatedEventPayload { reason, agent_type },
|
||||
);
|
||||
|
||||
@@ -459,6 +459,60 @@ fn compute_stats(all_conversations: &[ConversationSummary]) -> AgentStats {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public helpers for the embedded web server ──
|
||||
|
||||
pub async fn list_conversations_for_web(
|
||||
agent_type: Option<AgentType>,
|
||||
search: Option<String>,
|
||||
sort_by: Option<String>,
|
||||
folder_path: Option<String>,
|
||||
) -> Result<Vec<ConversationSummary>, AppCommandError> {
|
||||
tokio::task::spawn_blocking(move || list_conversations_sync(agent_type, search, sort_by, folder_path))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppCommandError::task_execution_failed("Failed to list conversations")
|
||||
.with_detail(e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_folders_for_web() -> Result<Vec<FolderInfo>, AppCommandError> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let all = list_conversations_sync(None, None, None, None);
|
||||
compute_folders(&all)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppCommandError::task_execution_failed("Failed to list folders").with_detail(e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_stats_for_web() -> Result<AgentStats, AppCommandError> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let all = list_conversations_sync(None, None, None, None);
|
||||
compute_stats(&all)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppCommandError::task_execution_failed("Failed to compute stats")
|
||||
.with_detail(e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_sidebar_data_for_web() -> Result<SidebarData, AppCommandError> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let all = list_conversations_sync(None, None, None, None);
|
||||
SidebarData {
|
||||
folders: compute_folders(&all),
|
||||
stats: compute_stats(&all),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppCommandError::task_execution_failed("Failed to build sidebar data")
|
||||
.with_detail(e.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_error_to_app_error(error: ParseError) -> AppCommandError {
|
||||
match error {
|
||||
ParseError::ConversationNotFound(id) => {
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant, UNIX_EPOCH};
|
||||
use base64::Engine as _;
|
||||
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use serde::Serialize;
|
||||
use tauri::Emitter;
|
||||
|
||||
use tokio::sync::Semaphore;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
@@ -985,7 +985,8 @@ pub async fn git_push(
|
||||
.strip_prefix("push-")
|
||||
.and_then(|value| value.parse::<i32>().ok())
|
||||
{
|
||||
let _ = app.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
&app,
|
||||
"folder://git-push-succeeded",
|
||||
GitPushSucceededEvent {
|
||||
folder_id,
|
||||
@@ -1552,7 +1553,8 @@ pub async fn git_commit(
|
||||
.strip_prefix("commit-")
|
||||
.and_then(|value| value.parse::<i32>().ok())
|
||||
{
|
||||
let _ = app.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
&app,
|
||||
"folder://git-commit-succeeded",
|
||||
GitCommitSucceededEvent {
|
||||
folder_id,
|
||||
@@ -2384,7 +2386,7 @@ impl WatchEventBatch {
|
||||
full_reload: self.overflowed,
|
||||
};
|
||||
|
||||
let _ = app.emit("folder://file-tree-changed", payload);
|
||||
crate::web::event_bridge::emit_event(app, "folder://file-tree-changed", payload);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use sea_orm::DatabaseConnection;
|
||||
use tauri::{Emitter, State};
|
||||
use tauri::State;
|
||||
|
||||
use crate::app_error::AppCommandError;
|
||||
use crate::db::service::app_metadata_service;
|
||||
@@ -130,7 +130,7 @@ pub async fn update_system_language_settings(
|
||||
.await
|
||||
.map_err(AppCommandError::from)?;
|
||||
|
||||
let _ = app.emit(LANGUAGE_SETTINGS_UPDATED_EVENT, &settings);
|
||||
crate::web::event_bridge::emit_event(&app, LANGUAGE_SETTINGS_UPDATED_EVENT, settings.clone());
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
use crate::app_error::AppCommandError;
|
||||
use crate::db::AppDatabase;
|
||||
@@ -534,7 +534,8 @@ pub async fn cleanup_dangling_merge(app: &AppHandle, merge_window_label: &str) {
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let _ = app.emit(
|
||||
crate::web::event_bridge::emit_event(
|
||||
app,
|
||||
"folder://merge-aborted",
|
||||
serde_json::json!({ "folder_id": folder_id }),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ mod network;
|
||||
mod parsers;
|
||||
mod process;
|
||||
mod terminal;
|
||||
mod web;
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
@@ -46,6 +47,8 @@ pub fn run() {
|
||||
.manage(windows::SettingsWindowState::new())
|
||||
.manage(windows::CommitWindowState::new())
|
||||
.manage(windows::MergeWindowState::new())
|
||||
.manage(web::WebServerState::new())
|
||||
.manage(web::event_bridge::WebEventBroadcaster::new())
|
||||
.setup(|app| {
|
||||
let app_data_dir = app.path().app_data_dir()?;
|
||||
let app_version = env!("CARGO_PKG_VERSION");
|
||||
@@ -322,12 +325,19 @@ pub fn run() {
|
||||
mcp_commands::mcp_set_server_apps,
|
||||
mcp_commands::mcp_remove_server,
|
||||
notification::send_notification,
|
||||
web::start_web_server,
|
||||
web::stop_web_server,
|
||||
web::get_web_server_status,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app, event| {
|
||||
if let tauri::RunEvent::ExitRequested { .. } = event {
|
||||
APP_QUITTING.store(true, Ordering::Relaxed);
|
||||
// Stop the embedded web server if running.
|
||||
if let Some(ws) = app.try_state::<web::WebServerState>() {
|
||||
let _ = tauri::async_runtime::block_on(web::stop_web_server(ws));
|
||||
}
|
||||
// Kill all terminal sessions to prevent orphaned processes.
|
||||
if let Some(tm) = app.try_state::<TerminalManager>() {
|
||||
tm.kill_all();
|
||||
|
||||
@@ -6,7 +6,6 @@ use std::sync::mpsc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
|
||||
use tauri::Emitter;
|
||||
|
||||
use super::error::TerminalError;
|
||||
use super::types::{TerminalEvent, TerminalInfo};
|
||||
@@ -408,7 +407,7 @@ fn read_loop(
|
||||
terminal_id: terminal_id.clone(),
|
||||
data,
|
||||
};
|
||||
let _ = app_handle.emit(&output_event, &event);
|
||||
crate::web::event_bridge::emit_event(app_handle, &output_event, event.clone());
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
@@ -428,5 +427,5 @@ fn emit_terminal_exit_event(app_handle: &tauri::AppHandle, terminal_id: &str) {
|
||||
terminal_id: terminal_id.to_string(),
|
||||
data: String::new(),
|
||||
};
|
||||
let _ = app_handle.emit(&exit_event, &event);
|
||||
crate::web::event_bridge::emit_event(app_handle, &exit_event, event.clone());
|
||||
}
|
||||
|
||||
33
src-tauri/src/web/auth.rs
Normal file
33
src-tauri/src/web/auth.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use axum::{
|
||||
extract::Request,
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthToken(pub String);
|
||||
|
||||
pub async fn require_token(
|
||||
request: Request,
|
||||
next: Next,
|
||||
token: String,
|
||||
) -> Response {
|
||||
// Allow WebSocket upgrade requests to authenticate via query param
|
||||
if let Some(query) = request.uri().query() {
|
||||
if query.contains(&format!("token={}", token)) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Check Authorization header
|
||||
if let Some(auth_header) = request.headers().get("authorization") {
|
||||
if let Ok(auth_str) = auth_header.to_str() {
|
||||
if auth_str.strip_prefix("Bearer ").map_or(false, |t| t == token) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(StatusCode::UNAUTHORIZED, "Invalid or missing token").into_response()
|
||||
}
|
||||
52
src-tauri/src/web/event_bridge.rs
Normal file
52
src-tauri/src/web/event_bridge.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use serde::Serialize;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct WebEvent {
|
||||
pub channel: String,
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
|
||||
pub struct WebEventBroadcaster {
|
||||
sender: broadcast::Sender<WebEvent>,
|
||||
}
|
||||
|
||||
impl WebEventBroadcaster {
|
||||
pub fn new() -> Self {
|
||||
let (sender, _) = broadcast::channel(4096);
|
||||
Self { sender }
|
||||
}
|
||||
|
||||
pub fn send(&self, channel: &str, payload: &impl Serialize) {
|
||||
if self.sender.receiver_count() == 0 {
|
||||
return;
|
||||
}
|
||||
if let Ok(value) = serde_json::to_value(payload) {
|
||||
let _ = self.sender.send(WebEvent {
|
||||
channel: channel.to_string(),
|
||||
payload: value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<WebEvent> {
|
||||
self.sender.subscribe()
|
||||
}
|
||||
|
||||
pub fn has_subscribers(&self) -> bool {
|
||||
self.sender.receiver_count() > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified event emission: sends to both Tauri webview and Web clients.
|
||||
pub fn emit_event(
|
||||
app: &tauri::AppHandle,
|
||||
event: &str,
|
||||
payload: impl Serialize + Clone,
|
||||
) {
|
||||
use tauri::{Emitter, Manager};
|
||||
let _ = app.emit(event, payload.clone());
|
||||
if let Some(web) = app.try_state::<WebEventBroadcaster>() {
|
||||
web.send(event, &payload);
|
||||
}
|
||||
}
|
||||
3
src-tauri/src/web/handlers/acp.rs
Normal file
3
src-tauri/src/web/handlers/acp.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// ACP (Agent Communication Protocol) web handlers.
|
||||
// TODO: Implement ACP handlers for web mode.
|
||||
// These require special handling for connection lifecycle and streaming events.
|
||||
264
src-tauri/src/web/handlers/conversations.rs
Normal file
264
src-tauri/src/web/handlers/conversations.rs
Normal file
@@ -0,0 +1,264 @@
|
||||
use axum::{extract::Extension, Json};
|
||||
use serde::Deserialize;
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::app_error::AppCommandError;
|
||||
use crate::db::service::{conversation_service, folder_service, import_service};
|
||||
use crate::db::AppDatabase;
|
||||
use crate::models::*;
|
||||
use crate::parsers::claude::ClaudeParser;
|
||||
use crate::parsers::codex::CodexParser;
|
||||
use crate::parsers::gemini::GeminiParser;
|
||||
use crate::parsers::openclaw::OpenClawParser;
|
||||
use crate::parsers::opencode::OpenCodeParser;
|
||||
use crate::parsers::AgentParser;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListFolderConversationsParams {
|
||||
pub folder_id: i32,
|
||||
pub agent_type: Option<AgentType>,
|
||||
pub search: Option<String>,
|
||||
pub sort_by: Option<String>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_folder_conversations(
|
||||
Extension(app): Extension<tauri::AppHandle>,
|
||||
Json(params): Json<ListFolderConversationsParams>,
|
||||
) -> Result<Json<Vec<DbConversationSummary>>, AppCommandError> {
|
||||
let db = app.state::<AppDatabase>();
|
||||
let result = conversation_service::list_by_folder(
|
||||
&db.conn,
|
||||
params.folder_id,
|
||||
params.agent_type,
|
||||
params.search,
|
||||
params.sort_by,
|
||||
params.status,
|
||||
)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ListConversationsParams {
|
||||
pub agent_type: Option<AgentType>,
|
||||
pub search: Option<String>,
|
||||
pub sort_by: Option<String>,
|
||||
pub folder_path: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_conversations(
|
||||
Json(params): Json<ListConversationsParams>,
|
||||
) -> Result<Json<Vec<ConversationSummary>>, AppCommandError> {
|
||||
let result = crate::commands::conversations::list_conversations_for_web(
|
||||
params.agent_type,
|
||||
params.search,
|
||||
params.sort_by,
|
||||
params.folder_path,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetConversationParams {
|
||||
pub agent_type: AgentType,
|
||||
pub conversation_id: String,
|
||||
}
|
||||
|
||||
pub async fn get_conversation(
|
||||
Json(params): Json<GetConversationParams>,
|
||||
) -> Result<Json<ConversationDetail>, AppCommandError> {
|
||||
let at = params.agent_type;
|
||||
let cid = params.conversation_id;
|
||||
let result = tokio::task::spawn_blocking(move || -> Result<ConversationDetail, AppCommandError> {
|
||||
let parser: Box<dyn AgentParser> = match at {
|
||||
AgentType::ClaudeCode => Box::new(ClaudeParser::new()),
|
||||
AgentType::Codex => Box::new(CodexParser::new()),
|
||||
AgentType::OpenCode => Box::new(OpenCodeParser::new()),
|
||||
AgentType::Gemini => Box::new(GeminiParser::new()),
|
||||
AgentType::OpenClaw => Box::new(OpenClawParser::new()),
|
||||
};
|
||||
parser
|
||||
.get_conversation(&cid)
|
||||
.map_err(|e| AppCommandError::not_found("Conversation not found").with_detail(e.to_string()))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppCommandError::task_execution_failed("Failed to load conversation")
|
||||
.with_detail(e.to_string())
|
||||
})??;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetFolderConversationParams {
|
||||
pub conversation_id: i32,
|
||||
}
|
||||
|
||||
pub async fn get_folder_conversation(
|
||||
Extension(app): Extension<tauri::AppHandle>,
|
||||
Json(params): Json<GetFolderConversationParams>,
|
||||
) -> Result<Json<DbConversationDetail>, AppCommandError> {
|
||||
let db = app.state::<AppDatabase>();
|
||||
let summary = conversation_service::get_by_id(&db.conn, params.conversation_id)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?;
|
||||
|
||||
let (turns, session_stats, _resolved_ext_id) = if let Some(ref ext_id) = summary.external_id {
|
||||
let at = summary.agent_type;
|
||||
let eid = ext_id.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<_, AppCommandError> {
|
||||
let parser: Box<dyn AgentParser> = match at {
|
||||
AgentType::ClaudeCode => Box::new(ClaudeParser::new()),
|
||||
AgentType::Codex => Box::new(CodexParser::new()),
|
||||
AgentType::OpenCode => Box::new(OpenCodeParser::new()),
|
||||
AgentType::Gemini => Box::new(GeminiParser::new()),
|
||||
AgentType::OpenClaw => Box::new(OpenClawParser::new()),
|
||||
};
|
||||
match parser.get_conversation(&eid) {
|
||||
Ok(d) => Ok((d.turns, d.session_stats, None::<String>)),
|
||||
Err(_) => Ok((vec![], None, None)),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppCommandError::task_execution_failed("Failed to read conversation turns")
|
||||
.with_detail(e.to_string())
|
||||
})??
|
||||
} else {
|
||||
(vec![], None, None)
|
||||
};
|
||||
|
||||
let mut summary = summary;
|
||||
summary.message_count = turns.len() as u32;
|
||||
|
||||
Ok(Json(DbConversationDetail {
|
||||
summary,
|
||||
turns,
|
||||
session_stats,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn list_folders() -> Result<Json<Vec<FolderInfo>>, AppCommandError> {
|
||||
let result = crate::commands::conversations::list_folders_for_web().await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn get_stats() -> Result<Json<AgentStats>, AppCommandError> {
|
||||
let result = crate::commands::conversations::get_stats_for_web().await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn get_sidebar_data() -> Result<Json<SidebarData>, AppCommandError> {
|
||||
let result = crate::commands::conversations::get_sidebar_data_for_web().await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportLocalConversationsParams {
|
||||
pub folder_id: i32,
|
||||
}
|
||||
|
||||
pub async fn import_local_conversations(
|
||||
Extension(app): Extension<tauri::AppHandle>,
|
||||
Json(params): Json<ImportLocalConversationsParams>,
|
||||
) -> Result<Json<ImportResult>, AppCommandError> {
|
||||
let db = app.state::<AppDatabase>();
|
||||
let folder = folder_service::get_folder_by_id(&db.conn, params.folder_id)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?
|
||||
.ok_or_else(|| AppCommandError::not_found("Folder not found"))?;
|
||||
let result = import_service::import_local_conversations(&db.conn, params.folder_id, &folder.path)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateConversationParams {
|
||||
pub folder_id: i32,
|
||||
pub agent_type: AgentType,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_conversation(
|
||||
Extension(app): Extension<tauri::AppHandle>,
|
||||
Json(params): Json<CreateConversationParams>,
|
||||
) -> Result<Json<i32>, AppCommandError> {
|
||||
let db = app.state::<AppDatabase>();
|
||||
let model = conversation_service::create(
|
||||
&db.conn,
|
||||
params.folder_id,
|
||||
params.agent_type,
|
||||
params.title,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?;
|
||||
Ok(Json(model.id))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateConversationStatusParams {
|
||||
pub conversation_id: i32,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
pub async fn update_conversation_status(
|
||||
Extension(app): Extension<tauri::AppHandle>,
|
||||
Json(params): Json<UpdateConversationStatusParams>,
|
||||
) -> Result<Json<()>, AppCommandError> {
|
||||
let db = app.state::<AppDatabase>();
|
||||
let status_enum: crate::db::entities::conversation::ConversationStatus =
|
||||
serde_json::from_value(serde_json::Value::String(params.status)).map_err(|e| {
|
||||
AppCommandError::invalid_input("Invalid conversation status").with_detail(e.to_string())
|
||||
})?;
|
||||
conversation_service::update_status(&db.conn, params.conversation_id, status_enum)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateConversationTitleParams {
|
||||
pub conversation_id: i32,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
pub async fn update_conversation_title(
|
||||
Extension(app): Extension<tauri::AppHandle>,
|
||||
Json(params): Json<UpdateConversationTitleParams>,
|
||||
) -> Result<Json<()>, AppCommandError> {
|
||||
let db = app.state::<AppDatabase>();
|
||||
conversation_service::update_title(&db.conn, params.conversation_id, params.title)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteConversationParams {
|
||||
pub conversation_id: i32,
|
||||
}
|
||||
|
||||
pub async fn delete_conversation(
|
||||
Extension(app): Extension<tauri::AppHandle>,
|
||||
Json(params): Json<DeleteConversationParams>,
|
||||
) -> Result<Json<()>, AppCommandError> {
|
||||
let db = app.state::<AppDatabase>();
|
||||
conversation_service::soft_delete(&db.conn, params.conversation_id)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
29
src-tauri/src/web/handlers/error.rs
Normal file
29
src-tauri/src/web/handlers/error.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
|
||||
use crate::app_error::{AppCommandError, AppErrorCode};
|
||||
|
||||
impl IntoResponse for AppCommandError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = match self.code {
|
||||
AppErrorCode::InvalidInput => StatusCode::BAD_REQUEST,
|
||||
AppErrorCode::NotFound => StatusCode::NOT_FOUND,
|
||||
AppErrorCode::AlreadyExists => StatusCode::CONFLICT,
|
||||
AppErrorCode::PermissionDenied => StatusCode::FORBIDDEN,
|
||||
AppErrorCode::AuthenticationFailed => StatusCode::UNAUTHORIZED,
|
||||
AppErrorCode::ConfigurationMissing
|
||||
| AppErrorCode::ConfigurationInvalid
|
||||
| AppErrorCode::DependencyMissing => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
AppErrorCode::NetworkError
|
||||
| AppErrorCode::DatabaseError
|
||||
| AppErrorCode::IoError
|
||||
| AppErrorCode::ExternalCommandFailed
|
||||
| AppErrorCode::WindowOperationFailed
|
||||
| AppErrorCode::TaskExecutionFailed => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
(status, Json(self)).into_response()
|
||||
}
|
||||
}
|
||||
2
src-tauri/src/web/handlers/folder_commands.rs
Normal file
2
src-tauri/src/web/handlers/folder_commands.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// Folder commands web handlers.
|
||||
// TODO: Implement folder command CRUD handlers for web mode.
|
||||
58
src-tauri/src/web/handlers/folders.rs
Normal file
58
src-tauri/src/web/handlers/folders.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use axum::{extract::Extension, Json};
|
||||
use serde::Deserialize;
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::app_error::AppCommandError;
|
||||
use crate::db::service::folder_service;
|
||||
use crate::db::AppDatabase;
|
||||
use crate::models::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FolderIdParams {
|
||||
pub folder_id: i32,
|
||||
}
|
||||
|
||||
pub async fn load_folder_history(
|
||||
Extension(app): Extension<tauri::AppHandle>,
|
||||
) -> Result<Json<Vec<FolderHistoryEntry>>, AppCommandError> {
|
||||
let db = app.state::<AppDatabase>();
|
||||
let result = folder_service::list_folders(&db.conn)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn get_folder(
|
||||
Extension(app): Extension<tauri::AppHandle>,
|
||||
Json(params): Json<FolderIdParams>,
|
||||
) -> Result<Json<FolderDetail>, AppCommandError> {
|
||||
let db = app.state::<AppDatabase>();
|
||||
let folder = folder_service::get_folder_by_id(&db.conn, params.folder_id)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?
|
||||
.ok_or_else(|| AppCommandError::not_found("Folder not found"))?;
|
||||
Ok(Json(folder))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AddFolderParams {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Web equivalent of `open_folder_window`: adds the folder to DB and returns its ID.
|
||||
/// The web client then navigates to `/folder?id=N` itself.
|
||||
pub async fn open_folder_window(
|
||||
Extension(app): Extension<tauri::AppHandle>,
|
||||
Json(params): Json<AddFolderParams>,
|
||||
) -> Result<Json<FolderHistoryEntry>, AppCommandError> {
|
||||
let db = app.state::<AppDatabase>();
|
||||
let entry = folder_service::add_folder(&db.conn, ¶ms.path)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?;
|
||||
Ok(Json(entry))
|
||||
}
|
||||
|
||||
// TODO: Add remaining folder handlers (git operations, file operations, etc.)
|
||||
// These will be added incrementally as needed.
|
||||
2
src-tauri/src/web/handlers/mcp.rs
Normal file
2
src-tauri/src/web/handlers/mcp.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// MCP (Model Context Protocol) web handlers.
|
||||
// TODO: Implement MCP marketplace and server management handlers for web mode.
|
||||
9
src-tauri/src/web/handlers/mod.rs
Normal file
9
src-tauri/src/web/handlers/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod error;
|
||||
pub mod conversations;
|
||||
pub mod folders;
|
||||
pub mod acp;
|
||||
pub mod terminal;
|
||||
pub mod system_settings;
|
||||
pub mod version_control;
|
||||
pub mod folder_commands;
|
||||
pub mod mcp;
|
||||
38
src-tauri/src/web/handlers/system_settings.rs
Normal file
38
src-tauri/src/web/handlers/system_settings.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use axum::{extract::Extension, Json};
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::app_error::AppCommandError;
|
||||
use crate::db::service::app_metadata_service;
|
||||
use crate::db::AppDatabase;
|
||||
use crate::models::*;
|
||||
|
||||
const SYSTEM_PROXY_SETTINGS_KEY: &str = "system_proxy_settings";
|
||||
const SYSTEM_LANGUAGE_SETTINGS_KEY: &str = "system_language_settings";
|
||||
|
||||
pub async fn get_system_proxy_settings(
|
||||
Extension(app): Extension<tauri::AppHandle>,
|
||||
) -> Result<Json<SystemProxySettings>, AppCommandError> {
|
||||
let db = app.state::<AppDatabase>();
|
||||
let raw = app_metadata_service::get_value(&db.conn, SYSTEM_PROXY_SETTINGS_KEY)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?;
|
||||
|
||||
let settings = raw
|
||||
.and_then(|v| serde_json::from_str::<SystemProxySettings>(&v).ok())
|
||||
.unwrap_or_default();
|
||||
Ok(Json(settings))
|
||||
}
|
||||
|
||||
pub async fn get_system_language_settings(
|
||||
Extension(app): Extension<tauri::AppHandle>,
|
||||
) -> Result<Json<SystemLanguageSettings>, AppCommandError> {
|
||||
let db = app.state::<AppDatabase>();
|
||||
let raw = app_metadata_service::get_value(&db.conn, SYSTEM_LANGUAGE_SETTINGS_KEY)
|
||||
.await
|
||||
.map_err(AppCommandError::from)?;
|
||||
|
||||
let settings = raw
|
||||
.and_then(|v| serde_json::from_str::<SystemLanguageSettings>(&v).ok())
|
||||
.unwrap_or_default();
|
||||
Ok(Json(settings))
|
||||
}
|
||||
3
src-tauri/src/web/handlers/terminal.rs
Normal file
3
src-tauri/src/web/handlers/terminal.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
// Terminal web handlers.
|
||||
// TODO: Implement terminal handlers for web mode.
|
||||
// Terminal I/O streams over WebSocket instead of Tauri events.
|
||||
2
src-tauri/src/web/handlers/version_control.rs
Normal file
2
src-tauri/src/web/handlers/version_control.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
// Version control web handlers.
|
||||
// TODO: Implement git settings and GitHub account handlers for web mode.
|
||||
189
src-tauri/src/web/mod.rs
Normal file
189
src-tauri/src/web/mod.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
pub mod auth;
|
||||
pub mod event_bridge;
|
||||
pub mod handlers;
|
||||
pub mod router;
|
||||
pub mod ws;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::app_error::{AppCommandError, AppErrorCode};
|
||||
|
||||
pub struct WebServerState {
|
||||
handle: Mutex<Option<tauri::async_runtime::JoinHandle<()>>>,
|
||||
port: AtomicU16,
|
||||
token: Mutex<String>,
|
||||
running: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl WebServerState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
handle: Mutex::new(None),
|
||||
port: AtomicU16::new(0),
|
||||
token: Mutex::new(String::new()),
|
||||
running: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebServerInfo {
|
||||
pub port: u16,
|
||||
pub token: String,
|
||||
pub addresses: Vec<String>,
|
||||
}
|
||||
|
||||
fn generate_random_token() -> String {
|
||||
uuid::Uuid::new_v4().to_string().replace('-', "")
|
||||
}
|
||||
|
||||
fn find_static_dir(app: &tauri::AppHandle) -> PathBuf {
|
||||
// 1. Production: Tauri bundles frontendDist into the resource directory.
|
||||
let resource = app.path().resource_dir().ok();
|
||||
if let Some(ref dir) = resource {
|
||||
// In production builds, the HTML files are at the resource root.
|
||||
if dir.join("index.html").exists() {
|
||||
eprintln!("[WEB] Serving static files from resource dir: {}", dir.display());
|
||||
return dir.clone();
|
||||
}
|
||||
// Or possibly in an "out" subdirectory.
|
||||
let out = dir.join("out");
|
||||
if out.join("index.html").exists() {
|
||||
eprintln!("[WEB] Serving static files from resource/out: {}", out.display());
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Dev mode: "out/" is at the project root, which is one level above src-tauri/.
|
||||
// The Cargo manifest dir at compile time gives us the src-tauri/ path.
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let project_out = manifest_dir.parent().map(|p| p.join("out"));
|
||||
if let Some(ref out) = project_out {
|
||||
if out.join("index.html").exists() {
|
||||
eprintln!("[WEB] Serving static files from project out/: {}", out.display());
|
||||
return out.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: current working directory / out
|
||||
let cwd_out = std::env::current_dir()
|
||||
.map(|d| d.join("out"))
|
||||
.unwrap_or_else(|_| PathBuf::from("out"));
|
||||
eprintln!(
|
||||
"[WEB] Fallback static dir (may not exist): {}",
|
||||
cwd_out.display()
|
||||
);
|
||||
cwd_out
|
||||
}
|
||||
|
||||
fn get_local_addresses(port: u16) -> Vec<String> {
|
||||
let mut addrs = vec![format!("http://127.0.0.1:{}", port)];
|
||||
// Try to get LAN IPs
|
||||
if let Ok(interfaces) = std::net::UdpSocket::bind("0.0.0.0:0") {
|
||||
// Connect to a public DNS to determine local IP
|
||||
if interfaces.connect("8.8.8.8:80").is_ok() {
|
||||
if let Ok(local_addr) = interfaces.local_addr() {
|
||||
addrs.push(format!("http://{}:{}", local_addr.ip(), port));
|
||||
}
|
||||
}
|
||||
}
|
||||
addrs
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_web_server(
|
||||
app: tauri::AppHandle,
|
||||
state: tauri::State<'_, WebServerState>,
|
||||
port: Option<u16>,
|
||||
host: Option<String>,
|
||||
) -> Result<WebServerInfo, AppCommandError> {
|
||||
// Check if already running
|
||||
if state.running.load(Ordering::Relaxed) {
|
||||
return Err(AppCommandError::new(
|
||||
AppErrorCode::AlreadyExists,
|
||||
"Web server is already running",
|
||||
));
|
||||
}
|
||||
|
||||
let port = port.unwrap_or(3080);
|
||||
let host = host.unwrap_or_else(|| "0.0.0.0".to_string());
|
||||
let token = generate_random_token();
|
||||
|
||||
// Determine static directory for serving the frontend.
|
||||
// In production: files are bundled into the resource directory.
|
||||
// In dev: the "out/" directory is at the project root (one level above src-tauri/).
|
||||
let static_dir = find_static_dir(&app);
|
||||
|
||||
let router = router::build_router(app.clone(), token.clone(), static_dir);
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", host, port)
|
||||
.parse()
|
||||
.map_err(|e: std::net::AddrParseError| {
|
||||
AppCommandError::invalid_input("Invalid host/port").with_detail(e.to_string())
|
||||
})?;
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| {
|
||||
AppCommandError::io_error("Failed to bind address").with_detail(e.to_string())
|
||||
})?;
|
||||
|
||||
let actual_port = listener.local_addr().map(|a| a.port()).unwrap_or(port);
|
||||
eprintln!("[WEB] Starting web server on {}", addr);
|
||||
|
||||
let handle = tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, router).await {
|
||||
eprintln!("[WEB] Server error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// Store state
|
||||
*state.handle.lock().unwrap() = Some(handle);
|
||||
state.port.store(actual_port, Ordering::Relaxed);
|
||||
*state.token.lock().unwrap() = token.clone();
|
||||
state.running.store(true, Ordering::Relaxed);
|
||||
|
||||
let addresses = get_local_addresses(actual_port);
|
||||
|
||||
Ok(WebServerInfo {
|
||||
port: actual_port,
|
||||
token,
|
||||
addresses,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_web_server(
|
||||
state: tauri::State<'_, WebServerState>,
|
||||
) -> Result<(), AppCommandError> {
|
||||
if let Some(handle) = state.handle.lock().unwrap().take() {
|
||||
handle.abort();
|
||||
}
|
||||
state.running.store(false, Ordering::Relaxed);
|
||||
state.port.store(0, Ordering::Relaxed);
|
||||
*state.token.lock().unwrap() = String::new();
|
||||
eprintln!("[WEB] Web server stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_web_server_status(
|
||||
state: tauri::State<'_, WebServerState>,
|
||||
) -> Result<Option<WebServerInfo>, AppCommandError> {
|
||||
if !state.running.load(Ordering::Relaxed) {
|
||||
return Ok(None);
|
||||
}
|
||||
let port = state.port.load(Ordering::Relaxed);
|
||||
let token = state.token.lock().unwrap().clone();
|
||||
let addresses = get_local_addresses(port);
|
||||
Ok(Some(WebServerInfo {
|
||||
port,
|
||||
token,
|
||||
addresses,
|
||||
}))
|
||||
}
|
||||
116
src-tauri/src/web/router.rs
Normal file
116
src-tauri/src/web/router.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use axum::{
|
||||
extract::Extension,
|
||||
http::{StatusCode, Uri},
|
||||
middleware::{self, Next},
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
use super::{auth, handlers, ws};
|
||||
|
||||
pub fn build_router(app: tauri::AppHandle, token: String, static_dir: std::path::PathBuf) -> Router {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
let token_for_ws = token.clone();
|
||||
|
||||
let api = Router::new()
|
||||
// Health check (lightweight, used for token validation)
|
||||
.route("/health", post(health_check))
|
||||
// Conversations
|
||||
.route("/list_conversations", post(handlers::conversations::list_conversations))
|
||||
.route("/get_conversation", post(handlers::conversations::get_conversation))
|
||||
.route("/list_folder_conversations", post(handlers::conversations::list_folder_conversations))
|
||||
.route("/get_folder_conversation", post(handlers::conversations::get_folder_conversation))
|
||||
.route("/import_local_conversations", post(handlers::conversations::import_local_conversations))
|
||||
.route("/list_folders", post(handlers::conversations::list_folders))
|
||||
.route("/get_stats", post(handlers::conversations::get_stats))
|
||||
.route("/get_sidebar_data", post(handlers::conversations::get_sidebar_data))
|
||||
.route("/create_conversation", post(handlers::conversations::create_conversation))
|
||||
.route("/update_conversation_status", post(handlers::conversations::update_conversation_status))
|
||||
.route("/update_conversation_title", post(handlers::conversations::update_conversation_title))
|
||||
.route("/delete_conversation", post(handlers::conversations::delete_conversation))
|
||||
// Folders
|
||||
.route("/load_folder_history", post(handlers::folders::load_folder_history))
|
||||
.route("/get_folder", post(handlers::folders::get_folder))
|
||||
.route("/open_folder_window", post(handlers::folders::open_folder_window))
|
||||
// System settings
|
||||
.route("/get_system_proxy_settings", post(handlers::system_settings::get_system_proxy_settings))
|
||||
.route("/get_system_language_settings", post(handlers::system_settings::get_system_language_settings))
|
||||
// Catch-all: return proper JSON 404 for unimplemented API endpoints
|
||||
.fallback(api_not_found)
|
||||
// Auth middleware for API routes
|
||||
.layer(middleware::from_fn(move |req, next| {
|
||||
auth::require_token(req, next, token.clone())
|
||||
}));
|
||||
|
||||
// WebSocket route (auth via query param)
|
||||
let ws_route = Router::new()
|
||||
.route("/ws/events", get(ws::ws_handler))
|
||||
.layer(middleware::from_fn(move |req, next| {
|
||||
auth::require_token(req, next, token_for_ws.clone())
|
||||
}));
|
||||
|
||||
// Static file serving.
|
||||
// Next.js static export produces "folder.html" for "/folder" route.
|
||||
// We use a middleware to rewrite "/folder" → "/folder.html" before ServeDir.
|
||||
let fallback = ServeDir::new(&static_dir)
|
||||
.fallback(ServeFile::new(static_dir.join("index.html")));
|
||||
|
||||
let static_dir_for_mw = static_dir.clone();
|
||||
let html_rewrite = middleware::from_fn(move |req: axum::extract::Request, next: Next| {
|
||||
let dir = static_dir_for_mw.clone();
|
||||
async move {
|
||||
let path = req.uri().path();
|
||||
// If path has no extension (not a file) and a .html version exists, rewrite
|
||||
if path != "/" && !path.contains('.') && !path.starts_with("/api") && !path.starts_with("/ws") {
|
||||
let html_path = format!("{}.html", path.trim_end_matches('/'));
|
||||
let html_file = dir.join(html_path.trim_start_matches('/'));
|
||||
if html_file.exists() {
|
||||
// Rebuild URI with .html suffix preserving query string
|
||||
let new_path = if let Some(q) = req.uri().query() {
|
||||
format!("{}?{}", html_path, q)
|
||||
} else {
|
||||
html_path
|
||||
};
|
||||
if let Ok(new_uri) = new_path.parse::<Uri>() {
|
||||
let (mut parts, body) = req.into_parts();
|
||||
parts.uri = new_uri;
|
||||
let req = axum::extract::Request::from_parts(parts, body);
|
||||
return next.run(req).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
next.run(req).await
|
||||
}
|
||||
});
|
||||
|
||||
Router::new()
|
||||
.nest("/api", api)
|
||||
.merge(ws_route)
|
||||
.fallback_service(fallback)
|
||||
.layer(html_rewrite)
|
||||
.layer(cors)
|
||||
.layer(Extension(app))
|
||||
}
|
||||
|
||||
async fn health_check() -> impl IntoResponse {
|
||||
Json(serde_json::json!({ "status": "ok" }))
|
||||
}
|
||||
|
||||
async fn api_not_found(uri: axum::http::Uri) -> impl IntoResponse {
|
||||
let command = uri.path().trim_start_matches('/');
|
||||
eprintln!("[WEB] Unimplemented API endpoint: {}", command);
|
||||
(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
Json(serde_json::json!({
|
||||
"code": "not_implemented",
|
||||
"message": format!("API endpoint '{}' is not available in web mode", command),
|
||||
})),
|
||||
)
|
||||
}
|
||||
50
src-tauri/src/web/ws.rs
Normal file
50
src-tauri/src/web/ws.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use axum::{
|
||||
extract::{Extension, WebSocketUpgrade},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum::extract::ws::{Message, WebSocket};
|
||||
use tauri::Manager;
|
||||
|
||||
use super::event_bridge::WebEventBroadcaster;
|
||||
|
||||
pub async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
Extension(app): Extension<tauri::AppHandle>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(|socket| handle_ws_connection(socket, app))
|
||||
}
|
||||
|
||||
async fn handle_ws_connection(mut socket: WebSocket, app: tauri::AppHandle) {
|
||||
let broadcaster = app.state::<WebEventBroadcaster>();
|
||||
let mut rx = broadcaster.subscribe();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = rx.recv() => {
|
||||
match result {
|
||||
Ok(event) => {
|
||||
if let Ok(msg) = serde_json::to_string(&event) {
|
||||
if socket.send(Message::Text(msg.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
eprintln!("[WS] receiver lagged, skipped {n} events");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
msg = socket.recv() => {
|
||||
match msg {
|
||||
Some(Ok(_)) => {
|
||||
// Client messages currently unused; reserved for future use
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user