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, user::UserRemote,
}, },
services::{ 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, scene::open_splash_window,
}, },
state::{init_app_data, init_app_data_scoped, AppDataRefreshScope, FDOLL}, state::{init_app_data, init_app_data_scoped, AppDataRefreshScope, FDOLL},
@@ -340,6 +344,31 @@ fn restart_app() {
app_handle.restart(); 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] #[tauri::command]
async fn logout_and_restart() -> Result<(), String> { async fn logout_and_restart() -> Result<(), String> {
crate::services::auth::logout_and_restart() crate::services::auth::logout_and_restart()
@@ -392,6 +421,9 @@ pub fn run() {
recolor_gif_base64, recolor_gif_base64,
quit_app, quit_app,
restart_app, restart_app,
get_client_config,
save_client_config,
open_client_config_manager,
open_doll_editor_window, open_doll_editor_window,
start_auth_flow, start_auth_flow,
logout_and_restart 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; pub mod app_data;

View File

@@ -395,10 +395,7 @@ pub async fn logout_and_restart() -> Result<(), OAuthError> {
let guard = lock_r!(FDOLL); let guard = lock_r!(FDOLL);
( (
guard.auth_pass.as_ref().map(|p| p.refresh_token.clone()), guard.auth_pass.as_ref().map(|p| p.refresh_token.clone()),
guard guard.auth_pass.as_ref().map(|p| p.session_state.clone()),
.auth_pass
.as_ref()
.map(|p| p.session_state.clone()),
guard guard
.app_config .app_config
.api_base_url .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 app_menu;
pub mod auth; pub mod auth;
pub mod client_config_manager;
pub mod cursor; pub mod cursor;
pub mod doll_editor; pub mod doll_editor;
pub mod health_manager; pub mod health_manager;

View File

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

View File

@@ -1,16 +1,15 @@
// in app-core/src/state.rs // in app-core/src/state.rs
use crate::{ use crate::{
get_app_handle, lock_r, lock_w, get_app_handle, lock_r, lock_w,
models::{ models::app_data::AppData,
app_config::{AppConfig, AuthConfig},
app_data::AppData,
},
remotes::{dolls::DollsRemote, friends::FriendRemote, user::UserRemote}, 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::{ use std::{
collections::HashSet, collections::HashSet,
env,
sync::{Arc, LazyLock, RwLock}, sync::{Arc, LazyLock, RwLock},
}; };
use tauri::Emitter; 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); let mut guard = lock_w!(FDOLL);
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
guard.tracing_guard = tracing_guard; guard.tracing_guard = tracing_guard;
guard.app_config = AppConfig { guard.app_config = load_app_config();
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.auth_pass = match load_auth_pass() { guard.auth_pass = match load_auth_pass() {
Ok(pass) => pass, Ok(pass) => pass,
Err(e) => { Err(e) => {

View File

@@ -15,14 +15,27 @@
signingOut = false; signingOut = false;
} }
} }
const openClientConfigManager = async () => {
try {
await invoke("open_client_config_manager");
} catch (error) {
console.error("Failed to open client config manager", error);
}
};
</script> </script>
<div class="size-full flex flex-col justify-between"> <div class="size-full flex flex-col justify-between">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<p>{$appData?.user?.name}'s preferences</p> <p>{$appData?.user?.name}'s preferences</p>
<div class="flex flex-row gap-2">
<button class="btn" class:btn-disabled={signingOut} onclick={handleSignOut}> <button class="btn" class:btn-disabled={signingOut} onclick={handleSignOut}>
{signingOut ? "Signing out..." : "Sign out"} {signingOut ? "Signing out..." : "Sign out"}
</button> </button>
<button class="btn btn-outline" onclick={openClientConfigManager}>
Advanced options
</button>
</div>
</div> </div>
<div class="w-full flex flex-row justify-between"> <div class="w-full flex flex-row justify-between">
<div></div> <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> <p class="text-sm opacity-70 wrap-break-word">{errorMessage}</p>
{/if} {/if}
</div> </div>
<div class="flex flex-row gap-2">
<button <button
class="btn" class="btn"
class:btn-disabled={isRestarting} class:btn-disabled={isRestarting}
@@ -55,5 +56,10 @@
Try again Try again
{/if} {/if}
</button> </button>
<button class="btn btn-outline" onclick={async () => invoke("open_client_config_manager")}>
Advanced options
</button>
</div>
</div> </div>
</div> </div>

View File

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