From eae86c73f78e6d6cf25f015b11d50b1516595f11 Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Thu, 6 Aug 2026 19:17:03 +0800 Subject: [PATCH] client server connection loop --- Cargo.lock | 127 +++++++++++++++++++++-- Cargo.toml | 2 +- src-common/Cargo.toml | 8 ++ src-common/src/lib.rs | 36 +++++++ src-server/Cargo.toml | 10 +- src-server/src/main.rs | 3 + src-server/src/network/mod.rs | 185 ++++++++++++++++++++++++++++++++++ src-tauri/Cargo.toml | 5 +- src-tauri/src/keypair/mod.rs | 2 +- src-tauri/src/lib.rs | 2 + src-tauri/src/network/mod.rs | 159 +++++++++++++++++++++++++++++ src-tauri/src/profile/mod.rs | 2 +- src-tauri/src/remotes/mod.rs | 2 +- 13 files changed, 528 insertions(+), 15 deletions(-) create mode 100644 src-common/Cargo.toml create mode 100644 src-common/src/lib.rs create mode 100644 src-server/src/network/mod.rs create mode 100644 src-tauri/src/network/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 0aa5601..6292e96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -241,6 +241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -259,8 +260,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite 0.29.0", "tower", "tower-layer", "tower-service", @@ -823,6 +826,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "dbus" version = "0.9.11" @@ -2451,7 +2460,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -3130,8 +3139,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -3141,7 +3160,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -3153,6 +3182,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3310,7 +3348,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature 2.2.0", "spki", "subtle", @@ -3723,7 +3761,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest 0.10.7", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -3996,7 +4034,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.7", "rsa", "serde", "sha1", @@ -4034,7 +4072,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.7", "serde", "serde_json", "sha2 0.10.9", @@ -4681,6 +4719,30 @@ 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 0.28.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4931,6 +4993,39 @@ 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.5", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.18", +] + [[package]] name = "typeid" version = "1.0.3" @@ -5958,6 +6053,7 @@ dependencies = [ "base64 0.22.1", "device_query", "ed25519-dalek", + "futures-util", "getrandom 0.4.3", "lazy_static", "objc2", @@ -5973,8 +6069,17 @@ dependencies = [ "tauri-plugin-opener", "tauri-specta", "tokio", + "tokio-tungstenite 0.28.0", "uuid", "windows 0.58.0", + "wyd-common", +] + +[[package]] +name = "wyd-common" +version = "0.1.0" +dependencies = [ + "serde", ] [[package]] @@ -5982,7 +6087,13 @@ name = "wyd-server" version = "0.1.0" dependencies = [ "axum", + "base64 0.22.1", + "ed25519-dalek", + "futures-util", + "serde_json", "tokio", + "uuid", + "wyd-common", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 60bd6f1..2f3f158 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["src-tauri", "src-server"] +members = ["src-common", "src-tauri", "src-server"] resolver = "2" diff --git a/src-common/Cargo.toml b/src-common/Cargo.toml new file mode 100644 index 0000000..96ef6a9 --- /dev/null +++ b/src-common/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "wyd-common" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +serde = { version = "1", features = ["derive"] } diff --git a/src-common/src/lib.rs b/src-common/src/lib.rs new file mode 100644 index 0000000..ba72281 --- /dev/null +++ b/src-common/src/lib.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; + +pub const VERSION: u8 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Profile { + pub id: String, + pub display_name: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ClientMessage { + Register { profile: Profile, signature: String }, + Signed { payload: String, signature: String }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ServerMessage { + Challenge { version: u8, challenge: String }, + Registered, +} + +pub fn register_bytes(challenge: &str, profile: &Profile) -> Vec { + format!( + "wyd-register-v{VERSION}\n{challenge}\n{}\n{}", + profile.id, profile.display_name + ) + .into_bytes() +} + +pub fn message_bytes(payload: &str) -> Vec { + format!("wyd-message-v{VERSION}\n{payload}").into_bytes() +} diff --git a/src-server/Cargo.toml b/src-server/Cargo.toml index da8d0e5..0ebb484 100644 --- a/src-server/Cargo.toml +++ b/src-server/Cargo.toml @@ -5,5 +5,11 @@ edition = "2024" publish = false [dependencies] -axum = "0.8" -tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread"] } +axum = { version = "0.8", features = ["ws"] } +tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "sync", "time"] } +base64 = "0.22.1" +ed25519-dalek = "3.0.0" +futures-util = "0.3" +serde_json = "1" +uuid = { version = "1", features = ["v4"] } +wyd-common = { path = "../src-common" } diff --git a/src-server/src/main.rs b/src-server/src/main.rs index 47cc2f8..53707a9 100644 --- a/src-server/src/main.rs +++ b/src-server/src/main.rs @@ -1,5 +1,7 @@ use axum::{Router, http::StatusCode, routing::get}; +mod network; + #[tokio::main] async fn main() -> std::io::Result<()> { let bind_addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:3000".to_string()); @@ -7,6 +9,7 @@ async fn main() -> std::io::Result<()> { let app = Router::new() .route("/", get("ok")) .route("/health", get("ok")) + .merge(network::routes()) .fallback((StatusCode::NOT_FOUND, "not found")); println!("wyd-server listening on http://{bind_addr}"); diff --git a/src-server/src/network/mod.rs b/src-server/src/network/mod.rs new file mode 100644 index 0000000..e24a237 --- /dev/null +++ b/src-server/src/network/mod.rs @@ -0,0 +1,185 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use axum::Router; +use axum::extract::State; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::response::Response; +use axum::routing::get; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::{Mutex, mpsc}; +use uuid::Uuid; +use wyd_common::{ClientMessage, Profile, ServerMessage, message_bytes, register_bytes}; + +type Clients = Arc>>; + +struct Client { + connection_id: Uuid, + key: VerifyingKey, + #[allow(dead_code)] // Used when presence and profile lookup are exposed. + profile: Profile, + #[allow(dead_code)] // Used when server-side message routing is added. + sender: mpsc::Sender, +} + +pub fn routes() -> Router { + Router::new() + .route("/v1/ws", get(upgrade)) + .with_state(Clients::default()) +} + +async fn upgrade(ws: WebSocketUpgrade, State(clients): State) -> Response { + ws.on_upgrade(move |socket| connected(socket, clients)) +} + +async fn connected(mut socket: WebSocket, clients: Clients) { + let challenge = Uuid::new_v4().to_string(); + if send( + &mut socket, + &ServerMessage::Challenge { + version: wyd_common::VERSION, + challenge: challenge.clone(), + }, + ) + .await + .is_err() + { + return; + } + + let Some(Ok(Message::Text(text))) = socket.recv().await else { + return; + }; + let Ok(ClientMessage::Register { profile, signature }) = serde_json::from_str(&text) else { + return; + }; + let Ok(key) = key(&profile.id) else { return }; + if !verify(&key, ®ister_bytes(&challenge, &profile), &signature) { + return; + } + + let connection_id = Uuid::new_v4(); + let public_key = profile.id.clone(); + let (sender, mut outgoing) = mpsc::channel(32); + clients.lock().await.insert( + public_key.clone(), + Client { + connection_id, + key, + profile, + sender, + }, + ); + if send(&mut socket, &ServerMessage::Registered).await.is_err() { + remove(&clients, &public_key, connection_id).await; + return; + } + + let (mut writer, mut reader) = socket.split(); + let mut ping = tokio::time::interval(Duration::from_secs(20)); + loop { + tokio::select! { + message = outgoing.recv() => { + let Some(message) = message else { break }; + if writer.send(message).await.is_err() { break; } + } + _ = ping.tick() => { + if writer.send(Message::Ping(Vec::new().into())).await.is_err() { break; } + } + message = reader.next() => match message { + Some(Ok(Message::Text(text))) => { + let Ok(ClientMessage::Signed { payload, signature }) = serde_json::from_str(&text) else { break }; + let registered_key = clients.lock().await.get(&public_key) + .filter(|client| client.connection_id == connection_id) + .map(|client| client.key); + let Some(registered_key) = registered_key else { break }; + if !verify(®istered_key, &message_bytes(&payload), &signature) { break; } + // The message is authenticated. Domain routing comes next. + } + Some(Ok(Message::Ping(data))) => { + if writer.send(Message::Pong(data)).await.is_err() { break; } + } + Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break, + _ => {} + } + } + } + + remove(&clients, &public_key, connection_id).await; +} + +async fn remove(clients: &Clients, public_key: &str, connection_id: Uuid) { + let mut clients = clients.lock().await; + if clients + .get(public_key) + .is_some_and(|client| client.connection_id == connection_id) + { + clients.remove(public_key); + } +} + +async fn send(socket: &mut WebSocket, message: &ServerMessage) -> Result<(), axum::Error> { + socket + .send(Message::Text( + serde_json::to_string(message).unwrap().into(), + )) + .await +} + +fn key(public_key: &str) -> Result { + let bytes = URL_SAFE_NO_PAD.decode(public_key).map_err(|_| ())?; + let bytes: [u8; 32] = bytes.try_into().map_err(|_| ())?; + VerifyingKey::from_bytes(&bytes).map_err(|_| ()) +} + +fn verify(key: &VerifyingKey, bytes: &[u8], signature: &str) -> bool { + let Ok(signature) = URL_SAFE_NO_PAD.decode(signature) else { + return false; + }; + let Ok(signature) = Signature::from_slice(&signature) else { + return false; + }; + key.verify(bytes, &signature).is_ok() +} + +#[cfg(test)] +mod tests { + use ed25519_dalek::{Signer, SigningKey}; + + use super::*; + + #[test] + fn verifies_registration_and_message_signatures() { + let signing_key = SigningKey::from_bytes(&[7; 32]); + let profile = Profile { + id: URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()), + display_name: "Wind".to_owned(), + }; + let registration = register_bytes("challenge", &profile); + let signature = URL_SAFE_NO_PAD.encode(signing_key.sign(®istration).to_bytes()); + + assert!(verify( + &signing_key.verifying_key(), + ®istration, + &signature + )); + assert!(!verify( + &signing_key.verifying_key(), + ®ister_bytes("another challenge", &profile), + &signature, + )); + + let message = message_bytes("hello"); + let signature = URL_SAFE_NO_PAD.encode(signing_key.sign(&message).to_bytes()); + assert!(verify(&signing_key.verifying_key(), &message, &signature)); + assert!(!verify( + &signing_key.verifying_key(), + &message_bytes("tampered"), + &signature, + )); + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 10ec648..1b0ae7f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,12 +27,15 @@ lazy_static = "1.5.0" ed25519-dalek = "3.0.0" getrandom = "0.4.3" device_query = "4.0.1" -tokio = "1.53.1" +tokio = { version = "1.53.1", features = ["macros", "sync", "time"] } +tokio-tungstenite = "0.28" +futures-util = "0.3" sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "migrate"] } tauri-specta = { version = "2.0.0-rc.21", features = ["derive", "typescript"] } specta = "2.0.0-rc.21" specta-typescript = { version = "0.0.9" } uuid = { version = "1", features = ["v4"] } +wyd-common = { path = "../src-common" } [dev-dependencies] tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] } diff --git a/src-tauri/src/keypair/mod.rs b/src-tauri/src/keypair/mod.rs index 868821b..b90ec9f 100644 --- a/src-tauri/src/keypair/mod.rs +++ b/src-tauri/src/keypair/mod.rs @@ -114,7 +114,7 @@ impl StoredKeypair { } } -#[allow(dead_code)] // The signing key will be read by the pending WebSocket sender. +#[derive(Clone)] pub struct AppKeypair { signing_key: SigningKey, public_key: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1458044..edbc1fd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod cursor; mod db; mod friends; mod keypair; +mod network; mod profile; mod remotes; mod ufa; @@ -12,6 +13,7 @@ async fn launch_app(app: &tauri::App) -> Result<(), Box> let handle = app.handle(); db::init(handle).await?; keypair::init(handle).await?; + network::init(handle).await?; ufa::init(); cursor::init(handle); windowing::init(handle); diff --git a/src-tauri/src/network/mod.rs b/src-tauri/src/network/mod.rs new file mode 100644 index 0000000..3600936 --- /dev/null +++ b/src-tauri/src/network/mod.rs @@ -0,0 +1,159 @@ +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use tauri::{AppHandle, Manager}; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message; +use wyd_common::{ClientMessage, Profile, ServerMessage, message_bytes, register_bytes}; + +use crate::db::AppDatabase; +use crate::keypair::AppKeypair; +use crate::remotes::{self, Remote}; + +pub struct Network { + senders: Mutex>>, +} + +impl Network { + #[allow(dead_code)] // Ready for the first domain message sender. + pub fn send(&self, remote_id: &str, payload: String) -> Result<(), String> { + let senders = self.senders.lock().map_err(|error| error.to_string())?; + let sender = senders + .get(remote_id) + .ok_or_else(|| "remote is not configured".to_string())?; + sender + .try_send(payload) + .map_err(|error| format!("remote is not ready: {error}")) + } +} + +pub async fn init(handle: &AppHandle) -> Result<(), Box> { + let database = handle.state::(); + let keypair = handle.state::().inner().clone(); + let profile = crate::profile::get(&database, keypair.public_key()).await?; + let remotes = remotes::all(&database).await?; + let mut senders = HashMap::new(); + + for remote in remotes { + let (sender, receiver) = mpsc::channel(32); + senders.insert(remote.id.clone(), sender); + tauri::async_runtime::spawn(run(remote, profile.clone(), keypair.clone(), receiver)); + } + + handle.manage(Network { + senders: Mutex::new(senders), + }); + Ok(()) +} + +async fn run( + remote: Remote, + profile: crate::user::User, + keypair: AppKeypair, + mut outgoing: mpsc::Receiver, +) { + loop { + if let Err(error) = connect(&remote, &profile, &keypair, &mut outgoing).await { + eprintln!("remote {} disconnected: {error}", remote.id); + } + tokio::time::sleep(Duration::from_secs(5)).await; + } +} + +async fn connect( + remote: &Remote, + profile: &crate::user::User, + keypair: &AppKeypair, + outgoing: &mut mpsc::Receiver, +) -> Result<(), Box> { + let (socket, _) = tokio_tungstenite::connect_async(url(remote)).await?; + let (mut writer, mut reader) = socket.split(); + + let challenge = match recv(&mut reader).await? { + ServerMessage::Challenge { version, challenge } if version == wyd_common::VERSION => { + challenge + } + _ => return Err("server did not send a compatible challenge".into()), + }; + let profile = Profile { + id: profile.id.clone(), + display_name: profile.display_name.clone(), + }; + send( + &mut writer, + &ClientMessage::Register { + signature: keypair.sign(®ister_bytes(&challenge, &profile)), + profile, + }, + ) + .await?; + + if !matches!(recv(&mut reader).await?, ServerMessage::Registered) { + return Err("server rejected registration".into()); + } + + loop { + tokio::select! { + payload = outgoing.recv() => { + let payload = payload.ok_or("network sender closed")?; + send(&mut writer, &ClientMessage::Signed { + signature: keypair.sign(&message_bytes(&payload)), + payload, + }).await?; + } + message = reader.next() => match message.ok_or("server closed the socket")?? { + Message::Ping(data) => writer.send(Message::Pong(data)).await?, + Message::Close(_) => return Ok(()), + _ => {} + } + } + } +} + +async fn send( + writer: &mut S, + message: &ClientMessage, +) -> Result<(), Box> +where + S: futures_util::Sink + Unpin, + S::Error: std::error::Error + Send + Sync + 'static, +{ + writer + .send(Message::Text(serde_json::to_string(message)?.into())) + .await?; + Ok(()) +} + +async fn recv(reader: &mut S) -> Result> +where + S: futures_util::Stream> + Unpin, +{ + while let Some(message) = reader.next().await { + if let Message::Text(text) = message? { + return Ok(serde_json::from_str(&text)?); + } + } + Err("server closed the socket".into()) +} + +fn url(remote: &Remote) -> String { + let address = remote.address.trim_end_matches('/'); + let address = address + .strip_prefix("http://") + .or_else(|| address.strip_prefix("https://")) + .or_else(|| address.strip_prefix("ws://")) + .or_else(|| address.strip_prefix("wss://")) + .unwrap_or(address); + let scheme = if remote.address.starts_with("https://") || remote.address.starts_with("wss://") { + "wss" + } else { + "ws" + }; + let port = remote + .port + .map(|port| format!(":{port}")) + .unwrap_or_default(); + format!("{scheme}://{address}{port}/v1/ws") +} diff --git a/src-tauri/src/profile/mod.rs b/src-tauri/src/profile/mod.rs index 0508165..19e4136 100644 --- a/src-tauri/src/profile/mod.rs +++ b/src-tauri/src/profile/mod.rs @@ -14,7 +14,7 @@ pub struct ProfileChanged { pub profile: User, } -async fn get(database: &AppDatabase, public_key: &str) -> Result { +pub async fn get(database: &AppDatabase, public_key: &str) -> Result { let display_name = sqlx::query_scalar::<_, String>("SELECT display_name FROM profile WHERE id = ?1") .bind(PROFILE_ID) diff --git a/src-tauri/src/remotes/mod.rs b/src-tauri/src/remotes/mod.rs index f8c0b54..615bb3e 100644 --- a/src-tauri/src/remotes/mod.rs +++ b/src-tauri/src/remotes/mod.rs @@ -28,7 +28,7 @@ pub struct RemotesChanged { pub remotes: Vec, } -async fn all(database: &AppDatabase) -> Result, sqlx::Error> { +pub async fn all(database: &AppDatabase) -> Result, sqlx::Error> { sqlx::query_as::<_, Remote>( "SELECT id, address, name, port FROM remotes \ ORDER BY COALESCE(name, address) COLLATE NOCASE, id",