client configuration manager
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
pub mod app_config;
|
||||
pub mod app_data;
|
||||
|
||||
@@ -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
|
||||
|
||||
178
src-tauri/src/services/client_config_manager.rs
Normal file
178
src-tauri/src/services/client_config_manager.rs
Normal 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.");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
109
src/routes/client-config-manager/+page.svelte
Normal file
109
src/routes/client-config-manager/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user