From e60b5e2e5a05ee6ef45b7dc63697dca8b02c038f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 11 Oct 2026 18:13:01 +0500 Subject: [PATCH] feat(client): add native login & password prefill for Ascension client (0.7.0) - Implement LibSerialize v1, raw DEFLATE and print base64 codec in glue_config - Prefill login credentials in join_realm, portable_launch and play - Preserve all existing saved client accounts without loss - Rebuild production 0.7.0 NSIS artifacts and update SHA-256 checksums --- Cargo.lock | 1 + crates/coa-core/Cargo.toml | 1 + crates/coa-core/src/client.rs | 1 + crates/coa-core/src/control/direct_ingress.rs | 6 +- crates/coa-core/src/glue_config.rs | 499 ++++++++++++++++++ crates/coa-core/src/lib.rs | 1 + docs/RELEASE_READINESS.md | 8 +- src-tauri/src/control_cmds.rs | 72 ++- src-tauri/src/lib.rs | 21 +- 9 files changed, 573 insertions(+), 37 deletions(-) create mode 100644 crates/coa-core/src/glue_config.rs diff --git a/Cargo.lock b/Cargo.lock index 768ac2a..41d2d62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -594,6 +594,7 @@ dependencies = [ "coa-registry-proto", "dunce", "ed25519-dalek", + "flate2", "fs4", "hex", "if-addrs", diff --git a/crates/coa-core/Cargo.toml b/crates/coa-core/Cargo.toml index 251450f..bebdd7b 100644 --- a/crates/coa-core/Cargo.toml +++ b/crates/coa-core/Cargo.toml @@ -22,6 +22,7 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking", tar = "0.4" num-bigint = "0.4" zip = { version = "2", default-features = false, features = ["deflate"] } +flate2 = "1.0" sha1 = "0.10" tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "std"] } tempfile = "3" diff --git a/crates/coa-core/src/client.rs b/crates/coa-core/src/client.rs index ab40986..b917853 100644 --- a/crates/coa-core/src/client.rs +++ b/crates/coa-core/src/client.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use crate::error::{Error, Result}; use crate::fsx; +pub use crate::glue_config; pub const ADDON_NAME: &str = "CoABotUI"; const EXECUTABLES: [&str; 3] = ["Ascension.exe", "Wow.exe", "WoW.exe"]; diff --git a/crates/coa-core/src/control/direct_ingress.rs b/crates/coa-core/src/control/direct_ingress.rs index 4fd9cd2..294de03 100644 --- a/crates/coa-core/src/control/direct_ingress.rs +++ b/crates/coa-core/src/control/direct_ingress.rs @@ -325,9 +325,9 @@ mod tests { let mut client_world = TcpStream::connect(format!("127.0.0.1:{ingress_world}")).unwrap(); client_world.write_all(b"WORLD_PING").unwrap(); - let mut resp = [0u8; 16]; - let n = client_world.read(&mut resp).unwrap(); - assert_eq!(&resp[..n], b"WORLD_PONG"); + let mut resp = [0u8; 10]; + client_world.read_exact(&mut resp).unwrap(); + assert_eq!(&resp, b"WORLD_PONG"); drop(client_world); t_world.join().unwrap(); diff --git a/crates/coa-core/src/glue_config.rs b/crates/coa-core/src/glue_config.rs new file mode 100644 index 0000000..ec3cd9f --- /dev/null +++ b/crates/coa-core/src/glue_config.rs @@ -0,0 +1,499 @@ +//! Ascension client `GlueConfig.json` reader, writer and login prefill. +//! +//! Ascension stores saved accounts in `/WTF/Custom/GlueConfig.json`. +//! The file format is: +//! 1. Data structure: array of accounts `[[username, password, autoLogin?], ...]`. +//! 2. Serialization: LibSerialize v1 binary format. +//! 3. Compression: raw DEFLATE (`wbits = -15`). +//! 4. Encoding: custom 6-bit Base64 character set (`a-zA-Z0-9()`). +//! +//! On client startup, `AccountLogin.lua` calls `GetLastAccount()` which reads the first +//! element of `GlueConfig.json` and automatically prefills `AccountLoginAccountEdit` +//! and `AccountLoginPasswordEdit`. + +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use flate2::read::DeflateDecoder; +use flate2::write::DeflateEncoder; +use flate2::Compression; + +use crate::error::{Error, Result}; +use crate::fsx; + +pub const CUSTOM_ALPHABET: &[u8; 64] = + b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789()"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SavedAccount { + pub username: String, + pub password: String, + pub auto_login: Option, +} + +impl SavedAccount { + pub fn new(username: impl Into, password: impl Into) -> Self { + Self { + username: username.into(), + password: password.into(), + auto_login: None, + } + } +} + +pub fn encode_for_print(data: &[u8]) -> String { + let mut out = String::new(); + let mut i = 0; + let n = data.len(); + while i + 2 < n { + let x1 = data[i] as u32; + let x2 = data[i + 1] as u32; + let x3 = data[i + 2] as u32; + let mut cache = x1 | (x2 << 8) | (x3 << 16); + for _ in 0..4 { + out.push(CUSTOM_ALPHABET[(cache % 64) as usize] as char); + cache /= 64; + } + i += 3; + } + if i < n { + let mut cache = 0u32; + let mut bitlen: usize = 0; + while i < n { + cache |= (data[i] as u32) << bitlen; + bitlen += 8; + i += 1; + } + while bitlen > 0 { + out.push(CUSTOM_ALPHABET[(cache % 64) as usize] as char); + cache /= 64; + bitlen = bitlen.saturating_sub(6); + } + } + out +} + +fn decode_char(b: u8) -> Option { + match b { + b'a'..=b'z' => Some(b - b'a'), + b'A'..=b'Z' => Some(b - b'A' + 26), + b'0'..=b'9' => Some(b - b'0' + 52), + b'(' => Some(62), + b')' => Some(63), + _ => None, + } +} + +pub fn decode_for_print(s: &str) -> Vec { + let mut out = Vec::new(); + let mut bit_buf = 0u64; + let mut bit_cnt = 0; + for b in s.bytes() { + let Some(val) = decode_char(b) else { continue }; + bit_buf |= (val as u64) << bit_cnt; + bit_cnt += 6; + while bit_cnt >= 8 { + out.push((bit_buf & 0xFF) as u8); + bit_buf >>= 8; + bit_cnt -= 8; + } + } + out +} + +pub fn compress_deflate(data: &[u8]) -> Result> { + let mut enc = DeflateEncoder::new(Vec::new(), Compression::best()); + enc.write_all(data) + .map_err(|e| Error::Invalid(format!("deflate compress error: {e}")))?; + enc.finish() + .map_err(|e| Error::Invalid(format!("deflate finish error: {e}"))) +} + +pub fn decompress_deflate(data: &[u8]) -> Result> { + let mut dec = DeflateDecoder::new(data); + let mut out = Vec::new(); + dec.read_to_end(&mut out) + .map_err(|e| Error::Invalid(format!("deflate decompress error: {e}")))?; + Ok(out) +} + +struct LibSerializeReader<'a> { + data: &'a [u8], + pos: usize, + string_refs: Vec, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +enum LibValue { + Nil, + Bool(bool), + Num(i64), + Str(String), + Array(Vec), +} + +impl<'a> LibSerializeReader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { + data, + pos: 0, + string_refs: Vec::new(), + } + } + + fn read_byte(&mut self) -> Result { + if self.pos >= self.data.len() { + return Err(Error::Invalid("LibSerialize unexpected EOF".into())); + } + let b = self.data[self.pos]; + self.pos += 1; + Ok(b) + } + + fn read_int(&mut self, n: usize) -> Result { + let mut res = 0u64; + for i in 0..n { + let b = self.read_byte()?; + res |= (b as u64) << (i * 8); + } + Ok(res) + } + + fn read_string(&mut self, len: usize) -> Result { + if self.pos + len > self.data.len() { + return Err(Error::Invalid( + "LibSerialize string exceeds buffer length".into(), + )); + } + let slice = &self.data[self.pos..self.pos + len]; + self.pos += len; + let s = String::from_utf8_lossy(slice).into_owned(); + if len > 2 { + self.string_refs.push(s.clone()); + } + Ok(s) + } + + fn read_value(&mut self) -> Result { + let val = self.read_byte()?; + if val % 2 == 1 { + return Ok(LibValue::Num(((val - 1) / 2) as i64)); + } + if val % 4 == 2 { + let typ = (val - 2) / 4; + let count = (typ / 4) as usize; + let typ = typ % 4; + return match typ { + 0 => Ok(LibValue::Str(self.read_string(count)?)), + 1 => { + // Map/table: skip key/value pairs + for _ in 0..count { + self.read_value()?; + self.read_value()?; + } + Ok(LibValue::Nil) + } + 2 => { + let mut arr = Vec::with_capacity(count); + for _ in 0..count { + arr.push(self.read_value()?); + } + Ok(LibValue::Array(arr)) + } + _ => Ok(LibValue::Nil), + }; + } + if val % 8 == 4 { + let b2 = self.read_byte()? as u16; + let packed = (b2 * 256) + (val as u16); + let num = if val % 16 == 12 { + -(((packed - 12) / 16) as i64) + } else { + ((packed - 4) / 16) as i64 + }; + return Ok(LibValue::Num(num)); + } + let typ = val / 8; + match typ { + 0 => Ok(LibValue::Nil), + 1 => Ok(LibValue::Num(self.read_int(2)? as i64)), + 2 => Ok(LibValue::Num(-(self.read_int(2)? as i64))), + 12 => Ok(LibValue::Bool(true)), + 13 => Ok(LibValue::Bool(false)), + 14 => { + let len = self.read_byte()? as usize; + Ok(LibValue::Str(self.read_string(len)?)) + } + 15 => { + let len = self.read_int(2)? as usize; + Ok(LibValue::Str(self.read_string(len)?)) + } + 17 => { + let count = self.read_byte()? as usize; + for _ in 0..count { + self.read_value()?; + self.read_value()?; + } + Ok(LibValue::Nil) + } + 20 => { + let count = self.read_byte()? as usize; + let mut arr = Vec::with_capacity(count); + for _ in 0..count { + arr.push(self.read_value()?); + } + Ok(LibValue::Array(arr)) + } + 26 => { + let ref_idx = self.read_byte()? as usize; + if ref_idx == 0 || ref_idx > self.string_refs.len() { + return Err(Error::Invalid("invalid string ref index".into())); + } + Ok(LibValue::Str(self.string_refs[ref_idx - 1].clone())) + } + _ => Ok(LibValue::Nil), + } + } +} + +pub fn deserialize_accounts(bytes: &[u8]) -> Result> { + if bytes.is_empty() || bytes[0] != 1 { + return Err(Error::Invalid("unsupported LibSerialize version".into())); + } + let mut reader = LibSerializeReader::new(&bytes[1..]); + let root = reader.read_value()?; + let LibValue::Array(entries) = root else { + return Ok(Vec::new()); + }; + let mut accounts = Vec::new(); + for entry in entries { + if let LibValue::Array(items) = entry { + if items.len() >= 2 { + let username = match &items[0] { + LibValue::Str(s) => s.clone(), + _ => continue, + }; + let password = match &items[1] { + LibValue::Str(s) => s.clone(), + _ => continue, + }; + let auto_login = match items.get(2) { + Some(LibValue::Bool(b)) => Some(*b), + _ => None, + }; + accounts.push(SavedAccount { + username, + password, + auto_login, + }); + } + } + } + Ok(accounts) +} + +fn write_string(buf: &mut Vec, s: &str) { + let bytes = s.as_bytes(); + let len = bytes.len(); + if len <= 15 { + buf.push(((len << 4) | 2) as u8); + } else if len <= 255 { + buf.push(112); // STR_8 (14 << 3) + buf.push(len as u8); + } else { + buf.push(120); // STR_16 (15 << 3) + buf.push((len & 0xFF) as u8); + buf.push(((len >> 8) & 0xFF) as u8); + } + buf.extend_from_slice(bytes); +} + +pub fn serialize_accounts(accounts: &[SavedAccount]) -> Vec { + let mut buf = Vec::new(); + buf.push(1); // LibSerialize version 1 + let n = accounts.len(); + if n <= 15 { + buf.push(((n << 4) | 10) as u8); + } else { + buf.push(160); // ARRAY_8 (20 << 3) + buf.push(n.min(255) as u8); + } + for acc in accounts { + let entry_len = if acc.auto_login.is_some() { 3 } else { 2 }; + buf.push(((entry_len << 4) | 10) as u8); + write_string(&mut buf, &acc.username); + write_string(&mut buf, &acc.password); + if let Some(auto) = acc.auto_login { + buf.push(if auto { 96 } else { 104 }); // BOOL_T = 96 (12 << 3), BOOL_F = 104 (13 << 3) + } + } + buf +} + +pub fn glue_config_path(client: &Path) -> PathBuf { + client.join("WTF").join("Custom").join("GlueConfig.json") +} + +pub fn read_glue_config(client: &Path) -> Result> { + let path = glue_config_path(client); + if !path.is_file() { + return Ok(Vec::new()); + } + let text = fs::read_to_string(&path) + .map_err(|e| Error::Invalid(format!("failed to read GlueConfig.json: {e}")))?; + let compressed = decode_for_print(text.trim()); + if compressed.is_empty() { + return Ok(Vec::new()); + } + let decompressed = decompress_deflate(&compressed)?; + deserialize_accounts(&decompressed) +} + +pub fn write_glue_config(client: &Path, accounts: &[SavedAccount]) -> Result<()> { + let path = glue_config_path(client); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let serialized = serialize_accounts(accounts); + let compressed = compress_deflate(&serialized)?; + let encoded = encode_for_print(&compressed); + fsx::atomic_write(&path, encoded.as_bytes()) +} + +/// Prefill the client login box with the given username and password. +/// +/// If `GlueConfig.json` exists, any previous entries for this username are updated and moved to the +/// head of the list, preserving all other saved accounts. If it does not exist, a new config is created. +pub fn prefill_login(client: &Path, username: &str, password: &str) -> Result<()> { + if username.is_empty() || password.is_empty() { + return Ok(()); + } + let mut accounts = read_glue_config(client).unwrap_or_default(); + let auto_login = accounts + .iter() + .find(|a| a.username.eq_ignore_ascii_case(username)) + .and_then(|a| a.auto_login); + accounts.retain(|a| !a.username.eq_ignore_ascii_case(username)); + accounts.insert( + 0, + SavedAccount { + username: username.to_string(), + password: password.to_string(), + auto_login, + }, + ); + write_glue_config(client, &accounts) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_print_encoding_roundtrip() { + let sample = b"hello world 1234567890!@#$%^&*()_+"; + let enc = encode_for_print(sample); + let dec = decode_for_print(&enc); + assert_eq!(dec, sample); + } + + #[test] + fn test_deflate_roundtrip() { + let sample = b"repeated text repeated text repeated text 12345"; + let comp = compress_deflate(sample).unwrap(); + let decomp = decompress_deflate(&comp).unwrap(); + assert_eq!(decomp, sample); + } + + #[test] + fn test_known_python_vector() { + let encoded = "JrP0QPqCn4qcnyxdcmJaCm4Gba"; + let comp = decode_for_print(encoded); + let decomp = decompress_deflate(&comp).unwrap(); + let accounts = deserialize_accounts(&decomp).unwrap(); + assert_eq!( + accounts, + vec![SavedAccount { + username: "TESTUSER".into(), + password: "TESTPASS".into(), + auto_login: None, + }] + ); + } + + #[test] + fn test_accounts_roundtrip() { + let accounts = vec![ + SavedAccount { + username: "Alice".into(), + password: "secret_password".into(), + auto_login: None, + }, + SavedAccount { + username: "Bob".into(), + password: "another_password".into(), + auto_login: Some(true), + }, + ]; + let ser = serialize_accounts(&accounts); + let de = deserialize_accounts(&ser).unwrap(); + assert_eq!(de, accounts); + } + + #[test] + fn test_prefill_login_logic() { + let tmp = tempfile::tempdir().unwrap(); + let client_path = tmp.path(); + + prefill_login(client_path, "PlayerOne", "pass1").unwrap(); + let accounts = read_glue_config(client_path).unwrap(); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].username, "PlayerOne"); + assert_eq!(accounts[0].password, "pass1"); + + // Adding PlayerTwo moves it to head + prefill_login(client_path, "PlayerTwo", "pass2").unwrap(); + let accounts = read_glue_config(client_path).unwrap(); + assert_eq!(accounts.len(), 2); + assert_eq!(accounts[0].username, "PlayerTwo"); + assert_eq!(accounts[1].username, "PlayerOne"); + + // Re-adding PlayerOne updates password and moves it back to head + prefill_login(client_path, "playerone", "newpass").unwrap(); + let accounts = read_glue_config(client_path).unwrap(); + assert_eq!(accounts.len(), 2); + assert_eq!(accounts[0].username, "playerone"); + assert_eq!(accounts[0].password, "newpass"); + assert_eq!(accounts[1].username, "PlayerTwo"); + } + + #[test] + fn test_real_ascension_glue_config_payload() { + let raw = "JXzPvbsKEQ5xu6zMC2jNrI5LAooKEUjMzo6LC(5wGiPLFkPKFX6wyMfME(MRBEPME2rwLwb5J)o7OpxGzTMKZ5xuAzwuQjM3Mm3ctm2ulnZmJDmjTckN8toXCUaBw5ujPwCjfbIWqJmobTkkGqCpuxdoKMki)IkjXKYjvTskGq8WFFD9cCPvsfKtIvMArfiqao7Pfo7TLoAKr(7zGHzMNAfuIPKBM5Db(am"; + let comp = decode_for_print(raw); + let decomp = decompress_deflate(&comp).unwrap(); + let accounts = deserialize_accounts(&decomp).unwrap(); + assert_eq!(accounts.len(), 9); + assert_eq!(accounts[0].username, "georgickchanel@gmail.com"); + assert_eq!(accounts[0].password, "foddos-qiqgo7-negkYz"); + assert_eq!(accounts[1].username, "LOCAL"); + assert_eq!(accounts[1].password, "LOCAL"); + assert_eq!(accounts[2].username, "Corfirean"); + assert_eq!(accounts[2].password, "784359663Ab"); + assert_eq!(accounts[3].username, "local"); + assert_eq!(accounts[3].password, "local"); + assert_eq!(accounts[4].username, "test"); + assert_eq!(accounts[4].password, "test123"); + assert_eq!(accounts[4].auto_login, Some(true)); + assert_eq!(accounts[5].username, "PTGUEST"); + assert_eq!(accounts[5].password, "portable"); + assert_eq!(accounts[6].username, "PTHOME"); + assert_eq!(accounts[6].password, "portable"); + assert_eq!(accounts[7].username, "player"); + assert_eq!(accounts[7].password, "PCI8CK9A22OIQV6I"); + assert_eq!(accounts[8].username, "admin"); + assert_eq!(accounts[8].password, "admin"); + } +} diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index e4aa05f..a6cf015 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -29,6 +29,7 @@ pub mod error; pub mod firewall; pub mod friends; pub mod fsx; +pub mod glue_config; pub mod health; pub mod install; pub mod layout; diff --git a/docs/RELEASE_READINESS.md b/docs/RELEASE_READINESS.md index 185d10d..5a4fa35 100644 --- a/docs/RELEASE_READINESS.md +++ b/docs/RELEASE_READINESS.md @@ -72,7 +72,7 @@ All P0 and P1 security, correctness, and reliability findings identified during All tests executed with `--locked` dependencies and strict warnings: -- **`coa-core`**: **608 PASSED**, 0 failed, 50 ignored (real-server live tests). +- **`coa-core`**: **614 PASSED**, 0 failed, 50 ignored (real-server live tests). - **`coa-registry-proto`**: **19 PASSED**, 0 failed. - **`coa-control-proto`**: **20 PASSED**, 0 failed (14 lib unit tests + 6 hostile-input & fuzz smoke tests). - **`coa-server-manager-lib`**: **2 PASSED**, 0 failed. @@ -86,7 +86,7 @@ All tests executed with `--locked` dependencies and strict warnings: - **Code Linter (`cargo clippy --workspace --all-targets -- -D warnings`)**: **PASSED (0 warnings, 0 errors)**. - **Dependency Audit (`npm audit --omit=dev`)**: **0 vulnerabilities found**. -**Total automated passing tests**: **705 tests** (649 in workspace crates, 56 in standalone services). +**Total automated passing tests**: **711 tests** (655 in workspace crates, 56 in standalone services). --- @@ -144,8 +144,8 @@ The release candidate produces standard Windows installer artifacts: | Distribution Artifact | Type | Size (Bytes) | SHA-256 Checksum | Description | |---|---|---|---|---| -| `CoA-Server-Manager_0.7.0_x64-setup.exe` | NSIS Installer | 7,888,725 | `947944d95c2a8f80b1d4cc09182f1ea0cb1332d0ee6d77de6f9c65930ce274d5` | Standalone Windows installer with passive update support | -| `CoA-Server-Manager_0.7.0_x64.nsis.zip` | Archive | 7,871,146 | `f29680b3ba3b51437fed3a4b33a5c9cda01adf37235ca63cdf51d2adf23cdfbd` | Portable binary archive for testing and verification | +| `CoA-Server-Manager_0.7.0_x64-setup.exe` | NSIS Installer | 7,898,742 | `f94aa232ce1f34427af648d11246009d785b74f57f8afa00f557aba9f74d2fd8` | Standalone Windows installer with passive update support | +| `CoA-Server-Manager_0.7.0_x64.nsis.zip` | Archive | 7,881,163 | `6f3a0734391607dcda449a6fe2ef0c7d461f9dff59ec67e95ef07fe254d31e1e` | Portable binary archive for testing and verification | Verification command: ```powershell diff --git a/src-tauri/src/control_cmds.rs b/src-tauri/src/control_cmds.rs index 4872f70..9d73854 100644 --- a/src-tauri/src/control_cmds.rs +++ b/src-tauri/src/control_cmds.rs @@ -39,7 +39,7 @@ fn registry_url() -> Result { .unwrap_or_else(|| settings.effective_url().to_string())) } -fn player(state: &State<'_, AppState>) -> Result, ServiceError> { +pub(crate) fn player(state: &State<'_, AppState>) -> Result, ServiceError> { state.control.clone().ok_or_else(|| { fail( "stopped", @@ -48,7 +48,7 @@ fn player(state: &State<'_, AppState>) -> Result, ServiceErro }) } -fn realm_id(text: &str) -> Result { +pub(crate) fn realm_id(text: &str) -> Result { RealmId::parse(text).map_err(|e| fail("invalid", e.to_string())) } @@ -238,22 +238,25 @@ pub(crate) async fn join_realm( let prefer = Some(load_prefs().preferred_username).filter(|n| !n.is_empty()); let character_for_check = character.clone(); let portable = state.portable.clone(); + let pc_bg = pc.clone(); + let rid_bg = rid; let mut outcome = tauri::async_runtime::spawn_blocking(move || -> Result { let url = registry_url()?; - let (target, detail) = target_of(&url, &rid)?; - let mut ch = pc.connect(&target).map_err(from_control)?; - let (_automatic, existing_only, route) = pc.welcome(&mut ch).map_err(from_control)?; - if pc - .credentials(&rid) + let (target, detail) = target_of(&url, &rid_bg)?; + let mut ch = pc_bg.connect(&target).map_err(from_control)?; + let (_automatic, existing_only, route) = + pc_bg.welcome(&mut ch).map_err(from_control)?; + if pc_bg + .credentials(&rid_bg) .map_err(|e| fail("other", e.to_string()))? .is_none() && existing_only { return Ok(JoinOutcome::stop("needs_link", None)); } - let account = pc - .ensure_account(&mut ch, &rid, prefer.as_deref()) + let account = pc_bg + .ensure_account(&mut ch, &rid_bg, prefer.as_deref()) .map_err(from_control)?; let mut out = JoinOutcome { status: "ready".into(), @@ -276,14 +279,14 @@ pub(crate) async fn join_realm( out.status = "incompatible".into(); return Ok(out); } - let here = pc.claimed_here(&rid).map_err(from_control)?; + let here = pc_bg.claimed_here(&rid_bg).map_err(from_control)?; if !here.iter().any(|(id, _)| id.to_string() == character) { // Phase 12.1: Transfer canonical character to remote Host over the encrypted control channel let char_id_str = character.clone(); let bundle = portable.call(move |s| s.export_for_remote_transfer(&char_id_str))??; let cuuid = bundle.character_id.as_uuid(); - let _outcome = pc + let _outcome = pc_bg .transfer_character( &mut ch, cuuid, @@ -293,7 +296,7 @@ pub(crate) async fn join_realm( bundle.collections, ) .map_err(from_control)?; - let _ = pc.acknowledge(&mut ch, cuuid, &rid, &character); + let _ = pc_bg.acknowledge(&mut ch, cuuid, &rid_bg, &character); } } // Phase 14: Try direct route if provided, with quick TCP probe. If direct fails or unavailable, fall back to Relay. @@ -316,7 +319,7 @@ pub(crate) async fn join_realm( } } if !direct_worked { - match pc.allocate_relay(&mut ch) { + match pc_bg.allocate_relay(&mut ch) { Ok(relay_alloc) => { let r = format!("{}:{}", relay_alloc.relay_host, relay_alloc.auth_port); out.route = Some(r); @@ -335,27 +338,38 @@ pub(crate) async fn join_realm( if outcome.status == "ready" { let host = outcome.route.clone().unwrap_or_default(); - let client_ready = { + let client_path = { let profile = coa_core::remote_client::load(&remote_dir()) .map_err(|e| fail("other", e.to_string()))?; - profile - .client_path - .as_deref() - .is_some_and(|p| coa_core::client::detect(std::path::Path::new(p), None).is_some()) + profile.client_path }; + let client_ready = client_path + .as_deref() + .is_some_and(|p| coa_core::client::detect(std::path::Path::new(p), None).is_some()); if !client_ready { outcome.status = "needs_client".into(); - } else if launch { - let dir = remote_dir(); - let mut profile = - coa_core::remote_client::load(&dir).map_err(|e| fail("other", e.to_string()))?; - profile.host = host; - coa_core::remote_client::save(&dir, &profile) - .map_err(|e| fail("launch", e.to_string()))?; - super::play(state, REMOTE_CLIENT_ID.to_string()) - .await - .map_err(|e| fail("launch", e.technical))?; - outcome.status = "launched".into(); + } else { + if let Some(ref cp) = client_path { + if let Ok(Some(creds)) = pc.credentials(&rid) { + let _ = coa_core::glue_config::prefill_login( + std::path::Path::new(cp), + &creds.username, + &creds.password, + ); + } + } + if launch { + let dir = remote_dir(); + let mut profile = coa_core::remote_client::load(&dir) + .map_err(|e| fail("other", e.to_string()))?; + profile.host = host; + coa_core::remote_client::save(&dir, &profile) + .map_err(|e| fail("launch", e.to_string()))?; + super::play(state, REMOTE_CLIENT_ID.to_string()) + .await + .map_err(|e| fail("launch", e.technical))?; + outcome.status = "launched".into(); + } } } Ok(outcome) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 256e24c..72ae5e2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -212,7 +212,9 @@ async fn portable_launch( state: State<'_, AppState>, realm: String, ) -> std::result::Result<(), ServiceError> { - let (install, address) = portable_call(&state, move |s| s.realm_launch_info(&realm)).await?; + let realm_launch = realm.clone(); + let (install, address) = + portable_call(&state, move |s| s.realm_launch_info(&realm_launch)).await?; let other = |e: UiError| ServiceError { code: "launch".into(), message: e.technical, @@ -227,6 +229,19 @@ async fn portable_launch( coa_core::remote_client::load(&dir).map_err(|e| other(e.into()))?; profile.host = address; coa_core::remote_client::save(&dir, &profile).map_err(|e| other(e.into()))?; + if let Some(ref cp) = profile.client_path { + if let Ok(pc) = control_cmds::player(&state) { + if let Ok(rid) = control_cmds::realm_id(&realm) { + if let Ok(Some(creds)) = pc.credentials(&rid) { + let _ = coa_core::glue_config::prefill_login( + std::path::Path::new(cp), + &creds.username, + &creds.password, + ); + } + } + } + } } play(state, REMOTE_CLIENT_ID.to_string()) .await @@ -2190,6 +2205,10 @@ async fn play( .into(), )); } + let existing = coa_core::glue_config::read_glue_config(&client).unwrap_or_default(); + if existing.is_empty() { + let _ = coa_core::glue_config::prefill_login(&client, "local", "local"); + } coa_core::client::launch(&client)?; Ok(DriverOutcome { ok: true,