feat: load legacy Cuts and Res runtime assets

This commit is contained in:
2026-07-18 14:32:04 +09:00
parent da8da06fac
commit 5eb4054120
32 changed files with 2893 additions and 120 deletions

View File

@@ -44,10 +44,7 @@ public sealed class DatabaseOptionsLoader
{
var path = jsonPath ?? DefaultConfigurationPath;
var options = LoadJson(path);
ApplyEnvironmentOverrides(options);
options.Normalize();
options.ValidateRuntimeSettings();
return options;
return ApplyEnvironmentOverridesAndValidate(options);
}
public async Task<DatabaseOptions> LoadAsync(
@@ -56,6 +53,12 @@ public sealed class DatabaseOptionsLoader
{
var path = jsonPath ?? DefaultConfigurationPath;
var options = await LoadJsonAsync(path, cancellationToken).ConfigureAwait(false);
return ApplyEnvironmentOverridesAndValidate(options);
}
internal DatabaseOptions ApplyEnvironmentOverridesAndValidate(DatabaseOptions options)
{
ArgumentNullException.ThrowIfNull(options);
ApplyEnvironmentOverrides(options);
options.Normalize();
options.ValidateRuntimeSettings();

View File

@@ -0,0 +1,287 @@
using System.Security;
using System.Text;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure;
/// <summary>
/// Reads the legacy executable-relative Res\MmoneyCoder.ini contract without
/// exposing any of its values through diagnostics or exception messages.
/// </summary>
public sealed class LegacyIniDatabaseOptionsLoader
{
public const int MaximumFileBytes = 64 * 1024;
private static readonly UTF8Encoding StrictUtf8 =
new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
private static readonly Encoding StrictCp949 = CreateCp949Encoding();
private readonly DatabaseOptionsLoader _optionsLoader;
public LegacyIniDatabaseOptionsLoader(
Func<string, string?>? readEnvironmentVariable = null)
{
_optionsLoader = new DatabaseOptionsLoader(readEnvironmentVariable);
}
public DatabaseOptions Load(string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
byte[] bytes;
try
{
var fullPath = Path.GetFullPath(path);
var attributes = File.GetAttributes(fullPath);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
{
throw new InvalidDataException();
}
using var stream = new FileStream(
fullPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4096,
FileOptions.SequentialScan);
if (stream.Length is <= 0 or > MaximumFileBytes)
{
throw new InvalidDataException();
}
bytes = new byte[checked((int)stream.Length)];
stream.ReadExactly(bytes);
}
catch (Exception exception) when (IsExpectedFileFailure(exception))
{
throw InvalidConfiguration();
}
var text = Decode(bytes);
var values = Parse(text);
var options = CreateOptions(values);
options = _optionsLoader.ApplyEnvironmentOverridesAndValidate(options);
if (!options.IsConfigured(DataSourceKind.Oracle) ||
!options.IsConfigured(DataSourceKind.MariaDb))
{
throw InvalidConfiguration();
}
return options;
}
private static string Decode(byte[] bytes)
{
string text;
try
{
text = StrictUtf8.GetString(bytes);
}
catch (DecoderFallbackException)
{
try
{
text = StrictCp949.GetString(bytes);
}
catch (DecoderFallbackException)
{
throw InvalidConfiguration();
}
}
return text.Length > 0 && text[0] == '\uFEFF'
? text[1..]
: text;
}
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> Parse(
string text)
{
var sections = new Dictionary<string, Dictionary<string, string>>(
StringComparer.Ordinal);
Dictionary<string, string>? current = null;
using var reader = new StringReader(text);
while (reader.ReadLine() is { } sourceLine)
{
var line = sourceLine.Trim();
if (line.Length == 0)
{
continue;
}
if (line.StartsWith("[", StringComparison.Ordinal))
{
if (line.Length < 3 || line[^1] != ']')
{
throw InvalidConfiguration();
}
var sectionName = line[1..^1];
if (!sections.TryGetValue(sectionName, out current))
{
current = new Dictionary<string, string>(StringComparer.Ordinal);
sections.Add(sectionName, current);
}
continue;
}
if (line.StartsWith("//", StringComparison.Ordinal))
{
continue;
}
if (current is null)
{
throw InvalidConfiguration();
}
var separatorIndex = line.IndexOf('=');
if (separatorIndex <= 0)
{
continue;
}
var key = line[..separatorIndex].Trim();
if (key is not ("ConnectionName" or "ID" or "Pass"))
{
continue;
}
var value = line[(separatorIndex + 1)..].Trim();
if (value.Length == 0 || value.Any(char.IsControl))
{
throw InvalidConfiguration();
}
// The original reader keeps the last occurrence in file order.
current[key] = value;
}
return sections.ToDictionary(
static pair => pair.Key,
static pair => (IReadOnlyDictionary<string, string>)pair.Value,
StringComparer.Ordinal);
}
private static DatabaseOptions CreateOptions(
IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> sections)
{
if (!sections.TryGetValue("Oracle", out var oracle) ||
!sections.TryGetValue("Maria", out var maria) ||
!TryGetRequired(oracle, "ConnectionName", out var oracleConnection) ||
!TryGetRequired(oracle, "ID", out var oracleId) ||
!TryGetRequired(oracle, "Pass", out var oraclePassword) ||
!TryGetRequired(maria, "ConnectionName", out var mariaConnection) ||
!TryGetRequired(maria, "ID", out var mariaId) ||
!TryGetRequired(maria, "Pass", out var mariaPassword) ||
!TryParseConnectionName(
oracleConnection,
out var oracleHost,
out var oraclePort,
out var oracleSid) ||
!TryParseConnectionName(
mariaConnection,
out var mariaHost,
out var mariaPort,
out var mariaDatabase))
{
throw InvalidConfiguration();
}
return new DatabaseOptions
{
Oracle = new OracleDatabaseOptions
{
Host = oracleHost,
Port = oraclePort,
Sid = oracleSid,
ServiceName = null,
UserName = oracleId,
Password = oraclePassword
},
MariaDb = new MariaDbDatabaseOptions
{
Host = mariaHost,
Port = mariaPort,
Database = mariaDatabase,
UserName = mariaId,
Password = mariaPassword,
// The legacy MySQL connection string did not request TLS.
TlsMode = MariaDbTlsMode.Disabled
}
};
}
private static bool TryGetRequired(
IReadOnlyDictionary<string, string> section,
string key,
out string value)
{
if (section.TryGetValue(key, out var candidate) &&
!string.IsNullOrWhiteSpace(candidate))
{
value = candidate;
return true;
}
value = string.Empty;
return false;
}
private static bool TryParseConnectionName(
string value,
out string host,
out int port,
out string database)
{
host = string.Empty;
port = 0;
database = string.Empty;
var colonIndex = value.IndexOf(':');
var slashIndex = value.IndexOf('/', colonIndex + 1);
if (colonIndex <= 0 || slashIndex <= colonIndex + 1 ||
slashIndex == value.Length - 1 ||
value.IndexOf(':', colonIndex + 1) >= 0 ||
value.IndexOf('/', slashIndex + 1) >= 0)
{
return false;
}
var parsedHost = value[..colonIndex].Trim();
var portText = value[(colonIndex + 1)..slashIndex].Trim();
var parsedDatabase = value[(slashIndex + 1)..].Trim();
if (parsedHost.Length == 0 || parsedDatabase.Length == 0 ||
parsedHost.Any(character => char.IsWhiteSpace(character) || char.IsControl(character)) ||
parsedDatabase.Any(character => char.IsWhiteSpace(character) || char.IsControl(character)) ||
!int.TryParse(portText, out var parsedPort) ||
parsedPort is < 1 or > 65_535)
{
return false;
}
host = parsedHost;
port = parsedPort;
database = parsedDatabase;
return true;
}
private static Encoding CreateCp949Encoding()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
}
private static bool IsExpectedFileFailure(Exception exception) =>
exception is IOException or UnauthorizedAccessException or SecurityException or
ArgumentException or NotSupportedException;
private static DatabaseConfigurationException InvalidConfiguration() =>
new("실행 폴더의 레거시 데이터베이스 설정을 읽을 수 없습니다.");
}

View File

@@ -30,6 +30,43 @@ public sealed class DatabaseRuntime
return Create(options);
}
public static DatabaseRuntime CreateLegacyExecutableDefault(
string baseDirectory,
string? fallbackConfigurationPath = null,
string? legacyOverridePath = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory);
var legacyIniPath = Path.Combine(
Path.GetFullPath(baseDirectory),
"Res",
"MmoneyCoder.ini");
if (File.Exists(legacyIniPath))
{
return Create(new LegacyIniDatabaseOptionsLoader().Load(legacyIniPath));
}
var localOverride = legacyOverridePath ?? ResolveLegacyOverridePath();
if (!string.IsNullOrWhiteSpace(localOverride) && File.Exists(localOverride))
{
return Create(new LegacyIniDatabaseOptionsLoader().Load(localOverride));
}
return CreateDefault(fallbackConfigurationPath);
}
private static string? ResolveLegacyOverridePath()
{
var localAppData = Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData);
return string.IsNullOrWhiteSpace(localAppData)
? null
: Path.Combine(
localAppData,
"MBN_STOCK_WEBVIEW",
"Res",
"MmoneyCoder.ini");
}
public static DatabaseRuntime Create(DatabaseOptions options)
{
ArgumentNullException.ThrowIfNull(options);

View File

@@ -111,6 +111,8 @@ internal static class LegacyComparisonWorkflowCatalog
public static IReadOnlyList<LegacyComparisonTarget> FixedTargets => FixedTargetList;
internal static IReadOnlyList<LegacyComparisonActionDefinition> Actions => ActionList;
public static LegacyComparisonTarget? GetFixedTarget(string targetId) =>
targetId is not null && FixedById.TryGetValue(targetId, out var target) ? target : null;
@@ -195,13 +197,31 @@ internal static class LegacyComparisonWorkflowCatalog
LegacyComparisonTarget? first,
LegacyComparisonTarget? second)
{
var views = ActionList.Select(action =>
var placements = ActionList
.Select(action => new LegacyComparisonActionPlacement(action.ActionId, action.Section))
.ToArray();
return CreateActionViews(first, second, placements);
}
internal static IReadOnlyList<LegacyComparisonActionView> CreateActionViews(
LegacyComparisonTarget? first,
LegacyComparisonTarget? second,
IReadOnlyList<LegacyComparisonActionPlacement> placements)
{
ArgumentNullException.ThrowIfNull(placements);
var views = placements.Select(placement =>
{
if (!ActionsById.TryGetValue(placement.ActionId, out var action))
{
throw new InvalidOperationException(
"The runtime comparison layout contains an unknown action.");
}
var compatibility = GetCompatibility(action, first, second);
return new LegacyComparisonActionView(
action.ActionId,
action.Label,
action.Section,
placement.Section,
compatibility.IsAvailable,
compatibility.Reason);
}).ToArray();

View File

@@ -11,6 +11,7 @@ public sealed class LegacyComparisonWorkflowController
{
private readonly ILegacyComparisonDataService _dataService;
private readonly ILegacyComparisonPairStore _pairStore;
private readonly LegacyComparisonUiLayout _uiLayout;
private List<SearchResultState> _domesticResults = [];
private List<SearchResultState> _worldResults = [];
private List<SavedPairState> _savedPairs = [];
@@ -38,10 +39,12 @@ public sealed class LegacyComparisonWorkflowController
public LegacyComparisonWorkflowController(
ILegacyComparisonDataService dataService,
ILegacyComparisonPairStore pairStore)
ILegacyComparisonPairStore pairStore,
LegacyComparisonUiLayout? uiLayout = null)
{
_dataService = dataService ?? throw new ArgumentNullException(nameof(dataService));
_pairStore = pairStore ?? throw new ArgumentNullException(nameof(pairStore));
_uiLayout = uiLayout ?? LegacyComparisonUiLayout.Default;
}
public LegacyComparisonWorkflowSnapshot Current => CreateSnapshot();
@@ -606,7 +609,7 @@ public sealed class LegacyComparisonWorkflowController
result.Target,
result.SelectionId))
.ToArray();
var fixedTargets = LegacyComparisonWorkflowCatalog.FixedTargets
var fixedTargets = _uiLayout.FixedTargets
.Select(target => LegacyComparisonWorkflowCatalog.ToView(target, target.TargetId))
.ToArray();
var savedPairs = _savedPairs.Select((pair, index) =>
@@ -645,7 +648,7 @@ public sealed class LegacyComparisonWorkflowController
: LegacyComparisonWorkflowCatalog.ToView(_secondTarget, "comparison-draft-second"),
Array.AsReadOnly(savedPairs),
_selectedPairId,
LegacyComparisonWorkflowCatalog.CreateActionViews(
_uiLayout.CreateActionViews(
selectedPair?.First,
selectedPair?.Second),
_statusMessage,

View File

@@ -259,6 +259,35 @@ public sealed class LegacyFixedActionCatalog
return RuntimeAssets[market];
}
internal LegacyFixedActionCatalog WithRuntimeOrder(
IReadOnlyDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedAction>> actionsByMarket)
{
ArgumentNullException.ThrowIfNull(actionsByMarket);
var ordered = new List<LegacyFixedAction>(Actions.Count);
foreach (var market in Enum.GetValues<LegacyFixedMarket>())
{
var source = actionsByMarket.TryGetValue(market, out var runtimeActions)
? runtimeActions
: _byMarket[market];
if (source is null || source.Count != _byMarket[market].Count ||
source.Any(action => action is null || action.Market != market) ||
!source.Select(action => action.Id).ToHashSet(StringComparer.Ordinal)
.SetEquals(_byMarket[market].Select(action => action.Id)))
{
throw new InvalidDataException(
$"The runtime fixed-action order for '{MarketText(market)}' is invalid.");
}
ordered.AddRange(source);
}
return new LegacyFixedActionCatalog(
ReadOnly(ordered),
RuntimeAssets,
DocumentSha256,
GeneratorSourceSha256);
}
public LegacyMaterializedFixedAction Materialize(
string actionId,
DateTimeOffset localNow)

View File

@@ -70,6 +70,7 @@ public sealed class LegacyIndustrySelectionWorkflow
private readonly IIndustrySelectionService _selectionService;
private readonly TimeProvider _timeProvider;
private readonly LegacyIndustryActionLayout _actionLayout;
private IReadOnlyList<IndustrySelection> _industries = NoIndustries;
private IReadOnlyList<LegacyIndustryActionDefinition> _actions = NoActions;
private IndustryMarket? _market;
@@ -84,11 +85,13 @@ public sealed class LegacyIndustrySelectionWorkflow
public LegacyIndustrySelectionWorkflow(
IIndustrySelectionService selectionService,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
LegacyIndustryActionLayout? actionLayout = null)
{
_selectionService = selectionService ??
throw new ArgumentNullException(nameof(selectionService));
_timeProvider = timeProvider ?? TimeProvider.System;
_actionLayout = actionLayout ?? LegacyIndustryActionLayout.Default;
}
public LegacyIndustrySelectionSnapshot Current => CreateSnapshot();
@@ -104,7 +107,7 @@ public sealed class LegacyIndustrySelectionWorkflow
Interlocked.Increment(ref _loadGeneration);
_market = market;
_industries = NoIndustries;
_actions = LegacyIndustryActionCatalog.GetActions(market);
_actions = _actionLayout.GetActions(market);
_selectedIndex = null;
_selectedIndustry = null;
_firstIndustry = null;
@@ -146,7 +149,7 @@ public sealed class LegacyIndustrySelectionWorkflow
_market = market;
_industries = normalizedRows;
_actions = LegacyIndustryActionCatalog.GetActions(market);
_actions = _actionLayout.GetActions(market);
_selectedIndustry = Reconcile(previousSelected, normalizedRows);
_selectedIndex = _selectedIndustry is null
? null

View File

@@ -0,0 +1,745 @@
using System.Collections.ObjectModel;
using System.Text;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public enum LegacyRuntimeUiCatalogFailure
{
None,
InvalidBaseDirectory,
FileNotFound,
FileUnavailable,
FileTooLarge,
InvalidEncoding,
InvalidFormat,
TooManySections,
TooManyRows,
UnknownEntry,
DuplicateEntry,
MissingEntry
}
public sealed record LegacyRuntimeUiCatalogWarning(
string FileName,
LegacyRuntimeUiCatalogFailure Failure);
public sealed class LegacyIndustryActionLayout
{
private readonly IReadOnlyDictionary<IndustryMarket,
IReadOnlyList<LegacyIndustryActionDefinition>> _actions;
private LegacyIndustryActionLayout(
IReadOnlyDictionary<IndustryMarket,
IReadOnlyList<LegacyIndustryActionDefinition>> actions)
{
_actions = actions;
}
public static LegacyIndustryActionLayout Default { get; } = Create(
LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kospi),
LegacyIndustryActionCatalog.GetActions(IndustryMarket.Kosdaq));
public IReadOnlyList<LegacyIndustryActionDefinition> GetActions(IndustryMarket market) =>
_actions.TryGetValue(market, out var actions)
? actions
: throw new ArgumentOutOfRangeException(
nameof(market), market, "Unsupported industry market.");
internal static LegacyIndustryActionLayout Create(
IReadOnlyList<LegacyIndustryActionDefinition> kospi,
IReadOnlyList<LegacyIndustryActionDefinition> kosdaq)
{
Validate(IndustryMarket.Kospi, kospi);
Validate(IndustryMarket.Kosdaq, kosdaq);
return new LegacyIndustryActionLayout(
new ReadOnlyDictionary<IndustryMarket,
IReadOnlyList<LegacyIndustryActionDefinition>>(
new Dictionary<IndustryMarket,
IReadOnlyList<LegacyIndustryActionDefinition>>
{
[IndustryMarket.Kospi] = Array.AsReadOnly(kospi.ToArray()),
[IndustryMarket.Kosdaq] = Array.AsReadOnly(kosdaq.ToArray())
}));
}
private static void Validate(
IndustryMarket market,
IReadOnlyList<LegacyIndustryActionDefinition> actions)
{
ArgumentNullException.ThrowIfNull(actions);
var expected = LegacyIndustryActionCatalog.GetActions(market);
if (actions.Count != expected.Count ||
actions.Any(action => action is null || action.Market != market) ||
!actions.Select(action => action.ActionId).ToHashSet(StringComparer.Ordinal)
.SetEquals(expected.Select(action => action.ActionId)))
{
throw new InvalidDataException("The runtime industry action layout is invalid.");
}
}
}
internal sealed record LegacyComparisonActionPlacement(string ActionId, string Section);
public sealed class LegacyComparisonUiLayout
{
private readonly IReadOnlyList<string> _fixedTargetIds;
private readonly IReadOnlyList<LegacyComparisonActionPlacement> _actionPlacements;
private LegacyComparisonUiLayout(
IReadOnlyList<string> fixedTargetIds,
IReadOnlyList<LegacyComparisonActionPlacement> actionPlacements)
{
_fixedTargetIds = fixedTargetIds;
_actionPlacements = actionPlacements;
}
public static LegacyComparisonUiLayout Default { get; } = Create(
LegacyComparisonWorkflowCatalog.FixedTargets.Select(target => target.TargetId),
LegacyComparisonWorkflowCatalog.Actions.Select(action =>
new LegacyComparisonActionPlacement(action.ActionId, action.Section)));
internal IReadOnlyList<LegacyComparisonTarget> FixedTargets => _fixedTargetIds
.Select(id => LegacyComparisonWorkflowCatalog.GetFixedTarget(id) ??
throw new InvalidOperationException(
"The runtime comparison layout contains an unknown target."))
.ToArray();
internal IReadOnlyList<LegacyComparisonActionView> CreateActionViews(
LegacyComparisonTarget? first,
LegacyComparisonTarget? second) =>
LegacyComparisonWorkflowCatalog.CreateActionViews(
first,
second,
_actionPlacements);
internal static LegacyComparisonUiLayout Create(
IEnumerable<string> fixedTargetIds,
IEnumerable<LegacyComparisonActionPlacement> actionPlacements)
{
ArgumentNullException.ThrowIfNull(fixedTargetIds);
ArgumentNullException.ThrowIfNull(actionPlacements);
var targetIds = fixedTargetIds.ToArray();
var placements = actionPlacements.ToArray();
var expectedTargetIds = LegacyComparisonWorkflowCatalog.FixedTargets
.Select(target => target.TargetId)
.ToHashSet(StringComparer.Ordinal);
var expectedActionIds = LegacyComparisonWorkflowCatalog.Actions
.Select(action => action.ActionId)
.ToHashSet(StringComparer.Ordinal);
if (targetIds.Length != expectedTargetIds.Count ||
targetIds.Distinct(StringComparer.Ordinal).Count() != targetIds.Length ||
!targetIds.ToHashSet(StringComparer.Ordinal).SetEquals(expectedTargetIds) ||
placements.Length != expectedActionIds.Count ||
placements.Any(placement =>
placement is null ||
string.IsNullOrWhiteSpace(placement.Section) ||
placement.Section.Any(char.IsControl)) ||
placements.Select(placement => placement.ActionId)
.Distinct(StringComparer.Ordinal).Count() != placements.Length ||
!placements.Select(placement => placement.ActionId)
.ToHashSet(StringComparer.Ordinal).SetEquals(expectedActionIds))
{
throw new InvalidDataException("The runtime comparison UI layout is invalid.");
}
return new LegacyComparisonUiLayout(
Array.AsReadOnly(targetIds),
Array.AsReadOnly(placements));
}
}
public sealed record LegacyRuntimeUiCatalogComposition(
LegacyFixedActionCatalog FixedCatalog,
LegacyIndustryActionLayout IndustryLayout,
LegacyComparisonUiLayout ComparisonLayout,
IReadOnlyList<LegacyRuntimeUiCatalogWarning> Warnings)
{
public string? WarningMessage => Warnings.Count == 0
? null
: "런타임 UI INI 일부를 읽지 못해 검증된 내장 목록을 사용합니다: " +
string.Join(", ", Warnings.Select(warning =>
$"{warning.FileName} ({warning.Failure})"));
}
/// <summary>
/// Reads only the six non-secret CP949 UI catalogs next to the executable. Raw INI text
/// controls section and row order, while every executable action remains joined to one
/// pre-registered C# identity. A malformed file can therefore change neither scene aliases
/// nor mutation data and falls back independently to the embedded catalog.
/// </summary>
public static class LegacyRuntimeUiCatalogLoader
{
public const int MaximumFileBytes = 64 * 1024;
public const int MaximumSections = 20;
public const int MaximumRowsPerSection = 50;
public const int MaximumLineLength = 256;
private static readonly Encoding Cp949 = CreateCp949Encoding();
public static LegacyRuntimeUiCatalogComposition ResolveFromBaseDirectory(
string? baseDirectory)
{
var warnings = new List<LegacyRuntimeUiCatalogWarning>();
if (!TryCreateResourceDirectory(baseDirectory, out var resourceDirectory))
{
warnings.Add(new LegacyRuntimeUiCatalogWarning(
"Res",
LegacyRuntimeUiCatalogFailure.InvalidBaseDirectory));
return CreateFallback(warnings);
}
var fixedCatalog = LegacyFixedActionCatalog.Default;
var fixedOrders = new Dictionary<LegacyFixedMarket,
IReadOnlyList<LegacyFixedAction>>();
LoadFixed(
resourceDirectory,
"해외.ini",
LegacyFixedMarket.Overseas,
fixedCatalog,
fixedOrders,
warnings);
LoadFixed(
resourceDirectory,
"환율.ini",
LegacyFixedMarket.Exchange,
fixedCatalog,
fixedOrders,
warnings);
LoadFixed(
resourceDirectory,
"지수.ini",
LegacyFixedMarket.Index,
fixedCatalog,
fixedOrders,
warnings);
fixedCatalog = fixedCatalog.WithRuntimeOrder(fixedOrders);
var kospi = LoadIndustry(
resourceDirectory,
"업종_코스피.ini",
IndustryMarket.Kospi,
warnings);
var kosdaq = LoadIndustry(
resourceDirectory,
"업종_코스닥.ini",
IndustryMarket.Kosdaq,
warnings);
var industryLayout = LegacyIndustryActionLayout.Create(kospi, kosdaq);
var comparisonLayout = LoadComparison(
resourceDirectory,
"종목비교.ini",
warnings);
return new LegacyRuntimeUiCatalogComposition(
fixedCatalog,
industryLayout,
comparisonLayout,
Array.AsReadOnly(warnings.ToArray()));
}
private static LegacyRuntimeUiCatalogComposition CreateFallback(
IReadOnlyList<LegacyRuntimeUiCatalogWarning> warnings) => new(
LegacyFixedActionCatalog.Default,
LegacyIndustryActionLayout.Default,
LegacyComparisonUiLayout.Default,
warnings);
private static void LoadFixed(
string resourceDirectory,
string fileName,
LegacyFixedMarket market,
LegacyFixedActionCatalog catalog,
IDictionary<LegacyFixedMarket, IReadOnlyList<LegacyFixedAction>> orders,
ICollection<LegacyRuntimeUiCatalogWarning> warnings)
{
if (TryReadDocument(
Path.Combine(resourceDirectory, fileName),
out var document,
out var failure) &&
TryJoinRows(
document,
catalog.GetActions(market),
action => action.Section,
action => action.Label,
out var actions,
out failure))
{
orders.Add(market, actions);
return;
}
orders.Add(market, catalog.GetActions(market));
warnings.Add(new LegacyRuntimeUiCatalogWarning(fileName, failure));
}
private static IReadOnlyList<LegacyIndustryActionDefinition> LoadIndustry(
string resourceDirectory,
string fileName,
IndustryMarket market,
ICollection<LegacyRuntimeUiCatalogWarning> warnings)
{
var embedded = LegacyIndustryActionCatalog.GetActions(market);
if (!TryReadDocument(
Path.Combine(resourceDirectory, fileName),
out var document,
out var failure))
{
warnings.Add(new LegacyRuntimeUiCatalogWarning(fileName, failure));
return embedded;
}
var expectedSection = market == IndustryMarket.Kospi ? "코스피" : "코스닥";
if (document.Sections.Count != 1 ||
!string.Equals(
document.Sections[0].Name,
expectedSection,
StringComparison.Ordinal))
{
warnings.Add(new LegacyRuntimeUiCatalogWarning(
fileName,
LegacyRuntimeUiCatalogFailure.InvalidFormat));
return embedded;
}
var expected = embedded.Select(action => new IndustryJoinRow(
expectedSection,
action.Label,
action)).ToArray();
if (!TryJoinRows(
document,
expected,
row => row.Section,
row => row.Label,
out var ordered,
out failure))
{
warnings.Add(new LegacyRuntimeUiCatalogWarning(fileName, failure));
return embedded;
}
return Array.AsReadOnly(ordered
.Select(row => row.Action with { Section = expectedSection })
.ToArray());
}
private static LegacyComparisonUiLayout LoadComparison(
string resourceDirectory,
string fileName,
ICollection<LegacyRuntimeUiCatalogWarning> warnings)
{
if (!TryReadDocument(
Path.Combine(resourceDirectory, fileName),
out var document,
out var failure) ||
!TryCreateComparisonLayout(document, out var layout, out failure))
{
warnings.Add(new LegacyRuntimeUiCatalogWarning(fileName, failure));
return LegacyComparisonUiLayout.Default;
}
return layout;
}
private static bool TryCreateComparisonLayout(
IniDocument document,
out LegacyComparisonUiLayout layout,
out LegacyRuntimeUiCatalogFailure failure)
{
layout = LegacyComparisonUiLayout.Default;
if (document.Sections.Count != 6 ||
!string.Equals(document.Sections[0].Name, "지수", StringComparison.Ordinal))
{
failure = LegacyRuntimeUiCatalogFailure.InvalidFormat;
return false;
}
var targetByLabel = LegacyComparisonWorkflowCatalog.FixedTargets
.ToDictionary(target => target.DisplayName, StringComparer.Ordinal);
var targetIds = new List<string>(targetByLabel.Count);
var targetLabels = new HashSet<string>(StringComparer.Ordinal);
foreach (var label in document.Sections[0].Rows)
{
if (!targetLabels.Add(label))
{
failure = LegacyRuntimeUiCatalogFailure.DuplicateEntry;
return false;
}
if (!targetByLabel.TryGetValue(label, out var target))
{
failure = LegacyRuntimeUiCatalogFailure.UnknownEntry;
return false;
}
targetIds.Add(target.TargetId);
}
if (targetIds.Count != targetByLabel.Count)
{
failure = LegacyRuntimeUiCatalogFailure.MissingEntry;
return false;
}
var actions = LegacyComparisonWorkflowCatalog.Actions;
var directActions = actions
.Where(action => action.Kind != LegacyComparisonActionKind.Return)
.ToDictionary(action => action.Label, StringComparer.Ordinal);
var returnActions = actions
.Where(action => action.Kind == LegacyComparisonActionKind.Return)
.ToArray();
var returnSectionNames = returnActions
.Select(action => action.Section)
.Distinct(StringComparer.Ordinal)
.ToArray();
if (returnSectionNames.Length != 1)
{
failure = LegacyRuntimeUiCatalogFailure.InvalidFormat;
return false;
}
var expectedSectionNames = directActions.Keys
.Append(returnSectionNames[0])
.ToHashSet(StringComparer.Ordinal);
var actualSectionNames = document.Sections.Skip(1)
.Select(section => section.Name)
.ToHashSet(StringComparer.Ordinal);
if (actualSectionNames.Count != expectedSectionNames.Count ||
!actualSectionNames.SetEquals(expectedSectionNames))
{
failure = LegacyRuntimeUiCatalogFailure.UnknownEntry;
return false;
}
var placements = new List<LegacyComparisonActionPlacement>(actions.Count);
var actionIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var section in document.Sections.Skip(1))
{
if (directActions.TryGetValue(section.Name, out var directAction))
{
if (section.Rows.Count != 0)
{
failure = LegacyRuntimeUiCatalogFailure.InvalidFormat;
return false;
}
if (!actionIds.Add(directAction.ActionId))
{
failure = LegacyRuntimeUiCatalogFailure.DuplicateEntry;
return false;
}
placements.Add(new LegacyComparisonActionPlacement(
directAction.ActionId,
section.Name));
continue;
}
if (!string.Equals(
section.Name,
returnSectionNames[0],
StringComparison.Ordinal))
{
failure = LegacyRuntimeUiCatalogFailure.UnknownEntry;
return false;
}
var expectedReturnRows = returnActions.Select(action =>
new ComparisonJoinRow(section.Name, action.Label, action)).ToArray();
var returnDocument = new IniDocument(
Array.AsReadOnly(new[] { section }));
if (!TryJoinRows(
returnDocument,
expectedReturnRows,
row => row.Section,
row => row.Label,
out var orderedReturns,
out failure))
{
return false;
}
foreach (var row in orderedReturns)
{
actionIds.Add(row.Action.ActionId);
placements.Add(new LegacyComparisonActionPlacement(
row.Action.ActionId,
section.Name));
}
}
if (placements.Count != actions.Count || actionIds.Count != actions.Count)
{
failure = LegacyRuntimeUiCatalogFailure.MissingEntry;
return false;
}
try
{
layout = LegacyComparisonUiLayout.Create(targetIds, placements);
failure = LegacyRuntimeUiCatalogFailure.None;
return true;
}
catch (InvalidDataException)
{
failure = LegacyRuntimeUiCatalogFailure.InvalidFormat;
return false;
}
}
private static bool TryJoinRows<T>(
IniDocument document,
IReadOnlyList<T> expected,
Func<T, string> sectionSelector,
Func<T, string> labelSelector,
out IReadOnlyList<T> ordered,
out LegacyRuntimeUiCatalogFailure failure)
{
ordered = Array.Empty<T>();
var expectedSections = expected.Select(sectionSelector)
.ToHashSet(StringComparer.Ordinal);
var actualSections = document.Sections.Select(section => section.Name)
.ToHashSet(StringComparer.Ordinal);
if (document.Sections.Count != expectedSections.Count ||
!actualSections.SetEquals(expectedSections))
{
failure = LegacyRuntimeUiCatalogFailure.InvalidFormat;
return false;
}
var expectedOccurrences = new Dictionary<EntryKey, T>();
var expectedCounts = new Dictionary<EntryPair, int>();
foreach (var value in expected)
{
var pair = new EntryPair(sectionSelector(value), labelSelector(value));
expectedCounts.TryGetValue(pair, out var occurrence);
expectedCounts[pair] = occurrence + 1;
expectedOccurrences.Add(
new EntryKey(pair.Section, pair.Label, occurrence),
value);
}
var result = new List<T>(expected.Count);
var actualCounts = new Dictionary<EntryPair, int>();
foreach (var section in document.Sections)
{
foreach (var label in section.Rows)
{
var pair = new EntryPair(section.Name, label);
actualCounts.TryGetValue(pair, out var occurrence);
actualCounts[pair] = occurrence + 1;
if (!expectedOccurrences.TryGetValue(
new EntryKey(section.Name, label, occurrence),
out var value))
{
failure = expectedCounts.ContainsKey(pair)
? LegacyRuntimeUiCatalogFailure.DuplicateEntry
: LegacyRuntimeUiCatalogFailure.UnknownEntry;
return false;
}
result.Add(value);
}
}
if (result.Count != expected.Count)
{
failure = LegacyRuntimeUiCatalogFailure.MissingEntry;
return false;
}
ordered = Array.AsReadOnly(result.ToArray());
failure = LegacyRuntimeUiCatalogFailure.None;
return true;
}
private static bool TryReadDocument(
string path,
out IniDocument document,
out LegacyRuntimeUiCatalogFailure failure)
{
document = IniDocument.Empty;
byte[] bytes;
try
{
using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
4096,
FileOptions.SequentialScan);
if (stream.Length > MaximumFileBytes)
{
failure = LegacyRuntimeUiCatalogFailure.FileTooLarge;
return false;
}
bytes = new byte[(int)stream.Length];
stream.ReadExactly(bytes);
}
catch (Exception exception) when (
exception is FileNotFoundException or DirectoryNotFoundException)
{
failure = LegacyRuntimeUiCatalogFailure.FileNotFound;
return false;
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException or
ArgumentException or NotSupportedException or
System.Security.SecurityException)
{
failure = LegacyRuntimeUiCatalogFailure.FileUnavailable;
return false;
}
string text;
try
{
text = Cp949.GetString(bytes);
}
catch (DecoderFallbackException)
{
failure = LegacyRuntimeUiCatalogFailure.InvalidEncoding;
return false;
}
return TryParse(text, out document, out failure);
}
private static bool TryParse(
string text,
out IniDocument document,
out LegacyRuntimeUiCatalogFailure failure)
{
document = IniDocument.Empty;
var sections = new List<IniSection>();
var sectionNames = new HashSet<string>(StringComparer.Ordinal);
List<string>? currentRows = null;
using var reader = new StringReader(text);
while (reader.ReadLine() is { } sourceLine)
{
var line = sourceLine.Trim();
if (line.Length == 0)
{
continue;
}
if (line.Length > MaximumLineLength ||
line.Any(character => char.IsControl(character) || char.IsSurrogate(character)))
{
failure = LegacyRuntimeUiCatalogFailure.InvalidFormat;
return false;
}
if (line[0] == '[')
{
if (line.Length < 3 || line[^1] != ']' ||
line.AsSpan(1, line.Length - 2).Contains('[') ||
line.AsSpan(1, line.Length - 2).Contains(']'))
{
failure = LegacyRuntimeUiCatalogFailure.InvalidFormat;
return false;
}
var name = line[1..^1].Trim();
if (name.Length == 0 || !sectionNames.Add(name))
{
failure = name.Length == 0
? LegacyRuntimeUiCatalogFailure.InvalidFormat
: LegacyRuntimeUiCatalogFailure.DuplicateEntry;
return false;
}
if (sections.Count >= MaximumSections)
{
failure = LegacyRuntimeUiCatalogFailure.TooManySections;
return false;
}
currentRows = [];
sections.Add(new IniSection(name, currentRows));
continue;
}
if (currentRows is null)
{
failure = LegacyRuntimeUiCatalogFailure.InvalidFormat;
return false;
}
if (currentRows.Count >= MaximumRowsPerSection)
{
failure = LegacyRuntimeUiCatalogFailure.TooManyRows;
return false;
}
currentRows.Add(line);
}
if (sections.Count == 0)
{
failure = LegacyRuntimeUiCatalogFailure.InvalidFormat;
return false;
}
document = new IniDocument(Array.AsReadOnly(sections.ToArray()));
failure = LegacyRuntimeUiCatalogFailure.None;
return true;
}
private static bool TryCreateResourceDirectory(
string? baseDirectory,
out string resourceDirectory)
{
resourceDirectory = string.Empty;
if (string.IsNullOrWhiteSpace(baseDirectory) ||
baseDirectory.Any(char.IsControl) ||
!Path.IsPathFullyQualified(baseDirectory))
{
return false;
}
try
{
resourceDirectory = Path.Combine(Path.GetFullPath(baseDirectory), "Res");
return true;
}
catch (Exception exception) when (
exception is ArgumentException or IOException or NotSupportedException or
System.Security.SecurityException)
{
return false;
}
}
private static Encoding CreateCp949Encoding()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
}
private sealed record IniSection(string Name, IReadOnlyList<string> Rows);
private sealed record IniDocument(IReadOnlyList<IniSection> Sections)
{
public static IniDocument Empty { get; } = new(Array.Empty<IniSection>());
}
private sealed record IndustryJoinRow(
string Section,
string Label,
LegacyIndustryActionDefinition Action);
private sealed record ComparisonJoinRow(
string Section,
string Label,
LegacyComparisonActionDefinition Action);
private readonly record struct EntryPair(string Section, string Label);
private readonly record struct EntryKey(string Section, string Label, int Occurrence);
}

View File

@@ -23,9 +23,55 @@
<ApplicationDisplayVersion>0.1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<Version>0.1.0</Version>
<!--
Keep the legacy runtime assets outside this repository while reproducing the
original executable-folder layout. Override this property at build time when
the read-only MBN_STOCK_N checkout is in a different location:
/p:LegacyRuntimeSourceRoot="D:\path\to\MBN_STOCK_N\bin\Debug"
-->
<LegacyRuntimeSourceRoot Condition="'$(LegacyRuntimeSourceRoot)' == ''">$(MSBuildProjectDirectory)\..\..\..\MBN_STOCK_N\MBN_STOCK_N\bin\Debug</LegacyRuntimeSourceRoot>
<LegacyRuntimeSourceRoot>$([System.IO.Path]::GetFullPath('$(LegacyRuntimeSourceRoot)'))</LegacyRuntimeSourceRoot>
<LegacyCutsSourceRoot>$(LegacyRuntimeSourceRoot)\Cuts</LegacyCutsSourceRoot>
<LegacyResSourceRoot>$(LegacyRuntimeSourceRoot)\Res</LegacyResSourceRoot>
</PropertyGroup>
<ItemGroup>
<!-- Explicit allowlist: these legacy Res assets contain UI/menu data only. -->
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\a.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\aa.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\dot.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\Find_16x16.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\Find_32x32.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\green.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\Grow.bmp" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\logo.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\MmoneyCoder.ico" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\MoveDown_16x16.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\MoveUp_16x16.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\P.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\pie.bmp" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\Preview.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\pro#00083.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\profit.bmp" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\red.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\sample_1.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\sell.bmp" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\setup.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\t.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\VRIPNG.png" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\기타.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\업종.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\업종_코스닥.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\업종_코스피.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\종목.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\종목_0111.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\종목비교.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\지수.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\지수_0318.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\지수_250401.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\해외.ini" />
<LegacyPackagedResAsset Include="$(LegacyResSourceRoot)\환율.ini" />
<Compile Include="..\..\LegacySceneRuntimeFactory.cs"
Link="Runtime\LegacySceneRuntimeFactory.cs" />
<Content Include="..\..\Assets\LockScreenLogo.scale-200.png" Link="Assets\LockScreenLogo.scale-200.png" />
@@ -39,9 +85,99 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="$(LegacyCutsSourceRoot)\**\*">
<Link>Cuts\%(RecursiveDir)%(Filename)%(Extension)</Link>
<TargetPath>Cuts\%(RecursiveDir)%(Filename)%(Extension)</TargetPath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="@(LegacyPackagedResAsset)">
<Link>Res\%(Filename)%(Extension)</Link>
<TargetPath>Res\%(Filename)%(Extension)</TargetPath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<!--
The active legacy DB INI contains credentials. It is available beside the
executable for local F5/unpackaged development only and is never MSIX Content.
Historical copies, backups, archives and afiedt.buf.txt are not included at all.
-->
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
<Target Name="ValidateLegacyRuntimeAssets" BeforeTargets="PrepareForBuild">
<Error Condition="!Exists('$(LegacyRuntimeSourceRoot)')"
Text="Legacy runtime source root was not found: $(LegacyRuntimeSourceRoot). Set /p:LegacyRuntimeSourceRoot to the read-only MBN_STOCK_N bin\Debug directory." />
<Error Condition="!Exists('$(LegacyCutsSourceRoot)')"
Text="Legacy Cuts source directory was not found: $(LegacyCutsSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)')"
Text="Legacy Res source directory was not found: $(LegacyResSourceRoot)." />
<Error Condition="!Exists('$(LegacyCutsSourceRoot)\5001.t2s')"
Text="The required legacy scene file Cuts\5001.t2s is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="'$(GenerateAppxPackageOnBuild)' != 'true' and
'$(PublishAppxPackage)' != 'true' and
!Exists('$(LegacyResSourceRoot)\MmoneyCoder.ini')"
Text="The required legacy database settings file Res\MmoneyCoder.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\종목.ini')"
Text="The required legacy stock menu file Res\종목.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\업종_코스피.ini')"
Text="The required legacy KOSPI industry menu file Res\업종_코스피.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\업종_코스닥.ini')"
Text="The required legacy KOSDAQ industry menu file Res\업종_코스닥.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\해외.ini')"
Text="The required legacy overseas menu file Res\해외.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\환율.ini')"
Text="The required legacy exchange menu file Res\환율.ini is missing from $(LegacyRuntimeSourceRoot)." />
<Error Condition="!Exists('$(LegacyResSourceRoot)\지수.ini')"
Text="The required legacy index menu file Res\지수.ini is missing from $(LegacyRuntimeSourceRoot)." />
</Target>
<!--
Do not declare the credential-bearing INI as Content, None, or another item
harvested by PackagingOutputs. The MSIX tooling harvests every
CopyToOutputDirectory item into its loose-package recipe, even when
GenerateAppxPackageOnBuild is false. This post-build copy provides the original
executable-relative layout only for ordinary local output. The CPS-only
up-to-date items below are not package payload candidates.
-->
<ItemGroup>
<!-- These CPS-only items participate in Visual Studio's fast up-to-date check. -->
<UpToDateCheckInput Include="$(LegacyResSourceRoot)\MmoneyCoder.ini" />
<UpToDateCheckBuilt Include="$(TargetDir)Res\MmoneyCoder.ini" />
</ItemGroup>
<Target Name="CopyLegacyDatabaseIniToLocalOutput"
AfterTargets="Build"
Inputs="$(LegacyResSourceRoot)\MmoneyCoder.ini"
Outputs="$(TargetDir)Res\MmoneyCoder.ini"
Condition="'$(GenerateAppxPackageOnBuild)' != 'true' and '$(PublishAppxPackage)' != 'true'">
<MakeDir Directories="$(TargetDir)Res" />
<Copy SourceFiles="$(LegacyResSourceRoot)\MmoneyCoder.ini"
DestinationFiles="$(TargetDir)Res\MmoneyCoder.ini"
SkipUnchangedFiles="true" />
<ItemGroup>
<FileWrites Include="$(TargetDir)Res\MmoneyCoder.ini" />
</ItemGroup>
</Target>
<!-- The credential-bearing local development copy must not survive Clean. -->
<Target Name="RemoveLegacyDatabaseIniFromLocalOutput"
BeforeTargets="Clean">
<Delete Files="$(TargetDir)Res\MmoneyCoder.ini"
Condition="Exists('$(TargetDir)Res\MmoneyCoder.ini')" />
</Target>
<Target Name="RejectLegacyDatabaseIniFromPackagePayload"
BeforeTargets="_ComputeAppxPackagePayload">
<ItemGroup>
<_ForbiddenLegacyDatabasePackagePayload Include="@(PackagingOutputs)"
Condition="'%(PackagingOutputs.TargetPath)' == 'Res\MmoneyCoder.ini' or
'%(PackagingOutputs.TargetPath)' == 'Res/MmoneyCoder.ini'" />
</ItemGroup>
<Error Condition="'@(_ForbiddenLegacyDatabasePackagePayload)' != ''"
Text="Credential-bearing Res\MmoneyCoder.ini must never enter an MSIX payload or package recipe." />
</Target>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260317003" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3967.48" />

View File

@@ -57,6 +57,8 @@ public sealed partial class MainWindow : Window
var cutMenuComposition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates(
runtimeCutMenuOverridePath,
AppContext.BaseDirectory);
var runtimeUiCatalogs = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory(
AppContext.BaseDirectory);
ILegacyStockLookup stockLookup;
IIndustrySelectionService industrySelectionService;
@@ -76,7 +78,8 @@ public sealed partial class MainWindow : Window
string? initializationError = null;
try
{
_databaseRuntime = DatabaseRuntime.CreateDefault();
_databaseRuntime = DatabaseRuntime.CreateLegacyExecutableDefault(
AppContext.BaseDirectory);
DataQueryExecutor.Configure(_databaseRuntime.Executor);
var databaseMutationAuthorization =
new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization);
@@ -163,7 +166,9 @@ public sealed partial class MainWindow : Window
DataQueryExecutor.Reset();
}
_industryWorkflow = new LegacyIndustrySelectionWorkflow(industrySelectionService);
_industryWorkflow = new LegacyIndustrySelectionWorkflow(
industrySelectionService,
actionLayout: runtimeUiCatalogs.IndustryLayout);
var overseasWorkflow = new LegacyOverseasSelectionWorkflow(
overseasIndustrySearchService,
overseasStockSearchService);
@@ -177,6 +182,7 @@ public sealed partial class MainWindow : Window
var manualListsWorkflow = CreateManualListsWorkflow(stockLookup);
_controller = new LegacyOperatorController(
stockLookup,
fixedCatalog: runtimeUiCatalogs.FixedCatalog,
industryWorkflow: _industryWorkflow,
themeWorkflow: new LegacyThemeWorkflow(themeSelectionService),
expertWorkflow: new LegacyExpertWorkflowController(expertSelectionService),
@@ -184,7 +190,8 @@ public sealed partial class MainWindow : Window
tradingHaltSelectionService),
comparisonWorkflow: new LegacyComparisonWorkflowController(
new LegacyComparisonDataService(stockLookup, worldStockSearchService),
comparisonStore),
comparisonStore,
runtimeUiCatalogs.ComparisonLayout),
manualFinancialWorkflow: new LegacyManualFinancialWorkflow(
manualFinancialService,
manualFinancialStockSearchService),
@@ -205,9 +212,15 @@ public sealed partial class MainWindow : Window
{
_controller.ReportError(initializationError);
}
else if (cutMenuComposition.WarningMessage is { } cutMenuWarning)
else if (new[]
{
cutMenuComposition.WarningMessage,
runtimeUiCatalogs.WarningMessage
}
.Where(message => message is not null)
.ToArray() is { Length: > 0 } catalogWarnings)
{
_controller.ReportWarning(cutMenuWarning);
_controller.ReportWarning(string.Join(" ", catalogWarnings));
}
InitializePlayoutRuntime();

View File

@@ -23,6 +23,7 @@ public static class PlayoutOptionsLoader
{
var options = LoadJson(path ?? DefaultPath);
ApplyEnvironment(options);
ApplyExecutableAssetDefaults(options, AppContext.BaseDirectory);
return options;
}
@@ -42,6 +43,36 @@ public static class PlayoutOptionsLoader
RequiredLiveAuthorizationValue,
StringComparison.Ordinal);
internal static void ApplyExecutableAssetDefaults(
PlayoutOptions options,
string? baseDirectory)
{
ArgumentNullException.ThrowIfNull(options);
if (!string.IsNullOrWhiteSpace(options.SceneDirectory) ||
string.IsNullOrWhiteSpace(baseDirectory))
{
return;
}
try
{
var cutsDirectory = Path.Combine(
Path.GetFullPath(baseDirectory),
"Cuts");
if (Directory.Exists(cutsDirectory))
{
options.SceneDirectory = cutsDirectory;
}
}
catch (Exception exception) when (
exception is ArgumentException or IOException or NotSupportedException or
System.Security.SecurityException)
{
// A missing or invalid executable asset root leaves the existing safe
// configuration unchanged; validation reports it if a command needs it.
}
}
private static PlayoutOptions LoadJson(string path)
{
if (!File.Exists(path))