LoadingDialog: support localization, remove winforms, prevent invalid thread association errors

This commit is contained in:
Soreepeong 2024-07-21 18:38:01 +09:00
parent efaa346d5e
commit 9db4e2f3a1
7 changed files with 272 additions and 142 deletions

View file

@ -12,6 +12,24 @@
///////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////
#undef APSTUDIO_READONLY_SYMBOLS #undef APSTUDIO_READONLY_SYMBOLS
/////////////////////////////////////////////////////////////////////////////
// English (United States) resources
#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU)
LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US
#pragma code_page(1252)
/////////////////////////////////////////////////////////////////////////////
//
// RT_MANIFEST
//
RT_MANIFEST_THEMES RT_MANIFEST "themes.manifest"
#endif // English (United States) resources
/////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////
// English (United Kingdom) resources // English (United Kingdom) resources

View file

@ -197,6 +197,9 @@
<ItemGroup> <ItemGroup>
<None Include="module.def" /> <None Include="module.def" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Manifest Include="themes.manifest" />
</ItemGroup>
<Target Name="RemoveExtraFiles" AfterTargets="PostBuildEvent"> <Target Name="RemoveExtraFiles" AfterTargets="PostBuildEvent">
<Delete Files="$(OutDir)$(TargetName).lib" /> <Delete Files="$(OutDir)$(TargetName).lib" />
<Delete Files="$(OutDir)$(TargetName).exp" /> <Delete Files="$(OutDir)$(TargetName).exp" />

View file

@ -163,4 +163,7 @@
<Filter>Dalamud.Boot DLL</Filter> <Filter>Dalamud.Boot DLL</Filter>
</None> </None>
</ItemGroup> </ItemGroup>
<ItemGroup>
<Manifest Include="themes.manifest" />
</ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<description>Windows Forms Common Control manifest</description>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*" />
</dependentAssembly>
</dependency>
</assembly>

View file

@ -32,7 +32,6 @@
</PropertyGroup> </PropertyGroup>
<PropertyGroup Label="Build"> <PropertyGroup Label="Build">
<UseWindowsForms>true</UseWindowsForms>
<EnableDynamicLoading>true</EnableDynamicLoading> <EnableDynamicLoading>true</EnableDynamicLoading>
<DebugSymbols>true</DebugSymbols> <DebugSymbols>true</DebugSymbols>
<DebugType>portable</DebugType> <DebugType>portable</DebugType>

View file

@ -1,33 +1,43 @@
using System.Drawing; using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Drawing;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms; using CheapLoc;
using Dalamud.Plugin.Internal; using Dalamud.Plugin.Internal;
using Dalamud.Utility; using Dalamud.Utility;
using Windows.Win32.Foundation;
using Windows.Win32.UI.WindowsAndMessaging; using Serilog;
using TerraFX.Interop.Windows;
using static TerraFX.Interop.Windows.TASKDIALOG_FLAGS;
using static TerraFX.Interop.Windows.Windows;
namespace Dalamud; namespace Dalamud;
/// <summary> /// <summary>
/// Class providing an early-loading dialog. /// Class providing an early-loading dialog.
/// </summary> /// </summary>
internal class LoadingDialog [SuppressMessage(
"StyleCop.CSharp.LayoutRules",
"SA1519:Braces should not be omitted from multi-line child statement",
Justification = "Multiple fixed blocks")]
internal sealed unsafe class LoadingDialog
{ {
// TODO: We can't localize any of what's in here at the moment, because Localization is an EarlyLoadedService. private static int wasGloballyHidden;
private static int wasGloballyHidden = 0;
private Thread? thread; private Thread? thread;
private TaskDialogButton? inProgressHideButton; private HWND hwndTaskDialog;
private TaskDialogPage? page;
private bool canHide;
private State currentState = State.LoadingDalamud;
private DateTime firstShowTime; private DateTime firstShowTime;
private State currentState = State.LoadingDalamud;
private bool canHide;
/// <summary> /// <summary>
/// Enum representing the state of the dialog. /// Enum representing the state of the dialog.
@ -58,13 +68,16 @@ internal class LoadingDialog
get => this.currentState; get => this.currentState;
set set
{ {
if (this.currentState == value)
return;
this.currentState = value; this.currentState = value;
this.UpdatePage(); this.UpdateMainInstructionText();
} }
} }
/// <summary> /// <summary>
/// Gets or sets a value indicating whether or not the dialog can be hidden by the user. /// Gets or sets a value indicating whether the dialog can be hidden by the user.
/// </summary> /// </summary>
/// <exception cref="InvalidOperationException">Thrown if called before the dialog has been created.</exception> /// <exception cref="InvalidOperationException">Thrown if called before the dialog has been created.</exception>
public bool CanHide public bool CanHide
@ -72,8 +85,11 @@ internal class LoadingDialog
get => this.canHide; get => this.canHide;
set set
{ {
if (this.canHide == value)
return;
this.canHide = value; this.canHide = value;
this.UpdatePage(); this.UpdateButtonEnabled();
} }
} }
@ -103,150 +119,232 @@ internal class LoadingDialog
/// </summary> /// </summary>
public void HideAndJoin() public void HideAndJoin()
{ {
if (this.thread == null || !this.thread.IsAlive) if (this.thread?.IsAlive is not true)
return; return;
this.inProgressHideButton?.PerformClick(); SendMessageW(this.hwndTaskDialog, WM.WM_CLOSE, default, default);
this.thread!.Join(); this.thread.Join();
} }
private void UpdatePage() private void UpdateMainInstructionText()
{ {
if (this.page == null) if (this.hwndTaskDialog == default)
return; return;
this.page.Heading = this.currentState switch fixed (void* pszText = this.currentState switch
{ {
State.LoadingDalamud => "Dalamud is loading...", State.LoadingDalamud => Loc.Localize(
State.LoadingPlugins => "Waiting for plugins to load...", "LoadingDialogMainInstructionLoadingDalamud",
State.AutoUpdatePlugins => "Updating plugins...", "Dalamud is loading..."),
_ => throw new ArgumentOutOfRangeException(), State.LoadingPlugins => Loc.Localize(
}; "LoadingDialogMainInstructionLoadingPlugins",
"Waiting for plugins to load..."),
var context = string.Empty; State.AutoUpdatePlugins => Loc.Localize(
if (this.currentState == State.LoadingPlugins) "LoadingDialogMainInstructionAutoUpdatePlugins",
"Updating plugins..."),
_ => string.Empty, // should not happen
})
{ {
context = "\nPreparing..."; SendMessageW(
this.hwndTaskDialog,
(uint)TASKDIALOG_MESSAGES.TDM_SET_ELEMENT_TEXT,
(WPARAM)(int)TASKDIALOG_ELEMENTS.TDE_MAIN_INSTRUCTION,
(LPARAM)pszText);
}
}
private void UpdateContentText()
{
if (this.hwndTaskDialog == default)
return;
var contentBuilder = new StringBuilder(
Loc.Localize(
"LoadingDialogContentInfo",
"Some of the plugins you have installed through Dalamud are taking a long time to load.\n" +
"This is likely normal, please wait a little while longer."));
if (this.CurrentState == State.LoadingPlugins)
{
var tracker = Service<PluginManager>.GetNullable()?.StartupLoadTracking; var tracker = Service<PluginManager>.GetNullable()?.StartupLoadTracking;
if (tracker != null) if (tracker != null)
{ {
var nameString = tracker.GetPendingInternalNames() var nameString = string.Join(
", ",
tracker.GetPendingInternalNames()
.Select(x => tracker.GetPublicName(x)) .Select(x => tracker.GetPublicName(x))
.Where(x => x != null) .Where(x => x != null));
.Aggregate(string.Empty, (acc, x) => acc + x + ", ");
if (!nameString.IsNullOrEmpty()) if (!nameString.IsNullOrEmpty())
context = $"\nWaiting for: {nameString[..^2]}"; {
contentBuilder
.AppendLine()
.AppendLine()
.Append(
string.Format(
Loc.Localize("LoadingDialogContentCurrentPlugin", "Waiting for: {0}"),
nameString));
}
} }
} }
// Add some text if loading takes more than a few minutes // Add some text if loading takes more than a few minutes
if (DateTime.Now - this.firstShowTime > TimeSpan.FromMinutes(3)) if (DateTime.Now - this.firstShowTime > TimeSpan.FromMinutes(3))
context += "\nIt's been a while now. Please report this issue on our Discord server.";
this.page.Text = this.currentState switch
{ {
State.LoadingDalamud => "Please wait while Dalamud loads...", contentBuilder
State.LoadingPlugins => "Please wait while Dalamud loads plugins...", .AppendLine()
State.AutoUpdatePlugins => "Please wait while Dalamud updates your plugins...", .AppendLine()
_ => throw new ArgumentOutOfRangeException(), .Append(
#pragma warning disable SA1513 Loc.Localize(
} + context; "LoadingDialogContentTakingTooLong",
#pragma warning restore SA1513 "It's been a while now. Please report this issue on our Discord server."));
this.inProgressHideButton!.Enabled = this.canHide;
} }
private async Task DialogStatePeriodicUpdate(CancellationToken token) fixed (void* pszText = contentBuilder.ToString())
{ {
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(50)); SendMessageW(
while (!token.IsCancellationRequested) this.hwndTaskDialog,
{ (uint)TASKDIALOG_MESSAGES.TDM_SET_ELEMENT_TEXT,
await timer.WaitForNextTickAsync(token); (WPARAM)(int)TASKDIALOG_ELEMENTS.TDE_CONTENT,
this.UpdatePage(); (LPARAM)pszText);
} }
} }
private void UpdateButtonEnabled()
{
if (this.hwndTaskDialog == default)
return;
SendMessageW(this.hwndTaskDialog, (uint)TASKDIALOG_MESSAGES.TDM_ENABLE_BUTTON, IDOK, this.canHide ? 1 : 0);
}
private HRESULT TaskDialogCallback(HWND hwnd, uint msg, WPARAM wParam, LPARAM lParam)
{
switch ((TASKDIALOG_NOTIFICATIONS)msg)
{
case TASKDIALOG_NOTIFICATIONS.TDN_CREATED:
this.hwndTaskDialog = hwnd;
this.UpdateMainInstructionText();
this.UpdateContentText();
this.UpdateButtonEnabled();
SendMessageW(hwnd, (int)TASKDIALOG_MESSAGES.TDM_SET_PROGRESS_BAR_MARQUEE, 1, 0);
// Bring to front
SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, SWP.SWP_NOSIZE | SWP.SWP_NOMOVE);
SetWindowPos(hwnd, HWND.HWND_NOTOPMOST, 0, 0, 0, 0, SWP.SWP_NOSIZE | SWP.SWP_NOMOVE);
ShowWindow(hwnd, SW.SW_SHOW);
SetForegroundWindow(hwnd);
SetFocus(hwnd);
SetActiveWindow(hwnd);
return S.S_OK;
case TASKDIALOG_NOTIFICATIONS.TDN_DESTROYED:
this.hwndTaskDialog = default;
return S.S_OK;
case TASKDIALOG_NOTIFICATIONS.TDN_TIMER:
this.UpdateContentText();
return S.S_OK;
}
return S.S_OK;
}
private void ThreadStart() private void ThreadStart()
{ {
Application.EnableVisualStyles();
this.inProgressHideButton = new TaskDialogButton("Hide", this.canHide);
// We don't have access to the asset service here. // We don't have access to the asset service here.
var workingDirectory = Service<Dalamud>.Get().StartInfo.WorkingDirectory; var workingDirectory = Service<Dalamud>.Get().StartInfo.WorkingDirectory;
TaskDialogIcon? dialogIcon = null; using var extractedIcon =
if (!workingDirectory.IsNullOrEmpty()) string.IsNullOrEmpty(workingDirectory)
{ ? null
var extractedIcon = Icon.ExtractAssociatedIcon(Path.Combine(workingDirectory, "Dalamud.Injector.exe")); : Icon.ExtractAssociatedIcon(Path.Combine(workingDirectory, "Dalamud.Injector.exe"));
if (extractedIcon != null)
{
dialogIcon = new TaskDialogIcon(extractedIcon);
}
}
dialogIcon ??= TaskDialogIcon.Information; fixed (void* pszWindowTitle = "Dalamud")
this.page = new TaskDialogPage fixed (void* pszHide = Loc.Localize("LoadingDialogHide", "Hide"))
fixed (void* pszThemesManifestResourceName = "RT_MANIFEST_THEMES")
fixed (void* pszDalamudBoot = "Dalamud.Boot.dll")
{ {
ProgressBar = new TaskDialogProgressBar(TaskDialogProgressBarState.Marquee), var taskDialogButton = new TASKDIALOG_BUTTON
Caption = "Dalamud",
Icon = dialogIcon,
Buttons = { this.inProgressHideButton },
AllowMinimize = false,
AllowCancel = false,
Expander = new TaskDialogExpander
{ {
CollapsedButtonText = "What does this mean?", nButtonID = IDOK,
ExpandedButtonText = "What does this mean?", pszButtonText = (ushort*)pszHide,
Text = "Some of the plugins you have installed through Dalamud are taking a long time to load.\n" + };
"This is likely normal, please wait a little while longer.", var taskDialogConfig = new TASKDIALOGCONFIG
}, {
SizeToContent = true, cbSize = (uint)sizeof(TASKDIALOGCONFIG),
hwndParent = default,
hInstance = (HINSTANCE)Marshal.GetHINSTANCE(Assembly.GetExecutingAssembly().ManifestModule),
dwFlags = (int)TDF_CAN_BE_MINIMIZED |
(int)TDF_SHOW_MARQUEE_PROGRESS_BAR |
(int)TDF_CALLBACK_TIMER |
(extractedIcon is null ? 0 : (int)TDF_USE_HICON_MAIN),
dwCommonButtons = 0,
pszWindowTitle = (ushort*)pszWindowTitle,
pszMainIcon = extractedIcon is null ? TD.TD_INFORMATION_ICON : (ushort*)extractedIcon.Handle,
pszMainInstruction = null,
pszContent = null,
cButtons = 1,
pButtons = &taskDialogButton,
nDefaultButton = IDOK,
cRadioButtons = 0,
pRadioButtons = null,
nDefaultRadioButton = 0,
pszVerificationText = null,
pszExpandedInformation = null,
pszExpandedControlText = null,
pszCollapsedControlText = null,
pszFooterIcon = null,
pszFooter = null,
pfCallback = &HResultFuncBinder,
lpCallbackData = 0,
cxWidth = 0,
}; };
this.UpdatePage(); HANDLE hActCtx = default;
GCHandle gch = default;
// Call private TaskDialog ctor nuint cookie = 0;
var ctor = typeof(TaskDialog).GetConstructor( try
BindingFlags.Instance | BindingFlags.NonPublic,
null,
Array.Empty<Type>(),
null);
var taskDialog = (TaskDialog)ctor!.Invoke(Array.Empty<object>())!;
this.page.Created += (_, _) =>
{ {
var hwnd = new HWND(taskDialog.Handle); var actctx = new ACTCTXW
{
// Bring to front cbSize = (uint)sizeof(ACTCTXW),
Windows.Win32.PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, dwFlags = ACTCTX_FLAG_HMODULE_VALID | ACTCTX_FLAG_RESOURCE_NAME_VALID,
SET_WINDOW_POS_FLAGS.SWP_NOSIZE | SET_WINDOW_POS_FLAGS.SWP_NOMOVE); lpResourceName = (ushort*)pszThemesManifestResourceName,
Windows.Win32.PInvoke.SetWindowPos(hwnd, HWND.HWND_NOTOPMOST, 0, 0, 0, 0, hModule = GetModuleHandleW((ushort*)pszDalamudBoot),
SET_WINDOW_POS_FLAGS.SWP_SHOWWINDOW | SET_WINDOW_POS_FLAGS.SWP_NOSIZE |
SET_WINDOW_POS_FLAGS.SWP_NOMOVE);
Windows.Win32.PInvoke.SetForegroundWindow(hwnd);
Windows.Win32.PInvoke.SetFocus(hwnd);
Windows.Win32.PInvoke.SetActiveWindow(hwnd);
}; };
hActCtx = CreateActCtxW(&actctx);
if (hActCtx == default)
throw new Win32Exception("CreateActCtxW failure.");
// Call private "ShowDialogInternal" if (!ActivateActCtx(hActCtx, &cookie))
var showDialogInternal = typeof(TaskDialog).GetMethod( throw new Win32Exception("ActivateActCtx failure.");
"ShowDialogInternal",
BindingFlags.Instance | BindingFlags.NonPublic,
null,
[typeof(IntPtr), typeof(TaskDialogPage), typeof(TaskDialogStartupLocation)],
null);
var cts = new CancellationTokenSource(); gch = GCHandle.Alloc((Func<HWND, uint, WPARAM, LPARAM, HRESULT>)this.TaskDialogCallback);
_ = this.DialogStatePeriodicUpdate(cts.Token); taskDialogConfig.lpCallbackData = GCHandle.ToIntPtr(gch);
TaskDialogIndirect(&taskDialogConfig, null, null, null).ThrowOnError();
showDialogInternal!.Invoke( }
taskDialog, catch (Exception e)
[IntPtr.Zero, this.page, TaskDialogStartupLocation.CenterScreen]); {
Log.Error(e, "TaskDialogIndirect failure.");
}
finally
{
if (gch.IsAllocated)
gch.Free();
if (cookie != 0)
DeactivateActCtx(0, cookie);
ReleaseActCtx(hActCtx);
}
}
Interlocked.Exchange(ref wasGloballyHidden, 1); Interlocked.Exchange(ref wasGloballyHidden, 1);
cts.Cancel();
return;
[UnmanagedCallersOnly]
static HRESULT HResultFuncBinder(HWND hwnd, uint msg, WPARAM wParam, LPARAM lParam, nint user) =>
((Func<HWND, uint, WPARAM, LPARAM, HRESULT>)GCHandle.FromIntPtr(user).Target!)
.Invoke(hwnd, msg, wParam, lParam);
} }
} }

View file

@ -282,6 +282,7 @@ internal static class ServiceManager
async Task WaitWithTimeoutConsent(IEnumerable<Task> tasksEnumerable, LoadingDialog.State state) async Task WaitWithTimeoutConsent(IEnumerable<Task> tasksEnumerable, LoadingDialog.State state)
{ {
loadingDialog.CurrentState = state;
var tasks = tasksEnumerable.AsReadOnlyCollection(); var tasks = tasksEnumerable.AsReadOnlyCollection();
if (tasks.Count == 0) if (tasks.Count == 0)
return; return;
@ -294,7 +295,6 @@ internal static class ServiceManager
{ {
loadingDialog.Show(); loadingDialog.Show();
loadingDialog.CanHide = true; loadingDialog.CanHide = true;
loadingDialog.CurrentState = state;
} }
} }
}).ConfigureAwait(false); }).ConfigureAwait(false);