From 5c5f4dbf017f46c166cdcf7bcb6753c57fc07629 Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Sun, 2 Aug 2026 09:54:28 +0800 Subject: [PATCH] User profile --- .../20260802000000_create_profile.sql | 6 + src-tauri/src/friends/mod.rs | 24 ++- src-tauri/src/lib.rs | 59 +++++--- src-tauri/src/profile/mod.rs | 138 ++++++++++++++++++ src-tauri/src/user/mod.rs | 11 ++ src/lib/bindings.ts | 23 ++- src/lib/components/friends-list.svelte | 4 +- src/lib/components/profile.svelte | 70 +++++++++ src/routes/+page.svelte | 4 + 9 files changed, 299 insertions(+), 40 deletions(-) create mode 100644 src-tauri/migrations/20260802000000_create_profile.sql create mode 100644 src-tauri/src/profile/mod.rs create mode 100644 src-tauri/src/user/mod.rs create mode 100644 src/lib/components/profile.svelte diff --git a/src-tauri/migrations/20260802000000_create_profile.sql b/src-tauri/migrations/20260802000000_create_profile.sql new file mode 100644 index 0000000..acc9886 --- /dev/null +++ b/src-tauri/migrations/20260802000000_create_profile.sql @@ -0,0 +1,6 @@ +CREATE TABLE profile ( + id INTEGER PRIMARY KEY NOT NULL CHECK (id = 1), + display_name TEXT NOT NULL +); + +INSERT INTO profile (id, display_name) VALUES (1, ''); diff --git a/src-tauri/src/friends/mod.rs b/src-tauri/src/friends/mod.rs index cde521c..73a403c 100644 --- a/src-tauri/src/friends/mod.rs +++ b/src-tauri/src/friends/mod.rs @@ -1,24 +1,18 @@ use crate::db::{self, AppDatabase}; +use crate::user::User; use serde::{Deserialize, Serialize}; use specta::Type; use tauri::{AppHandle, State}; use tauri_specta::Event; -#[derive(Debug, Clone, Serialize, Deserialize, Type, sqlx::FromRow)] -#[serde(rename_all = "camelCase")] -pub struct Friend { - pub id: String, - pub display_name: String, -} - #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] #[serde(rename_all = "camelCase")] pub struct FriendsChanged { - pub friends: Vec, + pub friends: Vec, } -async fn all(database: &AppDatabase) -> Result, sqlx::Error> { - sqlx::query_as::<_, Friend>( +async fn all(database: &AppDatabase) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>( "SELECT id, display_name FROM friends ORDER BY display_name COLLATE NOCASE, id", ) .fetch_all(database.pool()) @@ -38,8 +32,8 @@ async fn emit_changed(handle: &AppHandle, database: &AppDatabase) -> Result<(), pub async fn create_friend( handle: AppHandle, database: State<'_, AppDatabase>, - friend: Friend, -) -> Result { + friend: User, +) -> Result { sqlx::query("INSERT INTO friends (id, display_name) VALUES (?1, ?2)") .bind(&friend.id) .bind(&friend.display_name) @@ -57,7 +51,7 @@ pub async fn create_friend( pub async fn list_friends( handle: AppHandle, database: State<'_, AppDatabase>, -) -> Result, String> { +) -> Result, String> { let friends = all(&database).await.map_err(db::command_error)?; FriendsChanged { @@ -74,8 +68,8 @@ pub async fn list_friends( pub async fn get_friend( database: State<'_, AppDatabase>, id: String, -) -> Result, String> { - sqlx::query_as::<_, Friend>("SELECT id, display_name FROM friends WHERE id = ?1") +) -> Result, String> { + sqlx::query_as::<_, User>("SELECT id, display_name FROM friends WHERE id = ?1") .bind(id) .fetch_optional(database.pool()) .await diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ad2b6fb..1458044 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,8 +2,10 @@ mod cursor; mod db; mod friends; mod keypair; +mod profile; mod remotes; mod ufa; +mod user; mod windowing; async fn launch_app(app: &tauri::App) -> Result<(), Box> { @@ -18,24 +20,7 @@ async fn launch_app(app: &tauri::App) -> Result<(), Box> #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let specta_builder = tauri_specta::Builder::::new() - .error_handling(tauri_specta::ErrorHandlingMode::Throw) - .commands(tauri_specta::collect_commands![ - friends::create_friend, - 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, - remotes::RemotesChanged, - ]); + let specta_builder = specta_builder(); #[cfg(debug_assertions)] specta_builder @@ -57,3 +42,41 @@ pub fn run() { .expect("error while running tauri application") .run(|_, _| ()); } + +fn specta_builder() -> tauri_specta::Builder { + tauri_specta::Builder::::new() + .error_handling(tauri_specta::ErrorHandlingMode::Throw) + .commands(tauri_specta::collect_commands![ + friends::create_friend, + friends::list_friends, + friends::get_friend, + friends::delete_friend, + remotes::create_remote, + remotes::list_remotes, + remotes::get_remote, + remotes::update_remote, + remotes::delete_remote, + profile::get_profile, + profile::update_profile, + keypair::get_public_key, + ]) + .events(tauri_specta::collect_events![ + friends::FriendsChanged, + remotes::RemotesChanged, + profile::ProfileChanged, + ]) +} + +#[cfg(test)] +mod tests { + #[test] + #[ignore = "run explicitly to regenerate frontend bindings"] + fn export_typescript_bindings() { + super::specta_builder() + .export( + specta_typescript::Typescript::default(), + "../src/lib/bindings.ts", + ) + .expect("export TypeScript bindings"); + } +} diff --git a/src-tauri/src/profile/mod.rs b/src-tauri/src/profile/mod.rs new file mode 100644 index 0000000..0508165 --- /dev/null +++ b/src-tauri/src/profile/mod.rs @@ -0,0 +1,138 @@ +use crate::db::{self, AppDatabase}; +use crate::keypair::AppKeypair; +use crate::user::User; +use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri::{AppHandle, State}; +use tauri_specta::Event; + +const PROFILE_ID: i64 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] +#[serde(rename_all = "camelCase")] +pub struct ProfileChanged { + pub profile: User, +} + +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) + .fetch_one(database.pool()) + .await?; + + Ok(User { + id: public_key.to_owned(), + display_name: if display_name.len() <= 0 { + "Anonymous".to_string() + } else { + display_name + }, + }) +} + +async fn update( + database: &AppDatabase, + public_key: &str, + display_name: String, +) -> Result { + sqlx::query("UPDATE profile SET display_name = ?1 WHERE id = ?2") + .bind(display_name) + .bind(PROFILE_ID) + .execute(database.pool()) + .await?; + + get(database, public_key).await +} + +fn emit_changed(handle: &AppHandle, profile: User) -> Result<(), String> { + ProfileChanged { profile } + .emit(handle) + .map_err(db::command_error) +} + +#[tauri::command] +#[specta::specta] +pub async fn get_profile( + handle: AppHandle, + database: State<'_, AppDatabase>, + keypair: State<'_, AppKeypair>, +) -> Result { + let profile = get(&database, keypair.public_key()) + .await + .map_err(db::command_error)?; + emit_changed(&handle, profile.clone())?; + Ok(profile) +} + +#[tauri::command] +#[specta::specta] +pub async fn update_profile( + handle: AppHandle, + database: State<'_, AppDatabase>, + keypair: State<'_, AppKeypair>, + display_name: String, +) -> Result { + let profile = update(&database, keypair.public_key(), display_name) + .await + .map_err(db::command_error)?; + emit_changed(&handle, profile.clone())?; + Ok(profile) +} + +#[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 profile_uses_the_supplied_keypair_identity() { + let database = database().await; + + let profile = update(&database, "public-key", "Wind".to_owned()) + .await + .expect("update profile"); + + assert_eq!( + profile, + User { + id: "public-key".to_owned(), + display_name: "Wind".to_owned(), + } + ); + assert_eq!( + get(&database, "rotated-public-key").await.unwrap(), + User { + id: "rotated-public-key".to_owned(), + display_name: "Wind".to_owned(), + } + ); + } + + #[tokio::test] + async fn profile_table_does_not_duplicate_the_public_key() { + let database = database().await; + let columns = sqlx::query_scalar::<_, String>( + "SELECT name FROM pragma_table_info('profile') ORDER BY cid", + ) + .fetch_all(database.pool()) + .await + .expect("read profile columns"); + + assert_eq!(columns, vec!["id", "display_name"]); + } +} diff --git a/src-tauri/src/user/mod.rs b/src-tauri/src/user/mod.rs new file mode 100644 index 0000000..b43b4d4 --- /dev/null +++ b/src-tauri/src/user/mod.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +/// Public user identity exchanged with peers and remote servers. `id` is the +/// user's Ed25519 public key and is not persisted as local profile metadata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type, sqlx::FromRow)] +#[serde(rename_all = "camelCase")] +pub struct User { + pub id: String, + pub display_name: String, +} diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index 1bc72e4..ca1d4a1 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -5,13 +5,13 @@ export const commands = { -async createFriend(friend: Friend) : Promise { +async createFriend(friend: User) : Promise { return await TAURI_INVOKE("create_friend", { friend }); }, -async listFriends() : Promise { +async listFriends() : Promise { return await TAURI_INVOKE("list_friends"); }, -async getFriend(id: string) : Promise { +async getFriend(id: string) : Promise { return await TAURI_INVOKE("get_friend", { id }); }, async deleteFriend(id: string) : Promise { @@ -32,6 +32,12 @@ async updateRemote(id: string, remote: RemoteInput) : Promise { async deleteRemote(id: string) : Promise { return await TAURI_INVOKE("delete_remote", { id }); }, +async getProfile() : Promise { + return await TAURI_INVOKE("get_profile"); +}, +async updateProfile(displayName: string) : Promise { + return await TAURI_INVOKE("update_profile", { displayName }); +}, async getPublicKey() : Promise { return await TAURI_INVOKE("get_public_key"); } @@ -42,9 +48,11 @@ async getPublicKey() : Promise { export const events = __makeEvents__<{ friendsChanged: FriendsChanged, +profileChanged: ProfileChanged, remotesChanged: RemotesChanged }>({ friendsChanged: "friends-changed", +profileChanged: "profile-changed", remotesChanged: "remotes-changed" }) @@ -54,11 +62,16 @@ remotesChanged: "remotes-changed" /** user-defined types **/ -export type Friend = { id: string; displayName: string } -export type FriendsChanged = { friends: Friend[] } +export type FriendsChanged = { friends: User[] } +export type ProfileChanged = { profile: User } 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[] } +/** + * Public user identity exchanged with peers and remote servers. `id` is the + * user's Ed25519 public key and is not persisted as local profile metadata. + */ +export type User = { id: string; displayName: string } /** tauri-specta globals **/ diff --git a/src/lib/components/friends-list.svelte b/src/lib/components/friends-list.svelte index 062a15d..667584e 100644 --- a/src/lib/components/friends-list.svelte +++ b/src/lib/components/friends-list.svelte @@ -1,8 +1,8 @@ + +
+

Profile

+ + {#if profile} +

Public key: {profile.id}

+ {:else} +

Loading profile...

+ {/if} + +
+ + +
+ + {#if error} +

{error}

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