feat: restore legacy modal and drag behavior

This commit is contained in:
2026-07-16 01:33:48 +09:00
parent 83398044c6
commit d7ec571343
30 changed files with 3597 additions and 321 deletions

View File

@@ -1,3 +1,4 @@
using System.Runtime.InteropServices;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
@@ -11,17 +12,31 @@ namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
public sealed partial class MainWindow : Window
{
private const int SmCxDrag = 68;
private const int SmCyDrag = 69;
private const uint WmSettingChange = 0x001A;
private const uint WmDisplayChange = 0x007E;
private const uint WmDpiChanged = 0x02E0;
private const nuint InteractionMetricsSubclassId = 0x4D424E49;
private readonly CancellationTokenSource _lifetimeCancellation = new();
private readonly SemaphoreSlim _intentGate = new(1, 1);
private readonly WindowSubclassProcedure _windowSubclassProcedure;
private readonly LegacyOperatorController _controller;
private readonly LegacyIndustrySelectionWorkflow _industryWorkflow;
private DatabaseRuntime? _databaseRuntime;
private bool _closing;
private bool _interactionMetricsRefreshQueued;
private bool _intentBusy;
private bool _windowSubclassAttached;
private bool _webViewReady;
private nint _windowHandle;
private LegacyInteractionMetrics _interactionMetrics =
LegacyInteractionMetrics.Default;
public MainWindow()
{
_windowSubclassProcedure = OnWindowSubclassMessage;
InitializeComponent();
EnsureCloseConfirmationAttached();
var localApplicationData = Environment.GetFolderPath(
@@ -202,6 +217,9 @@ public sealed partial class MainWindow : Window
{
EnsureCloseConfirmationAttached();
var windowHandle = WindowNative.GetWindowHandle(this);
_windowHandle = windowHandle;
AttachInteractionMetricsRefresh();
RefreshInteractionMetrics();
var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle);
var appWindow = AppWindow.GetFromWindowId(windowId);
appWindow.Title = "V-Stock 증권정보송출시스템 for 매일경제TV (26.03.26)";
@@ -244,8 +262,11 @@ public sealed partial class MainWindow : Window
CoreWebView2HostResourceAccessKind.DenyCors);
coreWebView.Settings.AreDevToolsEnabled =
System.Diagnostics.Debugger.IsAttached;
coreWebView.Settings.AreBrowserAcceleratorKeysEnabled = false;
coreWebView.Settings.AreDefaultContextMenusEnabled = false;
coreWebView.Settings.IsStatusBarEnabled = false;
coreWebView.Settings.IsZoomControlEnabled = false;
coreWebView.Settings.IsPinchZoomEnabled = false;
coreWebView.NavigationStarting += OnNavigationStarting;
coreWebView.NavigationCompleted += OnNavigationCompleted;
coreWebView.NewWindowRequested += OnNewWindowRequested;
@@ -327,6 +348,7 @@ public sealed partial class MainWindow : Window
private async Task ProcessIntentAsync(LegacyUiIntent intent)
{
var gateEntered = false;
var dispatchStarted = false;
try
{
// MainForm performed database search synchronously. The busy snapshot
@@ -337,7 +359,11 @@ public sealed partial class MainWindow : Window
_lifetimeCancellation.Token);
if (!gateEntered)
{
PostState(_controller.Current);
PostState(IsNamedPlaylistModalIntent(intent)
? ReportIntentFailure(
intent,
"다른 요청을 처리 중이어서 DB 재생목록 요청을 시작하지 않았습니다.")
: _controller.Current);
return;
}
@@ -348,7 +374,8 @@ public sealed partial class MainWindow : Window
if (IsOperatorMutationIntent(intent) && IsOperatorMutationQuarantined())
{
PostState(_controller.ReportError(
PostState(ReportIntentFailure(
intent,
"이전 저장 결과가 불명확하여 이번 실행의 추가 변경이 차단되었습니다. DB 또는 로컬 파일을 읽기 전용으로 대조한 뒤 앱을 다시 시작하세요."));
return;
}
@@ -357,14 +384,16 @@ public sealed partial class MainWindow : Window
IsOperatorMutationIntent(intent) &&
!IsFixedSectionBatchMutationIntent(intent))
{
PostState(_controller.ReportError(
PostState(ReportIntentFailure(
intent,
"고정 컷 섹션의 수동 입력 중에는 현재 입력 단계만 변경할 수 있습니다. 완료하거나 취소한 뒤 다른 편성 작업을 실행하세요."));
return;
}
if (IsOperatorMutationIntent(intent) && !CanAcceptOperatorMutation())
{
PostState(_controller.ReportError(
PostState(ReportIntentFailure(
intent,
"PREPARE 또는 송출 중에는 플레이리스트와 운영 데이터를 변경할 수 없습니다. TAKE OUT 후 다시 시도하세요."));
return;
}
@@ -433,6 +462,7 @@ public sealed partial class MainWindow : Window
PostState(_controller.Current);
}
dispatchStarted = true;
var state = await HandleIntentAsync(intent, _lifetimeCancellation.Token);
ObserveOperatorMutationQuarantine(state);
if (!_closing)
@@ -450,8 +480,12 @@ public sealed partial class MainWindow : Window
catch
{
_intentBusy = false;
PostState(_controller.ReportError(
"요청을 처리하지 못했습니다. 데이터베이스 연결 상태를 확인하세요."));
var failureState = ReportIntentFailure(
intent,
"요청을 처리하지 못했습니다. 데이터베이스 연결 상태를 확인하세요.",
writeOutcomeMayBeUnknown: dispatchStarted);
ObserveOperatorMutationQuarantine(failureState);
PostState(failureState);
}
finally
{
@@ -467,6 +501,82 @@ public sealed partial class MainWindow : Window
}
}
private static bool IsNamedPlaylistModalIntent(LegacyUiIntent intent) =>
intent is LegacyRefreshNamedPlaylistsIntent or
LegacySelectNamedPlaylistIntent or
LegacyLoadSelectedNamedPlaylistIntent or
LegacyLoadNamedPlaylistByIdIntent or
LegacyCreateNamedPlaylistIntent or
LegacySaveCurrentNamedPlaylistIntent or
LegacySaveCurrentNamedPlaylistToIntent or
LegacyDeleteSelectedNamedPlaylistIntent;
private LegacyOperatorSnapshot ReportIntentFailure(
LegacyUiIntent intent,
string message,
bool writeOutcomeMayBeUnknown = false) =>
intent switch
{
LegacyRefreshNamedPlaylistsIntent refresh
when !string.IsNullOrWhiteSpace(refresh.RequestId) =>
_controller.ReportNamedPlaylistCommandFailure(
"refresh-named-playlists",
refresh.RequestId,
message),
LegacySelectNamedPlaylistIntent selection
when !string.IsNullOrWhiteSpace(selection.RequestId) =>
_controller.ReportNamedPlaylistCommandFailure(
"select-named-playlist",
selection.RequestId,
message),
LegacyCreateNamedPlaylistIntent create
when writeOutcomeMayBeUnknown &&
!string.IsNullOrWhiteSpace(create.RequestId) =>
_controller.ReportNamedPlaylistWriteOutcomeUnknown(
"create-named-playlist",
create.RequestId,
message),
LegacyCreateNamedPlaylistIntent create
when !string.IsNullOrWhiteSpace(create.RequestId) =>
_controller.ReportNamedPlaylistCommandFailure(
"create-named-playlist",
create.RequestId,
message),
LegacyDeleteSelectedNamedPlaylistIntent delete
when writeOutcomeMayBeUnknown &&
!string.IsNullOrWhiteSpace(delete.RequestId) =>
_controller.ReportNamedPlaylistWriteOutcomeUnknown(
"delete-selected-named-playlist",
delete.RequestId,
message),
LegacyDeleteSelectedNamedPlaylistIntent delete
when !string.IsNullOrWhiteSpace(delete.RequestId) =>
_controller.ReportNamedPlaylistCommandFailure(
"delete-selected-named-playlist",
delete.RequestId,
message),
LegacyLoadNamedPlaylistByIdIntent load
when !string.IsNullOrWhiteSpace(load.DefinitionId) =>
_controller.ReportNamedPlaylistCommandFailure(
"load-named-playlist-by-id",
load.DefinitionId,
message),
LegacySaveCurrentNamedPlaylistToIntent save
when writeOutcomeMayBeUnknown &&
!string.IsNullOrWhiteSpace(save.DefinitionId) =>
_controller.ReportNamedPlaylistWriteOutcomeUnknown(
"save-current-named-playlist-to",
save.DefinitionId,
message),
LegacySaveCurrentNamedPlaylistToIntent save
when !string.IsNullOrWhiteSpace(save.DefinitionId) =>
_controller.ReportNamedPlaylistCommandFailure(
"save-current-named-playlist-to",
save.DefinitionId,
message),
_ => _controller.ReportError(message)
};
private async Task<LegacyOperatorSnapshot> HandleIntentAsync(
LegacyUiIntent intent,
CancellationToken cancellationToken)
@@ -714,10 +824,14 @@ public sealed partial class MainWindow : Window
cancellationToken),
LegacyDeleteOperatorCatalogIntent =>
await _controller.DeleteOperatorCatalogAsync(cancellationToken),
LegacyRefreshNamedPlaylistsIntent =>
await _controller.RefreshNamedPlaylistsAsync(cancellationToken),
LegacyRefreshNamedPlaylistsIntent refresh =>
await RefreshNamedPlaylistsForDispatchAsync(
refresh.RequestId,
cancellationToken),
LegacySelectNamedPlaylistIntent selection =>
_controller.SelectNamedPlaylist(selection.DefinitionId),
SelectNamedPlaylistForDispatch(
selection.DefinitionId,
selection.RequestId),
LegacyLoadSelectedNamedPlaylistIntent =>
await _controller.LoadSelectedNamedPlaylistAsync(cancellationToken),
LegacyLoadNamedPlaylistByIdIntent load =>
@@ -725,8 +839,9 @@ public sealed partial class MainWindow : Window
load.DefinitionId,
cancellationToken),
LegacyCreateNamedPlaylistIntent create =>
await _controller.CreateNamedPlaylistAsync(
await CreateNamedPlaylistForDispatchAsync(
create.Title,
create.RequestId,
cancellationToken),
LegacySaveCurrentNamedPlaylistIntent =>
await _controller.SaveCurrentNamedPlaylistAsync(cancellationToken),
@@ -734,8 +849,10 @@ public sealed partial class MainWindow : Window
await _controller.SaveCurrentNamedPlaylistToAsync(
save.DefinitionId,
cancellationToken),
LegacyDeleteSelectedNamedPlaylistIntent =>
await _controller.DeleteSelectedNamedPlaylistAsync(cancellationToken),
LegacyDeleteSelectedNamedPlaylistIntent delete =>
await DeleteNamedPlaylistForDispatchAsync(
delete.RequestId,
cancellationToken),
LegacyOpenManualFinancialIntent manual =>
await _controller.OpenManualFinancialAsync(
manual.Screen,
@@ -835,18 +952,280 @@ public sealed partial class MainWindow : Window
};
}
private async Task<LegacyOperatorSnapshot> RefreshNamedPlaylistsForDispatchAsync(
string requestId,
CancellationToken cancellationToken)
{
var state = await _controller.RefreshNamedPlaylistsAsync(cancellationToken);
var succeeded = state.NamedPlaylist?.ReadStatus ==
LegacyNamedPlaylistReadStatus.Ready;
return _controller.CompleteNamedPlaylistDispatch(
"refresh-named-playlists",
requestId,
succeeded);
}
private LegacyOperatorSnapshot SelectNamedPlaylistForDispatch(
string definitionId,
string requestId)
{
var state = _controller.SelectNamedPlaylist(definitionId);
var succeeded = string.Equals(
state.NamedPlaylist?.SelectedDefinitionId,
definitionId,
StringComparison.Ordinal);
return _controller.CompleteNamedPlaylistDispatch(
"select-named-playlist",
requestId,
succeeded);
}
private async Task<LegacyOperatorSnapshot> CreateNamedPlaylistForDispatchAsync(
string title,
string requestId,
CancellationToken cancellationToken)
{
var previousRevision = _controller.Current.NamedPlaylist?.Revision ?? -1;
var state = await _controller.CreateNamedPlaylistAsync(title, cancellationToken);
var succeeded = (state.NamedPlaylist?.Revision ?? -1) > previousRevision &&
IsCommittedNamedPlaylistMutation(state.NamedPlaylist?.LastMutationOutcome);
return _controller.CompleteNamedPlaylistDispatch(
"create-named-playlist",
requestId,
succeeded);
}
private async Task<LegacyOperatorSnapshot> DeleteNamedPlaylistForDispatchAsync(
string requestId,
CancellationToken cancellationToken)
{
var previousRevision = _controller.Current.NamedPlaylist?.Revision ?? -1;
var state = await _controller.DeleteSelectedNamedPlaylistAsync(cancellationToken);
var succeeded = (state.NamedPlaylist?.Revision ?? -1) > previousRevision &&
IsCommittedNamedPlaylistMutation(state.NamedPlaylist?.LastMutationOutcome);
return _controller.CompleteNamedPlaylistDispatch(
"delete-selected-named-playlist",
requestId,
succeeded);
}
private static bool IsCommittedNamedPlaylistMutation(
LegacyNamedPlaylistMutationOutcome? outcome) =>
outcome is LegacyNamedPlaylistMutationOutcome.CommittedFresh or
LegacyNamedPlaylistMutationOutcome.CommittedOptimistic;
private void PostState(LegacyOperatorSnapshot snapshot)
{
if (!_closing && _webViewReady && Browser.CoreWebView2 is not null)
{
// SystemInformation.DragSize was evaluated on every original
// ListView.MouseDown. Refreshing at every native-to-web state boundary,
// in addition to the settings/DPI message hook, prevents a startup-only
// value from surviving a runtime Windows preference change.
RefreshInteractionMetrics();
Browser.CoreWebView2.PostWebMessageAsJson(
LegacyBridgeProtocol.SerializeState(
snapshot,
_intentBusy,
CreatePlayoutSnapshot()));
CreatePlayoutSnapshot(),
interactionMetrics: _interactionMetrics));
}
}
private void AttachInteractionMetricsRefresh()
{
if (_windowSubclassAttached || _windowHandle == 0)
{
return;
}
try
{
_windowSubclassAttached = SetWindowSubclass(
_windowHandle,
_windowSubclassProcedure,
InteractionMetricsSubclassId,
0);
}
catch (DllNotFoundException)
{
}
catch (EntryPointNotFoundException)
{
}
catch (BadImageFormatException)
{
}
catch (PlatformNotSupportedException)
{
}
}
private void DetachInteractionMetricsRefresh()
{
if (!_windowSubclassAttached || _windowHandle == 0)
{
return;
}
try
{
RemoveWindowSubclass(
_windowHandle,
_windowSubclassProcedure,
InteractionMetricsSubclassId);
}
catch (DllNotFoundException)
{
}
catch (EntryPointNotFoundException)
{
}
catch (BadImageFormatException)
{
}
catch (PlatformNotSupportedException)
{
}
finally
{
_windowSubclassAttached = false;
}
}
private nint OnWindowSubclassMessage(
nint windowHandle,
uint message,
nuint wParam,
nint lParam,
nuint subclassId,
nuint referenceData)
{
if (message is WmSettingChange or WmDisplayChange or WmDpiChanged)
{
QueueInteractionMetricsRefresh();
}
return DefSubclassProc(windowHandle, message, wParam, lParam);
}
private void QueueInteractionMetricsRefresh()
{
if (_closing || _interactionMetricsRefreshQueued)
{
return;
}
_interactionMetricsRefreshQueued = true;
if (!DispatcherQueue.TryEnqueue(
Microsoft.UI.Dispatching.DispatcherQueuePriority.High,
() =>
{
_interactionMetricsRefreshQueued = false;
if (_closing)
{
return;
}
RefreshInteractionMetrics();
if (_webViewReady)
{
PostState(_controller.Current);
}
}))
{
_interactionMetricsRefreshQueued = false;
}
}
private void RefreshInteractionMetrics()
{
_interactionMetrics = ReadSystemInteractionMetrics();
}
private LegacyInteractionMetrics ReadSystemInteractionMetrics()
{
try
{
// GetSystemMetrics returns physical pixels and is not DPI-aware.
// Asking the DPI-aware API for its 96-DPI values produces the same
// logical DIP coordinate space used by XAML. The bridge also carries
// XamlRoot.RasterizationScale so JavaScript can account for a zoom
// value retained by an older WebView profile without private API use.
return LegacyInteractionMetrics.FromPixelsAtDpi(
GetSystemMetricsForDpi(
SmCxDrag,
LegacyInteractionMetrics.CssPixelsPerInch),
GetSystemMetricsForDpi(
SmCyDrag,
LegacyInteractionMetrics.CssPixelsPerInch),
LegacyInteractionMetrics.CssPixelsPerInch,
ReadHostRasterizationScale());
}
catch (DllNotFoundException)
{
return CreateFallbackInteractionMetrics();
}
catch (EntryPointNotFoundException)
{
return CreateFallbackInteractionMetrics();
}
catch (BadImageFormatException)
{
return CreateFallbackInteractionMetrics();
}
catch (PlatformNotSupportedException)
{
return CreateFallbackInteractionMetrics();
}
}
private LegacyInteractionMetrics CreateFallbackInteractionMetrics() =>
LegacyInteractionMetrics.Default with
{
HostRasterizationScale = ReadHostRasterizationScale()
};
private double ReadHostRasterizationScale()
{
var scale = Root.XamlRoot?.RasterizationScale ?? 1d;
return double.IsFinite(scale) && scale > 0d ? scale : 1d;
}
[DllImport("user32.dll", ExactSpelling = true)]
private static extern int GetSystemMetricsForDpi(int nIndex, uint dpi);
[DllImport("comctl32.dll", ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetWindowSubclass(
nint windowHandle,
WindowSubclassProcedure subclassProcedure,
nuint subclassId,
nuint referenceData);
[DllImport("comctl32.dll", ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool RemoveWindowSubclass(
nint windowHandle,
WindowSubclassProcedure subclassProcedure,
nuint subclassId);
[DllImport("comctl32.dll", ExactSpelling = true)]
private static extern nint DefSubclassProc(
nint windowHandle,
uint message,
nuint wParam,
nint lParam);
[UnmanagedFunctionPointer(CallingConvention.Winapi)]
private delegate nint WindowSubclassProcedure(
nint windowHandle,
uint message,
nuint wParam,
nint lParam,
nuint subclassId,
nuint referenceData);
private void OnProcessFailed(object? sender, CoreWebView2ProcessFailedEventArgs args)
{
ObserveOperatorMutationQuarantine(
@@ -882,6 +1261,7 @@ public sealed partial class MainWindow : Window
Root.Loaded -= OnRootLoaded;
Closed -= OnClosed;
DetachCloseConfirmation();
DetachInteractionMetricsRefresh();
_lifetimeCancellation.Cancel();
ShutdownPlayoutRuntime();
ShutdownManualListsRuntime();