acdream/src/AcDream.Launcher/MainWindow.axaml.cs
Erik 981e168fb9 fix(launcher): Campaign LA gate-round-1 review findings F1-F6 + hardening
F1: the crash reporter comment claimed the launcher never holds a password
in any field - false (ProfileEditorDialogViewModel, AccountProfile.Password,
StartRequest.Password). Reworded to the true, narrower invariant (no throw
site interpolates a credential VALUE into an exception message) and pinned
it with CrashReportNeverContainsAStoredPassword: a real STJ failure over a
profiles document containing a known password, corrupted after the
credential, must yield a crash file with the stack and without the value.

F2: the co-deploy Inputs covered only Bake own sources; a Content edit
never refreshed the 83 MB exe. Now the full reference closure. Fixing it
surfaced two more incrementality traps, both fixed and comment-documented:
SkipUnchangedFiles left the output older than the triggering input (target
re-ran forever - added an explicit Touch), and %(Item.Metadata) in a plain
Include does not batch (the literal percent-text became a permanently
out-of-date phantom input - globs are now spelled per project). Verified:
Core edit retriggers, then two consecutive clean incremental builds.

F3: RID publishes ran BOTH co-deploy paths (two self-contained bake
publishes). Build-time target now guarded on _IsPublishing; verified a
real win-x64 publish runs zero build-target co-deploys and still ships
both exes.

F4: comment misattributed PublishBakeTool=false to CI lanes; it is
target-local recursion guarding. F5: the x:Name reflection sweep now walks
the markup as XML and tolerates template-scoped names (no generated field
exists for those). F6: dead using removed. Hardening: the crash reporter
positional --data-dir fallback requires a fully-qualified path so a
relative or flag-shaped value cannot create ./crash-reports at an
arbitrary CWD.

Launcher 67/67, Launcher.Core 317/317.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 08:19:23 +02:00

173 lines
5.4 KiB
C#

using System.ComponentModel;
using AcDream.Launcher.ViewModels;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
namespace AcDream.Launcher;
public sealed partial class MainWindow : Window
{
private readonly DispatcherTimer _statusTimer;
private LauncherWindowViewModel? _observedViewModel;
private Control? _focusBeforeModal;
private bool _wasModalOpen;
public MainWindow()
{
// InitializeComponent(), not AvaloniaXamlLoader.Load(this): only the
// generated method assigns the x:Name backing fields. Loading the XAML
// directly leaves every named control (ProfilesTree, the editor text
// boxes, FirstRunDatDirectoryTextBox, UpdateCloseButton, ...) null, so
// the first modal open or close threw NullReferenceException out of the
// dispatcher and took the whole process down through Program's guard.
InitializeComponent();
_statusTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(250),
};
_statusTimer.Tick += OnStatusTimerTick;
DataContextChanged += OnDataContextChanged;
Opened += OnOpened;
Closed += OnClosed;
}
private void OnOpened(object? sender, EventArgs e) => _statusTimer.Start();
private void OnClosed(object? sender, EventArgs e)
{
_statusTimer.Stop();
_statusTimer.Tick -= OnStatusTimerTick;
DataContextChanged -= OnDataContextChanged;
ObserveViewModel(null);
Opened -= OnOpened;
Closed -= OnClosed;
}
private void OnStatusTimerTick(object? sender, EventArgs e)
{
if (DataContext is LauncherWindowViewModel viewModel)
{
viewModel.PollStatus();
}
}
private void OnDataContextChanged(object? sender, EventArgs e) =>
ObserveViewModel(DataContext as LauncherWindowViewModel);
private void ObserveViewModel(LauncherWindowViewModel? viewModel)
{
if (ReferenceEquals(_observedViewModel, viewModel))
{
return;
}
if (_observedViewModel is not null)
{
_observedViewModel.PropertyChanged -= OnViewModelPropertyChanged;
}
_observedViewModel = viewModel;
if (_observedViewModel is not null)
{
_observedViewModel.PropertyChanged += OnViewModelPropertyChanged;
}
_wasModalOpen = viewModel?.IsModalOpen == true;
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(LauncherWindowViewModel.IsModalOpen)
|| sender is not LauncherWindowViewModel viewModel)
{
return;
}
bool isModalOpen = viewModel.IsModalOpen;
if (isModalOpen && !_wasModalOpen)
{
_focusBeforeModal = TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() as Control;
Dispatcher.UIThread.Post(() => FocusActiveModal(viewModel));
}
else if (!isModalOpen && _wasModalOpen)
{
Control? focusToRestore = _focusBeforeModal;
_focusBeforeModal = null;
Dispatcher.UIThread.Post(() =>
{
if (focusToRestore?.Focus() != true)
{
ProfilesTree.Focus();
}
});
}
_wasModalOpen = isModalOpen;
}
private void FocusActiveModal(LauncherWindowViewModel viewModel)
{
if (viewModel.EditorDialog.IsOpen)
{
Control target = viewModel.EditorDialog.Kind switch
{
ProfileEditorKind.AddServer or ProfileEditorKind.EditServer => ServerNameTextBox,
ProfileEditorKind.AddAccount or ProfileEditorKind.EditAccount => AccountNameTextBox,
ProfileEditorKind.AddCharacter or ProfileEditorKind.EditCharacter => CharacterNameTextBox,
_ => EditorSubmitButton,
};
target.Focus();
}
else if (viewModel.FirstRunWizardShell.IsOpen)
{
FirstRunDatDirectoryTextBox.Focus();
}
else if (viewModel.UpdatePrompt.IsOpen)
{
UpdateCloseButton.Focus();
}
}
private void OnModalKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key != Key.Escape || DataContext is not LauncherWindowViewModel viewModel)
{
return;
}
viewModel.CloseActiveModal();
e.Handled = true;
}
private async void OnBrowseDatDirectory(object? sender, RoutedEventArgs e)
{
if (DataContext is not LauncherWindowViewModel viewModel)
{
return;
}
try
{
IReadOnlyList<IStorageFolder> folders = await StorageProvider
.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Choose the retail Asheron's Call DAT directory",
AllowMultiple = false,
});
if (folders.Count > 0)
{
viewModel.FirstRunWizardShell.SelectDatDirectory(
folders[0].Path.LocalPath);
}
}
catch (Exception ex)
{
viewModel.FirstRunWizardShell.ReportPickerError(ex.Message);
}
e.Handled = true;
}
}