Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ windows-sys = { version = "0.61", features = [
"Win32_Graphics_Dwm",
"Win32_Graphics_Gdi",
"Win32_Security_Cryptography",
"Win32_Storage_FileSystem",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Threading",
"Win32_UI_Input_KeyboardAndMouse",
Expand Down
111 changes: 105 additions & 6 deletions src-tauri/src/commands/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,27 @@ pub async fn get_game_accounts(
}

let accounts = ss.game_accounts.read().await;
let dtos = accounts.iter().map(GameAccountDto::from).collect();
let overrides = state.display_overrides.read().await;
let mut dtos: Vec<GameAccountDto> = accounts
.iter()
.map(|a| {
let mut dto = GameAccountDto::from(a);
if let Some(name) = overrides.names.get(&dto.id) {
dto.display_name = name.clone();
}
dto
})
.collect();
// Apply custom sort order
if !overrides.order.is_empty() {
dtos.sort_by_key(|d| {
overrides
.order
.iter()
.position(|id| id == &d.id)
.unwrap_or(usize::MAX)
});
}
Ok(dtos)
}

Expand Down Expand Up @@ -152,7 +172,26 @@ pub async fn refresh_accounts(
.await
.map_err(login_err_to_dto)?;

let dtos: Vec<GameAccountDto> = accounts.iter().map(GameAccountDto::from).collect();
let overrides = state.display_overrides.read().await;
let mut dtos: Vec<GameAccountDto> = accounts
.iter()
.map(|a| {
let mut dto = GameAccountDto::from(a);
if let Some(name) = overrides.names.get(&dto.id) {
dto.display_name = name.clone();
}
dto
})
.collect();
if !overrides.order.is_empty() {
dtos.sort_by_key(|d| {
overrides
.order
.iter()
.position(|id| id == &d.id)
.unwrap_or(usize::MAX)
});
}

drop(session_guard);
*ss.game_accounts.write().await = accounts;
Expand Down Expand Up @@ -295,12 +334,18 @@ pub async fn change_account_display_name(
let session_guard = ss.session.read().await;
let _session = auth::require_valid_session(&session_guard).map_err(to_dto)?;

let region = state.config.read().await.region.clone();
let game_code = format!("{}_{}", DEFAULT_SERVICE_CODE, DEFAULT_SERVICE_REGION);

let success =
beanfun_service::change_display_name(&ss.http_client, &game_code, &account_id, &new_name)
.await
.map_err(login_err_to_dto)?;
let success = beanfun_service::change_display_name(
&ss.http_client,
&region,
&game_code,
&account_id,
&new_name,
)
.await
.map_err(login_err_to_dto)?;

if success {
tracing::info!(account_id = %account_id, new_name = %new_name, "display name changed");
Expand All @@ -311,6 +356,60 @@ pub async fn change_account_display_name(
Ok(success)
}

/// Save a local display name override (persisted to display_overrides.json).
///
/// Used when the user renames an account locally (HK region, or TW without sync).
#[tauri::command]
pub async fn set_display_override(
account_id: String,
display_name: String,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let mut overrides = state.display_overrides.write().await;
if display_name.is_empty() {
overrides.names.remove(&account_id);
} else {
overrides
.names
.insert(account_id.clone(), display_name.clone());
}
if let Err(e) =
crate::services::account_storage::save_display_overrides(&state.overrides_path, &overrides)
.await
{
tracing::warn!("failed to save display overrides: {e}");
}
tracing::info!("display override saved: {account_id} = {display_name}");
Ok(())
}

/// Save custom account sort order (persisted to display_overrides).
#[tauri::command]
pub async fn set_account_order(
order: Vec<String>,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let mut overrides = state.display_overrides.write().await;
overrides.order = order;
if let Err(e) =
crate::services::account_storage::save_display_overrides(&state.overrides_path, &overrides)
.await
{
tracing::warn!("failed to save display overrides: {e}");
}
tracing::info!("account order saved ({} entries)", overrides.order.len());
Ok(())
}

/// Get all local display name overrides.
#[tauri::command]
pub async fn get_display_overrides(
state: State<'_, AppState>,
) -> Result<std::collections::HashMap<String, String>, ErrorDto> {
let overrides = state.display_overrides.read().await;
Ok(overrides.names.clone())
}

/// Retrieve the authenticated user's email address (context menu action).
///
/// Delegates to [`beanfun_service::get_email`]. Returns an empty string
Expand Down
130 changes: 127 additions & 3 deletions src-tauri/src/commands/launcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,9 @@ pub async fn launch_game(
// 9. Auto-kill Patcher.exe (respects config toggle)
if config.auto_kill_patcher {
let game_dir = launch_cmd.working_dir.clone();
let app_for_patcher = app.clone();
tauri::async_runtime::spawn(async move {
kill_patcher_loop(&game_dir).await;
kill_patcher_loop(&game_dir, &app_for_patcher).await;
});
}

Expand Down Expand Up @@ -373,8 +374,9 @@ pub async fn launch_game_direct(
// Auto-kill patcher
if config.auto_kill_patcher {
let game_dir = launch_cmd.working_dir.clone();
let app_for_patcher = app.clone();
tauri::async_runtime::spawn(async move {
kill_patcher_loop(&game_dir).await;
kill_patcher_loop(&game_dir, &app_for_patcher).await;
});
}

Expand All @@ -398,7 +400,7 @@ pub async fn launch_game_direct(
/// Uses native Windows Toolhelp32 APIs to enumerate processes in-process,
/// avoiding the `wmic.exe` console window popup that the previous
/// implementation caused (wmic spawns a visible console every 100ms).
async fn kill_patcher_loop(game_dir: &str) {
async fn kill_patcher_loop(game_dir: &str, app: &tauri::AppHandle) {
#[cfg(target_os = "windows")]
{
let patcher_path = std::path::Path::new(game_dir)
Expand All @@ -418,6 +420,17 @@ async fn kill_patcher_loop(game_dir: &str) {
.unwrap_or(false);

if found {
// Get client version from game exe
use tauri::Emitter;
let game_exe = std::path::Path::new(game_dir).join("MapleStory.exe");
let client_version = get_exe_version(&game_exe);
// Try to get server version from MapleStory login server
let server_version = get_server_version().await;
let payload = serde_json::json!({
"clientVersion": client_version,
"serverVersion": server_version,
});
let _ = app.emit("patcher-killed", payload);
return;
}
}
Expand All @@ -426,6 +439,117 @@ async fn kill_patcher_loop(game_dir: &str) {
#[cfg(not(target_os = "windows"))]
{
let _ = game_dir;
let _ = app;
}
}

/// Get the product version string from a Windows PE executable.
/// Returns something like "1.2.437.1" or empty string on failure.
fn get_exe_version(path: &std::path::Path) -> String {
#[cfg(target_os = "windows")]
{
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;

let wide: Vec<u16> = OsStr::new(path)
.encode_wide()
.chain(std::iter::once(0))
.collect();

unsafe {
let size = windows_sys::Win32::Storage::FileSystem::GetFileVersionInfoSizeW(
wide.as_ptr(),
std::ptr::null_mut(),
);
if size == 0 {
return String::new();
}
let mut buf = vec![0u8; size as usize];
if windows_sys::Win32::Storage::FileSystem::GetFileVersionInfoW(
wide.as_ptr(),
0,
size,
buf.as_mut_ptr() as *mut _,
) == 0
{
return String::new();
}
let mut ptr: *mut std::ffi::c_void = std::ptr::null_mut();
let mut len: u32 = 0;
let sub: Vec<u16> = OsStr::new("\\")
.encode_wide()
.chain(std::iter::once(0))
.collect();
if windows_sys::Win32::Storage::FileSystem::VerQueryValueW(
buf.as_ptr() as *const _,
sub.as_ptr(),
&mut ptr,
&mut len,
) == 0
{
return String::new();
}
let info = &*(ptr as *const windows_sys::Win32::Storage::FileSystem::VS_FIXEDFILEINFO);
let major = info.dwProductVersionMS & 0xFFFF; // ProductMinorPart
let minor = (info.dwProductVersionLS >> 16) & 0xFFFF; // FileBuildPart
format!("{major}.{minor}")
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = path;
String::new()
}
}

/// Get MapleStory server version by connecting to the login server.
/// Reads the handshake packet: skip 2 bytes, read u16 major, read maple string minor.
async fn get_server_version() -> String {
use tokio::io::AsyncReadExt;
use tokio::net::TcpStream;

let result: Result<String, Box<dyn std::error::Error + Send + Sync>> = async {
let mut stream = tokio::time::timeout(
std::time::Duration::from_secs(3),
TcpStream::connect("tw.login.maplestory.beanfun.com:8484"),
)
.await??;

let mut buf = [0u8; 256];
let n = tokio::time::timeout(std::time::Duration::from_secs(3), stream.read(&mut buf))
.await??;

if n < 6 {
return Ok(String::new());
}

// Handshake: [u16 packet_len] [u16 major_version] [u16 str_len] [str minor] ...
let major = u16::from_le_bytes([buf[2], buf[3]]);
let str_len = u16::from_le_bytes([buf[4], buf[5]]) as usize;
let minor = if n >= 6 + str_len {
String::from_utf8_lossy(&buf[6..6 + str_len]).to_string()
} else {
String::new()
};

let minor_clean = minor.split(':').next().unwrap_or("").to_string();
if minor_clean.is_empty() {
Ok(format!("{major}"))
} else {
Ok(format!("{major}.{minor_clean}"))
}
}
.await;

match result {
Ok(v) => {
tracing::info!("MapleStory server version: {v}");
v
}
Err(e) => {
tracing::warn!("failed to get server version: {e}");
String::new()
}
}
}

Expand Down
15 changes: 15 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ pub fn run() {
commands::account::get_remain_point,
commands::account::auto_paste_otp,
commands::account::change_account_display_name,
commands::account::set_display_override,
commands::account::set_account_order,
commands::account::get_display_overrides,
commands::account::get_auth_email,
commands::launcher::launch_game,
commands::launcher::launch_game_direct,
Expand Down Expand Up @@ -229,6 +232,16 @@ pub fn run() {
accounts_path.display()
);

let overrides_path = config_dir.join("display_overrides.json");
let display_overrides = tauri::async_runtime::block_on(async {
account_storage::load_display_overrides(&overrides_path).await
});
tracing::info!(
"loaded {} display overrides from {}",
display_overrides.names.len(),
overrides_path.display()
);

// 4. Initialise AppState with loaded config.
let auto_update_enabled = config.auto_update;
let update_channel = config.update_channel.clone();
Expand All @@ -244,6 +257,8 @@ pub fn run() {
config_path,
saved_accounts: tokio::sync::RwLock::new(saved_accounts),
accounts_path,
overrides_path,
display_overrides: tokio::sync::RwLock::new(display_overrides),
http_client,
};

Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/models/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ pub struct AppState {
pub saved_accounts: RwLock<Vec<SavedAccount>>,
/// Path to the `accounts.json` file on disk.
pub accounts_path: PathBuf,
/// Path to `display_overrides.dat` for local account customizations.
pub overrides_path: PathBuf,
/// Local account customizations (display names + sort order).
pub display_overrides: RwLock<crate::services::account_storage::DisplayOverrides>,
/// A shared HTTP client for non-session operations (update checks, etc.)
pub http_client: reqwest::Client,
}
Expand Down
Loading