mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
323 lines
10 KiB
Rust
323 lines
10 KiB
Rust
mod app_state;
|
|
mod commands;
|
|
mod managed_agents;
|
|
mod models;
|
|
mod relay;
|
|
mod util;
|
|
|
|
use app_state::{build_app_state, resolve_persisted_identity, AppState};
|
|
use commands::*;
|
|
use managed_agents::{
|
|
find_managed_agent_mut, load_managed_agents, save_managed_agents, start_managed_agent_process,
|
|
stop_managed_agent_process, sync_managed_agent_processes, BackendKind,
|
|
};
|
|
use tauri::{http, Manager, RunEvent};
|
|
use tauri_plugin_window_state::StateFlags;
|
|
|
|
fn restore_managed_agents_on_launch(app: &tauri::AppHandle) -> Result<(), String> {
|
|
let state = app.state::<AppState>();
|
|
let _store_guard = state
|
|
.managed_agents_store_lock
|
|
.lock()
|
|
.map_err(|error| error.to_string())?;
|
|
let mut records = load_managed_agents(app)?;
|
|
let mut runtimes = state
|
|
.managed_agent_processes
|
|
.lock()
|
|
.map_err(|error| error.to_string())?;
|
|
let mut changed = sync_managed_agent_processes(&mut records, &mut runtimes);
|
|
let pubkeys_to_restore = records
|
|
.iter()
|
|
.filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local)
|
|
.map(|record| record.pubkey.clone())
|
|
.collect::<Vec<_>>();
|
|
|
|
for pubkey in pubkeys_to_restore {
|
|
let record = find_managed_agent_mut(&mut records, &pubkey)?;
|
|
match start_managed_agent_process(app, record, &mut runtimes) {
|
|
Ok(()) => {
|
|
changed = true;
|
|
}
|
|
Err(error) => {
|
|
record.updated_at = util::now_iso();
|
|
record.last_error = Some(error);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if changed {
|
|
save_managed_agents(app, &records)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
|
|
let state = app.state::<AppState>();
|
|
let _store_guard = state
|
|
.managed_agents_store_lock
|
|
.lock()
|
|
.map_err(|error| error.to_string())?;
|
|
let mut records = load_managed_agents(app)?;
|
|
let mut runtimes = state
|
|
.managed_agent_processes
|
|
.lock()
|
|
.map_err(|error| error.to_string())?;
|
|
let mut changed = sync_managed_agent_processes(&mut records, &mut runtimes);
|
|
|
|
for record in records.iter_mut() {
|
|
// Only stop Local agents — Provider agents are managed externally.
|
|
if record.backend != BackendKind::Local {
|
|
continue;
|
|
}
|
|
if record.runtime_pid.is_none() && !runtimes.contains_key(&record.pubkey) {
|
|
continue;
|
|
}
|
|
|
|
stop_managed_agent_process(record, &mut runtimes)?;
|
|
changed = true;
|
|
}
|
|
|
|
if changed {
|
|
save_managed_agents(app, &records)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Proxy media requests through the Rust backend so they traverse the WARP tunnel.
|
|
///
|
|
/// WKWebView's networking stack bypasses WARP, causing 403s from Cloudflare Access.
|
|
/// This handler routes `sprout-media://localhost/{path}` through reqwest, which
|
|
/// runs in the Tauri process and goes through WARP.
|
|
async fn handle_sprout_media(
|
|
app: &tauri::AppHandle,
|
|
request: &http::Request<Vec<u8>>,
|
|
) -> http::Response<Vec<u8>> {
|
|
let state = app.state::<AppState>();
|
|
let base = relay::relay_api_base_url();
|
|
|
|
// Preserve path + query (thumbnails may have query params).
|
|
// Only proxy /media/ paths — reject anything else.
|
|
let path_and_query = request
|
|
.uri()
|
|
.path_and_query()
|
|
.map(|pq| pq.as_str())
|
|
.unwrap_or("/");
|
|
|
|
if !path_and_query.starts_with("/media/") {
|
|
return error_response(404, "not found");
|
|
}
|
|
|
|
let upstream_url = format!("{base}{path_and_query}");
|
|
|
|
let result = state
|
|
.http_client
|
|
.get(&upstream_url)
|
|
.timeout(std::time::Duration::from_secs(30))
|
|
.send()
|
|
.await;
|
|
|
|
match result {
|
|
Ok(resp) => {
|
|
let status = resp.status().as_u16();
|
|
let content_type = resp
|
|
.headers()
|
|
.get("content-type")
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("application/octet-stream")
|
|
.to_string();
|
|
|
|
match resp.bytes().await {
|
|
Ok(bytes) => http::Response::builder()
|
|
.status(status)
|
|
.header("content-type", &content_type)
|
|
.body(bytes.to_vec())
|
|
.unwrap_or_else(|_| error_response(500, "response build failed")),
|
|
Err(_) => error_response(502, "failed to read upstream body"),
|
|
}
|
|
}
|
|
Err(_) => error_response(502, "upstream request failed"),
|
|
}
|
|
}
|
|
|
|
fn error_response(status: u16, msg: &str) -> http::Response<Vec<u8>> {
|
|
http::Response::builder()
|
|
.status(status)
|
|
.header("content-type", "text/plain")
|
|
.body(msg.as_bytes().to_vec())
|
|
.unwrap_or_else(|_| {
|
|
http::Response::builder()
|
|
.status(500)
|
|
.body(Vec::new())
|
|
.unwrap()
|
|
})
|
|
}
|
|
|
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
pub fn run() {
|
|
let app = tauri::Builder::default()
|
|
.plugin(tauri_plugin_notification::init())
|
|
.plugin(tauri_plugin_opener::init())
|
|
.plugin(
|
|
tauri_plugin_window_state::Builder::default()
|
|
.with_state_flags(StateFlags::all() & !StateFlags::VISIBLE)
|
|
.build(),
|
|
)
|
|
.plugin(tauri_plugin_websocket::init())
|
|
.plugin(tauri_plugin_dialog::init())
|
|
.register_asynchronous_uri_scheme_protocol("sprout-media", |ctx, request, responder| {
|
|
let app = ctx.app_handle().clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
let response = handle_sprout_media(&app, &request).await;
|
|
responder.respond(response);
|
|
});
|
|
})
|
|
.manage(build_app_state())
|
|
.setup(|app| {
|
|
let app_handle = app.handle().clone();
|
|
|
|
// Resolve persisted identity key (env var → file → generate+save).
|
|
// This is fatal — the app should not start with an ephemeral identity
|
|
// that will be lost on restart, as that silently breaks channel
|
|
// memberships, DMs, and relay identity.
|
|
let state = app_handle.state::<AppState>();
|
|
resolve_persisted_identity(&app_handle, &state)
|
|
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
|
|
|
|
if let Err(error) = restore_managed_agents_on_launch(&app_handle) {
|
|
eprintln!("sprout-desktop: failed to restore managed agents: {error}");
|
|
}
|
|
|
|
Ok(())
|
|
})
|
|
.invoke_handler(tauri::generate_handler![
|
|
get_identity,
|
|
get_profile,
|
|
update_profile,
|
|
get_user_profile,
|
|
get_users_batch,
|
|
search_users,
|
|
get_presence,
|
|
set_presence,
|
|
get_relay_ws_url,
|
|
discover_acp_providers,
|
|
discover_managed_agent_prereqs,
|
|
sign_event,
|
|
create_auth_event,
|
|
get_channels,
|
|
create_channel,
|
|
open_dm,
|
|
hide_dm,
|
|
get_channel_details,
|
|
get_channel_members,
|
|
update_channel,
|
|
set_channel_topic,
|
|
set_channel_purpose,
|
|
archive_channel,
|
|
unarchive_channel,
|
|
delete_channel,
|
|
add_channel_members,
|
|
remove_channel_member,
|
|
join_channel,
|
|
leave_channel,
|
|
get_canvas,
|
|
set_canvas,
|
|
get_feed,
|
|
search_messages,
|
|
send_channel_message,
|
|
get_forum_posts,
|
|
get_forum_thread,
|
|
delete_message,
|
|
add_reaction,
|
|
remove_reaction,
|
|
get_event,
|
|
upload_media,
|
|
pick_and_upload_media,
|
|
upload_media_bytes,
|
|
list_tokens,
|
|
mint_token,
|
|
revoke_token,
|
|
revoke_all_tokens,
|
|
list_relay_agents,
|
|
list_managed_agents,
|
|
create_managed_agent,
|
|
start_managed_agent,
|
|
stop_managed_agent,
|
|
set_managed_agent_start_on_app_launch,
|
|
delete_managed_agent,
|
|
mint_managed_agent_token,
|
|
get_managed_agent_log,
|
|
get_agent_models,
|
|
update_managed_agent,
|
|
discover_backend_providers,
|
|
probe_backend_provider,
|
|
|
|
list_personas,
|
|
create_persona,
|
|
update_persona,
|
|
delete_persona,
|
|
list_teams,
|
|
create_team,
|
|
update_team,
|
|
delete_team,
|
|
export_team_to_json,
|
|
parse_team_file,
|
|
parse_persona_files,
|
|
export_persona_to_json,
|
|
])
|
|
.build(tauri::generate_context!())
|
|
.expect("error while building tauri application");
|
|
|
|
app.run(|app_handle, event| {
|
|
if matches!(event, RunEvent::ExitRequested { .. }) {
|
|
if let Err(error) = shutdown_managed_agents(app_handle) {
|
|
eprintln!("sprout-desktop: failed to stop managed agents: {error}");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use serde_json::json;
|
|
|
|
use crate::{models::ChannelInfo, util::percent_encode};
|
|
|
|
#[test]
|
|
fn channel_info_defaults_is_member_for_legacy_payloads() {
|
|
let channel: ChannelInfo = serde_json::from_value(json!({
|
|
"id": "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
|
"name": "general",
|
|
"channel_type": "stream",
|
|
"visibility": "open",
|
|
"description": "General discussion",
|
|
"topic": null,
|
|
"purpose": null,
|
|
"member_count": 3,
|
|
"last_message_at": null,
|
|
"archived_at": null,
|
|
"participants": [],
|
|
"participant_pubkeys": []
|
|
}))
|
|
.expect("legacy payload should deserialize");
|
|
|
|
assert!(channel.is_member);
|
|
}
|
|
|
|
#[test]
|
|
fn percent_encode_leaves_unreserved_chars() {
|
|
assert_eq!(
|
|
percent_encode("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"),
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn percent_encode_escapes_unicode_and_reserved_chars() {
|
|
assert_eq!(percent_encode("👍"), "%F0%9F%91%8D");
|
|
assert_eq!(percent_encode("a/b?c"), "a%2Fb%3Fc");
|
|
}
|
|
}
|