client configuration manager

This commit is contained in:
2026-01-03 21:49:42 +08:00
parent b48d15df59
commit f85d7e773d
12 changed files with 395 additions and 72 deletions

View File

@@ -9,7 +9,11 @@ use crate::{
user::UserRemote,
},
services::{
cursor::start_cursor_tracking, doll_editor::open_doll_editor_window,
client_config_manager::{
load_app_config, open_config_manager_window, save_app_config, AppConfig,
},
cursor::start_cursor_tracking,
doll_editor::open_doll_editor_window,
scene::open_splash_window,
},
state::{init_app_data, init_app_data_scoped, AppDataRefreshScope, FDOLL},
@@ -340,6 +344,31 @@ fn restart_app() {
app_handle.restart();
}
#[tauri::command]
fn get_client_config() -> AppConfig {
let mut guard = lock_w!(FDOLL);
guard.app_config = load_app_config();
guard.app_config.clone()
}
#[tauri::command]
fn save_client_config(config: AppConfig) -> Result<(), String> {
match save_app_config(config) {
Ok(saved) => {
let mut guard = lock_w!(FDOLL);
guard.app_config = saved;
Ok(())
}
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
fn open_client_config_manager() -> Result<(), String> {
open_config_manager_window();
Ok(())
}
#[tauri::command]
async fn logout_and_restart() -> Result<(), String> {
crate::services::auth::logout_and_restart()
@@ -392,6 +421,9 @@ pub fn run() {
recolor_gif_base64,
quit_app,
restart_app,
get_client_config,
save_client_config,
open_client_config_manager,
open_doll_editor_window,
start_auth_flow,
logout_and_restart

View File

@@ -1,13 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Default, Serialize, Deserialize, Clone, Debug)]
pub struct AuthConfig {
pub audience: String,
pub auth_url: String,
}
#[derive(Default, Serialize, Deserialize, Clone, Debug)]
pub struct AppConfig {
pub api_base_url: Option<String>,
pub auth: AuthConfig,
}

View File

@@ -1,2 +1 @@
pub mod app_config;
pub mod app_data;

View File

@@ -395,10 +395,7 @@ pub async fn logout_and_restart() -> Result<(), OAuthError> {
let guard = lock_r!(FDOLL);
(
guard.auth_pass.as_ref().map(|p| p.refresh_token.clone()),
guard
.auth_pass
.as_ref()
.map(|p| p.session_state.clone()),
guard.auth_pass.as_ref().map(|p| p.session_state.clone()),
guard
.app_config
.api_base_url

View File

@@ -0,0 +1,178 @@
use std::{fs, path::PathBuf};
use serde::{Deserialize, Serialize};
use tauri::Manager;
use thiserror::Error;
use tracing::{error, info, warn};
use url::Url;
use crate::get_app_handle;
#[derive(Default, Serialize, Deserialize, Clone, Debug)]
pub struct AuthConfig {
pub audience: String,
pub auth_url: String,
}
#[derive(Default, Serialize, Deserialize, Clone, Debug)]
pub struct AppConfig {
pub api_base_url: Option<String>,
pub auth: AuthConfig,
}
#[derive(Debug, Error)]
pub enum ClientConfigError {
#[error("failed to resolve app config dir: {0}")]
ResolvePath(tauri::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("failed to parse client config: {0}")]
Parse(#[from] serde_json::Error),
}
pub static CLIENT_CONFIG_MANAGER_WINDOW_LABEL: &str = "client_config_manager";
const CONFIG_FILENAME: &str = "client_config.json";
const DEFAULT_API_BASE_URL: &str = "https://api.fdolls.adamcv.com";
const DEFAULT_AUTH_URL: &str = "https://auth.adamcv.com/realms/friendolls/protocol/openid-connect";
const DEFAULT_JWT_AUDIENCE: &str = "friendolls-desktop";
fn config_file_path(app_handle: &tauri::AppHandle) -> Result<PathBuf, ClientConfigError> {
let dir = app_handle
.path()
.app_config_dir()
.map_err(ClientConfigError::ResolvePath)?;
Ok(dir.join(CONFIG_FILENAME))
}
fn strip_trailing_slash(value: &str) -> String {
value.trim_end_matches('/').to_string()
}
fn sanitize(mut config: AppConfig) -> AppConfig {
// Trim and normalize optional api_base_url
config.api_base_url = config
.api_base_url
.and_then(|v| {
let trimmed = v.trim().to_string();
if trimmed.is_empty() {
None
} else {
// ensure scheme present and no double prefixes
if let Ok(parsed) = Url::parse(&trimmed) {
Some(strip_trailing_slash(parsed.as_str()))
} else if let Ok(parsed) = Url::parse(&format!("https://{trimmed}")) {
Some(strip_trailing_slash(parsed.as_str()))
} else {
None
}
}
})
.or_else(|| Some(DEFAULT_API_BASE_URL.to_string()))
.map(|v| strip_trailing_slash(&v));
let auth_url_trimmed = config.auth.auth_url.trim();
if auth_url_trimmed.is_empty() {
config.auth.auth_url = DEFAULT_AUTH_URL.to_string();
} else if let Ok(parsed) = Url::parse(auth_url_trimmed) {
config.auth.auth_url = strip_trailing_slash(parsed.as_str());
} else if let Ok(parsed) = Url::parse(&format!("https://{auth_url_trimmed}")) {
config.auth.auth_url = strip_trailing_slash(parsed.as_str());
} else {
config.auth.auth_url = DEFAULT_AUTH_URL.to_string();
}
if config.auth.audience.trim().is_empty() {
config.auth.audience = DEFAULT_JWT_AUDIENCE.to_string();
} else {
config.auth.audience = config.auth.audience.trim().to_string();
}
config
}
pub fn default_app_config() -> AppConfig {
AppConfig {
api_base_url: Some(DEFAULT_API_BASE_URL.to_string()),
auth: AuthConfig {
audience: DEFAULT_JWT_AUDIENCE.to_string(),
auth_url: DEFAULT_AUTH_URL.to_string(),
},
}
}
pub fn load_app_config() -> AppConfig {
let app_handle = get_app_handle();
let path = match config_file_path(app_handle) {
Ok(p) => p,
Err(e) => {
warn!("Unable to resolve client config path: {e}");
return default_app_config();
}
};
if !path.exists() {
return default_app_config();
}
match fs::read_to_string(&path) {
Ok(content) => match serde_json::from_str::<AppConfig>(&content) {
Ok(cfg) => sanitize(cfg),
Err(e) => {
warn!("Failed to parse client config, using defaults: {e}");
default_app_config()
}
},
Err(e) => {
warn!("Failed to read client config, using defaults: {e}");
default_app_config()
}
}
}
pub fn save_app_config(config: AppConfig) -> Result<AppConfig, ClientConfigError> {
let app_handle = get_app_handle();
let path = config_file_path(app_handle)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let sanitized = sanitize(config);
let serialized = serde_json::to_string_pretty(&sanitized)?;
fs::write(&path, serialized)?;
Ok(sanitized)
}
pub fn open_config_manager_window() {
let app_handle = get_app_handle();
let existing_webview_window = app_handle.get_window(CLIENT_CONFIG_MANAGER_WINDOW_LABEL);
if let Some(window) = existing_webview_window {
if let Err(e) = window.show() {
error!("Failed to show client config manager window: {e}");
}
return;
}
info!("Starting client config manager...");
let webview_window = match tauri::WebviewWindowBuilder::new(
app_handle,
CLIENT_CONFIG_MANAGER_WINDOW_LABEL,
tauri::WebviewUrl::App("/client-config-manager".into()),
)
.title("Friendolls Client Config Manager")
.inner_size(600.0, 500.0)
.build()
{
Ok(window) => {
info!("Client config manager window builder succeeded");
window
}
Err(e) => {
error!("Failed to build client config manager window: {}", e);
return;
}
};
info!("Client config manager window initialized successfully.");
}

View File

@@ -1,5 +1,6 @@
pub mod app_menu;
pub mod auth;
pub mod client_config_manager;
pub mod cursor;
pub mod doll_editor;
pub mod health_manager;

View File

@@ -5,10 +5,12 @@ use tracing::{error, info};
use crate::{
get_app_handle, lock_r, lock_w,
models::app_config::AppConfig,
services::cursor::{normalized_to_absolute, CursorPosition, CursorPositions},
services::health_manager::{close_health_manager_window, show_health_manager_with_error},
services::scene::open_scene_window,
services::{
client_config_manager::AppConfig,
cursor::{normalized_to_absolute, CursorPosition, CursorPositions},
health_manager::{close_health_manager_window, show_health_manager_with_error},
scene::open_scene_window,
},
state::{init_app_data_scoped, AppDataRefreshScope, FDOLL},
};
use serde::{Deserialize, Serialize};
@@ -393,17 +395,11 @@ pub async fn report_cursor_data(cursor_position: CursorPosition) {
Ok(Ok(_)) => (),
Ok(Err(e)) => {
error!("Failed to emit cursor report: {}", e);
show_health_manager_with_error(Some(format!(
"WebSocket emit failed: {}",
e
)));
show_health_manager_with_error(Some(format!("WebSocket emit failed: {}", e)));
}
Err(e) => {
error!("Failed to execute blocking task for cursor report: {}", e);
show_health_manager_with_error(Some(format!(
"WebSocket task failed: {}",
e
)));
show_health_manager_with_error(Some(format!("WebSocket task failed: {}", e)));
}
}
}

View File

@@ -1,16 +1,15 @@
// in app-core/src/state.rs
use crate::{
get_app_handle, lock_r, lock_w,
models::{
app_config::{AppConfig, AuthConfig},
app_data::AppData,
},
models::app_data::AppData,
remotes::{dolls::DollsRemote, friends::FriendRemote, user::UserRemote},
services::auth::{load_auth_pass, AuthPass},
services::{
auth::{load_auth_pass, AuthPass},
client_config_manager::{load_app_config, AppConfig, AuthConfig},
},
};
use std::{
collections::HashSet,
env,
sync::{Arc, LazyLock, RwLock},
};
use tauri::Emitter;
@@ -53,13 +52,7 @@ pub fn init_fdoll_state(tracing_guard: Option<tracing_appender::non_blocking::Wo
let mut guard = lock_w!(FDOLL);
dotenvy::dotenv().ok();
guard.tracing_guard = tracing_guard;
guard.app_config = AppConfig {
api_base_url: Some(env::var("API_BASE_URL").expect("API_BASE_URL must be set")),
auth: AuthConfig {
audience: env::var("JWT_AUDIENCE").expect("JWT_AUDIENCE must be set"),
auth_url: env::var("AUTH_URL").expect("AUTH_URL must be set"),
},
};
guard.app_config = load_app_config();
guard.auth_pass = match load_auth_pass() {
Ok(pass) => pass,
Err(e) => {

View File

@@ -15,14 +15,27 @@
signingOut = false;
}
}
const openClientConfigManager = async () => {
try {
await invoke("open_client_config_manager");
} catch (error) {
console.error("Failed to open client config manager", error);
}
};
</script>
<div class="size-full flex flex-col justify-between">
<div class="flex flex-col gap-2">
<p>{$appData?.user?.name}'s preferences</p>
<div class="flex flex-row gap-2">
<button class="btn" class:btn-disabled={signingOut} onclick={handleSignOut}>
{signingOut ? "Signing out..." : "Sign out"}
</button>
<button class="btn btn-outline" onclick={openClientConfigManager}>
Advanced options
</button>
</div>
</div>
<div class="w-full flex flex-row justify-between">
<div></div>

View File

@@ -0,0 +1,109 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
type AuthConfig = {
audience: string;
auth_url: string;
};
type AppConfig = {
api_base_url?: string | null;
auth: AuthConfig;
};
let form: AppConfig = {
api_base_url: "",
auth: { audience: "", auth_url: "" },
};
let saving = false;
let errorMessage = "";
let successMessage = "";
const loadConfig = async () => {
try {
const config = (await invoke("get_client_config")) as AppConfig;
form = {
api_base_url: config.api_base_url ?? "",
auth: {
audience: config.auth.audience,
auth_url: config.auth.auth_url,
},
};
} catch (err) {
errorMessage = `Failed to load config: ${err}`;
}
};
const save = async () => {
if (saving) return;
saving = true;
errorMessage = "";
successMessage = "";
try {
await invoke("save_client_config", {
config: {
api_base_url: form.api_base_url?.trim() || null,
auth: {
audience: form.auth.audience.trim(),
auth_url: form.auth.auth_url.trim(),
},
},
});
successMessage = "Configuration saved. Please restart the app.";
await invoke("restart_app");
} catch (err) {
errorMessage = `Failed to save config: ${err}`;
} finally {
saving = false;
}
};
onMount(loadConfig);
</script>
<div class="p-6 flex flex-col gap-4">
<div class="flex flex-col gap-1">
<p class="text-xl font-semibold">Client Configuration</p>
<p class="opacity-70 text-sm">Set custom API and auth endpoints.</p>
</div>
<div class="flex flex-col gap-3">
<label class="flex flex-col gap-1">
<span class="text-sm">API Base URL</span>
<input
class="input input-bordered"
bind:value={form.api_base_url}
placeholder="https://api.fdolls.adamcv.com"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">Auth URL</span>
<input class="input input-bordered" bind:value={form.auth.auth_url} />
</label>
<label class="flex flex-col gap-1">
<span class="text-sm">JWT Audience</span>
<input class="input input-bordered" bind:value={form.auth.audience} />
</label>
</div>
{#if errorMessage}
<p class="text-sm text-error">{errorMessage}</p>
{/if}
{#if successMessage}
<p class="text-sm text-success">{successMessage}</p>
{/if}
<div class="flex flex-row gap-2">
<button
class="btn"
class:btn-disabled={saving}
disabled={saving}
on:click={save}
>
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>

View File

@@ -43,6 +43,7 @@
<p class="text-sm opacity-70 wrap-break-word">{errorMessage}</p>
{/if}
</div>
<div class="flex flex-row gap-2">
<button
class="btn"
class:btn-disabled={isRestarting}
@@ -55,5 +56,10 @@
Try again
{/if}
</button>
<button class="btn btn-outline" onclick={async () => invoke("open_client_config_manager")}>
Advanced options
</button>
</div>
</div>
</div>

View File

@@ -17,6 +17,14 @@
isContinuing = false;
}
};
const openClientConfigManager = async () => {
try {
await invoke("open_client_config_manager");
} catch (error) {
console.error("Failed to open client config manager", error);
}
};
</script>
<div class="size-full relative bg-linear-to-br from-base-100 to-[#b7f2ff77]">
@@ -32,7 +40,7 @@
a cute passive socialization layer!
</p>
</div>
<div>
<div class="flex flex-col gap-2">
<button
class="btn btn-primary"
onclick={handleContinue}
@@ -47,7 +55,11 @@
Sign in with browser
{/if}
</button>
<button class="btn btn-outline" onclick={openClientConfigManager}>
Advanced options
</button>
</div>
<p class="text-xs opacity-50 max-w-60">
An account is needed to identify you for connecting with friends.
</p>