feat: establish legacy parity migration baseline
This commit is contained in:
353
src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs
Normal file
353
src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs
Normal file
@@ -0,0 +1,353 @@
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
using MBN_STOCK_WEBVIEW.LegacyBridge;
|
||||
using Microsoft.UI.Windowing;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using MMoneyCoderSharp.Data;
|
||||
using Windows.Graphics;
|
||||
using WinRT.Interop;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
|
||||
|
||||
public sealed partial class MainWindow : Window
|
||||
{
|
||||
private readonly CancellationTokenSource _lifetimeCancellation = new();
|
||||
private readonly SemaphoreSlim _intentGate = new(1, 1);
|
||||
private readonly LegacyOperatorController _controller;
|
||||
private DatabaseRuntime? _databaseRuntime;
|
||||
private bool _closing;
|
||||
private bool _intentBusy;
|
||||
private bool _webViewReady;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
ILegacyStockLookup stockLookup;
|
||||
string? initializationError = null;
|
||||
try
|
||||
{
|
||||
_databaseRuntime = DatabaseRuntime.CreateDefault();
|
||||
DataQueryExecutor.Configure(_databaseRuntime.Executor);
|
||||
stockLookup = new LegacyParityStockSearchService(_databaseRuntime.Executor);
|
||||
}
|
||||
catch
|
||||
{
|
||||
initializationError =
|
||||
"데이터베이스가 설정되지 않았습니다. 로컬 설정을 확인하세요.";
|
||||
stockLookup = new UnavailableStockLookup(initializationError);
|
||||
DataQueryExecutor.Reset();
|
||||
}
|
||||
|
||||
_controller = new LegacyOperatorController(stockLookup);
|
||||
if (initializationError is not null)
|
||||
{
|
||||
_controller.ReportError(initializationError);
|
||||
}
|
||||
|
||||
Root.Loaded += OnRootLoaded;
|
||||
Closed += OnClosed;
|
||||
}
|
||||
|
||||
private async void OnRootLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Root.Loaded -= OnRootLoaded;
|
||||
if (_closing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ConfigureWindow();
|
||||
await InitializeWebViewAsync();
|
||||
}
|
||||
|
||||
private void ConfigureWindow()
|
||||
{
|
||||
var windowHandle = WindowNative.GetWindowHandle(this);
|
||||
var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle);
|
||||
var appWindow = AppWindow.GetFromWindowId(windowId);
|
||||
appWindow.Title = "V-Stock 증권정보송출시스템 for 매일경제TV (26.03.26)";
|
||||
|
||||
if (appWindow.Presenter is OverlappedPresenter presenter)
|
||||
{
|
||||
presenter.IsMaximizable = false;
|
||||
presenter.IsResizable = false;
|
||||
}
|
||||
|
||||
// WinForms stored 1905 x 1015 as ClientSize. AppWindow.Resize would include
|
||||
// the caption and borders and silently make the WebView viewport smaller.
|
||||
appWindow.ResizeClient(new SizeInt32(1905, 1015));
|
||||
var displayArea = DisplayArea.GetFromWindowId(windowId, DisplayAreaFallback.Primary);
|
||||
appWindow.Move(new PointInt32(displayArea.WorkArea.X, displayArea.WorkArea.Y));
|
||||
}
|
||||
|
||||
private async Task InitializeWebViewAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var webRoot = Path.Combine(AppContext.BaseDirectory, "Web");
|
||||
var startPage = Path.Combine(webRoot, "index.html");
|
||||
if (!File.Exists(startPage))
|
||||
{
|
||||
throw new FileNotFoundException("The parity Web UI was not found.", startPage);
|
||||
}
|
||||
|
||||
await Browser.EnsureCoreWebView2Async();
|
||||
if (_closing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var coreWebView = Browser.CoreWebView2 ??
|
||||
throw new InvalidOperationException("WebView2 was not initialized.");
|
||||
coreWebView.SetVirtualHostNameToFolderMapping(
|
||||
LegacyBridgeProtocol.TrustedHost,
|
||||
webRoot,
|
||||
CoreWebView2HostResourceAccessKind.DenyCors);
|
||||
coreWebView.Settings.AreDevToolsEnabled =
|
||||
System.Diagnostics.Debugger.IsAttached;
|
||||
coreWebView.Settings.AreDefaultContextMenusEnabled = false;
|
||||
coreWebView.Settings.IsStatusBarEnabled = false;
|
||||
coreWebView.NavigationStarting += OnNavigationStarting;
|
||||
coreWebView.NavigationCompleted += OnNavigationCompleted;
|
||||
coreWebView.NewWindowRequested += OnNewWindowRequested;
|
||||
coreWebView.WebMessageReceived += OnWebMessageReceived;
|
||||
coreWebView.ProcessFailed += OnProcessFailed;
|
||||
|
||||
_webViewReady = true;
|
||||
Browser.Source = new Uri(
|
||||
$"https://{LegacyBridgeProtocol.TrustedHost}/index.html");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
if (!_closing)
|
||||
{
|
||||
ShowError("WebView2 초기화에 실패했습니다.", exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnNavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs args)
|
||||
{
|
||||
if (!LegacyBridgeProtocol.IsTrustedSource(args.Uri) &&
|
||||
!string.Equals(args.Uri, "about:blank", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
args.Cancel = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs args)
|
||||
{
|
||||
if (_closing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LoadingOverlay.Visibility = Visibility.Collapsed;
|
||||
if (!args.IsSuccess)
|
||||
{
|
||||
ShowError("원본 호환 화면을 불러오지 못했습니다.", args.WebErrorStatus.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnNewWindowRequested(
|
||||
object? sender,
|
||||
CoreWebView2NewWindowRequestedEventArgs args)
|
||||
{
|
||||
args.Handled = true;
|
||||
}
|
||||
|
||||
private void OnWebMessageReceived(
|
||||
object? sender,
|
||||
CoreWebView2WebMessageReceivedEventArgs args)
|
||||
{
|
||||
if (_closing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LegacyUiIntent? intent = null;
|
||||
string? parseError = null;
|
||||
if (!LegacyBridgeProtocol.IsTrustedSource(args.Source) ||
|
||||
!LegacyBridgeProtocol.TryParseIntent(
|
||||
args.WebMessageAsJson,
|
||||
out intent,
|
||||
out parseError) ||
|
||||
intent is null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(parseError))
|
||||
{
|
||||
PostState(_controller.ReportError(parseError));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_ = ProcessIntentAsync(intent);
|
||||
}
|
||||
|
||||
private async Task ProcessIntentAsync(LegacyUiIntent intent)
|
||||
{
|
||||
var gateEntered = false;
|
||||
try
|
||||
{
|
||||
// MainForm performed database search synchronously. The busy snapshot
|
||||
// freezes the Web controls, while this zero-timeout gate prevents an
|
||||
// already-posted duplicate from growing an unbounded DB request queue.
|
||||
gateEntered = await _intentGate.WaitAsync(
|
||||
0,
|
||||
_lifetimeCancellation.Token);
|
||||
if (!gateEntered)
|
||||
{
|
||||
PostState(_controller.Current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_closing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent is LegacySearchStocksIntent searchIntent &&
|
||||
!(searchIntent.Trigger == LegacySearchTrigger.Button &&
|
||||
searchIntent.Text.Length == 0))
|
||||
{
|
||||
_intentBusy = true;
|
||||
PostState(_controller.Current);
|
||||
}
|
||||
|
||||
var state = await HandleIntentAsync(intent, _lifetimeCancellation.Token);
|
||||
if (!_closing)
|
||||
{
|
||||
_intentBusy = false;
|
||||
PostState(state);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch when (_closing)
|
||||
{
|
||||
}
|
||||
catch
|
||||
{
|
||||
_intentBusy = false;
|
||||
PostState(_controller.ReportError(
|
||||
"요청을 처리하지 못했습니다. 데이터베이스 연결 상태를 확인하세요."));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_closing)
|
||||
{
|
||||
_intentBusy = false;
|
||||
}
|
||||
|
||||
if (gateEntered)
|
||||
{
|
||||
_intentGate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LegacyOperatorSnapshot> HandleIntentAsync(
|
||||
LegacyUiIntent intent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return intent switch
|
||||
{
|
||||
LegacyReadyIntent => _controller.Current,
|
||||
LegacySearchStocksIntent search => await _controller.SearchStocksAsync(
|
||||
search.Text,
|
||||
search.Trigger,
|
||||
cancellationToken),
|
||||
LegacySelectStockIntent selection =>
|
||||
_controller.SelectStock(selection.ResultIndex),
|
||||
LegacyCutPointerDownIntent pointer => _controller.CutPointerDown(
|
||||
pointer.PhysicalIndex,
|
||||
pointer.Control,
|
||||
pointer.Shift,
|
||||
pointer.X,
|
||||
pointer.Y,
|
||||
pointer.TimestampMilliseconds),
|
||||
LegacyDropSelectedCutsIntent => _controller.DropSelectedCutsOnPlaylist(),
|
||||
LegacySetPlaylistEnabledIntent enabled =>
|
||||
_controller.SetPlaylistRowEnabled(enabled.RowId, enabled.Enabled),
|
||||
LegacyDismissDialogIntent => _controller.DismissDialog(),
|
||||
_ => _controller.ReportError("지원하지 않는 화면 요청입니다.")
|
||||
};
|
||||
}
|
||||
|
||||
private void PostState(LegacyOperatorSnapshot snapshot)
|
||||
{
|
||||
if (!_closing && _webViewReady && Browser.CoreWebView2 is not null)
|
||||
{
|
||||
Browser.CoreWebView2.PostWebMessageAsJson(
|
||||
LegacyBridgeProtocol.SerializeState(snapshot, _intentBusy));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnProcessFailed(object? sender, CoreWebView2ProcessFailedEventArgs args)
|
||||
{
|
||||
if (!_closing)
|
||||
{
|
||||
ShowError("WebView2 프로세스가 중단되었습니다.", args.ProcessFailedKind.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowError(string title, string message)
|
||||
{
|
||||
if (_closing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ErrorBar.Title = title;
|
||||
ErrorBar.Message = message;
|
||||
ErrorBar.IsOpen = true;
|
||||
}
|
||||
|
||||
private void OnClosed(object sender, WindowEventArgs args)
|
||||
{
|
||||
if (_closing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_closing = true;
|
||||
Root.Loaded -= OnRootLoaded;
|
||||
Closed -= OnClosed;
|
||||
_lifetimeCancellation.Cancel();
|
||||
DataQueryExecutor.Reset();
|
||||
|
||||
if (Browser.CoreWebView2 is not null)
|
||||
{
|
||||
Browser.CoreWebView2.NavigationStarting -= OnNavigationStarting;
|
||||
Browser.CoreWebView2.NavigationCompleted -= OnNavigationCompleted;
|
||||
Browser.CoreWebView2.NewWindowRequested -= OnNewWindowRequested;
|
||||
Browser.CoreWebView2.WebMessageReceived -= OnWebMessageReceived;
|
||||
Browser.CoreWebView2.ProcessFailed -= OnProcessFailed;
|
||||
}
|
||||
|
||||
_webViewReady = false;
|
||||
|
||||
// These process-lifetime guards deliberately remain undisposed. An in-flight
|
||||
// provider call may resume after Closed and must still be able to observe
|
||||
// cancellation and release the gate without racing disposed objects.
|
||||
}
|
||||
|
||||
private sealed class UnavailableStockLookup : ILegacyStockLookup
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
public UnavailableStockLookup(string message)
|
||||
{
|
||||
_message = message;
|
||||
}
|
||||
|
||||
public Task<LegacyStockSearchData> SearchAsync(
|
||||
string query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<LegacyStockSearchData>(
|
||||
new InvalidOperationException(_message));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user