User profile

This commit is contained in:
2026-08-02 09:54:28 +08:00
parent 0f5ded4cd9
commit 5c5f4dbf01
9 changed files with 299 additions and 40 deletions
@@ -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, '');
+9 -15
View File
@@ -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<Friend>,
pub friends: Vec<User>,
}
async fn all(database: &AppDatabase) -> Result<Vec<Friend>, sqlx::Error> {
sqlx::query_as::<_, Friend>(
async fn all(database: &AppDatabase) -> Result<Vec<User>, 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, String> {
friend: User,
) -> Result<User, String> {
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<Vec<Friend>, String> {
) -> Result<Vec<User>, 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<Option<Friend>, String> {
sqlx::query_as::<_, Friend>("SELECT id, display_name FROM friends WHERE id = ?1")
) -> Result<Option<User>, String> {
sqlx::query_as::<_, User>("SELECT id, display_name FROM friends WHERE id = ?1")
.bind(id)
.fetch_optional(database.pool())
.await
+41 -18
View File
@@ -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<dyn std::error::Error>> {
@@ -18,24 +20,7 @@ async fn launch_app(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>>
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let specta_builder = tauri_specta::Builder::<tauri::Wry>::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::Wry> {
tauri_specta::Builder::<tauri::Wry>::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");
}
}
+138
View File
@@ -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<User, sqlx::Error> {
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<User, sqlx::Error> {
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<User, String> {
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<User, String> {
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"]);
}
}
+11
View File
@@ -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,
}
+18 -5
View File
@@ -5,13 +5,13 @@
export const commands = {
async createFriend(friend: Friend) : Promise<Friend> {
async createFriend(friend: User) : Promise<User> {
return await TAURI_INVOKE("create_friend", { friend });
},
async listFriends() : Promise<Friend[]> {
async listFriends() : Promise<User[]> {
return await TAURI_INVOKE("list_friends");
},
async getFriend(id: string) : Promise<Friend | null> {
async getFriend(id: string) : Promise<User | null> {
return await TAURI_INVOKE("get_friend", { id });
},
async deleteFriend(id: string) : Promise<boolean> {
@@ -32,6 +32,12 @@ async updateRemote(id: string, remote: RemoteInput) : Promise<Remote | null> {
async deleteRemote(id: string) : Promise<boolean> {
return await TAURI_INVOKE("delete_remote", { id });
},
async getProfile() : Promise<User> {
return await TAURI_INVOKE("get_profile");
},
async updateProfile(displayName: string) : Promise<User> {
return await TAURI_INVOKE("update_profile", { displayName });
},
async getPublicKey() : Promise<string> {
return await TAURI_INVOKE("get_public_key");
}
@@ -42,9 +48,11 @@ async getPublicKey() : Promise<string> {
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 **/
+2 -2
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import { onMount } from "svelte";
import { commands, events, type Friend } from "$lib/bindings";
import { commands, events, type User } from "$lib/bindings";
let friends: Friend[] = [];
let friends: User[] = [];
let error = "";
onMount(() => {
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import { onMount } from "svelte";
import { commands, events, type User } from "$lib/bindings";
let profile: User | null = null;
let displayName = "";
let error = "";
onMount(() => {
let stop: (() => void) | undefined;
let disposed = false;
async function initialize() {
const unlisten = await events.profileChanged.listen((event) => {
profile = event.payload.profile;
displayName = event.payload.profile.displayName;
});
if (disposed) {
unlisten();
return;
}
stop = unlisten;
await commands.getProfile();
}
initialize().catch((err) => {
error = String(err);
});
return () => {
disposed = true;
stop?.();
};
});
async function updateProfile(event: SubmitEvent) {
event.preventDefault();
error = "";
try {
await commands.updateProfile(displayName);
} catch (err) {
error = String(err);
}
}
</script>
<section>
<h1>Profile</h1>
{#if profile}
<p>Public key: {profile.id}</p>
{:else}
<p>Loading profile...</p>
{/if}
<form onsubmit={updateProfile}>
<label>
Display name
<input name="displayName" bind:value={displayName} required />
</label>
<button type="submit">Save profile</button>
</form>
{#if error}
<p>{error}</p>
{/if}
</section>
+4
View File
@@ -1,9 +1,13 @@
<script>
import FriendsList from "$lib/components/friends-list.svelte";
import Profile from "$lib/components/profile.svelte";
import RemotesList from "$lib/components/remotes-list.svelte";
</script>
<main>
<div>
<Profile />
</div>
<div>
<FriendsList />
</div>