Initial commit
Some checks failed
Release / release (macos-latest) (push) Has been cancelled
Release / release (ubuntu-22.04) (push) Has been cancelled
Release / release (windows-latest) (push) Has been cancelled

I asked claude to scaffold a project.
I also made changes to it afterwards but they were mostly in getting workflows and testing stuff.
This commit is contained in:
2026-04-15 23:11:48 -04:00
commit faaea6c729
95 changed files with 13165 additions and 0 deletions

7
src-tauri/.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

31
src-tauri/Cargo.toml Normal file
View 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
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -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

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View 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: &notification.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(())
}

View File

@@ -0,0 +1,2 @@
pub mod chat;
pub mod user;

View 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
View 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
View 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
View 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
View 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
View 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
View 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"
]
}
}