added a welcome window

This commit is contained in:
2025-12-28 23:40:03 +08:00
parent 40d9872876
commit e17c95c763
10 changed files with 184 additions and 27 deletions

1
src-tauri/Cargo.lock generated
View File

@@ -1164,6 +1164,7 @@ dependencies = [
"tauri-plugin-positioner", "tauri-plugin-positioner",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
"tokio-util",
"tracing", "tracing",
"tracing-appender", "tracing-appender",
"tracing-subscriber", "tracing-subscriber",

View File

@@ -26,6 +26,7 @@ tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] }
tauri-plugin-global-shortcut = "2" tauri-plugin-global-shortcut = "2"
tauri-plugin-positioner = "2" tauri-plugin-positioner = "2"
reqwest = { version = "0.12.23", features = ["json", "native-tls", "blocking"] } reqwest = { version = "0.12.23", features = ["json", "native-tls", "blocking"] }
tokio-util = "0.7"
ts-rs = "11.0.1" ts-rs = "11.0.1"
device_query = "4.0.1" device_query = "4.0.1"
dotenvy = "0.15.7" dotenvy = "0.15.7"

View File

@@ -7,6 +7,7 @@ use crate::{
services::{ services::{
auth::{get_access_token, get_tokens}, auth::{get_access_token, get_tokens},
scene::{close_splash_window, open_scene_window, open_splash_window}, scene::{close_splash_window, open_scene_window, open_splash_window},
welcome::open_welcome_window,
ws::init_ws_client, ws::init_ws_client,
}, },
state::init_app_data, state::init_app_data,
@@ -57,21 +58,13 @@ async fn construct_app() {
pub async fn bootstrap() { pub async fn bootstrap() {
match get_tokens().await { match get_tokens().await {
Some(tokens) => { Some(_tokens) => {
info!("Tokens found in keyring - restoring user session"); info!("Tokens found in keyring - restoring user session");
construct_app().await; construct_app().await;
} }
None => { None => {
info!("No active session found - user needs to authenticate"); info!("No active session found - showing welcome first");
match crate::services::auth::init_auth_code_retrieval(|| { open_welcome_window();
info!("Authentication successful, creating scene...");
tauri::async_runtime::spawn(async {
construct_app().await;
});
}) {
Ok(it) => it,
Err(err) => todo!("Handle authentication error: {}", err),
};
} }
} }
} }

View File

@@ -6,7 +6,10 @@ use crate::{
UserBasicDto, UserBasicDto,
}, },
remotes::user::UserRemote, remotes::user::UserRemote,
services::{cursor::start_cursor_tracking, doll_editor::open_doll_editor_window}, services::{
cursor::start_cursor_tracking,
doll_editor::open_doll_editor_window,
},
state::{init_app_data, init_app_data_scoped, AppDataRefreshScope, FDOLL}, state::{init_app_data, init_app_data_scoped, AppDataRefreshScope, FDOLL},
}; };
use tauri::async_runtime; use tauri::async_runtime;
@@ -327,6 +330,22 @@ fn quit_app() -> Result<(), String> {
Ok(()) Ok(())
} }
#[tauri::command]
fn start_auth_flow() -> Result<(), String> {
// Cancel any in-flight auth listener/state before starting a new one
crate::services::auth::cancel_auth_flow();
crate::services::auth::init_auth_code_retrieval(|| {
tracing::info!("Authentication successful, creating scene...");
// Close welcome window if it's still open
crate::services::welcome::close_welcome_window();
tauri::async_runtime::spawn(async {
crate::app::bootstrap().await;
});
})
.map_err(|e| e.to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
@@ -354,7 +373,8 @@ pub fn run() {
remove_active_doll, remove_active_doll,
recolor_gif_base64, recolor_gif_base64,
quit_app, quit_app,
open_doll_editor_window open_doll_editor_window,
start_auth_flow
]) ])
.setup(|app| { .setup(|app| {
APP_HANDLE APP_HANDLE

View File

@@ -11,8 +11,9 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use thiserror::Error; use thiserror::Error;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener; use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use url::form_urlencoded; use url::form_urlencoded;
@@ -22,6 +23,14 @@ static REFRESH_LOCK: once_cell::sync::Lazy<Mutex<()>> =
static AUTH_SUCCESS_HTML: &str = include_str!("../assets/auth-success.html"); static AUTH_SUCCESS_HTML: &str = include_str!("../assets/auth-success.html");
const SERVICE_NAME: &str = "friendolls"; const SERVICE_NAME: &str = "friendolls";
pub fn cancel_auth_flow() {
let mut guard = lock_w!(FDOLL);
if let Some(token) = guard.oauth_flow.cancel_token.take() {
token.cancel();
}
guard.oauth_flow = Default::default();
}
/// Errors that can occur during OAuth authentication flow. /// Errors that can occur during OAuth authentication flow.
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum OAuthError { pub enum OAuthError {
@@ -49,6 +58,9 @@ pub enum OAuthError {
#[error("Callback timeout - no response received")] #[error("Callback timeout - no response received")]
CallbackTimeout, CallbackTimeout,
#[error("Authentication flow cancelled")]
Cancelled,
#[error("Invalid app configuration")] #[error("Invalid app configuration")]
InvalidConfig, InvalidConfig,
@@ -526,6 +538,7 @@ where
let code_verifier = generate_code_verifier(64); let code_verifier = generate_code_verifier(64);
let code_challenge = generate_code_challenge(&code_verifier); let code_challenge = generate_code_challenge(&code_verifier);
let state = generate_code_verifier(16); let state = generate_code_verifier(16);
let cancel_token = CancellationToken::new();
// Store state and code_verifier for validation // Store state and code_verifier for validation
let current_time = SystemTime::now() let current_time = SystemTime::now()
@@ -538,6 +551,7 @@ where
guard.oauth_flow.state = Some(state.clone()); guard.oauth_flow.state = Some(state.clone());
guard.oauth_flow.code_verifier = Some(code_verifier.clone()); guard.oauth_flow.code_verifier = Some(code_verifier.clone());
guard.oauth_flow.initiated_at = Some(current_time); guard.oauth_flow.initiated_at = Some(current_time);
guard.oauth_flow.cancel_token = Some(cancel_token.clone());
} }
let mut url = match url::Url::parse(&format!("{}/auth", &app_config.auth.auth_url)) { let mut url = match url::Url::parse(&format!("{}/auth", &app_config.auth.auth_url)) {
@@ -592,6 +606,7 @@ where
.append_pair("code_challenge", &code_challenge) .append_pair("code_challenge", &code_challenge)
.append_pair("code_challenge_method", "S256"); .append_pair("code_challenge_method", "S256");
let redirect_uri_clone = redirect_uri.clone(); let redirect_uri_clone = redirect_uri.clone();
let cancel_token_clone = cancel_token.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
info!("Starting callback listener task"); info!("Starting callback listener task");
let listener = match TcpListener::from_std(std_listener) { let listener = match TcpListener::from_std(std_listener) {
@@ -602,7 +617,7 @@ where
} }
}; };
match listen_for_callback(listener).await { match listen_for_callback(listener, cancel_token_clone).await {
Ok(params) => { Ok(params) => {
let (stored_state, stored_verifier) = { let (stored_state, stored_verifier) = {
let guard = lock_r!(FDOLL); let guard = lock_r!(FDOLL);
@@ -634,19 +649,13 @@ where
error!("Failed to save auth pass: {}", e); error!("Failed to save auth pass: {}", e);
} }
// Immediately refresh app data now that auth is available // Defer app initialization to the shared bootstrap path
tauri::async_runtime::spawn(async {
crate::state::init_app_data_scoped(
crate::state::AppDataRefreshScope::All,
)
.await;
});
on_success(); on_success();
} }
Err(e) => error!("Token exchange failed: {}", e), Err(e) => error!("Token exchange failed: {}", e),
} }
} }
Err(OAuthError::Cancelled) => info!("Callback listener was cancelled"),
Err(e) => error!("Callback listener error: {}", e), Err(e) => error!("Callback listener error: {}", e),
} }
}); });
@@ -753,7 +762,10 @@ pub async fn refresh_token(refresh_token: &str) -> Result<AuthPass, OAuthError>
/// Returns `OAuthError` if: /// Returns `OAuthError` if:
/// - Required callback parameters are missing /// - Required callback parameters are missing
/// - Timeout is reached before callback is received /// - Timeout is reached before callback is received
async fn listen_for_callback(listener: TcpListener) -> Result<OAuthCallbackParams, OAuthError> { async fn listen_for_callback(
listener: TcpListener,
cancel_token: CancellationToken,
) -> Result<OAuthCallbackParams, OAuthError> {
// Set a 5-minute timeout // Set a 5-minute timeout
let timeout = Duration::from_secs(300); let timeout = Duration::from_secs(300);
let start_time = Instant::now(); let start_time = Instant::now();
@@ -765,9 +777,17 @@ async fn listen_for_callback(listener: TcpListener) -> Result<OAuthCallbackParam
return Err(OAuthError::CallbackTimeout); return Err(OAuthError::CallbackTimeout);
} }
let accept_result = tokio::time::timeout(timeout - elapsed, listener.accept()).await; let remaining = timeout - elapsed;
let (mut stream, _) = match accept_result { let accept_result = tokio::select! {
_ = cancel_token.cancelled() => {
info!("Callback listener cancelled");
return Err(OAuthError::Cancelled);
},
res = tokio::time::timeout(remaining, listener.accept()) => res,
};
let (mut stream, _): (TcpStream, _) = match accept_result {
Ok(Ok(res)) => res, Ok(Ok(res)) => res,
Ok(Err(e)) => { Ok(Err(e)) => {
warn!("Accept error: {}", e); warn!("Accept error: {}", e);

View File

@@ -4,4 +4,6 @@ pub mod cursor;
pub mod doll_editor; pub mod doll_editor;
pub mod scene; pub mod scene;
pub mod sprite_recolor; pub mod sprite_recolor;
pub mod welcome;
pub mod ws; pub mod ws;

View File

@@ -0,0 +1,79 @@
use crate::get_app_handle;
use tauri::Manager;
use tauri_plugin_dialog::{DialogExt, MessageDialogBuilder, MessageDialogKind};
use tauri_plugin_positioner::WindowExt;
use tracing::{error, info};
pub static WELCOME_WINDOW_LABEL: &str = "welcome";
pub fn open_welcome_window() {
let app_handle = get_app_handle();
let existing_webview_window = app_handle.get_window(WELCOME_WINDOW_LABEL);
if let Some(window) = existing_webview_window {
if let Err(e) = window.show() {
error!("Failed to show existing welcome window: {}", e);
MessageDialogBuilder::new(
app_handle.dialog().clone(),
"Window Error",
"Failed to show the welcome screen. Please restart and try again.",
)
.kind(MessageDialogKind::Error)
.show(|_| {});
}
return;
}
let webview_window = match tauri::WebviewWindowBuilder::new(
app_handle,
WELCOME_WINDOW_LABEL,
tauri::WebviewUrl::App("/welcome".into()),
)
.title("Welcome to Friendolls")
.inner_size(420.0, 420.0)
.resizable(false)
.decorations(true)
.transparent(false)
.shadow(true)
.visible(false)
.skip_taskbar(false)
.always_on_top(false)
.visible_on_all_workspaces(false)
.build()
{
Ok(window) => {
info!("{} window builder succeeded", WELCOME_WINDOW_LABEL);
window
}
Err(e) => {
error!("Failed to build {} window: {}", WELCOME_WINDOW_LABEL, e);
return;
}
};
if let Err(e) = webview_window.move_window(tauri_plugin_positioner::Position::Center) {
error!("Failed to move welcome window to center: {}", e);
}
if let Err(e) = webview_window.show() {
error!("Failed to show welcome window: {}", e);
MessageDialogBuilder::new(
app_handle.dialog().clone(),
"Window Error",
"Failed to show the welcome screen. Please restart and try again.",
)
.kind(MessageDialogKind::Error)
.show(|_| {});
}
}
pub fn close_welcome_window() {
let app_handle = get_app_handle();
if let Some(window) = app_handle.get_window(WELCOME_WINDOW_LABEL) {
if let Err(e) = window.close() {
error!("Failed to close welcome window: {}", e);
} else {
info!("Welcome window closed");
}
}
}

View File

@@ -22,6 +22,7 @@ pub struct OAuthFlowTracker {
pub state: Option<String>, pub state: Option<String>,
pub code_verifier: Option<String>, pub code_verifier: Option<String>,
pub initiated_at: Option<u64>, pub initiated_at: Option<u64>,
pub cancel_token: Option<tokio_util::sync::CancellationToken>,
} }
pub struct Clients { pub struct Clients {

View File

@@ -1,3 +1,3 @@
<main class="card-body"> <main class="card-body">
<button class="btn btn-primary">Hello TailwindCSS!</button> <p class="text-lg">Friendolls desktop app shell</p>
</main> </main>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
let isContinuing = false;
const handleContinue = async () => {
if (isContinuing) return;
isContinuing = true;
try {
await invoke("start_auth_flow");
await getCurrentWebviewWindow().close();
} catch (error) {
console.error("Failed to start auth flow", error);
isContinuing = false;
}
};
</script>
<div class="w-full h-full bg-base-100 flex items-center justify-center p-8">
<div class="card w-full max-w-md bg-base-200 shadow-lg">
<div class="card-body gap-4">
<div class="flex flex-col gap-1">
<h1 class="text-2xl font-semibold">Friendolls</h1>
<p class="text-base text-base-content/80">
Passive social app connecting peers through mouse cursor interactions in the form of desktop pets.
</p>
</div>
<div class="card-actions justify-end pt-2">
<button class="btn btn-primary" onclick={handleContinue} disabled={isContinuing}>
{#if isContinuing}
Loading...
{:else}
Continue
{/if}
</button>
</div>
</div>
</div>
</div>