diff --git a/.gitignore b/.gitignore index d953b3683..491497b62 100644 --- a/.gitignore +++ b/.gitignore @@ -289,3 +289,6 @@ src/Update.exe # XLCore build directory src/XIVLauncher.Core/build + +# ApkalluCaller (Rust) build output staged into XL Resources +src/XIVLauncher/Resources/version.dll diff --git a/.gitmodules b/.gitmodules index e60625c09..26fd86c9d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "src/FfxivArgLauncher"] path = src/FfxivArgLauncher url = https://github.com/ottercorp/FfxivArgLauncher +[submodule "src/ApkalluCaller"] + path = src/ApkalluCaller + url = https://github.com/ottercorp/ApkalluCaller diff --git a/src/ApkalluCaller b/src/ApkalluCaller new file mode 160000 index 000000000..665a9b806 --- /dev/null +++ b/src/ApkalluCaller @@ -0,0 +1 @@ +Subproject commit 665a9b8061400b21b740eef16ee9dbe566a41d8e diff --git a/src/XIVLauncher.Common/Game/SdoLauncher.cs b/src/XIVLauncher.Common/Game/SdoLauncher.cs index 56a17f81b..c5b1d1ea2 100644 --- a/src/XIVLauncher.Common/Game/SdoLauncher.cs +++ b/src/XIVLauncher.Common/Game/SdoLauncher.cs @@ -40,7 +40,7 @@ public static List Get(bool showWeGameToken = false) new GuiLoginType { LoginType = LoginType.SdoStatic, DisplayName = "密码登录" }, new GuiLoginType { LoginType = LoginType.WeGameSid, DisplayName = "WeGame SID"} }; - if (showWeGameToken) + if (true) { types.Add(new GuiLoginType { LoginType = LoginType.WeGameToken, DisplayName = "WeGame抓包" }); } @@ -54,6 +54,7 @@ public enum LoginType SdoQrCode, WeGameToken, WeGameSid, + // 仅在内部使用,不出现在GUI上 AutoLoginSession } diff --git a/src/XIVLauncher.Common/Game/WeGame/WeGameLoginCapturer.cs b/src/XIVLauncher.Common/Game/WeGame/WeGameLoginCapturer.cs new file mode 100644 index 000000000..738d783fe --- /dev/null +++ b/src/XIVLauncher.Common/Game/WeGame/WeGameLoginCapturer.cs @@ -0,0 +1,183 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Serilog; +using StreamJsonRpc; + +namespace XIVLauncher.Common.Game.WeGame +{ + public sealed class WeGameLoginCapturer + { + private const string PipeName = "ApkalluCaller"; + private const int FfxivWeGameGameId = 2000340; + + public async Task<(string userid, string token)> CaptureAsync( + string sdologinDir, + CancellationToken ct, + IProgress progress) + { + progress?.Report("正在部署 version.dll..."); + DeployVersionDll(sdologinDir); + + progress?.Report("正在等待 WeGame 登录..."); + + var tcs = new TaskCompletionSource<(string, string)>( + TaskCreationOptions.RunContinuationsAsynchronously); + + NamedPipeServerStream pipe; + try + { + pipe = new NamedPipeServerStream( + PipeName, + PipeDirection.InOut, + maxNumberOfServerInstances: 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + } + catch (IOException ex) + { + throw new WeGameCapturePipeBusyException(ex); + } + + await using (pipe.ConfigureAwait(false)) + await using (ct.Register(() => + { + tcs.TrySetCanceled(ct); + try { pipe.Dispose(); } catch { /* swallow on disposal race */ } + }).ConfigureAwait(false)) + { + TryLaunchWeGame(progress); + + await pipe.WaitForConnectionAsync(ct).ConfigureAwait(false); + + var formatter = new SystemTextJsonFormatter(); + var handler = new NewLineDelimitedMessageHandler(pipe, pipe, formatter); + using var rpc = new JsonRpc(handler, new RpcHandler(tcs)); + rpc.StartListening(); + + var result = await tcs.Task.ConfigureAwait(false); + progress?.Report("已捕获登录信息, 正在登录盛趣..."); + + // Wait for the client to receive the response and close its end of + // the pipe before we dispose anything; otherwise rpc.Dispose() races + // the in-flight response write and the Rust side reads 0 bytes + // ("empty response (peer closed)"). 2s is generous — sdologin closes + // its IpcStream immediately after reading the response. + try + { + await rpc.Completion.WaitAsync(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false); + } + catch + { + // Timeout, cancellation, or rpc fault: response was almost + // certainly flushed by now; fall through to dispose. + } + + return result; + } + } + + private static void TryLaunchWeGame(IProgress progress) + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = $"wegame://StartFor={FfxivWeGameGameId}", + UseShellExecute = true, + }); + progress?.Report("已请求启动 WeGame, 请在 WeGame 中登录最终幻想 14..."); + } + catch (Exception ex) + { + Log.Warning(ex, "wegame:// 唤起失败"); + progress?.Report("自动唤起 WeGame 失败, 请手动启动 WeGame 并登录最终幻想 14..."); + } + } + + public static void DeployVersionDll(string sdologinDir) + { + var src = Path.Combine(Paths.ResourcesPath, "version.dll"); + + if (!File.Exists(src)) + throw new FileNotFoundException("找不到 version.dll 资源", src); + + var dst = Path.Combine(sdologinDir, "version.dll"); + + if (File.Exists(dst) && HashEquals(src, dst)) + { + Log.Information("version.dll 已最新, 跳过部署"); + return; + } + + try + { + File.Copy(src, dst, overwrite: true); + Log.Information("version.dll 已部署: {Dst}", dst); + } + catch (UnauthorizedAccessException ex) + { + throw new VersionDllPermissionDeniedException(src, dst, ex); + } + catch (IOException ex) when (IsAccessDenied(ex)) + { + throw new VersionDllPermissionDeniedException(src, dst, ex); + } + } + + public static bool HashEquals(string a, string b) + { + using var sha = SHA256.Create(); + using var fa = File.OpenRead(a); + using var fb = File.OpenRead(b); + return sha.ComputeHash(fa).SequenceEqual(sha.ComputeHash(fb)); + } + + private static bool IsAccessDenied(IOException ex) + { + const int errAccessDenied = 5; + const int errSharingViolation = 32; + var code = ex.HResult & 0xFFFF; + return code == errAccessDenied || code == errSharingViolation; + } + + private sealed class RpcHandler + { + private readonly TaskCompletionSource<(string, string)> _tcs; + public RpcHandler(TaskCompletionSource<(string, string)> tcs) => _tcs = tcs; + + [JsonRpcMethod("login.captured")] + public string LoginCaptured(string userid, string token) + { + _tcs.TrySetResult((userid, token)); + return "ok"; + } + } + } + + public sealed class VersionDllPermissionDeniedException : Exception + { + public string Source { get; } + public string Destination { get; } + + public VersionDllPermissionDeniedException(string src, string dst, Exception inner) + : base($"无权限写入 {dst}", inner) + { + Source = src; + Destination = dst; + } + } + + public sealed class WeGameCapturePipeBusyException : Exception + { + public WeGameCapturePipeBusyException(Exception inner) + : base("命名管道 ApkalluCaller 已被另一个进程占用", inner) + { + } + } +} diff --git a/src/XIVLauncher.Common/Game/WeGame/WeGamePathValidator.cs b/src/XIVLauncher.Common/Game/WeGame/WeGamePathValidator.cs new file mode 100644 index 000000000..a0a803f42 --- /dev/null +++ b/src/XIVLauncher.Common/Game/WeGame/WeGamePathValidator.cs @@ -0,0 +1,46 @@ +using System.IO; +using Newtonsoft.Json.Linq; + +namespace XIVLauncher.Common.Game.WeGame +{ + public static class WeGamePathValidator + { + public const int FfxivWeGameGameId = 2000340; + + public static bool IsValidSdologinDir(string sdologinDir) + { + if (string.IsNullOrEmpty(sdologinDir)) return false; + if (!Directory.Exists(sdologinDir)) return false; + if (!File.Exists(Path.Combine(sdologinDir, "sdologin.exe"))) return false; + + try + { + var root = Path.GetFullPath(Path.Combine(sdologinDir, "..", "..")); + return IsValidGameRoot(root); + } + catch + { + return false; + } + } + + public static bool IsValidGameRoot(string root) + { + if (string.IsNullOrEmpty(root)) return false; + var marker = Path.Combine(root, "rail_files", "rail_game_identify.json"); + if (!File.Exists(marker)) return false; + try + { + var json = JObject.Parse(File.ReadAllText(marker)); + return (int?)json["game_id"] == FfxivWeGameGameId; + } + catch + { + return false; + } + } + + public static string DeriveSdologinDir(string gameRoot) + => Path.Combine(gameRoot, "sdo", "sdologin"); + } +} diff --git a/src/XIVLauncher.Common/XIVLauncher.Common.csproj b/src/XIVLauncher.Common/XIVLauncher.Common.csproj index 119cdfc98..7a37af8d3 100644 --- a/src/XIVLauncher.Common/XIVLauncher.Common.csproj +++ b/src/XIVLauncher.Common/XIVLauncher.Common.csproj @@ -49,6 +49,7 @@ + diff --git a/src/XIVLauncher/Settings/ILauncherSettingsV3.cs b/src/XIVLauncher/Settings/ILauncherSettingsV3.cs index e6d9e3154..63bd379ae 100644 --- a/src/XIVLauncher/Settings/ILauncherSettingsV3.cs +++ b/src/XIVLauncher/Settings/ILauncherSettingsV3.cs @@ -56,6 +56,7 @@ public interface ILauncherSettingsV3 bool? EnableBeta { get; set; } bool? HasAgreeWeGameUsage { get; set; } bool? ShowWeGameTokenLogin { get; set; } + string WeGameLauncherPath { get; set; } CredType? CredType { get; set; } bool? EnableVerboseLog { get; set; } diff --git a/src/XIVLauncher/Windows/MainWindow.xaml.cs b/src/XIVLauncher/Windows/MainWindow.xaml.cs index 52982735e..a6d5f2e19 100644 --- a/src/XIVLauncher/Windows/MainWindow.xaml.cs +++ b/src/XIVLauncher/Windows/MainWindow.xaml.cs @@ -343,6 +343,19 @@ public void Initialize() MainWindowViewModel.AfterLoginAction.Start ); } + else if (savedAccount.AccountType == XivAccountType.WeGame) + { + // WeGameToken 不能复用 AutoLoginSessionKey, 必须重新走 LoginByWeGameToken。 + // 传空密码, 让 TryLogin 内部从 savedAccount.Password 解出 token。 + Model.TryLogin( + LoginType.WeGameToken, + savedAccount.LoginAccount, + null, + Model.IsFastLogin, + Model.IsReadWegameInfo, + MainWindowViewModel.AfterLoginAction.Start + ); + } else { Model.TryLogin( @@ -623,7 +636,10 @@ private void SwitchAccount(XivAccount account, bool saveAsCurrent) break; case XivAccountType.WeGame: LoginTypeSelection.SelectedValue = LoginType.WeGameToken; - LoginPassword.Password = MainWindowViewModel.PresudoPassword; + if (account.Password is not null) + { + LoginPassword.Password = MainWindowViewModel.PresudoPassword; + } break; case XivAccountType.WeGameSid: LoginTypeSelection.SelectedValue = LoginType.WeGameSid; @@ -731,6 +747,8 @@ private void LoginTypeSelection_OnSelectionChanged(object sender, SelectionChang FastLoginCheckBox.Visibility = Visibility.Visible; ReadWeGameInfoCheckBox.Visibility = Visibility.Collapsed; FastLoginCheckBox.Content = "快速登录"; + ReadWeGameInfoCheckBox.Content = "读取登录信息"; + ReadWeGameInfoCheckBox.ToolTip = "从WeGame版FFXIV中读取登录信息"; LoginPassword.Password = string.Empty; HintAssist.SetHint(this.LoginUsername, "盛趣账号"); HintAssist.SetHint(this.LoginPassword, "密码"); @@ -750,9 +768,15 @@ private void LoginTypeSelection_OnSelectionChanged(object sender, SelectionChang //FastLoginCheckBox.Visibility = Visibility.Collapsed; break; case LoginType.WeGameToken: - LoginPassword.Visibility = Visibility.Visible; - HintAssist.SetHint(this.LoginUsername, "SndaId"); - HintAssist.SetHint(this.LoginPassword, "抓包Token"); + // WeGameToken 全自动抓包, 不再支持手填 token; 用户名留空时也会从抓包结果回填。 + LoginPassword.Visibility = Visibility.Collapsed; + HintAssist.SetHint(this.LoginUsername, "SndaId (可留空, 自动抓取)"); + // 跟 SdoStatic 对齐: 勾选才把抓到的 token 持久化, 下次免抓包。 + FastLoginCheckBox.Content = "保存密码"; + // 复用同一个 checkbox: WeGameSid 用它"读取登录信息", WeGameToken 用它"强制重新抓包"。 + ReadWeGameInfoCheckBox.Visibility = Visibility.Visible; + ReadWeGameInfoCheckBox.Content = "强制重新抓包"; + ReadWeGameInfoCheckBox.ToolTip = "勾选后忽略已保存的 token, 强制重新启动 sdologin 抓包"; break; case LoginType.WeGameSid: FastLoginCheckBox.Visibility = Visibility.Collapsed; diff --git a/src/XIVLauncher/Windows/ViewModel/MainWindowViewModel.cs b/src/XIVLauncher/Windows/ViewModel/MainWindowViewModel.cs index 4b682a3e5..6735c2976 100644 --- a/src/XIVLauncher/Windows/ViewModel/MainWindowViewModel.cs +++ b/src/XIVLauncher/Windows/ViewModel/MainWindowViewModel.cs @@ -33,6 +33,7 @@ using XIVLauncher.Common.Game.Patch; using XIVLauncher.Common.Game.Patch.Acquisition; using XIVLauncher.Common.Game.Patch.PatchList; +using XIVLauncher.Common.Game.WeGame; using XIVLauncher.Common.Http; using XIVLauncher.Common.PlatformAbstractions; using XIVLauncher.Common.Util; @@ -228,6 +229,99 @@ private async Task ReadWegameInfo(string username, string targetAreaI } } + private Task EnsureWeGameLauncherPathAsync() + { + return _window.Dispatcher.InvokeAsync(() => + { + var current = App.Settings.WeGameLauncherPath; + if (WeGamePathValidator.IsValidSdologinDir(current)) + return current; + + using var dlg = new Microsoft.WindowsAPICodePack.Dialogs.CommonOpenFileDialog + { + Multiselect = false, + IsFolderPicker = true, + EnsurePathExists = true, + Title = "请选择 WeGame 版最终幻想 14 安装目录", + }; + + if (dlg.ShowDialog() != Microsoft.WindowsAPICodePack.Dialogs.CommonFileDialogResult.Ok) + return null; + + var root = dlg.FileName; + if (!WeGamePathValidator.IsValidGameRoot(root)) + { + CustomMessageBox.Show( + "未识别为 WeGame 版最终幻想 14 安装目录, 请确认所选路径。", + "WeGame 登录", MessageBoxButton.OK, MessageBoxImage.Error); + return null; + } + + var sdologinDir = WeGamePathValidator.DeriveSdologinDir(root); + if (!WeGamePathValidator.IsValidSdologinDir(sdologinDir)) + { + CustomMessageBox.Show( + $"未在 {sdologinDir} 下找到 sdologin.exe, 请确认所选路径完整。", + "WeGame 登录", MessageBoxButton.OK, MessageBoxImage.Error); + return null; + } + + App.Settings.WeGameLauncherPath = sdologinDir; + return sdologinDir; + }).Task; + } + + private async Task TryElevatedCopyAsync(VersionDllPermissionDeniedException ex) + { + var ask = CustomMessageBox.Builder + .NewFrom("写入 WeGame 安装目录失败, 需要管理员权限。\n" + + "点击\"确定\"后系统会弹出权限确认窗口, 请同意继续。") + .WithImage(MessageBoxImage.Warning) + .WithButtons(MessageBoxButton.OKCancel) + .WithCaption("WeGame 登录") + .WithParentWindow(_window) + .Show(); + + if (ask != MessageBoxResult.OK) return false; + + var psi = new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/C copy /Y \"{ex.Source}\" \"{ex.Destination}\"", + Verb = "runas", + UseShellExecute = true, + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true, + }; + + try + { + using var proc = Process.Start(psi); + if (proc == null) + { + CustomMessageBox.Show("启动复制进程失败。", "WeGame 登录", + MessageBoxButton.OK, MessageBoxImage.Error); + return false; + } + + await proc.WaitForExitAsync(loginCts.Token).ConfigureAwait(false); + + if (proc.ExitCode != 0 || + !WeGameLoginCapturer.HashEquals(ex.Source, ex.Destination)) + { + CustomMessageBox.Show("复制 version.dll 失败, 请稍后再试。", + "WeGame 登录", MessageBoxButton.OK, MessageBoxImage.Error); + return false; + } + + return true; + } + catch (System.ComponentModel.Win32Exception) + { + return false; + } + } + public enum LoginCard { Logining = 0, @@ -437,19 +531,88 @@ private async Task Login(LoginType loginType, string username, string inputPassw finalLoginType = LoginType.WeGameSid; break; case LoginType.WeGameToken: - if (inputPassword.IsNullOrEmpty()) + { + if (!App.Settings.HasAgreeWeGameUsage.GetValueOrDefault(false)) { - serect = await AccountManager.CredProvider.Decrypt(savedAccount.AutoLoginSessionKey); - //nSessionId = await AccountManager.CredProvider.Decrypt(savedAccount.NSessionId); - finalLoginType = LoginType.AutoLoginSession; + var readWeGameUsageAsk = CustomMessageBox.Builder + .NewFrom( + """ + 为保障您的账号安全,请在使用本功能前仔细阅读以下内容: + 🔐 功能原理说明 + 本工具通过读取最终幻想14启动器与盛趣服务器通信的会话密钥实现登录和超域传送功能,不会对WeGame平台本身进行任何修改,也不会获取您的WeGame账号密码等敏感信息。 + WeGame平台->最终幻想14启动器-(在此读取登录信息)->盛趣服务器 + ⚠️ 注意事项 + 本功能尚在测试中,保存的密钥有效期尚未测试清楚,如遇到登录问题可以用官方客户端重新登录。 + 点击【确认使用】即表示您已理解:妥善保管设备安全是密钥有效性的最终保障 + """) + .WithImage(MessageBoxImage.Warning) + .WithButtons(MessageBoxButton.YesNo) + .WithYesButtonText("确认使用") + .WithCaption("WeGame Token登录功能说明") + .WithYesCountdown(5) + .WithParentWindow(_window) + .Show(); + + if (readWeGameUsageAsk == MessageBoxResult.No) + { + App.Settings.HasAgreeWeGameUsage = false; + return; + } + else + { + App.Settings.HasAgreeWeGameUsage = true; + } } - if (serect.IsNullOrEmpty()) + + // WeGameToken 走自动抓包, GUI 已隐藏 token 输入框, 这里不再支持手填 token。 + // 默认用保存的 token 走 LoginByWeGameToken 刷新出新的 session id; + // 勾选 "强制重新抓包"(GUI 上复用 ReadWeGameInfoCheckBox) 则跳过这步, 直接重抓。 + // 注意: AutoLoginSessionKey 对 WeGameToken 登录方式无效, 不要回退到它。 + if (!readWeGameInfo && savedAccount?.Password != null) { - serect = inputPassword; - finalLoginType = LoginType.WeGameToken; + serect = await AccountManager.Decrypt(savedAccount.Password); + if (!string.IsNullOrEmpty(serect)) + { + if (string.IsNullOrEmpty(username)) + username = savedAccount.LoginAccount; + finalLoginType = LoginType.WeGameToken; + break; + } + } + + var sdologinDir = await EnsureWeGameLauncherPathAsync().ConfigureAwait(false); + if (sdologinDir == null) return; + + var captureProgress = new Progress(msg => LoginMessage = msg); + var captured = false; + while (!captured) + { + try + { + var capturer = new WeGameLoginCapturer(); + var (capUserId, capToken) = await capturer + .CaptureAsync(sdologinDir, loginCts.Token, captureProgress) + .ConfigureAwait(false); + username = capUserId; + serect = capToken; + finalLoginType = LoginType.WeGameToken; + captured = true; + } + catch (VersionDllPermissionDeniedException permEx) + { + if (!await TryElevatedCopyAsync(permEx).ConfigureAwait(false)) return; + // 复制完, 下个 while 循环重试 CaptureAsync + } + catch (WeGameCapturePipeBusyException) + { + CustomMessageBox.Show( + "命名管道 ApkalluCaller 已被另一个进程占用, 请确认是否同时启动了多个 XIVLauncherCN 后重试。", + "WeGame 登录", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } } - ArgumentException.ThrowIfNullOrEmpty(serect, "自动登录密钥或者Token"); break; + } case LoginType.SdoSlide: if (savedAccount != null && doingAutoLogin) { @@ -577,7 +740,9 @@ private async Task Login(LoginType loginType, string username, string inputPassw accountToSave.AreaName = Area.AreaName; - if (doingAutoLogin && accountToSave.AccountType != XivAccountType.WeGameSid) + // AutoLoginSessionKey 仅对 Sdo 帐号有效; WeGame 帐号下次登录用保存的 token 重新刷新 session, + // WeGameSid 用 TestSID。所以这里只给 Sdo 走 LoginBySessionKey 的快登/DcTravel 续期。 + if (doingAutoLogin && accountToSave.AccountType == XivAccountType.Sdo) { //accountToSave.NSessionId = nSessionId; @@ -600,6 +765,25 @@ private async Task Login(LoginType loginType, string username, string inputPassw accountToSave.TestSID = await AccountManager.Encrypt(serect); //accountToSave.TestSID = await AccountManager.CredProvider.Encrypt("password"); } + // WeGameToken: 跟 SdoStatic 一样, "保存密码"(doingAutoLogin) 勾选才把抓到的 token 落盘, + // 下次免抓包直接走 LoginByWeGameToken 复用。 + if (accountToSave.AccountType == XivAccountType.WeGame && doingAutoLogin) + { + accountToSave.Password = await AccountManager.Encrypt(serect); + + // 游戏内 DcTravel 续期: 复用本次的 token 重新走 LoginByWeGameToken + // 拉一份新的 tgt+session, 等价于 Sdo 的 LoginBySessionKey 续期。 + // 把 token 复制到独立局部, 避免被本方法末尾的 serect=null 清掉。 + if (this.dcTravelListener != null) + { + var savedToken = serect; + this.dcTravelListener.DcTraveler.RefreshGameSessionIdByAutoLoginFunc = async () => + { + var newLoginResult = await this.Launcher.LoginByWeGameToken(username, savedToken, false, this.dcTravelListener.DcTraveler).ConfigureAwait(false); + return newLoginResult.OauthLogin.SessionId; + }; + } + } accountToSave.GenerateId(); AccountManager.AddAccount(accountToSave); AccountManager.CurrentAccount = accountToSave; @@ -806,6 +990,25 @@ AfterLoginAction action this.AccountManager.Save(account); } + // WeGameToken: 若本次用的恰好是保存的 token, 服务端拒绝意味着它已失效; + // 清掉 Password, 下次登录跳过"保存的 token"分支自动重新抓包。 + // 比对解密后的值, 避免自动抓包刚刚取到的 capUserId 命中别的账号时误清。 + if (fallbackLoginType == LoginType.WeGameToken && !string.IsNullOrEmpty(username)) + { + var wgAccount = this.AccountManager.Accounts + .FirstOrDefault(x => x.UserName == username && x.AccountType == XivAccountType.WeGame); + if (wgAccount?.Password != null) + { + var savedToken = await AccountManager.Decrypt(wgAccount.Password); + if (savedToken == serect) + { + Log.Information("WeGameToken 保存的 token 已失效, 清除以触发下次重新抓包: {Username}", username); + wgAccount.Password = null; + this.AccountManager.Save(wgAccount); + } + } + } + msgbox = new CustomMessageBox.Builder() .WithCaption($"{Loc.Localize("LoginNoOauthTitle", "Login issue")}: {sdoLoginEx.ErrorCode}") .WithImage(MessageBoxImage.Question) diff --git a/src/XIVLauncher/XIVLauncher.csproj b/src/XIVLauncher/XIVLauncher.csproj index 0af6bf466..45cab7320 100644 --- a/src/XIVLauncher/XIVLauncher.csproj +++ b/src/XIVLauncher/XIVLauncher.csproj @@ -55,6 +55,7 @@ + @@ -64,6 +65,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest @@ -152,4 +156,41 @@ + + + $(MSBuildProjectDirectory)\..\ApkalluCaller + i686-pc-windows-msvc + $(ApkalluCallerDir)\target\$(ApkalluCallerTarget)\release\version.dll + $(MSBuildProjectDirectory)\Resources\version.dll + false + + + + + + + + + + + + + + + + + + +