✨ feat: Initial implementation
7
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
7110
src-tauri/Cargo.lock
generated
Normal file
46
src-tauri/Cargo.toml
Normal 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
@@ -0,0 +1,4 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
12
src-tauri/capabilities/default.json
Normal 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
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
256
src-tauri/src/database_studio.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
35
src-tauri/src/http_common.rs
Normal 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
@@ -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");
|
||||
}
|
||||
101
src-tauri/src/log_manager.rs
Normal 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
@@ -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()
|
||||
}
|
||||
272
src-tauri/src/store/account_store.rs
Normal 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
@@ -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))
|
||||
}
|
||||
328
src-tauri/src/store/image_cache.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
11
src-tauri/src/store/mod.rs
Normal 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;
|
||||
135
src-tauri/src/store/settings_store.rs
Normal 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
|
||||
}
|
||||
}
|
||||
651
src-tauri/src/store/user_store.rs
Normal 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 == ¤t_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,
|
||||
}
|
||||
710
src-tauri/src/vrchat_api/client.rs
Normal 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")
|
||||
}
|
||||
}
|
||||
129
src-tauri/src/vrchat_api/error.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
8
src-tauri/src/vrchat_api/mod.rs
Normal 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::*;
|
||||
40
src-tauri/src/vrchat_api/types/auth.rs
Normal 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,
|
||||
}
|
||||
69
src-tauri/src/vrchat_api/types/avatar.rs
Normal 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>,
|
||||
}
|
||||
254
src-tauri/src/vrchat_api/types/enums.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src-tauri/src/vrchat_api/types/mod.rs
Normal 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::*;
|
||||
22
src-tauri/src/vrchat_api/types/two_factor.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
297
src-tauri/src/vrchat_api/types/user.rs
Normal 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>,
|
||||
}
|
||||
80
src-tauri/src/vrchat_api/types/world.rs
Normal 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>,
|
||||
}
|
||||
100
src-tauri/src/vrchat_status.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
536
src-tauri/src/websocket/client.rs
Normal 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", ¬ification_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", ¬ification_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(())
|
||||
}
|
||||
}
|
||||
5
src-tauri/src/websocket/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod client;
|
||||
pub mod types;
|
||||
|
||||
pub use client::VRChatWebSocket;
|
||||
pub use types::*;
|
||||
505
src-tauri/src/websocket/types.rs
Normal 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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||