feat: complete legacy parity and modernize operator UI

This commit is contained in:
2026-07-22 12:34:46 +09:00
parent fc4007d676
commit 939c252d23
149 changed files with 26515 additions and 1736 deletions

View File

@@ -1,4 +1,6 @@
using System.Runtime.InteropServices;
using CorePagePlanProvider =
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.ILegacyScenePagePlanProvider;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
@@ -22,6 +24,7 @@ public sealed partial class MainWindow : Window
private readonly CancellationTokenSource _lifetimeCancellation = new();
private readonly SemaphoreSlim _intentGate = new(1, 1);
private readonly SemaphoreSlim _orderedLocalSelectionIntentGate = new(1, 1);
private readonly SemaphoreSlim _orderedPlayoutIntentGate = new(1, 1);
private readonly PlayoutLaunchAuthorization _playoutLaunchAuthorization;
private readonly WindowSubclassProcedure _windowSubclassProcedure;
@@ -86,11 +89,13 @@ public sealed partial class MainWindow : Window
IWorldStockSearchService worldStockSearchService;
IManualFinancialScreenService manualFinancialService;
IStockSearchService manualFinancialStockSearchService;
IOperatorCatalogStockSearchService operatorCatalogStockSearchService;
INamedPlaylistPersistenceService namedPlaylistPersistenceService;
IThemeCatalogPersistenceService themeCatalogPersistenceService;
IExpertCatalogPersistenceService expertCatalogPersistenceService;
IStockMasterIdentityValidationService stockMasterIdentityValidationService;
IOperatorCatalogSchemaValidationService operatorCatalogSchemaValidationService;
CorePagePlanProvider? fixedPagePlanProvider = null;
string? initializationError = null;
try
{
@@ -107,6 +112,8 @@ public sealed partial class MainWindow : Window
legacyOverridePath: selectedLegacyDatabaseIni,
preferLegacyOverride: selectedLegacyDatabaseIni is not null);
DataQueryExecutor.Configure(_databaseRuntime.Executor);
fixedPagePlanProvider = LegacySceneRuntimeFactory.CreatePagePlanProvider(
_databaseRuntime.Executor);
var databaseMutationAuthorization =
new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization);
stockLookup = new LegacyParityStockSearchService(_databaseRuntime.Executor);
@@ -131,8 +138,11 @@ public sealed partial class MainWindow : Window
_databaseRuntime.Options.Resilience,
errorDetector: null,
mutationAuthorization: databaseMutationAuthorization));
manualFinancialStockSearchService = new LegacyStockSearchService(
var sharedStockSearchService = new LegacyStockSearchService(
_databaseRuntime.Executor);
manualFinancialStockSearchService = sharedStockSearchService;
operatorCatalogStockSearchService =
new LegacyOperatorCatalogStockSearchService(sharedStockSearchService);
namedPlaylistPersistenceService = new LegacyNamedPlaylistPersistenceService(
_databaseRuntime.Executor,
new OracleNamedPlaylistMutationExecutor(
@@ -179,6 +189,8 @@ public sealed partial class MainWindow : Window
initializationError);
manualFinancialStockSearchService = new UnavailableStockSearchService(
initializationError);
operatorCatalogStockSearchService =
new UnavailableOperatorCatalogStockSearchService(initializationError);
namedPlaylistPersistenceService = new UnavailableNamedPlaylistPersistenceService(
initializationError);
themeCatalogPersistenceService = new UnavailableThemeCatalogPersistenceService(
@@ -205,6 +217,12 @@ public sealed partial class MainWindow : Window
"Data",
"종목비교.dat"),
Path.Combine(AppContext.BaseDirectory, "Data", "종목비교.dat"));
ILegacyComparisonPairImportService? comparisonImporter =
initializationError is not null || _databaseRuntime is null
? null
: new LegacyComparisonPairImportService(
_databaseRuntime.Executor,
TrustedLegacyComparisonFileSource.CreateDefault());
var manualListsWorkflow = CreateManualListsWorkflow(stockLookup);
_controller = new LegacyOperatorController(
stockLookup,
@@ -217,7 +235,8 @@ public sealed partial class MainWindow : Window
comparisonWorkflow: new LegacyComparisonWorkflowController(
new LegacyComparisonDataService(stockLookup, worldStockSearchService),
comparisonStore,
runtimeUiCatalogs.ComparisonLayout),
runtimeUiCatalogs.ComparisonLayout,
comparisonImporter),
manualFinancialWorkflow: new LegacyManualFinancialWorkflow(
manualFinancialService,
manualFinancialStockSearchService),
@@ -230,10 +249,11 @@ public sealed partial class MainWindow : Window
expertSelectionService,
themeCatalogPersistenceService,
expertCatalogPersistenceService,
manualFinancialStockSearchService,
operatorCatalogStockSearchService,
stockMasterIdentityValidationService,
operatorCatalogSchemaValidationService),
cutRows: cutMenuComposition.Rows);
cutRows: cutMenuComposition.Rows,
fixedPagePlanProvider: fixedPagePlanProvider);
if (initializationError is not null)
{
_controller.ReportError(initializationError);
@@ -324,6 +344,18 @@ public sealed partial class MainWindow : Window
LegacyBridgeProtocol.TrustedHost,
webRoot,
CoreWebView2HostResourceAccessKind.DenyCors);
// The package keeps one persistent WebView2 profile while Debug MSIX
// payloads can be replaced without changing their public URL/version.
// Ignore that profile's HTTP cache for this WebView session so every
// launch reads index.html and all relative assets from this payload.
await coreWebView.CallDevToolsProtocolMethodAsync(
"Network.setCacheDisabled",
"{\"cacheDisabled\":true}");
if (_closing)
{
return;
}
coreWebView.Settings.AreDevToolsEnabled =
System.Diagnostics.Debugger.IsAttached;
coreWebView.Settings.AreBrowserAcceleratorKeysEnabled = false;
@@ -412,6 +444,7 @@ public sealed partial class MainWindow : Window
private async Task ProcessIntentAsync(LegacyUiIntent intent)
{
var gateEntered = false;
var orderedLocalSelectionIntentEntered = false;
var orderedPlayoutIntentEntered = false;
var databaseGateEntered = false;
var dispatchStarted = false;
@@ -428,10 +461,24 @@ public sealed partial class MainWindow : Window
_lifetimeCancellation.Token);
if (orderedPlayoutIntentEntered)
{
await _orderedLocalSelectionIntentGate.WaitAsync(
_lifetimeCancellation.Token);
orderedLocalSelectionIntentEntered = true;
await _intentGate.WaitAsync(_lifetimeCancellation.Token);
gateEntered = true;
}
}
else if (IsQueuedLocalSelectionIntent(intent))
{
// WinForms handled these gestures on its one UI message queue. Keep
// their arrival order and let an in-progress automatic refresh finish
// instead of silently losing clicks, double-clicks, or keyboard focus.
await _orderedLocalSelectionIntentGate.WaitAsync(
_lifetimeCancellation.Token);
orderedLocalSelectionIntentEntered = true;
await _intentGate.WaitAsync(_lifetimeCancellation.Token);
gateEntered = true;
}
else
{
// Keep the zero-timeout admission used by database and edit intents;
@@ -500,6 +547,15 @@ public sealed partial class MainWindow : Window
if (IsOperatorMutationIntent(intent) && IsOperatorMutationQuarantined())
{
if (intent is LegacyCutPointerDownIntent cutPointer)
{
// A ListView click is still a selection gesture even when its
// second click could have appended a row. Preserve selection
// and focus while stripping append authority in quarantine.
PostState(SelectCutWithoutAppend(cutPointer));
return;
}
PostState(ReportIntentFailure(
intent,
"이전 저장 결과가 불명확하여 이번 실행의 추가 변경이 차단되었습니다. DB 또는 로컬 파일을 읽기 전용으로 대조한 뒤 앱을 다시 시작하세요."));
@@ -516,11 +572,22 @@ public sealed partial class MainWindow : Window
return;
}
if (IsOperatorMutationIntent(intent) && !CanAcceptOperatorMutation())
if (IsOperatorMutationIntent(intent) &&
!CanAcceptOperatorMutation(intent, out var mutationRejectionMessage))
{
if (intent is LegacyCutPointerDownIntent cutPointer)
{
// An automatic refresh can start after the Web snapshot enabled
// the list but before native admission. Do not drop that click:
// apply selection/focus only and prevent a double-click append
// until the known busy interval has ended.
PostState(SelectCutWithoutAppend(cutPointer));
return;
}
PostState(ReportIntentFailure(
intent,
"PREPARE 또는 송출 중에는 플레이리스트와 운영 데이터를 변경할 수 없습니다. TAKE OUT 후 다시 시도하세요."));
mutationRejectionMessage));
return;
}
@@ -545,6 +612,8 @@ public sealed partial class MainWindow : Window
!(searchIntent.Trigger == LegacySearchTrigger.Button &&
searchIntent.Text.Length == 0)) ||
intent is LegacySelectTabIntent or
LegacyActivateFixedActionIntent or
LegacyActivateFixedSectionIntent or
LegacySearchThemesIntent or LegacySelectThemeIntent or
LegacyActivateThemeResultIntent or
LegacySearchExpertsIntent or LegacySelectExpertIntent or
@@ -561,6 +630,7 @@ public sealed partial class MainWindow : Window
LegacySearchOperatorCatalogIntent or
LegacySelectOperatorCatalogResultIntent or
LegacyBeginCreateThemeCatalogIntent or
LegacyChangeCreateThemeMarketIntent or
LegacyBeginCreateExpertCatalogIntent or
LegacySearchOperatorCatalogStocksIntent or
LegacyActivateOperatorCatalogStockIntent or
@@ -569,6 +639,7 @@ public sealed partial class MainWindow : Window
LegacySaveOperatorCatalogIntent or
LegacyConfirmNativeDialogIntent or
LegacyAddComparisonPairIntent or
LegacyImportComparisonPairsIntent or
LegacyMoveComparisonPairIntent or
LegacyDeleteSelectedComparisonPairIntent or
LegacyDeleteAllComparisonPairsIntent or
@@ -579,6 +650,7 @@ public sealed partial class MainWindow : Window
LegacyActivateManualFinancialResultIntent or
LegacySearchManualFinancialStocksIntent or
LegacySaveManualFinancialIntent or
LegacySaveManualFinancialRawIntent or
LegacyDeleteManualFinancialIntent or
LegacyDeleteAllManualFinancialIntent or
LegacyOpenManualListIntent or
@@ -658,6 +730,11 @@ public sealed partial class MainWindow : Window
_intentGate.Release();
}
if (orderedLocalSelectionIntentEntered)
{
_orderedLocalSelectionIntentGate.Release();
}
if (orderedPlayoutIntentEntered)
{
_orderedPlayoutIntentGate.Release();
@@ -665,9 +742,31 @@ public sealed partial class MainWindow : Window
}
}
private LegacyOperatorSnapshot SelectCutWithoutAppend(
LegacyCutPointerDownIntent pointer) =>
_controller.CutPointerDown(
pointer.PhysicalIndex,
pointer.Control,
pointer.Shift,
pointer.X,
pointer.Y,
pointer.TimestampMilliseconds,
allowAppend: false);
private static bool IsOrderedPlayoutIntent(LegacyUiIntent intent) => intent is
LegacyExecutePlayoutIntent or LegacyGateAPrepareIntent;
private static bool IsQueuedLocalSelectionIntent(LegacyUiIntent intent) => intent is
LegacySelectStockIntent or
LegacySelectOperatorCatalogStockIntent or
LegacyActivateOperatorCatalogStockIntent or
LegacyCutPointerDownIntent or
LegacyCutKeyDownIntent or
LegacyClearCutSelectionIntent or
LegacySelectPlaylistRowIntent or
LegacyActivatePlaylistRowIntent or
LegacySelectPlaylistBoundaryIntent;
private static bool IsOperatorSettingsIntent(LegacyUiIntent intent) => intent is
LegacyChooseOperatorFolderIntent or
LegacyResetOperatorFolderIntent or
@@ -878,6 +977,8 @@ public sealed partial class MainWindow : Window
_controller.ClearComparisonTargets(),
LegacyAddComparisonPairIntent =>
await _controller.AddComparisonPairAsync(cancellationToken),
LegacyImportComparisonPairsIntent =>
_controller.RequestLegacyComparisonPairImport(),
LegacySelectComparisonPairIntent selection =>
_controller.SelectComparisonPair(selection.RowId),
LegacyMoveComparisonPairIntent move =>
@@ -961,6 +1062,11 @@ public sealed partial class MainWindow : Window
await _controller.BeginCreateThemeCatalogAsync(
create.Market,
cancellationToken),
LegacyChangeCreateThemeMarketIntent change =>
await _controller.ChangeCreateThemeCatalogMarketAsync(
change.Market,
change.EditorName,
cancellationToken),
LegacyBeginCreateExpertCatalogIntent =>
await _controller.BeginCreateExpertCatalogAsync(cancellationToken),
LegacySearchOperatorCatalogStocksIntent search =>
@@ -1030,6 +1136,7 @@ public sealed partial class MainWindow : Window
LegacySaveCurrentNamedPlaylistToIntent save =>
await _controller.SaveCurrentNamedPlaylistToAsync(
save.DefinitionId,
save.ShowSavedAlert,
cancellationToken),
LegacyDeleteSelectedNamedPlaylistIntent delete =>
await DeleteNamedPlaylistForDispatchAsync(
@@ -1073,6 +1180,10 @@ public sealed partial class MainWindow : Window
await _controller.SaveManualFinancialAsync(
save.Record,
cancellationToken),
LegacySaveManualFinancialRawIntent save =>
await _controller.SaveManualFinancialRawAsync(
save.Record,
cancellationToken),
LegacyDeleteManualFinancialIntent =>
await _controller.DeleteManualFinancialAsync(
all: false,
@@ -1358,6 +1469,9 @@ public sealed partial class MainWindow : Window
// 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.
// The original WinForms controls also followed GetDoubleClickTime;
// carrying it through the same settings-change path prevents a valid
// slow Windows double-click from being split into a single click.
return LegacyInteractionMetrics.FromPixelsAtDpi(
GetSystemMetricsForDpi(
SmCxDrag,
@@ -1366,7 +1480,8 @@ public sealed partial class MainWindow : Window
SmCyDrag,
LegacyInteractionMetrics.CssPixelsPerInch),
LegacyInteractionMetrics.CssPixelsPerInch,
ReadHostRasterizationScale());
hostRasterizationScale: ReadHostRasterizationScale(),
doubleClickTimeMilliseconds: (int)GetDoubleClickTime());
}
catch (DllNotFoundException)
{
@@ -1401,6 +1516,9 @@ public sealed partial class MainWindow : Window
[DllImport("user32.dll", ExactSpelling = true)]
private static extern int GetSystemMetricsForDpi(int nIndex, uint dpi);
[DllImport("user32.dll", ExactSpelling = true)]
private static extern uint GetDoubleClickTime();
[DllImport("comctl32.dll", ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetWindowSubclass(
@@ -1502,6 +1620,12 @@ public sealed partial class MainWindow : Window
public bool CanMutate => false;
public Task<ThemeCatalogStoredResult> ReadStoredAsync(
ThemeSelectionIdentity identity,
CancellationToken cancellationToken = default) =>
Task.FromException<ThemeCatalogStoredResult>(
new InvalidOperationException(_message));
public Task<bool> ThemeNameExistsAsync(
ThemeMarket market,
string themeTitle,
@@ -1818,6 +1942,28 @@ public sealed partial class MainWindow : Window
Task.FromException<StockSearchResult>(new InvalidOperationException(_message));
}
private sealed class UnavailableOperatorCatalogStockSearchService :
IOperatorCatalogStockSearchService
{
private readonly string _message;
public UnavailableOperatorCatalogStockSearchService(string message)
{
_message = message;
}
public Task<StockSearchResult> SearchAsync(
string query,
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromException<StockSearchResult>(new InvalidOperationException(_message));
public Task<StockSearchResult> SearchAllAsync(
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
CancellationToken cancellationToken = default) =>
Task.FromException<StockSearchResult>(new InvalidOperationException(_message));
}
private sealed class UnavailableOverseasStockSearchService : IOverseasStockSearchService
{
private readonly string _message;