using System.Diagnostics; using System.Reflection; using System.Runtime.InteropServices; using System.Text.Json; using MBN_STOCK_WEBVIEW.Core.Playout; using MBN_STOCK_WEBVIEW.Infrastructure; using Microsoft.UI.Windowing; using Microsoft.Web.WebView2.Core; using MMoneyCoderSharp.Data; using Windows.Graphics; using Windows.System; using WinRT.Interop; namespace MBN_STOCK_WEBVIEW; public sealed partial class MainWindow : Window { private const string AppHost = "app.mbn.local"; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly CancellationTokenSource _lifetimeCancellation = new(); private readonly SemaphoreSlim _databaseActivityGate = new(1, 1); private DatabaseRuntime? _databaseRuntime; private IMarketDataService? _marketDataService; private CancellationTokenSource? _marketDataCancellation; private CancellationTokenSource? _databaseHealthCancellation; private long _databaseStatusSequence; private string? _databaseInitializationError; private bool _webViewReady; public MainWindow() { InitializeComponent(); InitializeDatabaseRuntime(); InitializePlayoutRuntime(); Root.Loaded += OnRootLoaded; Closed += OnClosed; } private void InitializeDatabaseRuntime() { try { _databaseRuntime = DatabaseRuntime.CreateDefault(); DataQueryExecutor.Configure(_databaseRuntime.Executor); _marketDataService = new LegacyMarketDataService(_databaseRuntime.Executor); } catch (DatabaseOperationException exception) { _databaseInitializationError = exception.Message; DataQueryExecutor.Reset(); } catch { _databaseInitializationError = "데이터베이스 설정을 초기화하지 못했습니다. 로컬 설정 파일을 확인하세요."; DataQueryExecutor.Reset(); } } private async void OnRootLoaded(object sender, RoutedEventArgs e) { Root.Loaded -= OnRootLoaded; ResizeWindow(); await InitializeWebViewAsync(); if (_webViewReady) { _ = MonitorDatabaseHealthAsync(_lifetimeCancellation.Token); _ = ConnectPlayoutAsync(_lifetimeCancellation.Token); } } private void ResizeWindow() { try { var windowHandle = WindowNative.GetWindowHandle(this); var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle); AppWindow.GetFromWindowId(windowId).Resize(new SizeInt32(1600, 960)); } catch { // The system-selected size is still usable if sizing is unavailable. } } 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 packaged Web UI was not found.", startPage); } await Browser.EnsureCoreWebView2Async(); Browser.CoreWebView2.SetVirtualHostNameToFolderMapping( AppHost, webRoot, CoreWebView2HostResourceAccessKind.DenyCors); Browser.CoreWebView2.Settings.AreDevToolsEnabled = Debugger.IsAttached; Browser.CoreWebView2.Settings.AreDefaultContextMenusEnabled = Debugger.IsAttached; Browser.CoreWebView2.Settings.IsStatusBarEnabled = false; Browser.CoreWebView2.NavigationStarting += OnNavigationStarting; Browser.CoreWebView2.NavigationCompleted += OnNavigationCompleted; Browser.CoreWebView2.HistoryChanged += OnHistoryChanged; Browser.CoreWebView2.NewWindowRequested += OnNewWindowRequested; Browser.CoreWebView2.WebMessageReceived += OnWebMessageReceived; Browser.CoreWebView2.ProcessFailed += OnProcessFailed; _webViewReady = true; Browser.Source = new Uri($"https://{AppHost}/index.html"); } catch (Exception exception) { ShowError("WebView2 초기화에 실패했습니다.", exception.Message); } } private void OnNavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs args) { if (!Uri.TryCreate(args.Uri, UriKind.Absolute, out var uri)) { args.Cancel = true; return; } if (PlayoutBridgeProtocol.IsTrustedSource(uri.AbsoluteUri, AppHost) || uri.Scheme == "about") { QuarantineIfBrowserCorrelationCanBeLost(); LoadingOverlay.Visibility = Visibility.Visible; return; } args.Cancel = true; _ = OpenExternalUriAsync(uri); } private void OnNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs args) { LoadingOverlay.Visibility = Visibility.Collapsed; if (!args.IsSuccess) { ShowError("운영 화면을 불러오지 못했습니다.", args.WebErrorStatus.ToString()); } } private void OnHistoryChanged(object? sender, object args) { BackButton.IsEnabled = Browser.CanGoBack; ForwardButton.IsEnabled = Browser.CanGoForward; } private void OnNewWindowRequested(object? sender, CoreWebView2NewWindowRequestedEventArgs args) { args.Handled = true; if (Uri.TryCreate(args.Uri, UriKind.Absolute, out var uri)) { _ = OpenExternalUriAsync(uri); } } private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs args) { if (!PlayoutBridgeProtocol.IsTrustedSource(args.Source, AppHost)) { return; } try { var request = JsonSerializer.Deserialize(args.WebMessageAsJson, JsonOptions); switch (request?.Type) { case "ready": case "request-app-info": PostAppInfo(); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); PostPlayoutStatus(); break; case "request-database-status": _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); break; case "request-playout-status": HandlePlayoutStatusRequest(request.Payload); break; case "playout-command": _ = HandlePlayoutCommandAsync(request.Payload); break; case "playout-timeout-quarantine": _ = HandlePlayoutTimeoutQuarantineAsync(request.Payload); break; case "request-market-data" when request.Payload.ValueKind == JsonValueKind.Object: _ = HandleMarketDataRequestAsync(request.Payload); break; case "open-external" when request.Payload.ValueKind == JsonValueKind.Object: if (request.Payload.TryGetProperty("url", out var value) && Uri.TryCreate(value.GetString(), UriKind.Absolute, out var uri)) { _ = OpenExternalUriAsync(uri); } break; case "reload": ReloadBrowserSafely(); break; } } catch (JsonException exception) { PostMessage("bridge-error", new { message = exception.Message }); } catch { PostMessage("bridge-error", new { message = "요청 메시지를 처리하지 못했습니다." }); } } private async Task HandleMarketDataRequestAsync(JsonElement payload) { var requestId = GetString(payload, "requestId"); var viewName = GetString(payload, "view"); if (string.IsNullOrWhiteSpace(requestId) || requestId.Length > 128 || !TryParseMarketDataView(viewName, out var view)) { PostMessage("market-data-error", new { requestId = requestId ?? string.Empty, view = viewName ?? string.Empty, message = "지원하지 않는 시장 데이터 요청입니다." }); return; } var maximumRows = 250; if (payload.TryGetProperty("maximumRowsPerTable", out var rowLimit) && rowLimit.ValueKind == JsonValueKind.Number && rowLimit.TryGetInt32(out var requestedRows)) { maximumRows = requestedRows; } if (maximumRows is < 1 or > 2_000) { PostMessage("market-data-error", new { requestId, view = viewName, message = "한 번에 조회할 수 있는 행 수는 1~2,000개입니다." }); return; } if (_marketDataService is null) { PostMessage("market-data-error", new { requestId, view = viewName, message = _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다." }); return; } var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCancellation.Token); var previousCancellation = Interlocked.Exchange(ref _marketDataCancellation, requestCancellation); previousCancellation?.Cancel(); previousCancellation?.Dispose(); var databaseActivityEntered = false; try { if (Volatile.Read(ref _playoutCommandInFlight) != 0) { requestCancellation.Cancel(); return; } await _databaseActivityGate.WaitAsync(requestCancellation.Token); databaseActivityEntered = true; // Close the race where playout starts after this request is published // but before its market query begins. if (Volatile.Read(ref _playoutCommandInFlight) != 0) { requestCancellation.Cancel(); return; } var snapshot = await _marketDataService .GetAsync(view, maximumRows, requestCancellation.Token); requestCancellation.Token.ThrowIfCancellationRequested(); PostMessage("market-data", new { requestId, view = ToWireValue(snapshot.View), snapshot.RetrievedAt, tables = snapshot.Tables.Select(table => new { table.Name, source = ToWireValue(table.Source), table.Columns, table.Rows, table.TotalRowCount }) }); } catch (OperationCanceledException) when (requestCancellation.IsCancellationRequested) { // A newer navigation request or app shutdown superseded this response. } catch (DatabaseOperationException exception) { PostMarketDataError(requestId, view, exception.Message); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } catch { PostMarketDataError(requestId, view, "시장 데이터를 조회하지 못했습니다. DB 연결 상태를 확인하세요."); _ = PostDatabaseStatusAsync(_lifetimeCancellation.Token); } finally { if (databaseActivityEntered) { _databaseActivityGate.Release(); } if (ReferenceEquals(Interlocked.CompareExchange( ref _marketDataCancellation, null, requestCancellation), requestCancellation)) { requestCancellation.Dispose(); } } } private void PostMarketDataError(string requestId, MarketDataView view, string message) { PostMessage("market-data-error", new { requestId, view = ToWireValue(view), message }); } private async Task PostDatabaseStatusAsync( CancellationToken cancellationToken, bool periodic = false) { if (!_webViewReady) { return; } while (!cancellationToken.IsCancellationRequested) { if (periodic && Volatile.Read(ref _playoutCommandInFlight) != 0) { return; } bool entered; if (periodic) { entered = await _databaseActivityGate.WaitAsync(0, cancellationToken); } else { await _databaseActivityGate.WaitAsync(cancellationToken); entered = true; } if (!entered) { return; } var retryAfterPlayout = false; CancellationTokenSource? healthCancellation = null; try { if (Volatile.Read(ref _playoutCommandInFlight) != 0) { retryAfterPlayout = !periodic; } else { healthCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); Volatile.Write(ref _databaseHealthCancellation, healthCancellation); // Close the race where playout starts after this health request // entered the gate but before its diagnostic query begins. if (Volatile.Read(ref _playoutCommandInFlight) != 0) { healthCancellation.Cancel(); retryAfterPlayout = !periodic; } else { await QueryAndPostDatabaseStatusAsync(healthCancellation.Token); return; } } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return; } catch (OperationCanceledException) when (healthCancellation?.IsCancellationRequested == true) { // Playout has priority over diagnostics. Interactive requests resume // after the command; the periodic monitor keeps the last Web snapshot. retryAfterPlayout = !periodic; } finally { if (healthCancellation is not null) { Interlocked.CompareExchange( ref _databaseHealthCancellation, null, healthCancellation); healthCancellation.Dispose(); } _databaseActivityGate.Release(); } if (!retryAfterPlayout) { return; } // Let the already-active command enter the activity gate before an // interactive health request retries. await Task.Delay(TimeSpan.FromMilliseconds(25), cancellationToken); } } private async Task QueryAndPostDatabaseStatusAsync(CancellationToken cancellationToken) { if (_databaseRuntime is null) { var message = _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다."; PostDatabaseStatus(new[] { CreateUnavailableStatus("oracle", message), CreateUnavailableStatus("mariaDb", message) }); return; } try { var statuses = await _databaseRuntime.HealthService .CheckAllAsync(cancellationToken); PostDatabaseStatus(statuses.Select(status => new { source = ToWireValue(status.Source), configured = _databaseRuntime.Options.IsConfigured(status.Source), healthy = status.State == DatabaseHealthState.Healthy, state = status.State.ToString().ToLowerInvariant(), message = status.UserMessage, checkedAt = status.CheckedAt, latencyMs = Math.Max(0, (long)status.Elapsed.TotalMilliseconds) })); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch { PostDatabaseStatus(new[] { CreateUnavailableStatus("oracle", "Oracle 상태 확인에 실패했습니다."), CreateUnavailableStatus("mariaDb", "MariaDB 상태 확인에 실패했습니다.") }); } } private void PostDatabaseStatus(object sources) { PostMessage("database-status", new { sequence = Interlocked.Increment(ref _databaseStatusSequence), sources }); } private void CancelActiveDatabaseHealthCheck() { var cancellation = Volatile.Read(ref _databaseHealthCancellation); try { cancellation?.Cancel(); } catch (ObjectDisposedException) { // Completion won the race after the reference was read. } } private void CancelActiveMarketDataRequest() { var cancellation = Volatile.Read(ref _marketDataCancellation); try { cancellation?.Cancel(); } catch (ObjectDisposedException) { // Completion won the race after the reference was read. } } private async Task MonitorDatabaseHealthAsync(CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) { await PostDatabaseStatusAsync(cancellationToken, periodic: true); try { await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { break; } } } private static object CreateUnavailableStatus(string source, string message) => new { source, configured = false, healthy = false, state = "notconfigured", message, checkedAt = DateTimeOffset.Now, latencyMs = 0L }; private static string? GetString(JsonElement payload, string propertyName) => payload.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; private static bool TryParseMarketDataView(string? value, out MarketDataView view) { view = value?.ToLowerInvariant() switch { "kospi" => MarketDataView.Kospi, "kosdaq" => MarketDataView.Kosdaq, "index" => MarketDataView.Index, "overseas" => MarketDataView.Overseas, _ => default }; return value?.ToLowerInvariant() is "kospi" or "kosdaq" or "index" or "overseas"; } private static string ToWireValue(MarketDataView view) => view.ToString().ToLowerInvariant(); private static string ToWireValue(DataSourceKind source) => source switch { DataSourceKind.Oracle => "oracle", DataSourceKind.MariaDb => "mariaDb", _ => "unknown" }; private void PostAppInfo() { var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "1.0.0"; PostMessage("app-info", new { name = "MBN Stock WebView", version, framework = ".NET 8 / WinUI 3", windowsAppSdk = "1.8", architecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(), package = "MSIX", migratedQueryFiles = 71, databaseConfigured = _databaseRuntime is not null && new[] { DataSourceKind.Oracle, DataSourceKind.MariaDb } .Any(_databaseRuntime.Options.IsConfigured) }); } private void PostMessage(string type, object payload) { if (!_webViewReady) { return; } Browser.CoreWebView2.PostWebMessageAsJson( JsonSerializer.Serialize(new { type, payload }, JsonOptions)); } private static async Task OpenExternalUriAsync(Uri uri) { if (uri.Scheme is "https" or "http") { await Launcher.LaunchUriAsync(uri); } } private void OnProcessFailed(object? sender, CoreWebView2ProcessFailedEventArgs args) { QuarantineIfBrowserCorrelationCanBeLost(); ShowError("WebView2 프로세스가 종료되었습니다.", args.ProcessFailedKind.ToString()); } private void ShowError(string title, string message) { LoadingOverlay.Visibility = Visibility.Collapsed; ErrorBar.Title = title; ErrorBar.Message = message; ErrorBar.IsOpen = true; } private void OnBackClicked(object sender, RoutedEventArgs e) { if (Browser.CanGoBack) { Browser.GoBack(); } } private void OnForwardClicked(object sender, RoutedEventArgs e) { if (Browser.CanGoForward) { Browser.GoForward(); } } private void OnReloadClicked(object sender, RoutedEventArgs e) { if (_webViewReady) { ReloadBrowserSafely(); } } private void ReloadBrowserSafely() { QuarantineIfBrowserCorrelationCanBeLost(); Browser.Reload(); } private void QuarantineIfBrowserCorrelationCanBeLost() { if (Volatile.Read(ref _playoutCommandInFlight) != 0) { // Navigation or a renderer failure destroys the JavaScript request // correlation. Latch native OutcomeUnknown before the document can go away. _ = QuarantineForBrowserCorrelationLossAsync(); } } private void OnClosed(object sender, WindowEventArgs args) { var wasWebViewReady = _webViewReady; _webViewReady = false; _lifetimeCancellation.Cancel(); CancelActiveDatabaseHealthCheck(); ShutdownPlayoutRuntime(); var requestCancellation = Interlocked.Exchange(ref _marketDataCancellation, null); requestCancellation?.Cancel(); requestCancellation?.Dispose(); DataQueryExecutor.Reset(); if (!wasWebViewReady) { _lifetimeCancellation.Dispose(); return; } Browser.CoreWebView2.NavigationStarting -= OnNavigationStarting; Browser.CoreWebView2.NavigationCompleted -= OnNavigationCompleted; Browser.CoreWebView2.HistoryChanged -= OnHistoryChanged; Browser.CoreWebView2.NewWindowRequested -= OnNewWindowRequested; Browser.CoreWebView2.WebMessageReceived -= OnWebMessageReceived; Browser.CoreWebView2.ProcessFailed -= OnProcessFailed; _lifetimeCancellation.Dispose(); } private sealed record WebRequest(string Type, JsonElement Payload); }