User profile
This commit is contained in:
@@ -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, '');
|
||||||
@@ -1,24 +1,18 @@
|
|||||||
use crate::db::{self, AppDatabase};
|
use crate::db::{self, AppDatabase};
|
||||||
|
use crate::user::User;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, State};
|
||||||
use tauri_specta::Event;
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct FriendsChanged {
|
pub struct FriendsChanged {
|
||||||
pub friends: Vec<Friend>,
|
pub friends: Vec<User>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn all(database: &AppDatabase) -> Result<Vec<Friend>, sqlx::Error> {
|
async fn all(database: &AppDatabase) -> Result<Vec<User>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Friend>(
|
sqlx::query_as::<_, User>(
|
||||||
"SELECT id, display_name FROM friends ORDER BY display_name COLLATE NOCASE, id",
|
"SELECT id, display_name FROM friends ORDER BY display_name COLLATE NOCASE, id",
|
||||||
)
|
)
|
||||||
.fetch_all(database.pool())
|
.fetch_all(database.pool())
|
||||||
@@ -38,8 +32,8 @@ async fn emit_changed(handle: &AppHandle, database: &AppDatabase) -> Result<(),
|
|||||||
pub async fn create_friend(
|
pub async fn create_friend(
|
||||||
handle: AppHandle,
|
handle: AppHandle,
|
||||||
database: State<'_, AppDatabase>,
|
database: State<'_, AppDatabase>,
|
||||||
friend: Friend,
|
friend: User,
|
||||||
) -> Result<Friend, String> {
|
) -> Result<User, String> {
|
||||||
sqlx::query("INSERT INTO friends (id, display_name) VALUES (?1, ?2)")
|
sqlx::query("INSERT INTO friends (id, display_name) VALUES (?1, ?2)")
|
||||||
.bind(&friend.id)
|
.bind(&friend.id)
|
||||||
.bind(&friend.display_name)
|
.bind(&friend.display_name)
|
||||||
@@ -57,7 +51,7 @@ pub async fn create_friend(
|
|||||||
pub async fn list_friends(
|
pub async fn list_friends(
|
||||||
handle: AppHandle,
|
handle: AppHandle,
|
||||||
database: State<'_, AppDatabase>,
|
database: State<'_, AppDatabase>,
|
||||||
) -> Result<Vec<Friend>, String> {
|
) -> Result<Vec<User>, String> {
|
||||||
let friends = all(&database).await.map_err(db::command_error)?;
|
let friends = all(&database).await.map_err(db::command_error)?;
|
||||||
|
|
||||||
FriendsChanged {
|
FriendsChanged {
|
||||||
@@ -74,8 +68,8 @@ pub async fn list_friends(
|
|||||||
pub async fn get_friend(
|
pub async fn get_friend(
|
||||||
database: State<'_, AppDatabase>,
|
database: State<'_, AppDatabase>,
|
||||||
id: String,
|
id: String,
|
||||||
) -> Result<Option<Friend>, String> {
|
) -> Result<Option<User>, String> {
|
||||||
sqlx::query_as::<_, Friend>("SELECT id, display_name FROM friends WHERE id = ?1")
|
sqlx::query_as::<_, User>("SELECT id, display_name FROM friends WHERE id = ?1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(database.pool())
|
.fetch_optional(database.pool())
|
||||||
.await
|
.await
|
||||||
|
|||||||
+41
-18
@@ -2,8 +2,10 @@ mod cursor;
|
|||||||
mod db;
|
mod db;
|
||||||
mod friends;
|
mod friends;
|
||||||
mod keypair;
|
mod keypair;
|
||||||
|
mod profile;
|
||||||
mod remotes;
|
mod remotes;
|
||||||
mod ufa;
|
mod ufa;
|
||||||
|
mod user;
|
||||||
mod windowing;
|
mod windowing;
|
||||||
|
|
||||||
async fn launch_app(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let specta_builder = tauri_specta::Builder::<tauri::Wry>::new()
|
let specta_builder = specta_builder();
|
||||||
.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,
|
|
||||||
]);
|
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
specta_builder
|
specta_builder
|
||||||
@@ -57,3 +42,41 @@ pub fn run() {
|
|||||||
.expect("error while running tauri application")
|
.expect("error while running tauri application")
|
||||||
.run(|_, _| ());
|
.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -5,13 +5,13 @@
|
|||||||
|
|
||||||
|
|
||||||
export const commands = {
|
export const commands = {
|
||||||
async createFriend(friend: Friend) : Promise<Friend> {
|
async createFriend(friend: User) : Promise<User> {
|
||||||
return await TAURI_INVOKE("create_friend", { friend });
|
return await TAURI_INVOKE("create_friend", { friend });
|
||||||
},
|
},
|
||||||
async listFriends() : Promise<Friend[]> {
|
async listFriends() : Promise<User[]> {
|
||||||
return await TAURI_INVOKE("list_friends");
|
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 });
|
return await TAURI_INVOKE("get_friend", { id });
|
||||||
},
|
},
|
||||||
async deleteFriend(id: string) : Promise<boolean> {
|
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> {
|
async deleteRemote(id: string) : Promise<boolean> {
|
||||||
return await TAURI_INVOKE("delete_remote", { id });
|
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> {
|
async getPublicKey() : Promise<string> {
|
||||||
return await TAURI_INVOKE("get_public_key");
|
return await TAURI_INVOKE("get_public_key");
|
||||||
}
|
}
|
||||||
@@ -42,9 +48,11 @@ async getPublicKey() : Promise<string> {
|
|||||||
|
|
||||||
export const events = __makeEvents__<{
|
export const events = __makeEvents__<{
|
||||||
friendsChanged: FriendsChanged,
|
friendsChanged: FriendsChanged,
|
||||||
|
profileChanged: ProfileChanged,
|
||||||
remotesChanged: RemotesChanged
|
remotesChanged: RemotesChanged
|
||||||
}>({
|
}>({
|
||||||
friendsChanged: "friends-changed",
|
friendsChanged: "friends-changed",
|
||||||
|
profileChanged: "profile-changed",
|
||||||
remotesChanged: "remotes-changed"
|
remotesChanged: "remotes-changed"
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -54,11 +62,16 @@ remotesChanged: "remotes-changed"
|
|||||||
|
|
||||||
/** user-defined types **/
|
/** user-defined types **/
|
||||||
|
|
||||||
export type Friend = { id: string; displayName: string }
|
export type FriendsChanged = { friends: User[] }
|
||||||
export type FriendsChanged = { friends: Friend[] }
|
export type ProfileChanged = { profile: User }
|
||||||
export type Remote = { id: string; address: string; name: string | null; port: number | null }
|
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 RemoteInput = { address: string; name: string | null; port: number | null }
|
||||||
export type RemotesChanged = { remotes: Remote[] }
|
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 **/
|
/** tauri-specta globals **/
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
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 = "";
|
let error = "";
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
<script>
|
<script>
|
||||||
import FriendsList from "$lib/components/friends-list.svelte";
|
import FriendsList from "$lib/components/friends-list.svelte";
|
||||||
|
import Profile from "$lib/components/profile.svelte";
|
||||||
import RemotesList from "$lib/components/remotes-list.svelte";
|
import RemotesList from "$lib/components/remotes-list.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
<div>
|
||||||
|
<Profile />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<FriendsList />
|
<FriendsList />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user