Initial commit
I asked claude to scaffold a project. I also made changes to it afterwards but they were mostly in getting workflows and testing stuff.
7
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
8242
src-tauri/Cargo.lock
generated
Normal file
31
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "oxyde"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "oxyde_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
surrealdb = { version = "3.0.5" }
|
||||
surrealdb-types = { version = "3.0.5" }
|
||||
tokio = { version = "1.52.0", features = ["full"] }
|
||||
thiserror = "2.0.18"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
10
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 170 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 608 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
163
src-tauri/src/commands/chat.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use uuid::Uuid;
|
||||
use futures_util::StreamExt;
|
||||
use surrealdb::Notification;
|
||||
|
||||
use crate::db::AppState;
|
||||
use crate::error::{into_err, AppError};
|
||||
use crate::models::{Message, Room};
|
||||
|
||||
/// Wrapper emitted to the frontend for each LIVE query notification.
|
||||
/// Includes the action type so the frontend can distinguish create/update/delete.
|
||||
#[derive(serde::Serialize)]
|
||||
struct LiveMessageEvent<'a> {
|
||||
action: String,
|
||||
data: &'a Message,
|
||||
}
|
||||
|
||||
/// Create a new chat room.
|
||||
#[tauri::command]
|
||||
pub async fn create_room(
|
||||
state: State<'_, AppState>,
|
||||
name: String,
|
||||
) -> Result<Room, String> {
|
||||
let mut result: Vec<Room> = state
|
||||
.db
|
||||
.query("CREATE room SET name = $name, created = time::now()")
|
||||
.bind(("name", name))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::NotFound("room after create".into())))
|
||||
}
|
||||
|
||||
/// Fetch all rooms.
|
||||
#[tauri::command]
|
||||
pub async fn get_rooms(state: State<'_, AppState>) -> Result<Vec<Room>, String> {
|
||||
let result: Vec<Room> = state
|
||||
.db
|
||||
.query("SELECT * FROM room ORDER BY created DESC")
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Send a message to a room.
|
||||
#[tauri::command]
|
||||
pub async fn send_message(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
body: String,
|
||||
) -> Result<Message, String> {
|
||||
let mut result: Vec<Message> = state
|
||||
.db
|
||||
.query(
|
||||
"CREATE message SET
|
||||
room = type::record('room', $room_id),
|
||||
author = $auth,
|
||||
body = $body,
|
||||
created = time::now()",
|
||||
)
|
||||
.bind(("room_id", room_id))
|
||||
.bind(("body", body))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::NotFound("message after create".into())))
|
||||
}
|
||||
|
||||
/// Fetch all messages in a room, oldest first.
|
||||
#[tauri::command]
|
||||
pub async fn get_messages(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
) -> Result<Vec<Message>, String> {
|
||||
let result: Vec<Message> = state
|
||||
.db
|
||||
.query("SELECT *, author.username AS author_username FROM message WHERE room = type::record('room', $room_id) ORDER BY created ASC")
|
||||
.bind(("room_id", room_id))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Delete a message by its ID string (e.g. "message:abc123").
|
||||
#[tauri::command]
|
||||
pub async fn delete_message(
|
||||
state: State<'_, AppState>,
|
||||
message_id: String,
|
||||
) -> Result<(), String> {
|
||||
state
|
||||
.db
|
||||
.query("DELETE type::record($id) WHERE author = $auth")
|
||||
.bind(("id", message_id))
|
||||
.await
|
||||
.map_err(into_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a LIVE query for new messages in a room.
|
||||
/// Spawns a background tokio task that emits "chat:message" Tauri events.
|
||||
///
|
||||
/// Returns a local subscription UUID — pass it to `unsubscribe_room` on cleanup.
|
||||
/// Aborting the JoinHandle drops the stream, which closes the LIVE query automatically.
|
||||
#[tauri::command]
|
||||
pub async fn subscribe_room(
|
||||
state: State<'_, AppState>,
|
||||
app_handle: AppHandle,
|
||||
room_id: String,
|
||||
) -> Result<String, String> {
|
||||
let db = state.db.clone();
|
||||
|
||||
let mut stream = db
|
||||
.query("LIVE SELECT *, author.username AS author_username FROM message WHERE room = type::record('room', $room_id)")
|
||||
.bind(("room_id", room_id))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.stream::<Notification<Message>>(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
let sub_id = Uuid::new_v4();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
while let Some(Ok(notification)) = stream.next().await {
|
||||
let _ = app_handle.emit("chat:message", &LiveMessageEvent {
|
||||
action: format!("{:?}", notification.action),
|
||||
data: ¬ification.data,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
state.subscriptions.lock().unwrap().insert(sub_id, handle);
|
||||
|
||||
Ok(sub_id.to_string())
|
||||
}
|
||||
|
||||
/// Stop a LIVE query subscription.
|
||||
/// Aborts the background task — dropping the stream closes the LIVE query.
|
||||
#[tauri::command]
|
||||
pub async fn unsubscribe_room(
|
||||
state: State<'_, AppState>,
|
||||
sub_id: String,
|
||||
) -> Result<(), String> {
|
||||
let uuid = sub_id
|
||||
.parse::<Uuid>()
|
||||
.map_err(|e| into_err(AppError::Subscription(e.to_string())))?;
|
||||
|
||||
if let Some(handle) = state.subscriptions.lock().unwrap().remove(&uuid) {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
2
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod chat;
|
||||
pub mod user;
|
||||
142
src-tauri/src/commands/user.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::db::{AppState, SURREAL_ACCESS, SURREAL_DB, SURREAL_NS};
|
||||
use crate::error::{into_err, AppError};
|
||||
use crate::models::{Contact, User};
|
||||
|
||||
/// Create a new user account via SurrealDB Record Auth SIGNUP.
|
||||
/// Returns the created User record.
|
||||
#[tauri::command]
|
||||
pub async fn signup(
|
||||
state: State<'_, AppState>,
|
||||
email: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<User, String> {
|
||||
let credentials = surrealdb::opt::auth::Record {
|
||||
access: SURREAL_ACCESS.to_string(),
|
||||
namespace: SURREAL_NS.to_string(),
|
||||
database: SURREAL_DB.to_string(),
|
||||
params: serde_json::json!({
|
||||
"email": email,
|
||||
"username": username,
|
||||
"password": password,
|
||||
}),
|
||||
};
|
||||
// into_insecure_token() returns the raw JWT String directly (3.x API).
|
||||
let token = state.db.signup(credentials).await.map_err(into_err)?;
|
||||
*state.token.lock().unwrap() = Some(token.access.into_insecure_token());
|
||||
|
||||
let mut result: Vec<User> = state
|
||||
.db
|
||||
.query("SELECT * FROM $auth")
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::Auth("signup succeeded but $auth not set".into())))
|
||||
}
|
||||
|
||||
/// Authenticate an existing user via SurrealDB Record Auth SIGNIN.
|
||||
/// Returns the JWT token string.
|
||||
#[tauri::command]
|
||||
pub async fn signin(
|
||||
state: State<'_, AppState>,
|
||||
email: String,
|
||||
password: String,
|
||||
) -> Result<String, String> {
|
||||
let credentials = surrealdb::opt::auth::Record {
|
||||
access: SURREAL_ACCESS.to_string(),
|
||||
namespace: SURREAL_NS.to_string(),
|
||||
database: SURREAL_DB.to_string(),
|
||||
params: serde_json::json!({
|
||||
"email": email,
|
||||
"password": password,
|
||||
}),
|
||||
};
|
||||
let token_str = state.db.signin(credentials).await.map_err(into_err)?.access.into_insecure_token();
|
||||
*state.token.lock().unwrap() = Some(token_str.clone());
|
||||
Ok(token_str)
|
||||
}
|
||||
|
||||
/// Clear the current session. Invalidates the token in state.
|
||||
#[tauri::command]
|
||||
pub async fn signout(state: State<'_, AppState>) -> Result<(), String> {
|
||||
state.db.invalidate().await.map_err(into_err)?;
|
||||
*state.token.lock().unwrap() = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the currently authenticated user record.
|
||||
/// Relies on the DB connection being authenticated (token set via signin/signup).
|
||||
#[tauri::command]
|
||||
pub async fn get_me(state: State<'_, AppState>) -> Result<User, String> {
|
||||
let mut result: Vec<User> = state
|
||||
.db
|
||||
.query("SELECT * FROM $auth")
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::Auth("not authenticated".into())))
|
||||
}
|
||||
|
||||
/// Update mutable profile fields. Only provided fields are changed.
|
||||
#[tauri::command]
|
||||
pub async fn update_profile(
|
||||
state: State<'_, AppState>,
|
||||
username: Option<String>,
|
||||
avatar: Option<String>,
|
||||
) -> Result<User, String> {
|
||||
let mut result: Vec<User> = state
|
||||
.db
|
||||
.query(
|
||||
"UPDATE $auth SET
|
||||
username = $username ?? username,
|
||||
avatar = $avatar ?? avatar
|
||||
RETURN AFTER",
|
||||
)
|
||||
.bind(("username", username))
|
||||
.bind(("avatar", avatar))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::NotFound("user".into())))
|
||||
}
|
||||
|
||||
/// Return the contacts list for the current user.
|
||||
/// Contacts are `contact` records where `owner = $auth`.
|
||||
#[tauri::command]
|
||||
pub async fn get_contacts(state: State<'_, AppState>) -> Result<Vec<User>, String> {
|
||||
let result: Vec<User> = state
|
||||
.db
|
||||
.query("SELECT target.* FROM contact WHERE owner = $auth")
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Add a user to the current user's contact list. Stub — returns the Contact record.
|
||||
#[tauri::command]
|
||||
pub async fn add_contact(
|
||||
state: State<'_, AppState>,
|
||||
user_id: String,
|
||||
) -> Result<Contact, String> {
|
||||
let mut result: Vec<Contact> = state
|
||||
.db
|
||||
.query("CREATE contact SET owner = $auth, target = type::record('user', $uid)")
|
||||
.bind(("uid", user_id))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::NotFound("contact after create".into())))
|
||||
}
|
||||
44
src-tauri/src/db.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, LazyLock};
|
||||
use std::env;
|
||||
|
||||
use surrealdb::engine::remote::ws::{Client, Ws};
|
||||
use surrealdb::Surreal;
|
||||
use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
pub static SURREAL_URL: LazyLock<String> = LazyLock::new(|| {
|
||||
env::var("SURREAL_URL").unwrap_or_else(|_| "localhost:8000".to_string())
|
||||
});
|
||||
pub static SURREAL_NS: LazyLock<String> = LazyLock::new(|| {
|
||||
env::var("SURREAL_NS").unwrap_or_else(|_| "dev".to_string())
|
||||
});
|
||||
pub static SURREAL_DB: LazyLock<String> = LazyLock::new(|| {
|
||||
env::var("SURREAL_DB").unwrap_or_else(|_| "oxyde".to_string())
|
||||
});
|
||||
pub static SURREAL_ACCESS: LazyLock<String> = LazyLock::new(|| {
|
||||
env::var("SURREAL_ACCESS").unwrap_or_else(|_| "account".to_string())
|
||||
});
|
||||
|
||||
pub struct AppState {
|
||||
/// Long-lived authenticated WebSocket connection to SurrealDB.
|
||||
pub db: Arc<Surreal<Client>>,
|
||||
/// JWT token from Record Auth signin. Used to re-authenticate on reconnect.
|
||||
/// std::sync::Mutex is intentional: lock is acquired and released before any .await.
|
||||
pub token: Mutex<Option<String>>,
|
||||
/// Active LIVE query tasks keyed by their SurrealDB LIVE query UUID.
|
||||
/// Abort a handle + KILL the query to clean up.
|
||||
/// std::sync::Mutex is intentional: guards are never held across .await points.
|
||||
/// If a future command needs to lock across .await, switch to tokio::sync::Mutex.
|
||||
pub subscriptions: Mutex<HashMap<Uuid, JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
/// Connect to SurrealDB over WebSocket and select namespace/database.
|
||||
/// Call once at app startup before managing state.
|
||||
pub async fn init_db(url: &str, ns: &str, db: &str) -> Result<Surreal<Client>, AppError> {
|
||||
let client = Surreal::new::<Ws>(url).await?;
|
||||
client.use_ns(ns).use_db(db).await?;
|
||||
Ok(client)
|
||||
}
|
||||
47
src-tauri/src/error.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AppError {
|
||||
#[error("Database error: {0}")]
|
||||
Db(#[from] surrealdb::Error),
|
||||
|
||||
#[error("Auth error: {0}")]
|
||||
Auth(String),
|
||||
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Subscription error: {0}")]
|
||||
Subscription(String),
|
||||
}
|
||||
|
||||
impl From<AppError> for String {
|
||||
fn from(e: AppError) -> Self {
|
||||
e.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert any error that's Into<AppError> into the String Tauri commands require.
|
||||
/// Usage: `.map_err(into_err)`
|
||||
pub fn into_err<E: Into<AppError>>(e: E) -> String {
|
||||
e.into().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn app_error_converts_to_string() {
|
||||
let e = AppError::Auth("bad credentials".to_string());
|
||||
let s: String = into_err(e);
|
||||
assert_eq!(s, "Auth error: bad credentials");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_error_converts_to_string() {
|
||||
let e = AppError::NotFound("room".to_string());
|
||||
let s: String = into_err(e);
|
||||
assert_eq!(s, "Not found: room");
|
||||
}
|
||||
}
|
||||
52
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
mod commands;
|
||||
mod db;
|
||||
mod error;
|
||||
mod models;
|
||||
|
||||
use db::{init_db, AppState, SURREAL_DB, SURREAL_NS, SURREAL_URL};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(|app| {
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
let surreal = init_db(SURREAL_URL.as_str(), SURREAL_NS.as_str(), SURREAL_DB.as_str())
|
||||
.await
|
||||
.expect("Failed to connect to SurrealDB");
|
||||
|
||||
let state = AppState {
|
||||
db: Arc::new(surreal),
|
||||
token: Mutex::new(None),
|
||||
subscriptions: Mutex::new(HashMap::new()),
|
||||
};
|
||||
|
||||
app_handle.manage(state);
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::user::signup,
|
||||
commands::user::signin,
|
||||
commands::user::signout,
|
||||
commands::user::get_me,
|
||||
commands::user::update_profile,
|
||||
commands::user::get_contacts,
|
||||
commands::user::add_contact,
|
||||
commands::chat::create_room,
|
||||
commands::chat::get_rooms,
|
||||
commands::chat::send_message,
|
||||
commands::chat::get_messages,
|
||||
commands::chat::delete_message,
|
||||
commands::chat::subscribe_room,
|
||||
commands::chat::unsubscribe_room,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
oxyde_lib::run()
|
||||
}
|
||||
51
src-tauri/src/models.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use surrealdb::types::{Datetime, RecordId};
|
||||
use surrealdb_types::SurrealValue;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||
pub struct User {
|
||||
pub id: RecordId,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub avatar: Option<String>,
|
||||
pub created: Datetime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||
pub struct Room {
|
||||
pub id: RecordId,
|
||||
pub name: String,
|
||||
pub created: Datetime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||
pub struct Message {
|
||||
pub id: RecordId,
|
||||
pub room: RecordId,
|
||||
pub author: RecordId,
|
||||
pub author_username: Option<String>,
|
||||
pub body: String,
|
||||
pub created: Datetime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||
pub struct Contact {
|
||||
pub id: RecordId,
|
||||
pub owner: RecordId,
|
||||
pub target: RecordId,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn models_compile() {
|
||||
// Structural compile check — verifies field types resolve correctly.
|
||||
fn _assert_serialize<T: Serialize + for<'de> Deserialize<'de>>() {}
|
||||
_assert_serialize::<User>();
|
||||
_assert_serialize::<Room>();
|
||||
_assert_serialize::<Message>();
|
||||
_assert_serialize::<Contact>();
|
||||
}
|
||||
}
|
||||
36
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "oxyde",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.jimweaver.oxyde",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Oxyde",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "nsis", "app"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||