564 lines
19 KiB
C#
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);
|
|
}
|