From 0f5ded4cd94e339c8e077e6f1969873eed320262 Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Sat, 1 Aug 2026 20:06:00 +0800 Subject: [PATCH] servers list --- Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + .../20260801010000_create_remotes.sql | 6 + src-tauri/src/lib.rs | 11 +- src-tauri/src/remotes/mod.rs | 277 ++++++++++++++++++ src/lib/bindings.ts | 24 +- src/lib/components/remotes-list.svelte | 162 ++++++++++ src/routes/+page.svelte | 4 + 8 files changed, 483 insertions(+), 3 deletions(-) create mode 100644 src-tauri/migrations/20260801010000_create_remotes.sql create mode 100644 src-tauri/src/remotes/mod.rs create mode 100644 src/lib/components/remotes-list.svelte diff --git a/Cargo.lock b/Cargo.lock index 33b44fa..0aa5601 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5973,6 +5973,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-specta", "tokio", + "uuid", "windows 0.58.0", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ae19894..10ec648 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,6 +32,7 @@ sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "migra 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"] } [dev-dependencies] tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] } diff --git a/src-tauri/migrations/20260801010000_create_remotes.sql b/src-tauri/migrations/20260801010000_create_remotes.sql new file mode 100644 index 0000000..9c5f10d --- /dev/null +++ b/src-tauri/migrations/20260801010000_create_remotes.sql @@ -0,0 +1,6 @@ +CREATE TABLE remotes ( + id TEXT PRIMARY KEY NOT NULL, + address TEXT NOT NULL, + name TEXT, + port INTEGER CHECK (port IS NULL OR port BETWEEN 0 AND 65535) +); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5c0f5f2..ad2b6fb 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 remotes; mod ufa; mod windowing; @@ -24,9 +25,17 @@ pub fn run() { friends::list_friends, friends::get_friend, friends::delete_friend, + remotes::create_remote, + remotes::list_remotes, + remotes::get_remote, + remotes::update_remote, + remotes::delete_remote, keypair::get_public_key, ]) - .events(tauri_specta::collect_events![friends::FriendsChanged]); + .events(tauri_specta::collect_events![ + friends::FriendsChanged, + remotes::RemotesChanged, + ]); #[cfg(debug_assertions)] specta_builder diff --git a/src-tauri/src/remotes/mod.rs b/src-tauri/src/remotes/mod.rs new file mode 100644 index 0000000..f8c0b54 --- /dev/null +++ b/src-tauri/src/remotes/mod.rs @@ -0,0 +1,277 @@ +use crate::db::{self, AppDatabase}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri::{AppHandle, State}; +use tauri_specta::Event; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type, sqlx::FromRow)] +#[serde(rename_all = "camelCase")] +pub struct Remote { + pub id: String, + pub address: String, + pub name: Option, + pub port: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RemoteInput { + pub address: String, + pub name: Option, + pub port: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] +#[serde(rename_all = "camelCase")] +pub struct RemotesChanged { + pub remotes: Vec, +} + +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", + ) + .fetch_all(database.pool()) + .await +} + +async fn get(database: &AppDatabase, id: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, Remote>("SELECT id, address, name, port FROM remotes WHERE id = ?1") + .bind(id) + .fetch_optional(database.pool()) + .await +} + +async fn insert(database: &AppDatabase, input: RemoteInput) -> Result { + let remote = Remote { + id: Uuid::new_v4().to_string(), + address: input.address, + name: input.name, + port: input.port, + }; + + sqlx::query("INSERT INTO remotes (id, address, name, port) VALUES (?1, ?2, ?3, ?4)") + .bind(&remote.id) + .bind(&remote.address) + .bind(&remote.name) + .bind(remote.port) + .execute(database.pool()) + .await?; + + Ok(remote) +} + +async fn update( + database: &AppDatabase, + id: &str, + input: RemoteInput, +) -> Result, sqlx::Error> { + let result = sqlx::query("UPDATE remotes SET address = ?1, name = ?2, port = ?3 WHERE id = ?4") + .bind(&input.address) + .bind(&input.name) + .bind(input.port) + .bind(id) + .execute(database.pool()) + .await?; + + if result.rows_affected() == 0 { + return Ok(None); + } + + Ok(Some(Remote { + id: id.to_owned(), + address: input.address, + name: input.name, + port: input.port, + })) +} + +async fn delete(database: &AppDatabase, id: &str) -> Result { + let result = sqlx::query("DELETE FROM remotes WHERE id = ?1") + .bind(id) + .execute(database.pool()) + .await?; + + Ok(result.rows_affected() > 0) +} + +async fn emit_changed(handle: &AppHandle, database: &AppDatabase) -> Result<(), String> { + RemotesChanged { + remotes: all(database).await.map_err(db::command_error)?, + } + .emit(handle) + .map_err(db::command_error) +} + +#[tauri::command] +#[specta::specta] +pub async fn create_remote( + handle: AppHandle, + database: State<'_, AppDatabase>, + remote: RemoteInput, +) -> Result { + let remote = insert(&database, remote).await.map_err(db::command_error)?; + emit_changed(&handle, &database).await?; + Ok(remote) +} + +#[tauri::command] +#[specta::specta] +pub async fn list_remotes( + handle: AppHandle, + database: State<'_, AppDatabase>, +) -> Result, String> { + let remotes = all(&database).await.map_err(db::command_error)?; + + RemotesChanged { + remotes: remotes.clone(), + } + .emit(&handle) + .map_err(db::command_error)?; + + Ok(remotes) +} + +#[tauri::command] +#[specta::specta] +pub async fn get_remote( + database: State<'_, AppDatabase>, + id: String, +) -> Result, String> { + get(&database, &id).await.map_err(db::command_error) +} + +#[tauri::command] +#[specta::specta] +pub async fn update_remote( + handle: AppHandle, + database: State<'_, AppDatabase>, + id: String, + remote: RemoteInput, +) -> Result, String> { + let remote = update(&database, &id, remote) + .await + .map_err(db::command_error)?; + + if remote.is_some() { + emit_changed(&handle, &database).await?; + } + + Ok(remote) +} + +#[tauri::command] +#[specta::specta] +pub async fn delete_remote( + handle: AppHandle, + database: State<'_, AppDatabase>, + id: String, +) -> Result { + let changed = delete(&database, &id).await.map_err(db::command_error)?; + + if changed { + emit_changed(&handle, &database).await?; + } + + Ok(changed) +} + +#[cfg(test)] +mod tests { + use sqlx::sqlite::SqlitePoolOptions; + + use super::*; + + async fn database() -> AppDatabase { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("connect to in-memory SQLite"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("run database migrations"); + AppDatabase::new(pool) + } + + #[tokio::test] + async fn insert_generates_a_uuid_v4_and_preserves_optional_fields() { + let database = database().await; + let remote = insert( + &database, + RemoteInput { + address: "play.example.com".to_owned(), + name: None, + port: None, + }, + ) + .await + .expect("insert remote"); + + let id = Uuid::parse_str(&remote.id).expect("parse generated UUID"); + assert_eq!(id.get_version_num(), 4); + assert_eq!(remote.address, "play.example.com"); + assert_eq!(remote.name, None); + assert_eq!(remote.port, None); + assert_eq!(get(&database, &remote.id).await.unwrap(), Some(remote)); + } + + #[tokio::test] + async fn update_and_delete_cover_the_remote_lifecycle() { + let database = database().await; + let created = insert( + &database, + RemoteInput { + address: "old.example.com".to_owned(), + name: Some("Old name".to_owned()), + port: Some(25565), + }, + ) + .await + .expect("insert remote"); + + let updated = update( + &database, + &created.id, + RemoteInput { + address: "new.example.com".to_owned(), + name: Some("New name".to_owned()), + port: None, + }, + ) + .await + .expect("update remote") + .expect("remote exists"); + + assert_eq!(updated.id, created.id); + assert_eq!(updated.address, "new.example.com"); + assert_eq!(updated.name.as_deref(), Some("New name")); + assert_eq!(updated.port, None); + assert_eq!(all(&database).await.unwrap(), vec![updated.clone()]); + assert!(delete(&database, &updated.id).await.unwrap()); + assert!(!delete(&database, &updated.id).await.unwrap()); + assert_eq!(get(&database, &updated.id).await.unwrap(), None); + } + + #[tokio::test] + async fn update_returns_none_for_an_unknown_remote() { + let database = database().await; + + let updated = update( + &database, + "missing", + RemoteInput { + address: "example.com".to_owned(), + name: None, + port: None, + }, + ) + .await + .expect("update query succeeds"); + + assert_eq!(updated, None); + } +} diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index 44040c5..1bc72e4 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -17,6 +17,21 @@ async getFriend(id: string) : Promise { async deleteFriend(id: string) : Promise { return await TAURI_INVOKE("delete_friend", { id }); }, +async createRemote(remote: RemoteInput) : Promise { + return await TAURI_INVOKE("create_remote", { remote }); +}, +async listRemotes() : Promise { + return await TAURI_INVOKE("list_remotes"); +}, +async getRemote(id: string) : Promise { + return await TAURI_INVOKE("get_remote", { id }); +}, +async updateRemote(id: string, remote: RemoteInput) : Promise { + return await TAURI_INVOKE("update_remote", { id, remote }); +}, +async deleteRemote(id: string) : Promise { + return await TAURI_INVOKE("delete_remote", { id }); +}, async getPublicKey() : Promise { return await TAURI_INVOKE("get_public_key"); } @@ -26,9 +41,11 @@ async getPublicKey() : Promise { export const events = __makeEvents__<{ -friendsChanged: FriendsChanged +friendsChanged: FriendsChanged, +remotesChanged: RemotesChanged }>({ -friendsChanged: "friends-changed" +friendsChanged: "friends-changed", +remotesChanged: "remotes-changed" }) /** user-defined constants **/ @@ -39,6 +56,9 @@ friendsChanged: "friends-changed" export type Friend = { id: string; displayName: string } export type FriendsChanged = { friends: Friend[] } +export type Remote = { id: string; address: string; name: string | null; port: number | null } +export type RemoteInput = { address: string; name: string | null; port: number | null } +export type RemotesChanged = { remotes: Remote[] } /** tauri-specta globals **/ diff --git a/src/lib/components/remotes-list.svelte b/src/lib/components/remotes-list.svelte new file mode 100644 index 0000000..0bb85d2 --- /dev/null +++ b/src/lib/components/remotes-list.svelte @@ -0,0 +1,162 @@ + + +
+

Remotes

+ +
+

+ +

+ +

+ +

+ +

+ +

+ + +
+ + {#if error} +

{error}

+ {/if} + + {#if remotes.length === 0} +

No remotes saved.

+ {:else} +
    + {#each remotes as remote (remote.id)} +
  • + {#if editingId === remote.id} +
    updateRemote(event, remote.id)}> +

    + +

    + +

    + +

    + +

    + +

    + + + +
    + {:else} +

    + {remote.name ?? remote.address} + {#if remote.name} + — {remote.address} + {/if} + {#if remote.port !== null} + :{remote.port} + {/if} +

    +

    ID: {remote.id}

    + + + {/if} +
  • + {/each} +
+ {/if} +
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 74a87de..0ddc791 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,9 +1,13 @@
+
+ +