feat: Initial implementation

This commit is contained in:
2025-10-24 11:35:35 +07:00
commit cdeaab4e0a
143 changed files with 25287 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

7110
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

46
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,46 @@
[package]
name = "vrc-circle"
version = "0.0.1"
description = "A Tauri App"
authors = ["Yuzu <yuzu@kirameki.cafe>"]
edition = "2024"
default-run = "vrc-circle"
# 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 = "vrc_one_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
specta = { version = "2.0.0-rc.20", features = ["serde"] }
specta-typescript = "0.0.9"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dependencies]
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-opener = "2"
tauri-plugin-log = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json", "cookies"] }
base64 = "0.22"
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
futures-util = "0.3"
http = "1.1"
directories = "5.0"
dirs = "5.0"
chrono = { version = "0.4", features = ["serde"] }
specta = { version = "2.0.0-rc.20", features = ["serde"] }
tauri-specta = { version = "2.0.0-rc.20", features = ["typescript"] }
specta-typescript = "0.0.9"
sea-orm = { version = "0.12", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros"] }
log = "0.4"
sha2 = "0.10"
url = "2"

4
src-tauri/build.rs Normal file
View File

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

View File

@@ -0,0 +1,12 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"core:path:default",
"core:image:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

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: 85 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,256 @@
// TODO: Improve this dev tool
use sea_orm::{
ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DbBackend, Statement,
};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct TableInfo {
pub name: String,
pub sql: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct ColumnInfo {
pub cid: i32,
pub name: String,
pub r#type: String,
pub notnull: i32,
pub dflt_value: Option<String>,
pub pk: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct QueryResult {
pub columns: Vec<String>,
#[specta(type = Vec<HashMap<String, String>>)]
pub rows: Vec<HashMap<String, String>>,
pub rows_affected: Option<i32>,
}
pub struct DatabaseStudio {
db: DatabaseConnection,
}
impl DatabaseStudio {
pub async fn new() -> Result<Self, String> {
// Use per-user local data directory (e.g. %LOCALAPPDATA% on Windows)
let base_dir = dirs::data_local_dir()
.ok_or("Failed to resolve local data directory")?
.join("vrc-circle");
std::fs::create_dir_all(&base_dir)
.map_err(|e| format!("Failed to create app data directory: {}", e))?;
let db_path = base_dir.join("data.sqlite");
let db_url = format!(
"sqlite://{}?mode=rwc",
db_path.to_string_lossy().replace('\\', "/")
);
let mut options = ConnectOptions::new(db_url);
options.sqlx_logging(false);
let db = Database::connect(options)
.await
.map_err(|e| format!("Failed to connect to database: {}", e))?;
Ok(Self { db })
}
pub async fn list_tables(&self) -> Result<Vec<TableInfo>, String> {
let query = Statement::from_string(
DbBackend::Sqlite,
"SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
.to_string(),
);
let result = self
.db
.query_all(query)
.await
.map_err(|e| format!("Failed to list tables: {}", e))?;
let tables: Vec<TableInfo> = result
.into_iter()
.map(|row| {
let name: String = row.try_get("", "name").unwrap_or_default();
let sql: String = row.try_get("", "sql").unwrap_or_default();
TableInfo { name, sql }
})
.collect();
Ok(tables)
}
pub async fn get_table_schema(&self, table_name: &str) -> Result<Vec<ColumnInfo>, String> {
let query = Statement::from_string(
DbBackend::Sqlite,
format!("PRAGMA table_info('{}')", table_name),
);
let result = self
.db
.query_all(query)
.await
.map_err(|e| format!("Failed to get table schema: {}", e))?;
let columns: Vec<ColumnInfo> = result
.into_iter()
.map(|row| ColumnInfo {
cid: row.try_get("", "cid").unwrap_or_default(),
name: row.try_get("", "name").unwrap_or_default(),
r#type: row.try_get("", "type").unwrap_or_default(),
notnull: row.try_get("", "notnull").unwrap_or_default(),
dflt_value: row.try_get("", "dflt_value").ok(),
pk: row.try_get("", "pk").unwrap_or_default(),
})
.collect();
Ok(columns)
}
pub async fn get_table_data(
&self,
table_name: &str,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<QueryResult, String> {
let limit_val = limit.unwrap_or(100);
let offset_val = offset.unwrap_or(0);
let query_str = format!(
"SELECT * FROM {} LIMIT {} OFFSET {}",
table_name, limit_val, offset_val
);
let mut result = self.execute_query(&query_str).await?;
// Get actual column names from schema and remap the data
let schema = self.get_table_schema(table_name).await?;
let actual_columns: Vec<String> = schema.into_iter().map(|col| col.name).collect();
// Remap rows from column_0, column_1 to actual column names
let remapped_rows: Vec<HashMap<String, String>> = result
.rows
.into_iter()
.map(|row| {
let mut new_row = HashMap::new();
for (idx, actual_col) in actual_columns.iter().enumerate() {
let key = format!("column_{}", idx);
if let Some(value) = row.get(&key) {
new_row.insert(actual_col.clone(), value.clone());
}
}
new_row
})
.collect();
result.columns = actual_columns;
result.rows = remapped_rows;
Ok(result)
}
pub async fn get_table_count(&self, table_name: &str) -> Result<i32, String> {
let query = Statement::from_string(
DbBackend::Sqlite,
format!("SELECT COUNT(*) as count FROM {}", table_name),
);
let result = self
.db
.query_one(query)
.await
.map_err(|e| format!("Failed to count rows: {}", e))?;
if let Some(row) = result {
let count: i64 = row.try_get("", "count").unwrap_or_default();
Ok(count as i32)
} else {
Ok(0)
}
}
pub async fn execute_query(&self, query: &str) -> Result<QueryResult, String> {
use sea_orm::TryGetable;
let query_stmt = Statement::from_string(DbBackend::Sqlite, query.to_string());
let result = self
.db
.query_all(query_stmt)
.await
.map_err(|e| format!("Query execution failed: {}", e))?;
if result.is_empty() {
return Ok(QueryResult {
columns: vec![],
rows: vec![],
rows_affected: Some(0),
});
}
// Extract column names and convert rows
let mut columns: Vec<String> = Vec::new();
let mut rows: Vec<HashMap<String, String>> = Vec::new();
for row in result {
// Get columns from first row by trying all possible indices
if columns.is_empty() {
let mut idx = 0;
loop {
// Try to get as String first, then fall back to other types
match String::try_get_by_index(&row, idx) {
Ok(_) => {
idx += 1;
}
Err(_) => {
// Try as i64
match i64::try_get_by_index(&row, idx) {
Ok(_) => {
idx += 1;
}
Err(_) => break,
}
}
}
}
// We don't know column names yet, will be set by get_table_data
columns = (0..idx).map(|i| format!("column_{}", i)).collect();
}
// Convert row to HashMap with String values by index
let mut map = HashMap::new();
for idx in 0..columns.len() {
// Try multiple types and convert to string
let str_value = if let Ok(val) = String::try_get_by_index(&row, idx) {
val
} else if let Ok(val) = i64::try_get_by_index(&row, idx) {
val.to_string()
} else if let Ok(val) = f64::try_get_by_index(&row, idx) {
val.to_string()
} else if let Ok(val) = bool::try_get_by_index(&row, idx) {
val.to_string()
} else if let Ok(Some(val)) = Option::<String>::try_get_by_index(&row, idx) {
val
} else {
"NULL".to_string()
};
let key = format!("column_{}", idx);
map.insert(key, str_value);
}
rows.push(map);
}
Ok(QueryResult {
columns,
rows,
rows_affected: None,
})
}
}

View File

@@ -0,0 +1,35 @@
pub const USER_AGENT_STRING: &str = "VRC-Circle/0.0.1 contact@kirameki.cafe";
// Retry configuration
pub const MAX_REQUEST_RETRIES: u8 = 5;
// Backoff timings (milliseconds)
pub const INITIAL_BACKOFF: u64 = 500;
pub const MAX_BACKOFF: u64 = 10_000;
// Rate limiting
pub const MAX_DOWNLOADS_PER_SECOND: u32 = 10;
// Common API Request headers builder
use reqwest::header::{
ACCEPT, AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, ORIGIN, USER_AGENT,
};
pub fn build_api_headers(auth: Option<&str>, cookie: Option<&str>) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static(USER_AGENT_STRING));
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers.insert(ORIGIN, HeaderValue::from_static("https://vrchat.com"));
if let Some(auth_value) = auth {
headers.insert(AUTHORIZATION, HeaderValue::from_str(auth_value).unwrap());
}
if let Some(cookie_value) = cookie {
headers.insert("cookie", HeaderValue::from_str(cookie_value).unwrap());
}
headers
}

771
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,771 @@
pub mod database_studio;
pub mod http_common;
pub mod log_manager;
pub mod store;
pub mod vrchat_api;
pub mod vrchat_status;
pub mod websocket;
use database_studio::{ColumnInfo, DatabaseStudio, QueryResult, TableInfo};
use log::info;
use log_manager::{LogEntry, LogManager};
use std::sync::Arc;
use store::{AccountStore, AppSettings, ImageCacheStore, SettingsStore, StoredAccount, UserStore};
use tauri::{Manager, State};
use tauri_specta::{Builder as SpectaBuilder, collect_commands};
use tokio::sync::Mutex;
use vrchat_api::{
AgeVerificationStatus, AvatarPerformance, AvatarStyles, Badge, DeveloperType, DiscordDetails,
FriendRequestStatus, GoogleDetails, LimitedAvatar, LimitedUserFriend, LimitedWorld,
LoginCredentials, LoginResult, OrderOption, PastDisplayName, PerformanceRatings, ReleaseStatus,
SteamDetails, TwoFactorMethod, UnityPackageSummary, UpdateStatusRequest, User, UserState,
UserStatus, VRCError, VRChatClient,
};
use vrchat_status::{StatusPage, SystemStatus, VRChatStatusResponse};
use websocket::VRChatWebSocket;
#[cfg(debug_assertions)]
use specta_typescript::Typescript;
// Application State
struct AppState {
vrchat_client: Arc<Mutex<VRChatClient>>,
account_store: AccountStore,
websocket: Arc<Mutex<VRChatWebSocket>>,
user_store: UserStore,
settings_store: SettingsStore,
#[allow(dead_code)]
image_cache: Arc<ImageCacheStore>,
}
// VRChat API Commands
#[tauri::command]
#[specta::specta]
async fn vrchat_login(
email: String,
password: String,
state: State<'_, AppState>,
) -> Result<LoginResult, VRCError> {
let credentials = LoginCredentials { email, password };
let client = state.vrchat_client.lock().await;
client.login(&credentials).await
}
#[tauri::command]
#[specta::specta]
async fn vrchat_verify_2fa(
code: String,
method: String,
state: State<'_, AppState>,
) -> Result<bool, VRCError> {
let two_fa_method = TwoFactorMethod::from_str(&method)
.ok_or_else(|| VRCError::invalid_input(format!("Invalid 2FA method: {}", method)))?;
let client = state.vrchat_client.lock().await;
client.verify_two_factor(&code, two_fa_method).await
}
#[tauri::command]
#[specta::specta]
async fn vrchat_get_current_user(state: State<'_, AppState>) -> Result<User, VRCError> {
if let Some(user) = state.user_store.get_current_user().await {
return Ok(user);
}
let client = state.vrchat_client.lock().await;
let user = client.get_current_user().await?;
state.user_store.set_current_user(user.clone()).await;
Ok(user)
}
#[tauri::command]
#[specta::specta]
async fn vrchat_update_status(
status: UserStatus,
status_description: String,
state: State<'_, AppState>,
) -> Result<User, VRCError> {
let request = UpdateStatusRequest {
status,
status_description,
};
let client = state.vrchat_client.lock().await;
let user = client.update_status(&request).await?;
drop(client);
state.user_store.set_current_user(user.clone()).await;
Ok(user)
}
#[tauri::command]
#[specta::specta]
async fn vrchat_logout(state: State<'_, AppState>) -> Result<(), VRCError> {
let websocket = state.websocket.lock().await;
websocket.stop().await;
drop(websocket);
state.user_store.clear_all().await;
let client = state.vrchat_client.lock().await;
client.logout().await
}
#[tauri::command]
#[specta::specta]
async fn websocket_start(state: State<'_, AppState>) -> Result<(), VRCError> {
let client = state.vrchat_client.lock().await;
let (auth_cookie, two_factor_cookie) = client.export_cookies().await;
drop(client);
log::debug!(
"WebSocket starting with cookies - auth: {:?}, 2fa: {:?}",
auth_cookie
.as_ref()
.map(|c| format!("{}...", &c.chars().take(20).collect::<String>())),
two_factor_cookie
.as_ref()
.map(|c| format!("{}...", &c.chars().take(20).collect::<String>()))
);
let websocket = state.websocket.lock().await;
websocket.set_cookies(auth_cookie, two_factor_cookie).await;
websocket.start().await
}
#[tauri::command]
#[specta::specta]
async fn websocket_stop(state: State<'_, AppState>) -> Result<(), VRCError> {
let websocket = state.websocket.lock().await;
websocket.stop().await;
Ok(())
}
#[tauri::command]
#[specta::specta]
async fn vrchat_get_online_friends(
state: State<'_, AppState>,
) -> Result<Vec<LimitedUserFriend>, VRCError> {
let cached_friends = state.user_store.get_all_friends().await;
if !cached_friends.is_empty() {
return Ok(cached_friends);
}
let client = state.vrchat_client.lock().await;
let friends = client.get_all_friends().await?;
state.user_store.set_friends(friends.clone()).await;
Ok(friends)
}
#[tauri::command]
#[specta::specta]
async fn vrchat_get_uploaded_worlds(
state: State<'_, AppState>,
) -> Result<Vec<LimitedWorld>, VRCError> {
let client = state.vrchat_client.lock().await;
client.get_uploaded_worlds().await
}
#[tauri::command]
#[specta::specta]
async fn vrchat_get_uploaded_avatars(
state: State<'_, AppState>,
) -> Result<Vec<LimitedAvatar>, VRCError> {
let client = state.vrchat_client.lock().await;
client.get_uploaded_avatars().await
}
#[tauri::command]
#[specta::specta]
async fn get_online_friends(
state: State<'_, AppState>,
) -> Result<Vec<LimitedUserFriend>, VRCError> {
Ok(state.user_store.get_all_friends().await)
}
#[tauri::command]
#[specta::specta]
async fn check_image_cached(
url: String,
state: State<'_, AppState>,
) -> Result<Option<String>, VRCError> {
if url.trim().is_empty() {
return Ok(None);
}
let cache = state.image_cache.clone();
if let Some(path) = cache.get_cached_path(&url).await {
Ok(Some(path.to_string_lossy().to_string()))
} else {
Ok(None)
}
}
#[tauri::command]
#[specta::specta]
async fn cache_image(url: String, state: State<'_, AppState>) -> Result<String, VRCError> {
if url.trim().is_empty() {
return Err(VRCError::invalid_input("Image URL is empty".to_string()));
}
info!("Caching image: {}", url);
let client = state.vrchat_client.lock().await;
let (auth_cookie, two_factor_cookie) = client.export_cookies().await;
drop(client);
let cookies = match (auth_cookie, two_factor_cookie) {
(Some(auth), Some(two_fa)) => Some(format!("{}; {}", auth, two_fa)),
(Some(auth), None) => Some(auth),
(None, Some(two_fa)) => Some(two_fa),
(None, None) => None,
};
let cache = state.image_cache.clone();
let path = cache
.get_or_fetch(&url, cookies)
.await
.map_err(VRCError::unknown)?;
info!("Image cached to: {}", path.display());
Ok(path.to_string_lossy().to_string())
}
#[tauri::command]
#[specta::specta]
async fn get_cache_directory(state: State<'_, AppState>) -> Result<String, VRCError> {
let cache = state.image_cache.clone();
let dir = cache.get_cache_dir();
Ok(dir.to_string_lossy().to_string())
}
#[tauri::command]
#[specta::specta]
async fn get_all_friends(state: State<'_, AppState>) -> Result<Vec<LimitedUserFriend>, VRCError> {
Ok(state.user_store.get_all_friends().await)
}
#[tauri::command]
#[specta::specta]
async fn get_user(
user_id: String,
state: State<'_, AppState>,
) -> Result<Option<LimitedUserFriend>, VRCError> {
Ok(state.user_store.get_user(&user_id).await)
}
#[tauri::command]
#[specta::specta]
async fn get_user_by_id(user_id: String, state: State<'_, AppState>) -> Result<User, VRCError> {
if let Some(cached_user) = state.user_store.get_full_user(&user_id).await {
log::debug!("get_user_by_id: Returning cached user for {}", user_id);
return Ok(cached_user);
}
log::info!("get_user_by_id: Fetching user {} from API", user_id);
let client = state.vrchat_client.lock().await;
let user = client.get_user_by_id(&user_id).await?;
drop(client);
log::info!("get_user_by_id: Successfully fetched user {}", user_id);
state.user_store.cache_full_user(user.clone()).await;
Ok(user)
}
#[tauri::command]
#[specta::specta]
async fn is_friend(user_id: String, state: State<'_, AppState>) -> Result<bool, VRCError> {
Ok(state.user_store.is_friend(&user_id).await)
}
#[tauri::command]
#[specta::specta]
async fn is_user_online(user_id: String, state: State<'_, AppState>) -> Result<bool, VRCError> {
Ok(state.user_store.is_user_online(&user_id).await)
}
#[tauri::command]
#[specta::specta]
async fn vrchat_check_session(state: State<'_, AppState>) -> Result<bool, VRCError> {
let client = state.vrchat_client.lock().await;
Ok(client.has_valid_session().await)
}
#[tauri::command]
#[specta::specta]
async fn vrchat_clear_session(state: State<'_, AppState>) -> Result<(), VRCError> {
let client = state.vrchat_client.lock().await;
client.clear_cookies().await;
drop(client);
state.user_store.clear_all().await;
state
.account_store
.clear_last_active_account()
.await
.map_err(|e| VRCError::unknown(e))?;
Ok(())
}
// Account Management Commands
#[tauri::command]
#[specta::specta]
async fn save_current_account(user: User, state: State<'_, AppState>) -> Result<(), VRCError> {
let client = state.vrchat_client.lock().await;
let (auth_cookie, two_factor_cookie) = client.export_cookies().await;
let avatar_override = user
.user_icon
.clone()
.or_else(|| user.profile_pic_override.clone())
.or_else(|| user.profile_pic_override_thumbnail.clone());
let avatar_fallback = user
.current_avatar_thumbnail_image_url
.clone()
.or_else(|| user.current_avatar_image_url.clone());
let account = StoredAccount {
user_id: user.id.clone(),
username: user.username.clone(),
display_name: user.display_name.clone(),
avatar_url: avatar_override.clone().or_else(|| avatar_fallback.clone()),
avatar_fallback_url: avatar_fallback,
auth_cookie,
two_factor_cookie,
last_login: chrono::Utc::now().to_rfc3339(),
};
state
.account_store
.save_account(account)
.await
.map_err(|e| VRCError::unknown(e))
}
#[tauri::command]
#[specta::specta]
async fn get_all_accounts(state: State<'_, AppState>) -> Result<Vec<StoredAccount>, VRCError> {
state
.account_store
.get_all_accounts()
.await
.map_err(|e| VRCError::unknown(e))
}
#[tauri::command]
#[specta::specta]
async fn switch_account(user_id: String, state: State<'_, AppState>) -> Result<User, VRCError> {
state.user_store.clear_all().await;
let account = state
.account_store
.get_account(&user_id)
.await
.map_err(|e| VRCError::unknown(e))?
.ok_or_else(|| VRCError::invalid_input("Account not found"))?;
state
.account_store
.set_active_account(&user_id)
.await
.map_err(|e| VRCError::unknown(e))?;
let client = state.vrchat_client.lock().await;
client
.import_cookies(account.auth_cookie, account.two_factor_cookie)
.await;
match client.get_current_user().await {
Ok(user) => {
state.user_store.set_current_user(user.clone()).await;
Ok(user)
}
Err(err) => {
// TODO: Handle if switching account fails
Err(err)
}
}
}
#[tauri::command]
#[specta::specta]
async fn remove_account(user_id: String, state: State<'_, AppState>) -> Result<(), VRCError> {
state
.account_store
.remove_account(&user_id)
.await
.map_err(|e| VRCError::unknown(e))
}
#[tauri::command]
#[specta::specta]
async fn clear_all_accounts(state: State<'_, AppState>) -> Result<(), VRCError> {
state
.account_store
.clear_all_accounts()
.await
.map_err(|e| VRCError::unknown(e))
}
#[tauri::command]
#[specta::specta]
async fn load_last_account(state: State<'_, AppState>) -> Result<Option<User>, VRCError> {
let account = match state
.account_store
.get_last_active_account()
.await
.map_err(|e| VRCError::unknown(e))?
{
Some(acc) => acc,
None => return Ok(None),
};
let client = state.vrchat_client.lock().await;
client
.import_cookies(account.auth_cookie, account.two_factor_cookie)
.await;
match client.get_current_user().await {
Ok(user) => Ok(Some(user)),
Err(_) => {
// TODO: Handle if last account cannot be loaded
// client.clear_cookies().await;
Ok(None)
}
}
}
// Settings Commands
#[tauri::command]
#[specta::specta]
async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, VRCError> {
state
.settings_store
.get_settings()
.await
.map_err(|e| VRCError::unknown(e))
}
#[tauri::command]
#[specta::specta]
async fn save_settings(settings: AppSettings, state: State<'_, AppState>) -> Result<(), VRCError> {
info!(
"Saving settings: developer_mode={}",
settings.developer_mode
);
state
.settings_store
.save_settings(settings)
.await
.map_err(|e| VRCError::unknown(e))
}
#[tauri::command]
#[specta::specta]
async fn get_developer_mode(state: State<'_, AppState>) -> Result<bool, VRCError> {
state
.settings_store
.get_developer_mode()
.await
.map_err(|e| VRCError::unknown(e))
}
#[tauri::command]
#[specta::specta]
async fn set_developer_mode(enabled: bool, state: State<'_, AppState>) -> Result<(), VRCError> {
info!("Setting developer mode: {}", enabled);
state
.settings_store
.set_developer_mode(enabled)
.await
.map_err(|e| VRCError::unknown(e))
}
// Log Commands
#[tauri::command]
#[specta::specta]
async fn get_backend_logs() -> Result<Vec<LogEntry>, VRCError> {
LogManager::read_logs().map_err(|e| VRCError::unknown(e))
}
#[tauri::command]
#[specta::specta]
async fn clear_backend_logs() -> Result<(), VRCError> {
info!("Clearing backend logs");
LogManager::clear_logs().map_err(|e| VRCError::unknown(e))
}
#[tauri::command]
#[specta::specta]
async fn export_backend_logs() -> Result<String, VRCError> {
LogManager::export_logs().map_err(|e| VRCError::unknown(e))
}
// VRChat Service Status Commands
#[tauri::command]
#[specta::specta]
async fn get_vrchat_status() -> Result<VRChatStatusResponse, String> {
vrchat_status::fetch_vrchat_status().await
}
// Database Studio Commands
#[tauri::command]
#[specta::specta]
async fn db_list_tables() -> Result<Vec<TableInfo>, VRCError> {
let studio = DatabaseStudio::new()
.await
.map_err(|e| VRCError::unknown(e))?;
studio.list_tables().await.map_err(|e| VRCError::unknown(e))
}
#[tauri::command]
#[specta::specta]
async fn db_get_table_schema(table_name: String) -> Result<Vec<ColumnInfo>, VRCError> {
let studio = DatabaseStudio::new()
.await
.map_err(|e| VRCError::unknown(e))?;
studio
.get_table_schema(&table_name)
.await
.map_err(|e| VRCError::unknown(e))
}
#[tauri::command]
#[specta::specta]
async fn db_get_table_data(
table_name: String,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<QueryResult, VRCError> {
let studio = DatabaseStudio::new()
.await
.map_err(|e| VRCError::unknown(e))?;
studio
.get_table_data(&table_name, limit, offset)
.await
.map_err(|e| VRCError::unknown(e))
}
#[tauri::command]
#[specta::specta]
async fn db_get_table_count(table_name: String) -> Result<i32, VRCError> {
let studio = DatabaseStudio::new()
.await
.map_err(|e| VRCError::unknown(e))?;
studio
.get_table_count(&table_name)
.await
.map_err(|e| VRCError::unknown(e))
}
#[tauri::command]
#[specta::specta]
async fn db_execute_query(query: String) -> Result<QueryResult, VRCError> {
let studio = DatabaseStudio::new()
.await
.map_err(|e| VRCError::unknown(e))?;
studio
.execute_query(&query)
.await
.map_err(|e| VRCError::unknown(e))
}
// Binding Generation
fn create_specta_builder() -> SpectaBuilder<tauri::Wry> {
SpectaBuilder::<tauri::Wry>::new()
.commands(collect_commands![
vrchat_login,
vrchat_verify_2fa,
vrchat_get_current_user,
vrchat_update_status,
vrchat_logout,
vrchat_get_online_friends,
vrchat_get_uploaded_worlds,
vrchat_get_uploaded_avatars,
get_online_friends,
get_all_friends,
get_user,
get_user_by_id,
is_friend,
is_user_online,
vrchat_check_session,
vrchat_clear_session,
websocket_start,
websocket_stop,
save_current_account,
get_all_accounts,
switch_account,
remove_account,
clear_all_accounts,
load_last_account,
get_settings,
save_settings,
get_developer_mode,
set_developer_mode,
get_backend_logs,
clear_backend_logs,
export_backend_logs,
get_vrchat_status,
db_list_tables,
db_get_table_schema,
db_get_table_data,
db_get_table_count,
db_execute_query,
check_image_cached,
cache_image,
get_cache_directory,
])
// Core VRChat API types
.typ::<VRCError>()
.typ::<User>()
.typ::<LoginResult>()
.typ::<UpdateStatusRequest>()
// Enum types
.typ::<UserStatus>()
.typ::<ReleaseStatus>()
.typ::<DeveloperType>()
.typ::<AgeVerificationStatus>()
.typ::<FriendRequestStatus>()
.typ::<UserState>()
.typ::<PerformanceRatings>()
.typ::<OrderOption>()
// User-related types
.typ::<LimitedUserFriend>()
.typ::<PastDisplayName>()
.typ::<Badge>()
.typ::<DiscordDetails>()
.typ::<GoogleDetails>()
.typ::<SteamDetails>()
// World types
.typ::<LimitedWorld>()
.typ::<UnityPackageSummary>()
// Avatar types
.typ::<LimitedAvatar>()
.typ::<AvatarPerformance>()
.typ::<AvatarStyles>()
// Store types
.typ::<StoredAccount>()
.typ::<AppSettings>()
// Log types
.typ::<LogEntry>()
// Database types
.typ::<TableInfo>()
.typ::<ColumnInfo>()
.typ::<QueryResult>()
// Status types
.typ::<VRChatStatusResponse>()
.typ::<SystemStatus>()
.typ::<StatusPage>()
}
pub fn generate_bindings() {
use std::{fs, fs::File, io::Write};
eprintln!("Generating TypeScript bindings...");
let specta_builder = create_specta_builder();
let formatter = Typescript::default();
let bindings = specta_builder
.export_str(&formatter)
.expect("Failed to generate TypeScript bindings");
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let output_path = manifest_dir
.join("..")
.join("src")
.join("types")
.join("bindings.ts");
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent).expect("Failed to create bindings directory");
}
let mut file = File::create(&output_path).expect("Failed to create TypeScript bindings file");
file.write_all(
b"// @ts-nocheck\n// This file is auto-generated by Specta. Do not edit manually.\n\n",
)
.expect("Failed to write bindings header");
file.write_all(bindings.as_bytes())
.expect("Failed to write TypeScript bindings");
formatter.format(&output_path).ok();
eprintln!(
"Successfully generated bindings at {}",
output_path.display()
);
}
// Application Entry Point
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let specta_builder = create_specta_builder();
#[cfg(debug_assertions)]
{
eprintln!("Auto-generating TypeScript bindings in development mode...");
generate_bindings();
}
let vrchat_client = VRChatClient::new().expect("Failed to create VRChat client");
let account_store =
tauri::async_runtime::block_on(AccountStore::new()).expect("Failed to create AccountStore");
let settings_store = tauri::async_runtime::block_on(SettingsStore::new())
.expect("Failed to create SettingsStore");
let image_cache = Arc::new(
tauri::async_runtime::block_on(ImageCacheStore::new())
.expect("Failed to create ImageCacheStore"),
);
let user_store = UserStore::new();
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(
tauri_plugin_log::Builder::new()
.target(tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::Stdout,
))
.target(tauri_plugin_log::Target::new(
tauri_plugin_log::TargetKind::LogDir {
file_name: Some("vrc-circle".to_string()),
},
))
.level(log::LevelFilter::Info)
.build(),
)
.invoke_handler(specta_builder.invoke_handler())
.setup(move |app| {
// Initialize WebSocket with app handle and UserStore
let websocket = VRChatWebSocket::new(app.handle().clone(), user_store.clone());
let app_state = AppState {
vrchat_client: Arc::new(Mutex::new(vrchat_client)),
account_store,
websocket: Arc::new(Mutex::new(websocket)),
user_store,
settings_store,
image_cache: image_cache.clone(),
};
app.manage(app_state);
specta_builder.mount_events(app);
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -0,0 +1,101 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct LogEntry {
pub timestamp: String,
pub level: String,
pub source: String,
pub module: String,
pub message: String,
}
pub struct LogManager;
impl LogManager {
pub fn get_log_file_path() -> Result<PathBuf, String> {
let log_dir = dirs::data_local_dir()
.ok_or("Failed to get local data directory")?
.join("cafe.kirameki.vrc-circle")
.join("logs");
fs::create_dir_all(&log_dir)
.map_err(|e| format!("Failed to create logs directory: {}", e))?;
Ok(log_dir.join("vrc-circle.log"))
}
pub fn read_logs() -> Result<Vec<LogEntry>, String> {
let log_path = Self::get_log_file_path()?;
if !log_path.exists() {
return Ok(Vec::new());
}
let content =
fs::read_to_string(&log_path).map_err(|e| format!("Failed to read log file: {}", e))?;
let mut entries = Vec::new();
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
// Parse log line format: "2024-01-01 12:00:00 [INFO] module: message"
if let Some(entry) = Self::parse_log_line(line) {
entries.push(entry);
}
}
Ok(entries)
}
fn parse_log_line(line: &str) -> Option<LogEntry> {
// tauri-plugin-log format
// Example: "2024-01-01 12:00:00.123 [INFO] vrc_circle: message"
let parts: Vec<&str> = line.splitn(4, ' ').collect();
if parts.len() < 4 {
return None;
}
let timestamp = format!("{} {}", parts[0], parts[1]);
let level_part = parts[2].trim_matches(|c| c == '[' || c == ']');
let rest = parts[3];
// Split module and message by ": "
let (module, message) = if let Some(idx) = rest.find(": ") {
let (mod_part, msg_part) = rest.split_at(idx);
(mod_part.to_string(), msg_part[2..].to_string())
} else {
("unknown".to_string(), rest.to_string())
};
Some(LogEntry {
timestamp,
level: level_part.to_uppercase(),
source: "backend".to_string(),
module,
message,
})
}
pub fn clear_logs() -> Result<(), String> {
let log_path = Self::get_log_file_path()?;
if log_path.exists() {
fs::remove_file(&log_path).map_err(|e| format!("Failed to clear log file: {}", e))?;
}
Ok(())
}
pub fn export_logs() -> Result<String, String> {
let entries = Self::read_logs()?;
serde_json::to_string_pretty(&entries)
.map_err(|e| format!("Failed to serialize logs: {}", e))
}
}

17
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,17 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() > 1 && args[1] == "--generate-bindings" {
eprintln!("Generating TypeScript bindings...");
vrc_one_lib::generate_bindings();
eprintln!("Bindings generated successfully!");
return;
}
vrc_one_lib::run()
}

View File

@@ -0,0 +1,272 @@
use sea_orm::sea_query::{Expr, OnConflict};
use sea_orm::{
ActiveModelTrait, ActiveValue::Set, ColumnTrait, ConnectionTrait, DatabaseConnection,
EntityTrait, QueryFilter, QueryOrder, Schema, Statement, TransactionTrait,
};
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct StoredAccount {
pub user_id: String,
pub username: String,
pub display_name: String,
#[serde(default)]
pub avatar_url: Option<String>,
#[serde(default)]
pub avatar_fallback_url: Option<String>,
pub auth_cookie: Option<String>,
pub two_factor_cookie: Option<String>,
pub last_login: String,
}
mod account_entity {
use sea_orm::entity::prelude::*;
use sea_orm::ActiveModelBehavior;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "accounts")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: String,
pub username: String,
pub display_name: String,
pub avatar_url: Option<String>,
pub avatar_fallback_url: Option<String>,
pub auth_cookie: Option<String>,
pub two_factor_cookie: Option<String>,
pub last_login: String,
#[sea_orm(column_type = "Boolean", default_value = 0)]
pub last_active: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
use account_entity::{
ActiveModel as AccountActiveModel, Column as AccountColumn, Entity as AccountEntity,
Model as AccountModel,
};
pub struct AccountStore {
db: DatabaseConnection,
}
impl AccountStore {
pub async fn new() -> Result<Self, String> {
let db = crate::store::connect_db("accounts").await?;
let store = Self { db };
store.init_schema().await?;
Ok(store)
}
async fn init_schema(&self) -> Result<(), String> {
let backend = self.db.get_database_backend();
let schema = Schema::new(backend);
let create_table = schema
.create_table_from_entity(AccountEntity)
.if_not_exists()
.to_owned();
let statement: Statement = backend.build(&create_table);
self.db
.execute(statement)
.await
.map_err(|e| format!("Failed to initialize accounts table: {}", e))?;
Ok(())
}
pub async fn save_account(&self, account: StoredAccount) -> Result<(), String> {
let mut txn = self
.db
.begin()
.await
.map_err(|e| format!("Failed to begin save transaction: {}", e))?;
AccountEntity::update_many()
.col_expr(AccountColumn::LastActive, Expr::value(false))
.exec(&mut txn)
.await
.map_err(|e| format!("Failed to clear last active flag: {}", e))?;
let mut active_model = to_active_model(account);
active_model.last_active = Set(true);
AccountEntity::insert(active_model)
.on_conflict(
OnConflict::column(AccountColumn::UserId)
.update_columns([
AccountColumn::Username,
AccountColumn::DisplayName,
AccountColumn::AvatarUrl,
AccountColumn::AvatarFallbackUrl,
AccountColumn::AuthCookie,
AccountColumn::TwoFactorCookie,
AccountColumn::LastLogin,
AccountColumn::LastActive,
])
.to_owned(),
)
.exec(&mut txn)
.await
.map_err(|e| format!("Failed to upsert account: {}", e))?;
txn.commit()
.await
.map_err(|e| format!("Failed to commit account save: {}", e))
}
pub async fn get_account(&self, user_id: &str) -> Result<Option<StoredAccount>, String> {
let account = AccountEntity::find_by_id(user_id.to_string())
.one(&self.db)
.await
.map_err(|e| format!("Failed to load account: {}", e))?;
Ok(account.map(StoredAccount::from))
}
pub async fn get_last_active_account(&self) -> Result<Option<StoredAccount>, String> {
let account = AccountEntity::find()
.filter(AccountColumn::LastActive.eq(true))
.one(&self.db)
.await
.map_err(|e| format!("Failed to load last active account: {}", e))?;
Ok(account.map(StoredAccount::from))
}
pub async fn get_all_accounts(&self) -> Result<Vec<StoredAccount>, String> {
let accounts = AccountEntity::find()
.order_by_desc(AccountColumn::LastLogin)
.all(&self.db)
.await
.map_err(|e| format!("Failed to load accounts: {}", e))?;
Ok(accounts.into_iter().map(StoredAccount::from).collect())
}
pub async fn remove_account(&self, user_id: &str) -> Result<(), String> {
let mut txn = self
.db
.begin()
.await
.map_err(|e| format!("Failed to begin remove transaction: {}", e))?;
let target = AccountEntity::find_by_id(user_id.to_string())
.one(&mut txn)
.await
.map_err(|e| format!("Failed to find account: {}", e))?;
if target.is_none() {
txn.rollback().await.ok();
return Ok(());
}
let was_active = target.as_ref().map(|acc| acc.last_active).unwrap_or(false);
AccountEntity::delete_by_id(user_id.to_string())
.exec(&mut txn)
.await
.map_err(|e| format!("Failed to delete account: {}", e))?;
if was_active {
AccountEntity::update_many()
.col_expr(AccountColumn::LastActive, Expr::value(false))
.exec(&mut txn)
.await
.map_err(|e| format!("Failed to clear last active flag: {}", e))?;
}
txn.commit()
.await
.map_err(|e| format!("Failed to commit account removal: {}", e))
}
pub async fn set_active_account(&self, user_id: &str) -> Result<(), String> {
let mut txn = self
.db
.begin()
.await
.map_err(|e| format!("Failed to begin activation transaction: {}", e))?;
let account = AccountEntity::find_by_id(user_id.to_string())
.one(&mut txn)
.await
.map_err(|e| format!("Failed to find account: {}", e))?;
let Some(model) = account else {
txn.rollback().await.ok();
return Err("Account not found".to_string());
};
AccountEntity::update_many()
.col_expr(AccountColumn::LastActive, Expr::value(false))
.exec(&mut txn)
.await
.map_err(|e| format!("Failed to clear last active flags: {}", e))?;
let mut active_model: AccountActiveModel = model.into();
active_model.last_active = Set(true);
active_model
.update(&mut txn)
.await
.map_err(|e| format!("Failed to mark account active: {}", e))?;
txn.commit()
.await
.map_err(|e| format!("Failed to commit account activation: {}", e))
}
pub async fn clear_all_accounts(&self) -> Result<(), String> {
AccountEntity::delete_many()
.exec(&self.db)
.await
.map_err(|e| format!("Failed to clear accounts: {}", e))?;
Ok(())
}
pub async fn clear_last_active_account(&self) -> Result<(), String> {
AccountEntity::update_many()
.col_expr(AccountColumn::LastActive, Expr::value(false))
.exec(&self.db)
.await
.map_err(|e| format!("Failed to clear last active account: {}", e))?;
Ok(())
}
}
fn to_active_model(account: StoredAccount) -> AccountActiveModel {
AccountActiveModel {
user_id: Set(account.user_id),
username: Set(account.username),
display_name: Set(account.display_name),
avatar_url: Set(account.avatar_url),
avatar_fallback_url: Set(account.avatar_fallback_url),
auth_cookie: Set(account.auth_cookie),
two_factor_cookie: Set(account.two_factor_cookie),
last_login: Set(account.last_login),
last_active: Set(false),
}
}
impl From<AccountModel> for StoredAccount {
fn from(model: AccountModel) -> Self {
Self {
user_id: model.user_id,
username: model.username,
display_name: model.display_name,
avatar_url: model.avatar_url,
avatar_fallback_url: model.avatar_fallback_url,
auth_cookie: model.auth_cookie,
two_factor_cookie: model.two_factor_cookie,
last_login: model.last_login,
}
}
}

24
src-tauri/src/store/db.rs Normal file
View File

@@ -0,0 +1,24 @@
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
pub async fn connect_db(component: &str) -> Result<DatabaseConnection, String> {
// Use per-user local data directory (this is %LOCALAPPDATA% on Windows)
let base_dir = dirs::data_local_dir()
.ok_or("Failed to resolve local data directory")?
.join("vrc-circle");
std::fs::create_dir_all(&base_dir)
.map_err(|e| format!("Failed to create app data directory: {}", e))?;
let db_path = base_dir.join("data.sqlite");
let db_url = format!(
"sqlite://{}?mode=rwc",
db_path.to_string_lossy().replace('\\', "/")
);
let mut options = ConnectOptions::new(db_url);
options.sqlx_logging(false);
Database::connect(options)
.await
.map_err(|e| format!("Failed to connect to {} database: {}", component, e))
}

View File

@@ -0,0 +1,328 @@
use crate::http_common::{
INITIAL_BACKOFF as INITIAL_BACKOFF_MS, MAX_BACKOFF as MAX_BACKOFF_MS, MAX_DOWNLOADS_PER_SECOND,
MAX_REQUEST_RETRIES, USER_AGENT_STRING,
};
use reqwest::{
Client,
header::{COOKIE, USER_AGENT},
};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::fs;
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex;
use tokio::time::sleep;
const INITIAL_BACKOFF: Duration = Duration::from_millis(INITIAL_BACKOFF_MS);
const MAX_BACKOFF: Duration = Duration::from_millis(MAX_BACKOFF_MS);
pub struct ImageCacheStore {
base_dir: PathBuf,
client: Client,
in_flight: Arc<Mutex<HashSet<String>>>,
rate_limiter: Arc<Mutex<RateLimiter>>,
}
struct RateLimiter {
last_reset: Instant,
tokens: u32,
max_tokens: u32,
}
impl RateLimiter {
fn new(max_tokens: u32) -> Self {
Self {
last_reset: Instant::now(),
tokens: max_tokens,
max_tokens,
}
}
async fn acquire(&mut self) {
loop {
// Refill tokens if duration has passed
let now = Instant::now();
if now.duration_since(self.last_reset) >= Duration::from_secs(1) {
self.tokens = self.max_tokens;
self.last_reset = now;
}
// If we have tokens, consume one and return
if self.tokens > 0 {
self.tokens -= 1;
return;
}
// Otherwise, sleep until the next refill
let time_until_refill =
Duration::from_secs(1).saturating_sub(now.duration_since(self.last_reset));
if time_until_refill > Duration::from_millis(0) {
sleep(time_until_refill).await;
}
}
}
}
impl ImageCacheStore {
pub async fn new() -> Result<Self, String> {
// Use per-user local data directory (this is %LOCALAPPDATA% on Windows)
let base_dir = dirs::data_local_dir()
.ok_or("Failed to resolve local data directory")?
.join("vrc-circle");
let cache_dir = base_dir.join("cache").join("files");
log::info!("Image cache directory: {}", cache_dir.display());
fs::create_dir_all(&cache_dir)
.await
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| format!("Failed to create cache HTTP client: {}", e))?;
Ok(Self {
base_dir: cache_dir,
client,
in_flight: Arc::new(Mutex::new(HashSet::new())),
rate_limiter: Arc::new(Mutex::new(RateLimiter::new(MAX_DOWNLOADS_PER_SECOND))),
})
}
pub fn get_cache_dir(&self) -> &Path {
&self.base_dir
}
pub async fn get_cached_path(&self, url: &str) -> Option<PathBuf> {
if url.trim().is_empty() {
return None;
}
let (file_name, extension) = Self::hash_filename(url);
let mut file_path = self.base_dir.join(&file_name);
if let Some(ext) = extension {
file_path.set_extension(ext);
}
if fs::metadata(&file_path).await.is_ok() {
Some(file_path)
} else {
None
}
}
pub async fn get_or_fetch(
&self,
url: &str,
auth_cookies: Option<String>,
) -> Result<PathBuf, String> {
log::info!("[CACHE] get_or_fetch called for: {}", url);
if url.trim().is_empty() {
return Err("Image URL is empty".to_string());
}
let (file_name, extension) = Self::hash_filename(url);
let mut file_path = self.base_dir.join(&file_name);
if let Some(ext) = extension {
file_path.set_extension(ext);
}
log::info!("[CACHE] Computed file path: {}", file_path.display());
// If cache hit
if fs::metadata(&file_path).await.is_ok() {
log::info!("[CACHE] HIT: {}", file_path.display());
return Ok(file_path);
}
// Rate limiter, wait for a token before acquiring the in-flight lock
{
let mut limiter = self.rate_limiter.lock().await;
limiter.acquire().await;
}
// Deduplicate, if another download is in progress for this URL, wait for it to complete
{
let mut in_flight = self.in_flight.lock().await;
if in_flight.contains(url) {
drop(in_flight);
log::debug!("Download already in progress for: {}, waiting...", url);
// Poll for status
for _ in 0..60 {
// Wait up to 30 seconds (60 * 500ms)
sleep(Duration::from_millis(500)).await;
if fs::metadata(&file_path).await.is_ok() {
log::debug!(
"Download completed by another request: {}",
file_path.display()
);
return Ok(file_path);
}
}
return Err(format!("Timeout waiting for concurrent download: {}", url));
}
// Mark this URL as in-flight
in_flight.insert(url.to_string());
}
log::info!("Cache MISS: Downloading {}", url);
let result = self.do_download(url, auth_cookies, &file_path).await;
// Remove from in-flight set
{
let mut in_flight = self.in_flight.lock().await;
in_flight.remove(url);
}
result
}
async fn do_download(
&self,
url: &str,
auth_cookies: Option<String>,
file_path: &Path,
) -> Result<PathBuf, String> {
let tmp_path = file_path.with_extension("tmp");
if let Some(parent) = tmp_path.parent() {
fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to prepare cache directory: {}", e))?;
}
let bytes = self.download_with_retry(url, auth_cookies).await?;
log::info!("Downloaded {} bytes from {}", bytes.len(), url);
let mut file = fs::File::create(&tmp_path)
.await
.map_err(|e| format!("Failed to create cache file: {}", e))?;
file.write_all(&bytes)
.await
.map_err(|e| format!("Failed to write cache file: {}", e))?;
file.flush()
.await
.map_err(|e| format!("Failed to flush cache file: {}", e))?;
fs::rename(&tmp_path, &file_path)
.await
.map_err(|e| format!("Failed to finalize cache file: {}", e))?;
log::info!("Cached to: {}", file_path.display());
Ok(file_path.to_path_buf())
}
fn hash_filename(url: &str) -> (String, Option<String>) {
let mut hasher = Sha256::new();
hasher.update(url.as_bytes());
let hash = format!("{:x}", hasher.finalize());
let extension = url::Url::parse(url).ok().and_then(|parsed| {
Path::new(parsed.path())
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.split('?').next().unwrap_or(name))
.and_then(|name| Path::new(name).extension())
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_lowercase())
});
(hash, extension)
}
async fn download_with_retry(
&self,
url: &str,
auth_cookies: Option<String>,
) -> Result<Vec<u8>, String> {
let mut attempt = 0;
let mut backoff = INITIAL_BACKOFF;
loop {
let mut request = self.client.get(url).header(USER_AGENT, USER_AGENT_STRING);
// Only pass cookies if URL is from VRChat domains to prevent leaking credentials
let is_vrchat_domain = url.starts_with("https://api.vrchat.cloud/")
|| url.starts_with("https://files.vrchat.cloud/")
|| url.starts_with("https://assets.vrchat.com/")
|| url.starts_with("https://d348imysud55la.cloudfront.net/");
if is_vrchat_domain {
if let Some(ref cookies) = auth_cookies {
request = request.header(COOKIE, cookies);
}
}
let response = request.send().await;
match response {
Ok(resp) => {
let status = resp.status();
log::debug!("[CACHE] HTTP {}: {}", status.as_u16(), url);
if status.is_success() {
return resp.bytes().await.map(|bytes| bytes.to_vec()).map_err(|e| {
log::error!("[CACHE] Failed to read bytes: {}", e);
format!("Failed to read image bytes: {}", e)
});
}
if status.as_u16() == 429 || status.is_server_error() {
log::warn!("[CACHE] Retryable error {} for: {}", status.as_u16(), url);
if attempt >= MAX_REQUEST_RETRIES {
return Err(format!(
"Image request failed after retries (status {}): {}",
status.as_u16(),
url
));
}
let wait = Self::retry_after_seconds(&resp).unwrap_or(backoff);
sleep(wait).await;
attempt += 1;
backoff = (backoff * 2).min(MAX_BACKOFF);
continue;
}
log::error!("[CACHE] HTTP error {}: {}", status.as_u16(), url);
return Err(format!(
"Failed to download image (status {}): {}",
status.as_u16(),
url
));
}
Err(err) => {
log::error!(
"[CACHE] Network error: {} (attempt {}/{})",
err,
attempt + 1,
MAX_REQUEST_RETRIES
);
if attempt >= MAX_REQUEST_RETRIES {
return Err(format!("Failed to download image: {}", err));
}
sleep(backoff).await;
attempt += 1;
backoff = (backoff * 2).min(MAX_BACKOFF);
}
}
}
}
fn retry_after_seconds(response: &reqwest::Response) -> Option<Duration> {
response
.headers()
.get("retry-after")
.and_then(|value| value.to_str().ok())
.and_then(|seconds| seconds.parse::<u64>().ok())
.map(Duration::from_secs)
}
}

View File

@@ -0,0 +1,11 @@
pub mod account_store;
pub mod image_cache;
pub mod settings_store;
pub mod user_store;
pub mod db;
pub use account_store::{AccountStore, StoredAccount};
pub use image_cache::ImageCacheStore;
pub use settings_store::{AppSettings, SettingsStore};
pub use user_store::UserStore;
pub use db::connect_db;

View File

@@ -0,0 +1,135 @@
use sea_orm::sea_query::OnConflict;
use sea_orm::{
ActiveValue::Set, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter,
Schema, Statement,
};
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct AppSettings {
pub developer_mode: bool,
}
impl Default for AppSettings {
fn default() -> Self {
Self {
developer_mode: false,
}
}
}
mod settings_entity {
use sea_orm::ActiveModelBehavior;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "settings")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub key: String,
pub value: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
}
use settings_entity::{
ActiveModel as SettingsActiveModel, Column as SettingsColumn, Entity as SettingsEntity,
};
pub struct SettingsStore {
db: DatabaseConnection,
}
impl SettingsStore {
pub async fn new() -> Result<Self, String> {
let db = crate::store::connect_db("settings").await?;
let store = Self { db };
store.init_schema().await?;
Ok(store)
}
async fn init_schema(&self) -> Result<(), String> {
let backend = self.db.get_database_backend();
let schema = Schema::new(backend);
let create_table = schema
.create_table_from_entity(SettingsEntity)
.if_not_exists()
.to_owned();
let statement: Statement = backend.build(&create_table);
self.db
.execute(statement)
.await
.map_err(|e| format!("Failed to initialize settings table: {}", e))?;
Ok(())
}
async fn get_setting(&self, key: &str, default: &str) -> Result<String, String> {
let settings_row = SettingsEntity::find()
.filter(SettingsColumn::Key.eq(key))
.one(&self.db)
.await
.map_err(|e| format!("Failed to load setting '{}': {}", key, e))?;
Ok(settings_row
.map(|row| row.value)
.unwrap_or_else(|| default.to_string()))
}
async fn set_setting(&self, key: &str, value: &str) -> Result<(), String> {
let active_model = SettingsActiveModel {
key: Set(key.to_string()),
value: Set(value.to_string()),
};
SettingsEntity::insert(active_model)
.on_conflict(
OnConflict::column(SettingsColumn::Key)
.update_column(SettingsColumn::Value)
.to_owned(),
)
.exec(&self.db)
.await
.map_err(|e| format!("Failed to save setting '{}': {}", key, e))?;
Ok(())
}
pub async fn get_settings(&self) -> Result<AppSettings, String> {
let developer_mode = self.get_setting("developer_mode", "false").await?;
Ok(AppSettings {
developer_mode: developer_mode == "true",
})
}
pub async fn save_settings(&self, settings: AppSettings) -> Result<(), String> {
self.set_setting(
"developer_mode",
if settings.developer_mode {
"true"
} else {
"false"
},
)
.await
}
pub async fn get_developer_mode(&self) -> Result<bool, String> {
let value = self.get_setting("developer_mode", "false").await?;
Ok(value == "true")
}
pub async fn set_developer_mode(&self, enabled: bool) -> Result<(), String> {
self.set_setting("developer_mode", if enabled { "true" } else { "false" })
.await
}
}

View File

@@ -0,0 +1,651 @@
use crate::vrchat_api::types::{LimitedUserFriend, User, UserStatus};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UserRelationship {
CurrentUser,
Friend,
Known,
Unknown,
}
fn parse_user_status(status: &str) -> UserStatus {
match status {
"active" => UserStatus::Active,
"join me" => UserStatus::JoinMe,
"ask me" => UserStatus::AskMe,
"busy" => UserStatus::Busy,
_ => UserStatus::Offline, // Default fallback
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Platform {
StandaloneWindows,
Android,
Web,
Other(String),
}
fn parse_platform(platform: &str) -> Platform {
match platform {
"standalonewindows" => Platform::StandaloneWindows,
"android" => Platform::Android,
"web" => Platform::Web,
other => Platform::Other(other.to_string()),
}
}
/// Cached user representation
#[derive(Debug, Clone)]
pub struct CachedUser {
pub id: String,
pub display_name: String,
pub username: Option<String>,
pub user_icon: Option<String>,
pub profile_pic_override: Option<String>,
pub profile_pic_override_thumbnail: Option<String>,
pub current_avatar_image_url: Option<String>,
pub current_avatar_thumbnail_image_url: Option<String>,
pub bio: Option<String>,
pub status: Option<UserStatus>,
pub status_description: Option<String>,
pub location: Option<String>,
pub platform: Option<Platform>,
pub relationship: UserRelationship,
pub full_user: Option<User>,
pub friend_data: Option<LimitedUserFriend>,
pub last_updated: std::time::Instant,
}
impl CachedUser {
/// Create from LimitedUserFriend (friend list entry)
pub fn from_friend(friend: LimitedUserFriend) -> Self {
Self {
id: friend.id.clone(),
display_name: friend.display_name.clone(),
username: None,
user_icon: friend.user_icon.clone(),
profile_pic_override: friend.profile_pic_override.clone(),
profile_pic_override_thumbnail: friend.profile_pic_override_thumbnail.clone(),
current_avatar_image_url: friend.current_avatar_image_url.clone(),
current_avatar_thumbnail_image_url: friend.current_avatar_thumbnail_image_url.clone(),
bio: Some(friend.bio.clone()),
status: Some(friend.status.clone()),
status_description: Some(friend.status_description.clone()),
location: friend.location.clone(),
platform: Some(parse_platform(&friend.platform)),
relationship: UserRelationship::Friend,
full_user: None,
friend_data: Some(friend),
last_updated: std::time::Instant::now(),
}
}
/// Create from User (full user object)
pub fn from_user(user: User, relationship: UserRelationship) -> Self {
Self {
id: user.id.clone(),
display_name: user.display_name.clone(),
username: if user.username.is_empty() {
None
} else {
Some(user.username.clone())
},
user_icon: user.user_icon.clone(),
profile_pic_override: user.profile_pic_override.clone(),
profile_pic_override_thumbnail: user.profile_pic_override_thumbnail.clone(),
current_avatar_image_url: user.current_avatar_image_url.clone(),
current_avatar_thumbnail_image_url: user.current_avatar_thumbnail_image_url.clone(),
bio: Some(user.bio.clone()),
status: Some(user.status.clone()),
status_description: Some(user.status_description.clone()),
location: user.location.clone(),
platform: Some(parse_platform(&user.platform)),
relationship,
full_user: Some(user),
friend_data: None,
last_updated: std::time::Instant::now(),
}
}
/// Update from friend data
pub fn update_from_friend(&mut self, friend: LimitedUserFriend) {
self.display_name = friend.display_name.clone();
self.user_icon = friend.user_icon.clone();
self.profile_pic_override = friend.profile_pic_override.clone();
self.profile_pic_override_thumbnail = friend.profile_pic_override_thumbnail.clone();
self.current_avatar_image_url = friend.current_avatar_image_url.clone();
self.current_avatar_thumbnail_image_url = friend.current_avatar_thumbnail_image_url.clone();
self.bio = Some(friend.bio.clone());
self.status = Some(friend.status.clone());
self.status_description = Some(friend.status_description.clone());
self.location = friend.location.clone();
self.platform = Some(parse_platform(&friend.platform));
self.friend_data = Some(friend);
self.last_updated = std::time::Instant::now();
}
pub fn is_online(&self) -> bool {
self.location.as_ref().map_or(false, |loc| {
loc != "offline" && loc != "private" && !loc.is_empty()
})
}
/// Get as LimitedUserFriend if available
pub fn as_friend(&self) -> Option<&LimitedUserFriend> {
self.friend_data.as_ref()
}
pub fn age_seconds(&self) -> u64 {
self.last_updated.elapsed().as_secs()
}
}
/// Minimal representation of a current-user update emitted by the pipeline websocket.
#[derive(Debug, Clone)]
pub struct CurrentUserPipelineUpdate {
pub id: String,
pub display_name: String,
pub username: String,
pub status: String,
pub status_description: String,
pub bio: String,
pub user_icon: Option<String>,
pub profile_pic_override: Option<String>,
pub profile_pic_override_thumbnail: Option<String>,
pub current_avatar: Option<String>,
pub current_avatar_asset_url: Option<String>,
pub current_avatar_image_url: Option<String>,
pub current_avatar_thumbnail_image_url: Option<String>,
pub fallback_avatar: Option<String>,
pub tags: Vec<String>,
}
#[derive(Clone)]
pub struct UserStore {
users: Arc<RwLock<HashMap<String, CachedUser>>>,
current_user_id: Arc<RwLock<Option<String>>>,
}
impl UserStore {
pub fn new() -> Self {
Self {
users: Arc::new(RwLock::new(HashMap::new())),
current_user_id: Arc::new(RwLock::new(None)),
}
}
/// Current User Management
pub async fn set_current_user(&self, user: User) {
let user_id = user.id.clone();
let cached_user = CachedUser::from_user(user, UserRelationship::CurrentUser);
let mut users = self.users.write().await;
users.insert(user_id.clone(), cached_user);
drop(users);
let mut current = self.current_user_id.write().await;
*current = Some(user_id.clone());
log::info!("UserStore: Set current user to {}", user_id);
}
pub async fn get_current_user(&self) -> Option<User> {
let current_id = self.current_user_id.read().await;
let user_id = current_id.as_ref()?.clone();
drop(current_id);
let users = self.users.read().await;
users.get(&user_id)?.full_user.clone()
}
pub async fn get_current_user_id(&self) -> Option<String> {
let current = self.current_user_id.read().await;
current.clone()
}
pub async fn clear_current_user(&self) {
let mut current = self.current_user_id.write().await;
*current = None;
log::info!("UserStore: Cleared current user");
}
// Friend Management
// TODO: Refactor this code and function name
/// Initialize friends list (from REST API on startup)
pub async fn set_friends(&self, friends: Vec<LimitedUserFriend>) {
let mut users = self.users.write().await;
// Mark all existing friends as non-friends first
for user in users.values_mut() {
if user.relationship == UserRelationship::Friend {
user.relationship = UserRelationship::Known;
}
}
// Add/update friends
for friend in friends {
let user_id = friend.id.clone();
if let Some(existing) = users.get_mut(&user_id) {
existing.update_from_friend(friend);
existing.relationship = UserRelationship::Friend;
} else {
users.insert(user_id, CachedUser::from_friend(friend));
}
}
let friend_count = users
.values()
.filter(|u| u.relationship == UserRelationship::Friend)
.count();
log::info!("UserStore: Initialized {} friends", friend_count);
}
/// Upsert friend
pub async fn upsert_friend(&self, friend: LimitedUserFriend) {
let user_id = friend.id.clone();
// Check if this is the current user
// Of course, don't add yourself to friends list here
let current_id = self.current_user_id.read().await;
if let Some(ref current) = *current_id {
if current == &user_id {
log::debug!(
"UserStore: Skipping upsert_friend for current user {}",
user_id
);
drop(current_id);
// But still update other data if available
let mut users = self.users.write().await;
if let Some(existing) = users.get_mut(&user_id) {
if let Some(location) = friend.location.clone() {
existing.location = Some(location.clone());
if let Some(full_user) = existing.full_user.as_mut() {
full_user.location = Some(location);
}
}
if !friend.platform.is_empty() {
existing.platform = Some(parse_platform(&friend.platform));
if let Some(full_user) = existing.full_user.as_mut() {
full_user.platform = friend.platform.clone();
}
}
{
existing.status = Some(friend.status.clone());
if let Some(full_user) = existing.full_user.as_mut() {
full_user.status = friend.status;
}
}
if !friend.status_description.is_empty() {
existing.status_description = Some(friend.status_description.clone());
if let Some(full_user) = existing.full_user.as_mut() {
full_user.status_description = friend.status_description.clone();
}
}
existing.friend_data = Some(friend);
existing.last_updated = std::time::Instant::now();
}
return;
}
}
drop(current_id);
let mut users = self.users.write().await;
if let Some(existing) = users.get_mut(&user_id) {
existing.update_from_friend(friend);
existing.relationship = UserRelationship::Friend;
} else {
users.insert(user_id.clone(), CachedUser::from_friend(friend));
}
log::debug!("UserStore: Upserted friend {}", user_id);
}
/// Mark a friend as offline
pub async fn set_friend_offline(&self, user_id: &str) {
let mut users = self.users.write().await;
if let Some(user) = users.get_mut(user_id) {
user.location = Some("offline".to_string());
user.last_updated = std::time::Instant::now();
if let Some(ref mut friend_data) = user.friend_data {
friend_data.location = Some("offline".to_string());
friend_data.platform = String::new();
}
log::debug!("UserStore: User {} went offline", user_id);
}
}
/// Update friend's location
pub async fn update_user_location(
&self,
user_id: &str,
location: String,
platform: Option<String>,
) {
let mut users = self.users.write().await;
if let Some(user) = users.get_mut(user_id) {
user.location = Some(location.clone());
if let Some(plat) = platform {
user.platform = Some(parse_platform(&plat));
}
user.last_updated = std::time::Instant::now();
if let Some(ref mut friend_data) = user.friend_data {
friend_data.location = Some(location.clone());
if let Some(ref plat) = user.platform {
friend_data.platform = match plat {
Platform::StandaloneWindows => "standalonewindows".to_string(),
Platform::Android => "android".to_string(),
Platform::Web => "web".to_string(),
Platform::Other(s) => s.clone(),
};
}
}
log::trace!(
"UserStore: User {} location updated to {}",
user_id,
location
);
}
}
/// Remove a friend when the relationship is terminated :(
pub async fn remove_friend(&self, user_id: &str) {
let mut users = self.users.write().await;
if let Some(user) = users.get_mut(user_id) {
user.relationship = UserRelationship::Known;
user.friend_data = None;
user.location = None;
user.platform = None;
user.last_updated = std::time::Instant::now();
log::info!("UserStore: Removed friend {}", user_id);
} else {
log::debug!("UserStore: Attempted to remove unknown friend {}", user_id);
}
}
/// Patch the cached current-user record with data streamed from the websocket.
pub async fn apply_current_user_update(&self, patch: CurrentUserPipelineUpdate) {
use std::time::Instant;
{
let mut users = self.users.write().await;
let entry = users.entry(patch.id.clone()).or_insert_with(|| CachedUser {
id: patch.id.clone(),
display_name: patch.display_name.clone(),
username: Some(patch.username.clone()),
user_icon: patch.user_icon.clone(),
profile_pic_override: patch.profile_pic_override.clone(),
profile_pic_override_thumbnail: patch.profile_pic_override_thumbnail.clone(),
current_avatar_image_url: patch.current_avatar_image_url.clone(),
current_avatar_thumbnail_image_url: patch
.current_avatar_thumbnail_image_url
.clone(),
bio: Some(patch.bio.clone()),
status: Some(parse_user_status(&patch.status)),
status_description: Some(patch.status_description.clone()),
location: None,
platform: None,
relationship: UserRelationship::CurrentUser,
full_user: None,
friend_data: None,
last_updated: Instant::now(),
});
entry.display_name = patch.display_name.clone();
entry.username = Some(patch.username.clone());
entry.user_icon = patch.user_icon.clone();
entry.profile_pic_override = patch.profile_pic_override.clone();
entry.profile_pic_override_thumbnail = patch.profile_pic_override_thumbnail.clone();
entry.current_avatar_image_url = patch.current_avatar_image_url.clone();
entry.current_avatar_thumbnail_image_url =
patch.current_avatar_thumbnail_image_url.clone();
entry.bio = Some(patch.bio.clone());
entry.status = Some(parse_user_status(&patch.status));
entry.status_description = Some(patch.status_description.clone());
entry.relationship = UserRelationship::CurrentUser;
entry.last_updated = Instant::now();
if let Some(full_user) = entry.full_user.as_mut() {
full_user.display_name = patch.display_name.clone();
full_user.username = patch.username.clone();
full_user.status = parse_user_status(&patch.status);
full_user.status_description = patch.status_description.clone();
full_user.bio = patch.bio.clone();
full_user.user_icon = patch.user_icon.clone();
full_user.profile_pic_override = patch.profile_pic_override.clone();
full_user.profile_pic_override_thumbnail =
patch.profile_pic_override_thumbnail.clone();
full_user.current_avatar = patch.current_avatar.clone();
full_user.current_avatar_image_url = patch.current_avatar_image_url.clone();
full_user.current_avatar_thumbnail_image_url =
patch.current_avatar_thumbnail_image_url.clone();
full_user.fallback_avatar = patch.fallback_avatar.clone();
full_user.tags = patch.tags.clone();
}
}
let mut current_id = self.current_user_id.write().await;
*current_id = Some(patch.id);
}
/// Get all friends (online and offline)
pub async fn get_all_friends(&self) -> Vec<LimitedUserFriend> {
let users = self.users.read().await;
users
.values()
.filter(|u| u.relationship == UserRelationship::Friend)
.filter_map(|u| u.friend_data.clone())
.collect()
}
/// Get all online friends
pub async fn get_online_friends(&self) -> Vec<LimitedUserFriend> {
let users = self.users.read().await;
users
.values()
.filter(|u| u.relationship == UserRelationship::Friend)
.filter(|u| u.is_online())
.filter_map(|u| u.friend_data.clone())
.collect()
}
/// Get count of online friends
pub async fn get_online_friend_count(&self) -> usize {
let users = self.users.read().await;
users
.values()
.filter(|u| u.relationship == UserRelationship::Friend)
.filter(|u| u.is_online())
.count()
}
// General User Queries
/// Get a user by ID (returns friend data if they're a friend)
pub async fn get_user(&self, user_id: &str) -> Option<LimitedUserFriend> {
let users = self.users.read().await;
users.get(user_id)?.friend_data.clone()
}
/// Get a full User object by ID
pub async fn get_full_user(&self, user_id: &str) -> Option<User> {
let users = self.users.read().await;
users.get(user_id)?.full_user.clone()
}
/// Cache a full User object (for non-current users)
pub async fn cache_full_user(&self, user: User) {
let user_id = user.id.clone();
let mut users = self.users.write().await;
if let Some(existing) = users.get_mut(&user_id) {
// Update existing cached user with full user data
let relationship = existing.relationship.clone();
existing.display_name = user.display_name.clone();
// Only update username if it's not empty
// You only see usernames for yourself in the API
if !user.username.is_empty() {
existing.username = Some(user.username.clone());
}
existing.user_icon = user.user_icon.clone();
existing.profile_pic_override = user.profile_pic_override.clone();
existing.profile_pic_override_thumbnail = user.profile_pic_override_thumbnail.clone();
existing.current_avatar_image_url = user.current_avatar_image_url.clone();
existing.current_avatar_thumbnail_image_url =
user.current_avatar_thumbnail_image_url.clone();
existing.bio = Some(user.bio.clone());
existing.status = Some(user.status.clone());
existing.status_description = Some(user.status_description.clone());
existing.location = user.location.clone();
existing.platform = Some(parse_platform(&user.platform));
existing.full_user = Some(user);
existing.last_updated = std::time::Instant::now();
existing.relationship = relationship;
} else {
users.insert(
user_id.clone(),
CachedUser::from_user(user, UserRelationship::Known),
);
}
log::debug!("UserStore: Cached full user data for {}", user_id);
}
/// Get cached user entry
pub async fn get_cached_user(&self, user_id: &str) -> Option<CachedUser> {
let users = self.users.read().await;
users.get(user_id).cloned()
}
/// Check if a user is a friend
pub async fn is_friend(&self, user_id: &str) -> bool {
let users = self.users.read().await;
users
.get(user_id)
.map_or(false, |u| u.relationship == UserRelationship::Friend)
}
/// Check if a user is online
pub async fn is_user_online(&self, user_id: &str) -> bool {
let users = self.users.read().await;
users.get(user_id).map_or(false, |u| u.is_online())
}
/// Search users by display name (case-insensitive, partial match)
pub async fn search_users(&self, query: &str) -> Vec<CachedUser> {
let users = self.users.read().await;
let query_lower = query.to_lowercase();
users
.values()
.filter(|u| u.display_name.to_lowercase().contains(&query_lower))
.cloned()
.collect()
}
// Cache Management
/// Clear all cached users (keeps current user)
pub async fn clear_cache(&self) {
let current_id = self.current_user_id.read().await.clone();
let mut users = self.users.write().await;
if let Some(current_id) = current_id {
users.retain(|id, _| id == &current_id); // Keep only current user
} else {
users.clear();
}
log::info!("UserStore: Cleared cache");
}
/// Clear all data
pub async fn clear_all(&self) {
let mut users = self.users.write().await;
users.clear();
drop(users);
let mut current = self.current_user_id.write().await;
*current = None;
log::info!("UserStore: Cleared all data");
}
/// Remove stale entries that are older than max_age_seconds
pub async fn remove_stale(&self, max_age_seconds: u64) {
let current_id = self.current_user_id.read().await.clone();
let mut users = self.users.write().await;
let before_count = users.len();
users.retain(|id, user| {
// Keep current user and friends
if Some(id) == current_id.as_ref() || user.relationship == UserRelationship::Friend {
return true;
}
// Keep recent entries
user.age_seconds() < max_age_seconds
});
let removed = before_count - users.len();
if removed > 0 {
log::info!("UserStore: Removed {} stale entries", removed);
}
}
pub async fn get_stats(&self) -> UserStoreStats {
let users = self.users.read().await;
let current_id = self.current_user_id.read().await.clone();
UserStoreStats {
total_cached: users.len(),
friends: users
.values()
.filter(|u| u.relationship == UserRelationship::Friend)
.count(),
online_friends: users
.values()
.filter(|u| u.relationship == UserRelationship::Friend && u.is_online())
.count(),
has_current_user: current_id.is_some(),
}
}
}
impl Default for UserStore {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct UserStoreStats {
pub total_cached: usize,
pub friends: usize,
pub online_friends: usize,
pub has_current_user: bool,
}

View File

@@ -0,0 +1,710 @@
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use reqwest::header::HeaderMap;
use reqwest::{Client, Request, RequestBuilder, Response};
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{Duration, sleep};
use crate::http_common::{INITIAL_BACKOFF, MAX_BACKOFF, MAX_REQUEST_RETRIES};
use crate::vrchat_api::{
error::{VRCError, VRCResult},
types::*,
};
const API_BASE_URL: &str = "https://api.vrchat.cloud/api/1";
// Cookie Management
#[derive(Debug, Clone, Default)]
struct CookieStore {
auth_cookie: Option<String>,
two_factor_cookie: Option<String>,
}
impl CookieStore {
fn to_header_value(&self) -> Option<String> {
let mut parts = Vec::new();
if let Some(auth) = &self.auth_cookie {
parts.push(auth.clone());
}
if let Some(two_fa) = &self.two_factor_cookie {
parts.push(two_fa.clone());
}
if parts.is_empty() {
None
} else {
Some(parts.join("; "))
}
}
fn update_from_response(&mut self, response: &Response) {
for cookie_header in response.headers().get_all("set-cookie") {
if let Ok(cookie_str) = cookie_header.to_str() {
if let Some(cookie_pair) = cookie_str.split(';').next() {
if cookie_pair.starts_with("auth=") {
self.auth_cookie = Some(cookie_pair.to_string());
} else if cookie_pair.starts_with("twoFactorAuth=") {
self.two_factor_cookie = Some(cookie_pair.to_string());
}
}
}
}
}
fn clear(&mut self) {
*self = Self::default();
}
fn has_auth(&self) -> bool {
self.auth_cookie.is_some()
}
}
// VRChat HTTP Client for VRChat API requests
#[derive(Clone)]
pub struct VRChatClient {
http_client: Client,
cookies: Arc<Mutex<CookieStore>>,
}
impl VRChatClient {
/// Create a new VRChat API client
pub fn new() -> VRCResult<Self> {
let http_client = Client::builder()
.cookie_store(false)
.build()
.map_err(|e| VRCError::network(format!("Failed to create HTTP client: {}", e)))?;
Ok(Self {
http_client,
cookies: Arc::new(Mutex::new(CookieStore::default())),
})
}
// Authentication Methods
/// Attempt to log in with email and password
pub async fn login(&self, credentials: &LoginCredentials) -> VRCResult<LoginResult> {
let auth_header = Self::create_basic_auth(&credentials.email, &credentials.password);
let headers = self.build_headers(Some(&auth_header), None, None);
let response = self
.execute_request(
self.http_client
.get(&format!("{}/auth/user", API_BASE_URL))
.headers(headers),
)
.await?;
let mut cookies = self.cookies.lock().await;
cookies.update_from_response(&response);
drop(cookies);
let status = response.status();
if status == 429 {
return Err(VRCError::rate_limit(
"Too many requests. Please wait before trying again.",
));
}
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Login failed".to_string());
return Err(VRCError::auth(error_text));
}
let body = response.text().await?;
// Check if 2FA is required
if let Ok(two_fa) = serde_json::from_str::<TwoFactorAuthResponse>(&body) {
if let Some(methods) = two_fa.requires_two_factor_auth {
return Ok(LoginResult::TwoFactorRequired { methods });
}
}
let user: User = serde_json::from_str(&body)?;
Ok(LoginResult::Success { user })
}
/// Verify two-factor authentication code
pub async fn verify_two_factor(&self, code: &str, method: TwoFactorMethod) -> VRCResult<bool> {
let cookie_header = {
let cookies = self.cookies.lock().await;
cookies.to_header_value()
};
let headers = self.build_headers(None, None, cookie_header.as_deref());
let request_body = TwoFactorCode {
code: code.to_string(),
};
let response = self
.execute_request(
self.http_client
.post(&format!(
"{}/auth/twofactorauth/{}/verify",
API_BASE_URL,
method.endpoint()
))
.headers(headers)
.json(&request_body),
)
.await?;
let status = response.status();
let mut cookies = self.cookies.lock().await;
cookies.update_from_response(&response);
drop(cookies);
if status == 429 {
return Err(VRCError::rate_limit(
"Too many requests. Please wait before trying again.",
));
}
if !status.is_success() && status.as_u16() != 400 {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Verification failed".to_string());
return Err(VRCError::auth(error_text));
}
let body = response.text().await?;
let verify_response: TwoFactorVerifyResponse = serde_json::from_str(&body)?;
Ok(verify_response.verified)
}
/// Get the currently authenticated user
pub async fn get_current_user(&self) -> VRCResult<User> {
let cookie_header = {
let cookies = self.cookies.lock().await;
cookies.to_header_value()
};
let headers = self.build_headers(None, None, cookie_header.as_deref());
let response = self
.execute_request(
self.http_client
.get(&format!("{}/auth/user", API_BASE_URL))
.headers(headers),
)
.await?;
let status = response.status();
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Failed to get user".to_string());
return Err(VRCError::http(status.as_u16(), error_text));
}
let body = response.text().await?;
let user: User = serde_json::from_str(&body)?;
Ok(user)
}
/// Update the user's status and status description
pub async fn update_status(&self, request: &UpdateStatusRequest) -> VRCResult<User> {
// First get current user to get their userId
let current_user = self.get_current_user().await?;
let cookie_header = {
let cookies = self.cookies.lock().await;
cookies.to_header_value()
};
let cookie = cookie_header.ok_or_else(|| VRCError::auth("Not authenticated"))?;
let headers = self.build_headers(None, None, Some(&cookie));
let response = self
.execute_request(
self.http_client
.put(&format!("{}/users/{}", API_BASE_URL, current_user.id))
.headers(headers)
.json(request),
)
.await?;
let status = response.status();
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Failed to update status".to_string());
return Err(VRCError::http(status.as_u16(), error_text));
}
let user: User = response.json().await?;
Ok(user)
}
/// Log out the current user
pub async fn logout(&self) -> VRCResult<()> {
let cookie_header = {
let cookies = self.cookies.lock().await;
cookies.to_header_value()
};
if let Some(cookie) = cookie_header {
let headers = self.build_headers(None, None, Some(&cookie));
let _ = self
.execute_request(
self.http_client
.put(&format!("{}/logout", API_BASE_URL))
.headers(headers),
)
.await;
}
let mut cookies = self.cookies.lock().await;
cookies.clear();
Ok(())
}
/// Get online friends list
pub async fn get_online_friends(&self) -> VRCResult<Vec<LimitedUserFriend>> {
self.fetch_friends(false).await
}
pub async fn get_all_friends(&self) -> VRCResult<Vec<LimitedUserFriend>> {
let mut combined = Vec::new();
let mut seen = HashSet::new();
let mut online = self.fetch_friends(false).await?;
for friend in online.drain(..) {
seen.insert(friend.id.clone());
combined.push(friend);
}
let mut offline = self.fetch_friends(true).await?;
for friend in offline.drain(..) {
if seen.insert(friend.id.clone()) {
combined.push(friend);
}
}
Ok(combined)
}
async fn fetch_friends(&self, offline: bool) -> VRCResult<Vec<LimitedUserFriend>> {
let cookie_header = {
let cookies = self.cookies.lock().await;
cookies.to_header_value()
};
let cookie = cookie_header.ok_or_else(|| VRCError::auth("Not authenticated"))?;
let mut results = Vec::new();
let mut offset = 0usize;
const PAGE_SIZE: usize = 100;
loop {
let headers = self.build_headers(None, None, Some(&cookie));
let response = self
.execute_request(
self.http_client
.get(&format!(
"{}/auth/user/friends?offline={}&n={}&offset={}",
API_BASE_URL, offline, PAGE_SIZE, offset
))
.headers(headers),
)
.await?;
if !response.status().is_success() {
return Err(VRCError::http(
response.status().as_u16(),
"Failed to fetch friends",
));
}
let page: Vec<LimitedUserFriend> = response.json().await?;
let count = page.len();
if count == 0 {
break;
}
results.extend(page.into_iter());
if count < PAGE_SIZE {
break;
}
sleep(Duration::from_secs(1)).await;
offset += PAGE_SIZE;
}
Ok(results)
}
/// Fetch all worlds uploaded by the authenticated user
pub async fn get_uploaded_worlds(&self) -> VRCResult<Vec<LimitedWorld>> {
let cookie_header = {
let cookies = self.cookies.lock().await;
cookies.to_header_value()
};
let cookie = cookie_header.ok_or_else(|| VRCError::auth("Not authenticated"))?;
let mut worlds = Vec::new();
let mut offset: usize = 0;
const PAGE_SIZE: usize = 100;
loop {
let headers = self.build_headers(None, None, Some(&cookie));
let url = format!(
"{}/worlds?user=me&n={}&offset={}&order=descending&sort=updated",
API_BASE_URL, PAGE_SIZE, offset
);
let response = self
.execute_request(self.http_client.get(&url).headers(headers))
.await?;
if !response.status().is_success() {
return Err(VRCError::http(
response.status().as_u16(),
"Failed to fetch uploaded worlds",
));
}
let mut page: Vec<LimitedWorld> = response.json().await?;
let count = page.len();
if count == 0 {
break;
}
// Backfill statistics that might be missing from the list endpoint
for idx in 0..page.len() {
if page[idx].visits.is_none()
|| page[idx].favorites.is_none()
|| page[idx].popularity.is_none()
|| page[idx].occupants.is_none()
|| page[idx].capacity.is_none()
|| page[idx].recommended_capacity.is_none()
{
match self.get_world_details(&page[idx].id).await {
Ok(details) => {
if details.visits.is_some() {
page[idx].visits = details.visits;
}
if details.favorites.is_some() {
page[idx].favorites = details.favorites;
}
if details.popularity.is_some() {
page[idx].popularity = details.popularity;
}
if details.occupants.is_some() {
page[idx].occupants = details.occupants;
}
if details.capacity.is_some() {
page[idx].capacity = details.capacity;
}
if details.recommended_capacity.is_some() {
page[idx].recommended_capacity = details.recommended_capacity;
}
if details.heat.is_some() {
page[idx].heat = details.heat;
}
if details.organization.is_some() {
page[idx].organization = details.organization;
}
}
Err(err) => {
log::warn!(
"Failed to load additional details for world {}: {}",
page[idx].id,
err
);
}
}
}
}
offset += count;
worlds.append(&mut page);
if count < PAGE_SIZE {
break;
}
}
Ok(worlds)
}
/// Fetch all avatars uploaded by the authenticated user
pub async fn get_uploaded_avatars(&self) -> VRCResult<Vec<LimitedAvatar>> {
let cookie_header = {
let cookies = self.cookies.lock().await;
cookies.to_header_value()
};
let cookie = cookie_header.ok_or_else(|| VRCError::auth("Not authenticated"))?;
let mut avatars = Vec::new();
let mut offset: usize = 0;
const PAGE_SIZE: usize = 100;
loop {
let headers = self.build_headers(None, None, Some(&cookie));
let url = format!(
"{}/avatars?user=me&releaseStatus=all&sort=updated&order=descending&n={}&offset={}",
API_BASE_URL, PAGE_SIZE, offset
);
let response = self
.execute_request(self.http_client.get(&url).headers(headers))
.await?;
if !response.status().is_success() {
return Err(VRCError::http(
response.status().as_u16(),
"Failed to fetch uploaded avatars",
));
}
let mut page: Vec<LimitedAvatar> = response.json().await?;
let count = page.len();
if count == 0 {
break;
}
offset += count;
avatars.append(&mut page);
if count < PAGE_SIZE {
break;
}
}
Ok(avatars)
}
// TODO: Have to analyses consequences of rate limits when fetching world details in a loop
/// Fetch additional details for a specific world
pub async fn get_world_details(&self, world_id: &str) -> VRCResult<LimitedWorld> {
let cookie_header = {
let cookies = self.cookies.lock().await;
cookies.to_header_value()
};
let cookie = cookie_header.ok_or_else(|| VRCError::auth("Not authenticated"))?;
let headers = self.build_headers(None, None, Some(&cookie));
let response = self
.execute_request(
self.http_client
.get(&format!("{}/worlds/{}", API_BASE_URL, world_id))
.headers(headers),
)
.await?;
if !response.status().is_success() {
return Err(VRCError::http(
response.status().as_u16(),
format!("Failed to fetch world {}", world_id),
));
}
let world: LimitedWorld = response.json().await?;
Ok(world)
}
/// Fetch full user data by user ID
pub async fn get_user_by_id(&self, user_id: &str) -> VRCResult<User> {
let cookie_header = {
let cookies = self.cookies.lock().await;
cookies.to_header_value()
};
let cookie = cookie_header.ok_or_else(|| VRCError::auth("Not authenticated"))?;
let headers = self.build_headers(None, None, Some(&cookie));
let response = self
.execute_request(
self.http_client
.get(&format!("{}/users/{}", API_BASE_URL, user_id))
.headers(headers),
)
.await?;
let status = response.status();
if !status.is_success() {
let error_body = response
.text()
.await
.unwrap_or_else(|_| "Unable to read error response".to_string());
log::error!(
"Failed to fetch user {}: HTTP {} - {}",
user_id,
status.as_u16(),
error_body
);
return Err(VRCError::http(
status.as_u16(),
format!("Failed to fetch user {}: {}", user_id, error_body),
));
}
let body = response.text().await?;
log::debug!("User API response for {}: {}", user_id, body);
let user: User = serde_json::from_str(&body).map_err(|e| {
log::error!("Failed to parse user JSON: {}", e);
VRCError::unknown(format!("Failed to parse user data: {}", e))
})?;
Ok(user)
}
// Session Management
/// Check if the client has a valid session
pub async fn has_valid_session(&self) -> bool {
let cookies = self.cookies.lock().await;
cookies.has_auth()
}
/// Export cookies for storage
pub async fn export_cookies(&self) -> (Option<String>, Option<String>) {
let cookies = self.cookies.lock().await;
(
cookies.auth_cookie.clone(),
cookies.two_factor_cookie.clone(),
)
}
/// Import previously stored cookies
pub async fn import_cookies(&self, auth: Option<String>, two_factor: Option<String>) {
let mut cookies = self.cookies.lock().await;
cookies.auth_cookie = auth;
cookies.two_factor_cookie = two_factor;
}
/// Clear all stored cookies
pub async fn clear_cookies(&self) {
let mut cookies = self.cookies.lock().await;
cookies.clear();
}
// Private Helper Methods
fn create_basic_auth(email: &str, password: &str) -> String {
let credentials = format!("{}:{}", email, password);
let encoded = BASE64.encode(credentials.as_bytes());
format!("Basic {}", encoded)
}
fn build_headers(
&self,
auth: Option<&str>,
_referer: Option<&str>,
cookie: Option<&str>,
) -> HeaderMap {
crate::http_common::build_api_headers(auth, cookie)
}
async fn execute_request(&self, builder: RequestBuilder) -> VRCResult<Response> {
let request = builder
.build()
.map_err(|e| VRCError::network(format!("Failed to build request: {}", e)))?;
self.send_with_retry(request).await
}
async fn send_with_retry(&self, request: Request) -> VRCResult<Response> {
let mut attempt: u8 = 0;
let mut backoff = Duration::from_millis(INITIAL_BACKOFF);
loop {
let req = request
.try_clone()
.ok_or_else(|| VRCError::network("Failed to clone request for retry attempts"))?;
match self.http_client.execute(req).await {
Ok(response) => {
let status = response.status();
if status.as_u16() == 429 {
if attempt >= MAX_REQUEST_RETRIES {
return Err(VRCError::rate_limit(
"Too many requests. Please wait before trying again.",
));
}
let wait = Self::extract_retry_after(&response).unwrap_or(backoff);
drop(response);
sleep(wait).await;
attempt += 1;
backoff = (backoff * 2).min(Duration::from_millis(MAX_BACKOFF));
continue;
}
if status.is_server_error() {
if attempt >= MAX_REQUEST_RETRIES {
return Err(VRCError::http(
status.as_u16(),
status
.canonical_reason()
.unwrap_or("Server error")
.to_string(),
));
}
let wait = Self::extract_retry_after(&response).unwrap_or(backoff);
drop(response);
sleep(wait).await;
attempt += 1;
backoff = (backoff * 2).min(Duration::from_millis(MAX_BACKOFF));
continue;
}
return Ok(response);
}
Err(err) => {
if attempt >= MAX_REQUEST_RETRIES {
return Err(VRCError::network(format!(
"Request failed after retries: {}",
err
)));
}
sleep(backoff).await;
attempt += 1;
backoff = (backoff * 2).min(Duration::from_millis(MAX_BACKOFF));
}
}
}
}
fn extract_retry_after(response: &Response) -> Option<Duration> {
response
.headers()
.get("retry-after")
.and_then(|value| value.to_str().ok())
.and_then(|header| header.parse::<u64>().ok())
.map(Duration::from_secs)
}
}
impl Default for VRChatClient {
fn default() -> Self {
Self::new().expect("Failed to create default VRChatClient")
}
}

View File

@@ -0,0 +1,129 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use std::fmt;
/// Result type alias for VRChat API operations
pub type VRCResult<T> = Result<T, VRCError>;
/// Main error type for VRChat API operations
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(tag = "type", content = "data")]
pub enum VRCError {
/// Network-related errors
Network(String),
/// HTTP errors with status code
Http { status: u16, message: String },
/// Authentication errors
Authentication(String),
/// Rate limiting error
RateLimit(String),
/// JSON parsing errors
Parse(String),
/// Invalid input or request
InvalidInput(String),
/// Unknown or unexpected errors
Unknown(String),
}
impl VRCError {
/// Create a new HTTP error
pub fn http(status: u16, message: impl Into<String>) -> Self {
Self::Http {
status,
message: message.into(),
}
}
/// Create a new network error
pub fn network(message: impl Into<String>) -> Self {
Self::Network(message.into())
}
/// Create a new authentication error
pub fn auth(message: impl Into<String>) -> Self {
Self::Authentication(message.into())
}
/// Create a new rate limit error
pub fn rate_limit(message: impl Into<String>) -> Self {
Self::RateLimit(message.into())
}
/// Create a new parse error
pub fn parse(message: impl Into<String>) -> Self {
Self::Parse(message.into())
}
/// Create a new invalid input error
pub fn invalid_input(message: impl Into<String>) -> Self {
Self::InvalidInput(message.into())
}
/// Create an unknown error
pub fn unknown(message: impl Into<String>) -> Self {
Self::Unknown(message.into())
}
/// Get the error message
pub fn message(&self) -> &str {
match self {
Self::Network(msg)
| Self::Http { message: msg, .. }
| Self::Authentication(msg)
| Self::RateLimit(msg)
| Self::Parse(msg)
| Self::InvalidInput(msg)
| Self::Unknown(msg) => msg,
}
}
/// Get the HTTP status code if applicable
pub fn status_code(&self) -> Option<u16> {
match self {
Self::Http { status, .. } => Some(*status),
_ => None,
}
}
}
impl fmt::Display for VRCError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Network(msg) => write!(f, "Network error: {}", msg),
Self::Http { status, message } => write!(f, "HTTP {} error: {}", status, message),
Self::Authentication(msg) => write!(f, "Authentication error: {}", msg),
Self::RateLimit(msg) => write!(f, "Rate limit: {}", msg),
Self::Parse(msg) => write!(f, "Parse error: {}", msg),
Self::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
Self::Unknown(msg) => write!(f, "Unknown error: {}", msg),
}
}
}
impl std::error::Error for VRCError {}
/// Convert reqwest errors to VRCError
impl From<reqwest::Error> for VRCError {
fn from(err: reqwest::Error) -> Self {
if err.is_timeout() {
VRCError::network("Request timed out")
} else if err.is_connect() {
VRCError::network("Failed to connect to VRChat API")
} else {
VRCError::network(err.to_string())
}
}
}
/// Convert serde_json errors to VRCError
impl From<serde_json::Error> for VRCError {
fn from(err: serde_json::Error) -> Self {
VRCError::parse(format!("JSON parsing failed: {}", err))
}
}

View File

@@ -0,0 +1,8 @@
pub mod client;
pub mod error;
pub mod types;
// Re-export common types
pub use client::VRChatClient;
pub use error::{VRCError, VRCResult};
pub use types::*;

View File

@@ -0,0 +1,40 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use super::enums::UserStatus;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct LoginCredentials {
pub email: String,
pub password: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(tag = "type")]
pub enum LoginResult {
Success { user: super::user::User },
TwoFactorRequired { methods: Vec<String> },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TwoFactorAuthResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub requires_two_factor_auth: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TwoFactorCode {
pub code: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TwoFactorVerifyResponse {
pub verified: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UpdateStatusRequest {
pub status: UserStatus,
pub status_description: String,
}

View File

@@ -0,0 +1,69 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use super::enums::ReleaseStatus;
#[derive(Debug, Clone, Serialize, Deserialize, Type, Default)]
#[serde(rename_all = "camelCase")]
pub struct AvatarPerformance {
#[serde(default)]
pub android: Option<String>,
#[serde(default)]
pub ios: Option<String>,
#[serde(default)]
pub standalonewindows: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, Default)]
#[serde(rename_all = "camelCase")]
pub struct AvatarStyles {
#[serde(default)]
pub primary: Option<String>,
#[serde(default)]
pub secondary: Option<String>,
}
use crate::vrchat_api::types::world::UnityPackageSummary;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LimitedAvatar {
pub id: String,
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub author_id: Option<String>,
#[serde(default)]
pub author_name: Option<String>,
#[serde(default)]
pub image_url: Option<String>,
#[serde(default)]
pub thumbnail_image_url: Option<String>,
#[serde(default)]
pub asset_url: Option<String>,
#[serde(default)]
pub unity_package_url: Option<String>,
#[serde(default)]
pub release_status: ReleaseStatus,
#[serde(default)]
pub featured: Option<bool>,
#[serde(default)]
pub searchable: Option<bool>,
#[serde(default)]
pub listing_date: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub version: Option<i32>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub performance: Option<AvatarPerformance>,
#[serde(default)]
pub styles: Option<AvatarStyles>,
#[serde(default)]
pub unity_packages: Vec<UnityPackageSummary>,
}

View File

@@ -0,0 +1,254 @@
use serde::{Deserialize, Serialize};
use specta::Type;
/// User's current status
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "lowercase")]
pub enum UserStatus {
/// User is online and active
Active,
/// User is online and auto accepting invitations to join
#[serde(rename = "join me")]
JoinMe,
/// User is online but is hiding their location and requires invitation to join
#[serde(rename = "ask me")]
AskMe,
/// User is busy
Busy,
/// User is offline
Offline,
}
impl Default for UserStatus {
fn default() -> Self {
UserStatus::Offline
}
}
impl std::fmt::Display for UserStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UserStatus::Active => write!(f, "active"),
UserStatus::JoinMe => write!(f, "join me"),
UserStatus::AskMe => write!(f, "ask me"),
UserStatus::Busy => write!(f, "busy"),
UserStatus::Offline => write!(f, "offline"),
}
}
}
/// Release status of avatars and worlds
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "lowercase")]
pub enum ReleaseStatus {
/// Publicly released
Public,
/// Private/restricted access
Private,
/// Hidden from listings
Hidden,
// TODO: Should this be here? It's not really a status, more of a filter.
/// Filter for all statuses
All,
}
impl Default for ReleaseStatus {
fn default() -> Self {
ReleaseStatus::Public
}
}
impl std::fmt::Display for ReleaseStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ReleaseStatus::Public => write!(f, "public"),
ReleaseStatus::Private => write!(f, "private"),
ReleaseStatus::Hidden => write!(f, "hidden"),
ReleaseStatus::All => write!(f, "all"),
}
}
}
/// User's developer type/staff level
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "lowercase")]
pub enum DeveloperType {
/// Normal user
None,
/// Trusted user
Trusted,
/// VRChat Developer/Staff
Internal,
/// VRChat Moderator
Moderator,
}
impl Default for DeveloperType {
fn default() -> Self {
DeveloperType::None
}
}
impl std::fmt::Display for DeveloperType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DeveloperType::None => write!(f, "none"),
DeveloperType::Trusted => write!(f, "trusted"),
DeveloperType::Internal => write!(f, "internal"),
DeveloperType::Moderator => write!(f, "moderator"),
}
}
}
/// Age verification status
/// `verified` is obsolete. according to the unofficial docs, Users who have verified and are 18+ can switch to `plus18` status.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "lowercase")]
pub enum AgeVerificationStatus {
/// Age verification status is hidden
Hidden,
/// Legacy verified status (obsolete)
Verified,
/// User is verified to be 18+
#[serde(rename = "18+")]
Plus18,
}
impl Default for AgeVerificationStatus {
fn default() -> Self {
AgeVerificationStatus::Hidden
}
}
impl std::fmt::Display for AgeVerificationStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AgeVerificationStatus::Hidden => write!(f, "hidden"),
AgeVerificationStatus::Verified => write!(f, "verified"),
AgeVerificationStatus::Plus18 => write!(f, "18+"),
}
}
}
/// Friend request status
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
pub enum FriendRequestStatus {
/// No friend request
#[serde(rename = "")]
None,
/// Outgoing friend request pending
#[serde(rename = "outgoing")]
Outgoing,
/// Incoming friend request pending
#[serde(rename = "incoming")]
Incoming,
/// Completed friend request
#[serde(rename = "completed")]
Completed,
}
impl Default for FriendRequestStatus {
fn default() -> Self {
FriendRequestStatus::None
}
}
impl std::fmt::Display for FriendRequestStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FriendRequestStatus::None => write!(f, ""),
FriendRequestStatus::Outgoing => write!(f, "outgoing"),
FriendRequestStatus::Incoming => write!(f, "incoming"),
FriendRequestStatus::Completed => write!(f, "completed"),
}
}
}
/// State of the user
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "lowercase")]
pub enum UserState {
/// User is offline
Offline,
/// User is active
Active,
/// User is online
Online,
}
impl Default for UserState {
fn default() -> Self {
UserState::Offline
}
}
impl std::fmt::Display for UserState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UserState::Offline => write!(f, "offline"),
UserState::Active => write!(f, "active"),
UserState::Online => write!(f, "online"),
}
}
}
/// Avatar performance ratings
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
pub enum PerformanceRatings {
/// No rating
None,
/// Excellent performance
Excellent,
/// Good performance
Good,
/// Medium performance
Medium,
/// Poor performance
Poor,
/// Very poor performance
VeryPoor,
}
impl Default for PerformanceRatings {
fn default() -> Self {
PerformanceRatings::None
}
}
impl std::fmt::Display for PerformanceRatings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PerformanceRatings::None => write!(f, "None"),
PerformanceRatings::Excellent => write!(f, "Excellent"),
PerformanceRatings::Good => write!(f, "Good"),
PerformanceRatings::Medium => write!(f, "Medium"),
PerformanceRatings::Poor => write!(f, "Poor"),
PerformanceRatings::VeryPoor => write!(f, "VeryPoor"),
}
}
}
/// Sort order for API queries
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "lowercase")]
pub enum OrderOption {
/// Ascending order
Ascending,
/// Descending order
Descending,
}
impl Default for OrderOption {
fn default() -> Self {
OrderOption::Descending
}
}
impl std::fmt::Display for OrderOption {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OrderOption::Ascending => write!(f, "ascending"),
OrderOption::Descending => write!(f, "descending"),
}
}
}

View File

@@ -0,0 +1,14 @@
pub mod auth;
pub mod avatar;
pub mod enums;
pub mod two_factor;
pub mod user;
pub mod world;
// Re-export common types at crate level
pub use auth::*;
pub use avatar::*;
pub use enums::*;
pub use two_factor::*;
pub use user::*;
pub use world::*;

View File

@@ -0,0 +1,22 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TwoFactorMethod {
EmailOtp,
Totp,
}
impl TwoFactorMethod {
pub fn endpoint(&self) -> &'static str {
match self {
Self::EmailOtp => "emailotp",
Self::Totp => "totp",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"emailotp" => Some(Self::EmailOtp),
"totp" | "otp" => Some(Self::Totp),
_ => None,
}
}
}

View File

@@ -0,0 +1,297 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use super::enums::{UserStatus, DeveloperType, AgeVerificationStatus, FriendRequestStatus};
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub id: String,
#[serde(default)]
pub username: String,
pub display_name: String,
#[serde(default)]
pub accepted_privacy_version: Option<i32>,
#[serde(default)]
pub accepted_tos_version: Option<i32>,
#[serde(default)]
pub account_deletion_date: Option<String>,
#[serde(default)]
pub state: String,
#[serde(default)]
pub status: UserStatus,
#[serde(default)]
pub status_description: String,
#[serde(default)]
pub status_first_time: Option<bool>,
#[serde(default)]
pub status_history: Vec<String>,
#[serde(default)]
pub bio: String,
#[serde(default)]
pub bio_links: Vec<String>,
#[serde(default)]
pub age_verification_status: AgeVerificationStatus,
#[serde(default)]
pub age_verified: Option<bool>,
#[serde(default)]
pub is_adult: Option<bool>,
#[serde(default)]
pub date_joined: Option<String>,
#[serde(default)]
pub last_login: Option<String>,
#[serde(default)]
pub last_activity: Option<String>,
#[serde(default)]
pub last_platform: Option<String>,
#[serde(default)]
pub last_mobile: Option<String>,
#[serde(default)]
pub platform: String,
#[serde(default)]
pub platform_history: Vec<String>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub traveling_to_world: Option<String>,
#[serde(default)]
pub traveling_to_location: Option<String>,
#[serde(default)]
pub traveling_to_instance: Option<String>,
#[serde(default)]
pub home_location: Option<String>,
#[serde(default)]
pub instance_id: Option<String>,
#[serde(default)]
pub world_id: Option<String>,
#[serde(default)]
pub allow_avatar_copying: Option<bool>,
#[serde(default)]
pub two_factor_auth_enabled: Option<bool>,
#[serde(default)]
pub two_factor_auth_enabled_date: Option<String>,
#[serde(default)]
pub current_avatar: Option<String>,
#[serde(default)]
pub fallback_avatar: Option<String>,
#[serde(default)]
pub current_avatar_tags: Vec<String>,
#[serde(default)]
pub profile_pic_override: Option<String>,
#[serde(default)]
pub profile_pic_override_thumbnail: Option<String>,
#[serde(default)]
pub user_icon: Option<String>,
#[serde(default)]
pub current_avatar_image_url: Option<String>,
#[serde(default)]
pub current_avatar_thumbnail_image_url: Option<String>,
#[serde(default)]
pub banner_id: Option<String>,
#[serde(default)]
pub banner_url: Option<String>,
#[serde(default)]
pub pronouns: Option<String>,
#[serde(default)]
pub languages: Option<Vec<String>>,
#[serde(default)]
pub pronouns_history: Vec<String>,
#[serde(default)]
pub friends: Vec<String>,
#[serde(default)]
pub friend_group_names: Vec<String>,
#[serde(default)]
pub friend_key: Option<String>,
#[serde(default)]
pub friend_request_status: FriendRequestStatus,
#[serde(default)]
pub past_display_names: Option<Vec<PastDisplayName>>,
#[serde(default)]
pub badges: Option<Vec<Badge>>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub is_friend: Option<bool>,
#[serde(default)]
pub note: Option<String>,
#[serde(default)]
pub developer_type: DeveloperType,
#[serde(default)]
pub is_booping_enabled: Option<bool>,
#[serde(default)]
pub receive_mobile_invitations: Option<bool>,
#[serde(default)]
pub hide_content_filter_settings: Option<bool>,
#[serde(default)]
pub has_birthday: Option<bool>,
#[serde(default)]
pub has_email: Option<bool>,
#[serde(default)]
pub has_pending_email: Option<bool>,
#[serde(default)]
pub has_logged_in_from_client: Option<bool>,
#[serde(default)]
pub unsubscribe: Option<bool>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub email_verified: Option<bool>,
#[serde(default)]
pub obfuscated_email: Option<String>,
#[serde(default)]
pub user_language: Option<String>,
#[serde(default)]
pub user_language_code: Option<String>,
#[serde(default)]
pub discord_id: Option<String>,
#[serde(default)]
pub discord_details: Option<DiscordDetails>,
#[serde(default)]
pub google_id: Option<String>,
#[serde(default)]
pub google_details: Option<GoogleDetails>,
#[serde(default)]
pub steam_id: Option<String>,
#[serde(default)]
pub steam_details: Option<SteamDetails>,
#[serde(default)]
pub oculus_id: Option<String>,
#[serde(default)]
pub pico_id: Option<String>,
#[serde(default)]
pub vive_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DiscordDetails {
#[serde(default)]
pub global_name: Option<String>,
#[serde(default)]
pub id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct GoogleDetails {
#[serde(default)]
pub email_matches: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct SteamDetails {
#[serde(default)]
pub avatar: Option<String>,
#[serde(default)]
pub avatarfull: Option<String>,
#[serde(default)]
pub avatarhash: Option<String>,
#[serde(default)]
pub avatarmedium: Option<String>,
#[serde(default)]
pub communityvisibilitystate: Option<i32>,
#[serde(default)]
pub gameextrainfo: Option<String>,
#[serde(default)]
pub gameid: Option<String>,
#[serde(default)]
pub loccountrycode: Option<String>,
#[serde(default)]
pub locstatecode: Option<String>,
#[serde(default)]
pub personaname: Option<String>,
#[serde(default)]
pub personastate: Option<i32>,
#[serde(default)]
pub personastateflags: Option<i32>,
#[serde(default)]
pub primaryclanid: Option<String>,
#[serde(default)]
pub profilestate: Option<i32>,
#[serde(default)]
pub profileurl: Option<String>,
#[serde(default)]
pub steamid: Option<String>,
#[serde(default)]
pub timecreated: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PastDisplayName {
pub display_name: String,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub reverted: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct Badge {
pub badge_id: String,
#[serde(default)]
pub badge_name: String,
#[serde(default)]
pub badge_description: String,
#[serde(default)]
pub assigned_at: Option<String>,
#[serde(default)]
pub showcased: bool,
#[serde(default)]
pub badge_image_url: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub hidden: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LimitedUserFriend {
pub id: String,
pub display_name: String,
#[serde(default)]
pub bio: String,
#[serde(default)]
pub bio_links: Vec<String>,
#[serde(default)]
pub current_avatar_image_url: Option<String>,
#[serde(default)]
pub current_avatar_thumbnail_image_url: Option<String>,
#[serde(default)]
pub current_avatar_tags: Vec<String>,
#[serde(default)]
pub developer_type: DeveloperType,
#[serde(default)]
pub friend_key: Option<String>,
#[serde(default)]
pub is_friend: bool,
#[serde(default)]
pub image_url: Option<String>,
#[serde(default)]
pub last_platform: Option<String>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub last_login: Option<String>,
#[serde(default)]
pub last_activity: Option<String>,
#[serde(default)]
pub last_mobile: Option<String>,
#[serde(default)]
pub platform: String,
#[serde(default)]
pub profile_pic_override: Option<String>,
#[serde(default)]
pub profile_pic_override_thumbnail: Option<String>,
#[serde(default)]
pub status: UserStatus,
#[serde(default)]
pub status_description: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub user_icon: Option<String>,
}

View File

@@ -0,0 +1,80 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use super::enums::ReleaseStatus;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UnityPackageSummary {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub asset_url: Option<String>,
#[serde(default)]
pub asset_version: Option<i32>,
#[serde(default)]
pub platform: Option<String>,
#[serde(default)]
pub unity_version: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub performance_rating: Option<String>,
#[serde(default)]
pub scan_status: Option<String>,
#[serde(default)]
pub variant: Option<String>,
#[serde(default)]
pub unity_sort_number: Option<f64>,
#[serde(default)]
pub impostorizer_version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LimitedWorld {
pub id: String,
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub author_id: Option<String>,
#[serde(default)]
pub author_name: Option<String>,
#[serde(default)]
pub image_url: Option<String>,
#[serde(default)]
pub thumbnail_image_url: Option<String>,
#[serde(default)]
pub release_status: ReleaseStatus,
#[serde(default)]
pub publication_date: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub labs_publication_date: Option<String>,
#[serde(default)]
pub visits: Option<i32>,
#[serde(default)]
pub favorites: Option<i32>,
#[serde(default)]
pub popularity: Option<i32>,
#[serde(default)]
pub occupants: Option<i32>,
#[serde(default)]
pub capacity: Option<i32>,
#[serde(default)]
pub recommended_capacity: Option<i32>,
#[serde(default)]
pub heat: Option<i32>,
#[serde(default)]
pub organization: Option<String>,
#[serde(default)]
pub preview_youtube_id: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub unity_packages: Vec<UnityPackageSummary>,
}

View File

@@ -0,0 +1,100 @@
use serde::{Deserialize, Serialize};
use specta::Type;
const VRCHAT_STATUS_URL: &str = "https://status.vrchat.com/api/v2/status.json";
/// Response from VRChat status API
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct VRChatStatusResponse {
pub page: StatusPage,
pub status: SystemStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct StatusPage {
pub id: String,
pub name: String,
pub url: String,
#[serde(rename = "time_zone")]
pub time_zone: String,
#[serde(rename = "updated_at")]
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct SystemStatus {
/// Indicator of system status
pub indicator: StatusIndicator,
/// Human-readable status description
pub description: String,
}
impl SystemStatus {
/// Check if the system is operating normally
pub fn is_healthy(&self) -> bool {
matches!(self.indicator, StatusIndicator::None)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "lowercase")]
pub enum StatusIndicator {
None,
Minor,
Major,
Critical,
}
/// Fetch current VRChat service status
pub async fn fetch_vrchat_status() -> Result<VRChatStatusResponse, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let response = client
.get(VRCHAT_STATUS_URL)
.send()
.await
.map_err(|e| format!("Failed to fetch status: {}", e))?;
if !response.status().is_success() {
return Err(format!("HTTP error: {}", response.status()));
}
let response_text = response
.text()
.await
.map_err(|e| format!("Failed to read response text: {}", e))?;
log::debug!("VRChat status API response: {}", response_text);
let status = serde_json::from_str::<VRChatStatusResponse>(&response_text).map_err(|e| {
format!(
"Failed to parse status response: {}. Response was: {}",
e, response_text
)
})?;
Ok(status)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_severity_levels() {
let healthy = SystemStatus {
indicator: StatusIndicator::None,
description: "All Systems Operational".to_string(),
};
assert!(healthy.is_healthy());
let major = SystemStatus {
indicator: StatusIndicator::Major,
description: "Partial System Outage".to_string(),
};
assert!(!major.is_healthy());
}
}

View File

@@ -0,0 +1,536 @@
use futures_util::StreamExt;
use http::Request;
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use tokio::sync::Mutex;
use tokio::time::Duration;
use tokio_tungstenite::{
connect_async,
tungstenite::{Message, handshake::client::generate_key},
};
use super::types::*;
use crate::store::{UserStore, user_store::CurrentUserPipelineUpdate};
use crate::vrchat_api::error::{VRCError, VRCResult};
const PIPELINE_BASE_URL: &str = "wss://pipeline.vrchat.cloud/";
const PIPELINE_HOST: &str = "pipeline.vrchat.cloud";
use crate::http_common::USER_AGENT_STRING;
// const HEARTBEAT_INTERVAL_SECS: u64 = 30;
pub struct VRChatWebSocket {
auth_cookie: Arc<Mutex<Option<String>>>,
two_factor_cookie: Arc<Mutex<Option<String>>>,
app_handle: AppHandle,
running: Arc<Mutex<bool>>,
user_store: UserStore,
}
impl VRChatWebSocket {
pub fn new(app_handle: AppHandle, user_store: UserStore) -> Self {
Self {
auth_cookie: Arc::new(Mutex::new(None)),
two_factor_cookie: Arc::new(Mutex::new(None)),
app_handle,
running: Arc::new(Mutex::new(false)),
user_store,
}
}
pub fn get_user_store(&self) -> UserStore {
self.user_store.clone()
}
pub async fn set_cookies(
&self,
auth_cookie: Option<String>,
two_factor_cookie: Option<String>,
) {
let mut auth = self.auth_cookie.lock().await;
*auth = auth_cookie;
drop(auth);
let mut two_fa = self.two_factor_cookie.lock().await;
*two_fa = two_factor_cookie;
}
pub async fn start(&self) -> VRCResult<()> {
let mut running = self.running.lock().await;
if *running {
return Ok(());
}
*running = true;
drop(running);
let auth_cookie = self.auth_cookie.clone();
let two_factor_cookie = self.two_factor_cookie.clone();
let app_handle = self.app_handle.clone();
let running = self.running.clone();
let user_store = self.user_store.clone();
tokio::spawn(async move {
Self::run_connection_loop(
auth_cookie,
two_factor_cookie,
app_handle,
running,
user_store,
)
.await;
});
Ok(())
}
pub async fn stop(&self) {
let mut running = self.running.lock().await;
*running = false;
}
async fn run_connection_loop(
auth_cookie: Arc<Mutex<Option<String>>>,
two_factor_cookie: Arc<Mutex<Option<String>>>,
app_handle: AppHandle,
running: Arc<Mutex<bool>>,
user_store: UserStore,
) {
let mut reconnect_delay = 2;
const MAX_RECONNECT_DELAY: u64 = 60;
loop {
{
let is_running = running.lock().await;
if !*is_running {
break;
}
}
let cookies = {
let auth = auth_cookie.lock().await;
let two_fa = two_factor_cookie.lock().await;
(auth.clone(), two_fa.clone())
};
if cookies.0.is_none() {
tokio::time::sleep(Duration::from_secs(5)).await;
continue;
}
match Self::connect_and_listen(
&cookies.0.unwrap(),
cookies.1.as_deref(),
&app_handle,
&running,
&user_store,
)
.await
{
Ok(_) => {
// Reset delay on successful connection
reconnect_delay = 2;
}
Err(e) => {
log::error!("WebSocket error: {:?}", e);
}
}
{
let is_running = running.lock().await;
if !*is_running {
break;
}
}
// Exponential backoff for reconnection
log::debug!("Reconnecting WebSocket in {} seconds...", reconnect_delay);
tokio::time::sleep(Duration::from_secs(reconnect_delay)).await;
reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY);
}
}
async fn connect_and_listen(
auth_cookie: &str,
two_factor_cookie: Option<&str>,
app_handle: &AppHandle,
running: &Arc<Mutex<bool>>,
user_store: &UserStore,
) -> VRCResult<()> {
let auth_cookie_value = auth_cookie.split(';').next().unwrap_or(auth_cookie).trim();
let auth_token = auth_cookie_value
.splitn(2, '=')
.nth(1)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
VRCError::invalid_input(
"Auth cookie missing auth token required for pipeline WebSocket",
)
})?;
let websocket_url = format!("{PIPELINE_BASE_URL}?authToken={}", auth_token);
log::debug!("Attempting WebSocket connection to: {}", websocket_url);
log::trace!(
"Using auth cookie (first 20 chars): {}...",
&auth_cookie_value.chars().take(20).collect::<String>()
);
// Build Cookie
let mut cookie_parts = vec![auth_cookie_value.to_string()];
if let Some(two_fa) = two_factor_cookie {
let two_fa_value = two_fa.split(';').next().unwrap_or(two_fa).trim();
if !two_fa_value.is_empty() {
cookie_parts.push(two_fa_value.to_string());
}
}
let cookie_header = cookie_parts.join("; ");
// Build WebSocket request
let ws_key = generate_key();
let request = Request::builder()
.method("GET")
.uri(&websocket_url)
.header("Host", PIPELINE_HOST)
.header("User-Agent", USER_AGENT_STRING)
.header("Connection", "Upgrade")
.header("Upgrade", "websocket")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", ws_key)
.header("Cookie", cookie_header)
.body(())
.map_err(|e| VRCError::network(format!("Failed to build WebSocket request: {}", e)))?;
let (ws_stream, response) = connect_async(request)
.await
.map_err(|e| VRCError::network(format!("WebSocket connection failed: {}", e)))?;
log::debug!(
"WebSocket handshake response status: {:?}",
response.status()
);
log::info!("WebSocket connected");
let _ = app_handle.emit("websocket-connected", ());
let (_write, mut read) = ws_stream.split();
// Ping task
// Disabled, as VRChat pipeline seems to not require pings.
// let running_ping = running.clone();
// let ping_task = tokio::spawn(async move {
// let mut ping_interval = interval(Duration::from_secs(HEARTBEAT_INTERVAL_SECS));
// loop {
// ping_interval.tick().await;
// {
// let is_running = running_ping.lock().await;
// if !*is_running {
// break;
// }
// }
// if write.send(Message::Ping(vec![])).await.is_err() {
// break;
// }
// }
// });
// Main message loop
while let Some(msg) = read.next().await {
{
let is_running = running.lock().await;
if !*is_running {
break;
}
}
match msg {
Ok(Message::Text(text)) => {
if let Err(e) = Self::handle_message(&text, app_handle, user_store).await {
log::error!("Error handling WebSocket message: {:?}", e);
}
}
Ok(Message::Close(_)) => {
log::info!("WebSocket closed by server");
break;
}
Err(e) => {
log::error!("WebSocket read error: {:?}", e);
break;
}
_ => {}
}
}
//ping_task.abort();
let _ = app_handle.emit("websocket-disconnected", ());
Ok(())
}
async fn handle_message(
text: &str,
app_handle: &AppHandle,
user_store: &UserStore,
) -> VRCResult<()> {
log::trace!("WebSocket Message Received: {}", text);
// Parse the outer envelope
let message: WebSocketMessage = serde_json::from_str(text)
.map_err(|e| VRCError::parse(format!("Failed to parse WebSocket message: {}", e)))?;
// Handle different message types
match message {
WebSocketMessage::Notification(payload) => {
let payload = payload.into_inner();
log::trace!(
"Notification event: {}",
payload.kind.as_deref().unwrap_or("unknown")
);
let _ = app_handle.emit("vrchat-notification", &payload);
}
WebSocketMessage::ResponseNotification(payload) => {
let payload = payload.into_inner();
log::trace!(
"Notification response: notification={}, response={}",
payload.notification_id,
payload.response_id
);
let _ = app_handle.emit("vrchat-notification-response", &payload);
}
WebSocketMessage::SeeNotification(notification_id) => {
let notification_id = notification_id.into_inner();
log::trace!("Notification seen: {}", notification_id);
let _ = app_handle.emit("vrchat-notification-see", &notification_id);
}
WebSocketMessage::HideNotification(notification_id) => {
let notification_id = notification_id.into_inner();
log::trace!("Notification hide requested: {}", notification_id);
let _ = app_handle.emit("vrchat-notification-hide", &notification_id);
}
WebSocketMessage::ClearNotification => {
log::trace!("Notification clear requested");
let _ = app_handle.emit("vrchat-notification-clear", ());
}
WebSocketMessage::NotificationV2(payload) => {
let payload = payload.into_inner();
log::trace!("Notification v2: {}", payload.kind);
let _ = app_handle.emit("vrchat-notification-v2", &payload);
}
WebSocketMessage::NotificationV2Update(payload) => {
let payload = payload.into_inner();
log::trace!("Notification v2 update: {}", payload.id);
let _ = app_handle.emit("vrchat-notification-v2-update", &payload);
}
WebSocketMessage::NotificationV2Delete(payload) => {
let payload = payload.into_inner();
log::trace!("Notification v2 delete: {} ids", payload.ids.len());
let _ = app_handle.emit("vrchat-notification-v2-delete", &payload);
}
WebSocketMessage::FriendAdd(payload) => {
let content = payload.into_inner();
log::info!(
"Friend added: {} ({})",
content.user.display_name,
content.user_id
);
user_store.upsert_friend(content.user.clone()).await;
let event = FriendUpdateEvent {
user_id: content.user_id.clone(),
user: content.user.clone(),
};
let _ = app_handle.emit("friend-added", &event);
let _ = app_handle.emit("friend-update", &event);
}
WebSocketMessage::FriendDelete(payload) => {
let content = payload.into_inner();
log::info!("Friend removed: {}", content.user_id);
user_store.remove_friend(&content.user_id).await;
let event = FriendRemovedEvent {
user_id: content.user_id.clone(),
};
let _ = app_handle.emit("friend-removed", &event);
}
WebSocketMessage::FriendUpdate(payload) => {
let content = payload.into_inner();
log::debug!(
"Friend updated: {} ({})",
content.user.display_name,
content.user_id
);
user_store.upsert_friend(content.user.clone()).await;
let event = FriendUpdateEvent {
user_id: content.user_id,
user: content.user,
};
let _ = app_handle.emit("friend-update", &event);
}
WebSocketMessage::FriendOnline(payload) => {
let content = payload.into_inner();
log::info!(
"Friend online: {} ({})",
content.user.display_name,
content.user_id
);
user_store.upsert_friend(content.user.clone()).await;
if let Some(location) = content.location.clone() {
user_store
.update_user_location(&content.user_id, location, content.platform.clone())
.await;
}
let event = FriendOnlineEvent {
user_id: content.user_id,
user: content.user,
};
let _ = app_handle.emit("friend-online", &event);
}
WebSocketMessage::FriendActive(payload) => {
let content = payload.into_inner();
log::debug!(
"Friend active: {} ({})",
content.user.display_name,
content.user_id
);
user_store.upsert_friend(content.user.clone()).await;
let event = FriendOnlineEvent {
user_id: content.user_id.clone(),
user: content.user.clone(),
};
let _ = app_handle.emit("friend-active", &event);
let _ = app_handle.emit("friend-online", &event);
}
WebSocketMessage::FriendOffline(payload) => {
let content = payload.into_inner();
log::info!("Friend offline: {}", content.user_id);
user_store.set_friend_offline(&content.user_id).await;
let event = FriendOfflineEvent {
user_id: content.user_id,
};
let _ = app_handle.emit("friend-offline", &event);
}
WebSocketMessage::FriendLocation(payload) => {
let content = payload.into_inner();
log::debug!(
"Friend location: {} -> {}",
content.user_id,
content.location
);
if let Some(user) = content.user.clone() {
user_store.upsert_friend(user).await;
}
let platform = content.user.as_ref().map(|friend| friend.platform.clone());
user_store
.update_user_location(&content.user_id, content.location.clone(), platform)
.await;
let _ = app_handle.emit("friend-location", &content);
}
WebSocketMessage::UserUpdate(payload) => {
let content = payload.into_inner();
log::debug!("Current user update for {}", content.user_id);
let user = content.user.clone();
let patch = CurrentUserPipelineUpdate {
id: user.id.clone(),
display_name: user.display_name.clone(),
username: user.username.clone(),
status: user.status.clone(),
status_description: user.status_description.clone(),
bio: user.bio.clone(),
user_icon: user.user_icon.clone(),
profile_pic_override: user.profile_pic_override.clone(),
profile_pic_override_thumbnail: user
.profile_pic_override_thumbnail_image_url
.clone(),
current_avatar: user.current_avatar.clone(),
current_avatar_asset_url: user.current_avatar_asset_url.clone(),
current_avatar_image_url: user.current_avatar_image_url.clone(),
current_avatar_thumbnail_image_url: user
.current_avatar_thumbnail_image_url
.clone(),
fallback_avatar: user.fallback_avatar.clone(),
tags: user.tags.clone(),
};
user_store.apply_current_user_update(patch).await;
let _ = app_handle.emit("user-update", &content);
}
WebSocketMessage::UserLocation(payload) => {
let content = payload.into_inner();
log::debug!(
"Current user location: {} -> {}",
content.user_id,
content.location
);
if let Some(user) = content.user.clone() {
user_store.upsert_friend(user).await;
}
let platform = content.user.as_ref().map(|friend| friend.platform.clone());
user_store
.update_user_location(&content.user_id, content.location.clone(), platform)
.await;
let _ = app_handle.emit("user-location", &content);
}
WebSocketMessage::UserBadgeAssigned(payload) => {
let payload = payload.into_inner();
log::info!("Badge assigned: {}", payload.badge.badge_id);
let _ = app_handle.emit("user-badge-assigned", &payload);
}
WebSocketMessage::UserBadgeUnassigned(payload) => {
let payload = payload.into_inner();
log::info!("Badge unassigned: {}", payload.badge_id);
let _ = app_handle.emit("user-badge-unassigned", &payload);
}
WebSocketMessage::ContentRefresh(payload) => {
let payload = payload.into_inner();
log::debug!(
"Content refresh: {} {}",
payload.content_type,
payload.action_type.as_deref().unwrap_or("")
);
let _ = app_handle.emit("content-refresh", &payload);
}
WebSocketMessage::ModifiedImageUpdate(payload) => {
let payload = payload.into_inner();
log::debug!("Image modified: {}", payload.file_id);
let _ = app_handle.emit("modified-image-update", &payload);
}
WebSocketMessage::InstanceQueueJoined(payload) => {
let payload = payload.into_inner();
log::info!(
"Instance queue joined: {} (position {})",
payload.instance_location,
payload.position
);
let _ = app_handle.emit("instance-queue-joined", &payload);
}
WebSocketMessage::InstanceQueueReady(payload) => {
let payload = payload.into_inner();
log::info!(
"Instance queue ready: {} (expiry {})",
payload.instance_location,
payload.expiry
);
let _ = app_handle.emit("instance-queue-ready", &payload);
}
WebSocketMessage::GroupJoined(payload) => {
let payload = payload.into_inner();
log::info!("Group joined: {}", payload.group_id);
let _ = app_handle.emit("group-joined", &payload);
}
WebSocketMessage::GroupLeft(payload) => {
let payload = payload.into_inner();
log::info!("Group left: {}", payload.group_id);
let _ = app_handle.emit("group-left", &payload);
}
WebSocketMessage::GroupMemberUpdated(payload) => {
let payload = payload.into_inner();
log::debug!("Group member updated event received");
let _ = app_handle.emit("group-member-updated", &payload);
}
WebSocketMessage::GroupRoleUpdated(payload) => {
let payload = payload.into_inner();
log::debug!("Group role updated event received");
let _ = app_handle.emit("group-role-updated", &payload);
}
WebSocketMessage::Unknown => {
log::debug!("Unknown WebSocket message type");
}
}
Ok(())
}
}

View File

@@ -0,0 +1,5 @@
pub mod client;
pub mod types;
pub use client::VRChatWebSocket;
pub use types::*;

View File

@@ -0,0 +1,505 @@
use crate::vrchat_api::types::LimitedUserFriend;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use specta::Type;
use std::collections::BTreeMap;
/// Wrapper that transparently handles VRChat's double-encoded message payloads.
/// Some events ship their "content" field as a JSON string containing another
/// JSON document. Others already provide structured JSON. This helper will
/// attempt to deserialize the content value directly and, if that fails,
/// attempt to parse the inner string as JSON and deserialize that instead.
#[derive(Debug, Clone)]
pub struct DoubleEncoded<T> {
inner: T,
}
impl<T> DoubleEncoded<T> {
pub fn into_inner(self) -> T {
self.inner
}
pub fn as_inner(&self) -> &T {
&self.inner
}
}
impl<T> Serialize for DoubleEncoded<T>
where
T: Serialize,
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
self.inner.serialize(serializer)
}
}
impl<'de, T> Deserialize<'de> for DoubleEncoded<T>
where
T: DeserializeOwned,
{
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
let inner = decode_value::<T>(value).map_err(serde::de::Error::custom)?;
Ok(Self { inner })
}
}
fn decode_value<T>(value: Value) -> Result<T, serde_json::Error>
where
T: DeserializeOwned,
{
match serde_json::from_value::<T>(value.clone()) {
Ok(result) => Ok(result),
Err(primary_err) => {
if let Value::String(raw) = value {
// Attempt to parse the string as JSON and then deserialize to the target type.
match serde_json::from_str::<Value>(&raw) {
Ok(decoded) => serde_json::from_value::<T>(decoded),
Err(_) => Err(primary_err),
}
} else {
Err(primary_err)
}
}
}
}
/// WebSocket message envelope for pipeline events.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "content")]
pub enum WebSocketMessage {
#[serde(rename = "notification")]
Notification(DoubleEncoded<NotificationPayload>),
#[serde(rename = "response-notification")]
ResponseNotification(DoubleEncoded<ResponseNotificationContent>),
#[serde(rename = "see-notification")]
SeeNotification(DoubleEncoded<String>),
#[serde(rename = "hide-notification")]
HideNotification(DoubleEncoded<String>),
#[serde(rename = "clear-notification")]
ClearNotification,
#[serde(rename = "notification-v2")]
NotificationV2(DoubleEncoded<NotificationV2Payload>),
#[serde(rename = "notification-v2-update")]
NotificationV2Update(DoubleEncoded<NotificationV2UpdatePayload>),
#[serde(rename = "notification-v2-delete")]
NotificationV2Delete(DoubleEncoded<NotificationV2DeletePayload>),
#[serde(rename = "friend-add")]
FriendAdd(DoubleEncoded<FriendAddContent>),
#[serde(rename = "friend-delete")]
FriendDelete(DoubleEncoded<FriendDeleteContent>),
#[serde(rename = "friend-update")]
FriendUpdate(DoubleEncoded<FriendUpdateContent>),
#[serde(rename = "friend-online")]
FriendOnline(DoubleEncoded<FriendOnlineContent>),
#[serde(rename = "friend-active")]
FriendActive(DoubleEncoded<FriendActiveContent>),
#[serde(rename = "friend-offline")]
FriendOffline(DoubleEncoded<FriendOfflineContent>),
#[serde(rename = "friend-location")]
FriendLocation(DoubleEncoded<FriendLocationContent>),
#[serde(rename = "user-update")]
UserUpdate(DoubleEncoded<UserUpdateContent>),
#[serde(rename = "user-location")]
UserLocation(DoubleEncoded<UserLocationContent>),
#[serde(rename = "user-badge-assigned")]
UserBadgeAssigned(DoubleEncoded<UserBadgeAssignedContent>),
#[serde(rename = "user-badge-unassigned")]
UserBadgeUnassigned(DoubleEncoded<UserBadgeUnassignedContent>),
#[serde(rename = "content-refresh")]
ContentRefresh(DoubleEncoded<ContentRefreshContent>),
#[serde(rename = "modified-image-update")]
ModifiedImageUpdate(DoubleEncoded<ModifiedImageUpdateContent>),
#[serde(rename = "instance-queue-joined")]
InstanceQueueJoined(DoubleEncoded<InstanceQueueJoinedContent>),
#[serde(rename = "instance-queue-ready")]
InstanceQueueReady(DoubleEncoded<InstanceQueueReadyContent>),
#[serde(rename = "group-joined")]
GroupJoined(DoubleEncoded<GroupChangedContent>),
#[serde(rename = "group-left")]
GroupLeft(DoubleEncoded<GroupChangedContent>),
#[serde(rename = "group-member-updated")]
GroupMemberUpdated(DoubleEncoded<GroupMemberUpdatedContent>),
#[serde(rename = "group-role-updated")]
GroupRoleUpdated(DoubleEncoded<GroupRoleUpdatedContent>),
#[serde(other)]
Unknown,
}
// Notification payloads
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct NotificationPayload {
#[serde(default)]
pub id: Option<String>,
#[serde(rename = "type", default)]
pub kind: Option<String>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub sender_user_id: Option<String>,
#[serde(default)]
pub sender_username: Option<String>,
#[serde(default)]
pub receiver_user_id: Option<String>,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub details: Option<Value>,
#[serde(default)]
pub image_url: Option<String>,
#[serde(default)]
pub link: Option<String>,
#[serde(default)]
pub link_text: Option<String>,
#[serde(default)]
pub seen: Option<bool>,
#[serde(default)]
pub can_respond: Option<bool>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub expiry_after_seen: Option<i64>,
#[serde(default)]
pub require_seen: Option<bool>,
#[serde(default)]
pub hide_after_seen: Option<bool>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResponseNotificationContent {
pub notification_id: String,
pub receiver_id: String,
pub response_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct NotificationV2Payload {
pub id: String,
pub version: i32,
#[serde(rename = "type")]
pub kind: String,
pub category: String,
pub is_system: bool,
pub ignore_dnd: bool,
#[serde(default)]
pub sender_user_id: Option<String>,
#[serde(default)]
pub sender_username: Option<String>,
pub receiver_user_id: String,
#[serde(default)]
pub related_notifications_id: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub image_url: Option<String>,
#[serde(default)]
pub link: Option<String>,
#[serde(default)]
pub link_text: Option<String>,
#[serde(default)]
pub responses: Vec<NotificationV2Response>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub expiry_after_seen: Option<i64>,
#[serde(default)]
pub require_seen: Option<bool>,
#[serde(default)]
pub seen: Option<bool>,
#[serde(default)]
pub can_delete: Option<bool>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct NotificationV2Response {
#[serde(rename = "type", default)]
pub kind: Option<String>,
#[serde(default)]
pub data: Option<String>,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub text: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct NotificationV2UpdatePayload {
pub id: String,
pub version: i32,
#[serde(default)]
pub updates: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type, Default)]
#[serde(rename_all = "camelCase")]
pub struct NotificationV2DeletePayload {
#[serde(default)]
pub ids: Vec<String>,
pub version: i32,
}
// Friend events
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FriendAddContent {
#[serde(rename = "userId")]
pub user_id: String,
pub user: LimitedUserFriend,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FriendDeleteContent {
#[serde(rename = "userId")]
pub user_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FriendUpdateContent {
#[serde(rename = "userId")]
pub user_id: String,
pub user: LimitedUserFriend,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FriendOnlineContent {
#[serde(rename = "userId")]
pub user_id: String,
#[serde(default)]
pub platform: Option<String>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub can_request_invite: Option<bool>,
pub user: LimitedUserFriend,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FriendActiveContent {
#[serde(rename = "userid", alias = "userId")]
pub user_id: String,
#[serde(default)]
pub platform: Option<String>,
pub user: LimitedUserFriend,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FriendOfflineContent {
#[serde(rename = "userId")]
pub user_id: String,
#[serde(default)]
pub platform: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FriendLocationContent {
#[serde(rename = "userId")]
pub user_id: String,
pub location: String,
#[serde(default)]
pub traveling_to_location: Option<String>,
#[serde(default)]
pub world_id: Option<String>,
#[serde(default)]
pub can_request_invite: Option<bool>,
#[serde(default)]
pub user: Option<LimitedUserFriend>,
}
// User events
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UserUpdateContent {
#[serde(rename = "userId")]
pub user_id: String,
pub user: PipelineUserSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct PipelineUserSummary {
#[serde(default)]
pub bio: String,
#[serde(default)]
pub current_avatar: Option<String>,
#[serde(default)]
pub current_avatar_asset_url: Option<String>,
#[serde(default)]
pub current_avatar_image_url: Option<String>,
#[serde(default)]
pub current_avatar_thumbnail_image_url: Option<String>,
pub display_name: String,
#[serde(default)]
pub fallback_avatar: Option<String>,
pub id: String,
#[serde(default)]
pub profile_pic_override: Option<String>,
#[serde(default)]
pub profile_pic_override_thumbnail_image_url: Option<String>,
#[serde(default)]
pub status: String,
#[serde(default)]
pub status_description: String,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub user_icon: Option<String>,
pub username: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UserLocationContent {
#[serde(rename = "userId")]
pub user_id: String,
#[serde(default)]
pub user: Option<LimitedUserFriend>,
pub location: String,
#[serde(default)]
pub instance: Option<String>,
#[serde(default)]
pub traveling_to_location: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserBadgeAssignedContent {
pub badge: PipelineBadge,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PipelineBadge {
pub badge_id: String,
#[serde(default)]
pub badge_name: Option<String>,
#[serde(default)]
pub badge_description: Option<String>,
#[serde(default)]
pub badge_image_url: Option<String>,
#[serde(default)]
pub assigned_at: Option<String>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct UserBadgeUnassignedContent {
pub badge_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ContentRefreshContent {
pub content_type: String,
pub file_id: Option<String>,
#[serde(default)]
pub item_id: Option<String>,
#[serde(default)]
pub item_type: Option<String>,
#[serde(default)]
pub action_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ModifiedImageUpdateContent {
pub file_id: String,
pub pixel_size: i64,
pub version_number: i64,
pub needs_processing: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct InstanceQueueJoinedContent {
pub instance_location: String,
pub position: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct InstanceQueueReadyContent {
pub instance_location: String,
pub expiry: String,
}
// Group events
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct GroupChangedContent {
pub group_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GroupMemberUpdatedContent {
pub member: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GroupRoleUpdatedContent {
pub role: Value,
}
// Typed payloads for frontend events
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FriendOnlineEvent {
pub user_id: String,
pub user: LimitedUserFriend,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FriendOfflineEvent {
pub user_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FriendUpdateEvent {
pub user_id: String,
pub user: LimitedUserFriend,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FriendRemovedEvent {
pub user_id: String,
}

39
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,39 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "VRC Circle",
"version": "0.0.1",
"identifier": "cafe.kirameki.vrc-circle",
"build": {
"beforeDevCommand": "bun run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "bun run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "VRC Circle",
"width": 1200,
"height": 800
}
],
"security": {
"csp": null,
"assetProtocol": {
"enable": true,
"scope": ["**"]
}
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}