feat: establish legacy parity migration baseline

This commit is contained in:
2026-07-15 07:10:01 +09:00
parent e468f43886
commit f14800656b
36 changed files with 3166 additions and 0 deletions

View 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));
}
}