Files
friendolls/src-tauri/src/profile/mod.rs
T
2026-09-03 16:21:01 +08:00

222 lines
6.0 KiB
Rust

use crate::db::{self, AppDatabase};
use crate::keypair::AppKeypair;
use crate::network::Network;
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,
}
pub async fn get(database: &AppDatabase, public_key: &str) -> Result<User, sqlx::Error> {
let (display_name, skin_hash) = sqlx::query_as::<_, (String, Option<String>)>(
"SELECT display_name, skin_hash FROM profile WHERE id = ?1",
)
.bind(PROFILE_ID)
.fetch_one(database.pool())
.await?;
let display_name_configured = !display_name.is_empty();
Ok(User {
id: public_key.to_owned(),
display_name: if display_name.is_empty() {
"Anonymous".to_string()
} else {
display_name
},
display_name_configured,
skin_hash,
})
}
async fn update(
database: &AppDatabase,
public_key: &str,
display_name: String,
skin_hash: Option<String>,
) -> Result<User, sqlx::Error> {
sqlx::query(
"UPDATE profile SET display_name = ?1, skin_hash = COALESCE(?2, skin_hash) WHERE id = ?3",
)
.bind(display_name)
.bind(skin_hash)
.bind(PROFILE_ID)
.execute(database.pool())
.await?;
get(database, public_key).await
}
async fn reset_skin(database: &AppDatabase, public_key: &str) -> Result<User, sqlx::Error> {
sqlx::query("UPDATE profile SET skin_hash = NULL WHERE id = ?1")
.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>,
network: State<'_, Network>,
display_name: String,
skin_data: Option<Vec<u8>>,
) -> Result<User, String> {
let skin_hash = skin_data
.as_deref()
.map(|data| crate::skins::store_local(&handle, data))
.transpose()?;
let profile = update(&database, keypair.public_key(), display_name, skin_hash)
.await
.map_err(db::command_error)?;
network.update_profile(profile.clone());
emit_changed(&handle, profile.clone())?;
Ok(profile)
}
#[tauri::command]
#[specta::specta]
pub async fn reset_profile_skin(
handle: AppHandle,
database: State<'_, AppDatabase>,
keypair: State<'_, AppKeypair>,
network: State<'_, Network>,
) -> Result<User, String> {
let profile = reset_skin(&database, keypair.public_key())
.await
.map_err(db::command_error)?;
network.update_profile(profile.clone());
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(), None)
.await
.expect("update profile");
assert_eq!(
profile,
User {
id: "public-key".to_owned(),
display_name: "Wind".to_owned(),
display_name_configured: true,
skin_hash: None,
}
);
assert_eq!(
get(&database, "rotated-public-key").await.unwrap(),
User {
id: "rotated-public-key".to_owned(),
display_name: "Wind".to_owned(),
display_name_configured: true,
skin_hash: None,
}
);
let profile = update(
&database,
"public-key",
"Wind Two".to_owned(),
Some("a".repeat(64)),
)
.await
.expect("update profile and skin together");
assert_eq!(profile.display_name, "Wind Two");
assert_eq!(profile.skin_hash, Some("a".repeat(64)));
}
#[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", "skin_hash"]);
}
#[tokio::test]
async fn resetting_skin_preserves_the_display_name() {
let database = database().await;
update(
&database,
"public-key",
"Wind".to_owned(),
Some("a".repeat(64)),
)
.await
.expect("set profile skin");
let profile = reset_skin(&database, "public-key")
.await
.expect("reset profile skin");
assert_eq!(
profile,
User {
id: "public-key".to_owned(),
display_name: "Wind".to_owned(),
display_name_configured: true,
skin_hash: None,
}
);
}
}