Files
MBN_STOCK_WEBVIEW/MainWindow.xaml.cs

564 lines
19 KiB
C#

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 DatabaseRuntime? _databaseRuntime;
private IMarketDataService? _marketDataService;
private CancellationTokenSource? _marketDataCancellation;
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<WebRequest>(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();
try
{
var snapshot = await _marketDataService
.GetAsync(view, maximumRows, requestCancellation.Token);
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 (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)
{
if (!_webViewReady)
{
return;
}
if (_databaseRuntime is null)
{
var message = _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다.";
PostMessage("database-status", new
{
sources = new[]
{
CreateUnavailableStatus("oracle", message),
CreateUnavailableStatus("mariaDb", message)
}
});
return;
}
try
{
var statuses = await _databaseRuntime.HealthService
.CheckAllAsync(cancellationToken);
PostMessage("database-status", new
{
sources = 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)
{
}
catch
{
PostMessage("database-status", new
{
sources = new[]
{
CreateUnavailableStatus("oracle", "Oracle 상태 확인에 실패했습니다."),
CreateUnavailableStatus("mariaDb", "MariaDB 상태 확인에 실패했습니다.")
}
});
}
}
private async Task MonitorDatabaseHealthAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
await PostDatabaseStatusAsync(cancellationToken);
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();
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);
}