using System.IO; using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; using SheetMe.Designer.Services; using SheetMe.Designer.Views; namespace SheetMe.Designer; /// /// 서식생성기 애플리케이션 진입점. /// 명령행: --render-smoke <입력.xml> <출력.png> — 첫 페이지를 오프스크린 렌더해 PNG 저장(검증용) 후 종료. /// public partial class App : Application { /// /// 기동 — 진단 모드 분기, 아니면 HIS 세션을 확정한 뒤 메인 셸 표시. /// HIS 기동 규약: SheetMe.Designer.exe "UidCod,ComNum,ShtCod" (콤마 구분 단일 인자). /// /// Dispatcher 예외 폭주 차단용 — 10초 내 5회면 강제 종료(무한 팝업 루프 방지) private readonly Queue recentCrashes = new(); /// 진단 플래그로 기동했는가 — 이 모드에서는 어떤 경로로도 모달을 띄우지 않는다 private bool diagnosticMode; protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); // 진단 분기보다 먼저 등록한다 — 스모크 중 발생한 예외도 로그에 남아야 한다 Services.AppLog.Initialize(); DispatcherUnhandledException += OnDispatcherUnhandledException; AppDomain.CurrentDomain.UnhandledException += (_, args) => Services.AppLog.Error("처리되지 않은 예외(도메인)", args.ExceptionObject as Exception ?? new Exception(args.ExceptionObject?.ToString())); System.Threading.Tasks.TaskScheduler.UnobservedTaskException += (_, args) => { Services.AppLog.Error("관측되지 않은 Task 예외", args.Exception); args.SetObserved(); }; if (Services.StartupArguments.IsDiagnostic(e.Args)) { diagnosticMode = true; // 기본 ShutdownMode 는 OnLastWindowClose 다. 창을 띄웠다 전부 닫는 진단 // (--maxrect, --edit-smoke, --dialog-shots …)은 마지막 창을 닫는 순간 WPF 가 먼저 // Shutdown(0) 을 걸어 버리고, 뒤이은 Shutdown(RunDiagnostic(...)) 인자는 무시된다 — // 리포트에 '실패 3건' 이 찍혀도 프로세스는 0 을 돌려준다(실제로 그 상태였다). // 스크립트가 종료코드로 판정하면 실패를 성공으로 읽는다. ShutdownMode = ShutdownMode.OnExplicitShutdown; Services.UserSession.InitializeDiagnostic(); Shutdown(RunDiagnostic(e.Args)); return; } var launch = Services.StartupArguments.Parse(e.Args); var config = Services.ConfigService.Current; if (!Services.SessionBootstrap.TryInitialize(launch, config.ConnectionString, config.DevUidCod, out var error)) { // 순정 MessageBox 유지 — 테마 대화상자로 바꾸지 말 것. // (1) 이 시점엔 창이 하나도 없다. 기본 ShutdownMode 는 OnLastWindowClose 라 여기서 Window 를 // 띄우면 그 창이 앱의 유일한 창이 되고, 닫는 순간 WPF 가 자동 종료를 시작해 바로 아래 // Shutdown(2) 의 종료코드가 유실될 수 있다(런처가 실패를 성공으로 읽는다). // (2) ThemeManager.LoadSaved() 가 아직 아래(:54)라 라이트 사용자에게도 다크 창이 뜬다. // (3) '앱이 뜨지 못한다'를 알리는 마지막 통로다 — 알림 수단이 앱 상태에 의존하면 안 된다. MessageBox.Show(error, "서식생성기", MessageBoxButton.OK, MessageBoxImage.Error); Shutdown(2); return; } Services.ThemeManager.LoadSaved(); // 제목 표시줄은 OS 가 그린다 — 창이 만들어질 때마다 앱 테마에 맞춰 준다. // 창마다 손으로 넣으면 새 대화상자에서 반드시 빠지므로 클래스 단위로 한 번만 건다. EventManager.RegisterClassHandler(typeof(Window), Window.LoadedEvent, new RoutedEventHandler((sender, _) => { if (sender is Window window) { Services.WindowChromeTheme.Apply(window, !Services.ThemeManager.IsLight); } })); var main = new MainView(); if (launch?.ShtCod.Length > 0 && main.DataContext is ViewModels.MainViewModel viewModel) { // XAML DataContext 는 InitializeComponent 안에서 이미 생성되므로 Show 전에 넣을 수 있다. // MainView/MainViewModel 생성자를 건드리지 않는 최소 경로. viewModel.PendingSheetCode = launch.ShtCod; } main.Show(); } /// /// UI 스레드 미처리 예외 — 기록 후 계속 진행한다. 편집 중 문서를 예외 하나로 잃지 않게 하려는 것이며, /// 같은 예외가 반복되면(10초 내 5회) 무한 팝업 루프이므로 강제 종료한다. /// private void OnDispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e) { var code = Services.AppLog.Error("처리되지 않은 예외(UI)", e.Exception); // 진단 모드에서는 절대 모달을 띄우지 않는다 — 아래 MessageBox 는 사람이 누르기 전까지 돌아오지 않아 // 무인 실행이 타임아웃까지 매달린다. 이 핸들러가 진단 분기보다 먼저 등록되므로(로그를 남기려고) // RunDiagnostic 의 "모달 금지" 규칙을 여기서도 지켜야 앞뒤가 맞는다. if (diagnosticMode) { e.Handled = true; Console.Error.WriteLine($"진단 중 UI 예외 — 오류 코드: {code}"); Shutdown(3); return; } var now = DateTime.UtcNow; recentCrashes.Enqueue(now); while (recentCrashes.Count > 0 && (now - recentCrashes.Peek()).TotalSeconds > 10) { recentCrashes.Dequeue(); } // 아래 두 알림은 순정 MessageBox 를 유지한다 — 테마 대화상자로 바꾸지 말 것. // 여기는 이미 예외가 터진 자리다. WPF 창을 새로 만들면(템플릿 해석·DynamicResource·렌더) // 그 과정이 다시 던져 같은 핸들러로 재진입한다 — 무한 팝업을 막으려는 코드가 원인이 된다. // MessageBox 는 Win32 호출이라 WPF 렌더 스택에 의존하지 않는다. if (recentCrashes.Count >= 5) { e.Handled = true; MessageBox.Show("반복되는 오류로 프로그램을 종료합니다.\n로그를 확인해 주세요.\n오류 코드: " + code, "서식생성기", MessageBoxButton.OK, MessageBoxImage.Error); Shutdown(3); return; } // Handled 를 알림보다 먼저 세운다 — 알림이 던지면 '복구 가능한 예외'가 하드 크래시로 바뀐다 e.Handled = true; MessageBox.Show( $"오류가 발생했지만 작업은 계속할 수 있습니다.\n저장하지 않은 내용이 있으면 먼저 저장해 주세요.\n\n오류 코드: {code}", "서식생성기", MessageBoxButton.OK, MessageBoxImage.Warning); } /// 진단 플래그 분기 — 종료 코드를 반환한다(호출부가 Shutdown 처리) private int RunDiagnostic(string[] args) { if (args.Length >= 3 && args[0] == "--render-smoke") { return RunRenderSmoke(args[1], args[2]); } if (args.Length >= 2 && args[0] == "--edit-smoke") { return Diagnostics.EditSmoke.Run(args[1]); } if (args.Length >= 2 && args[0] == "--db-smoke") { var max = args.Length >= 3 && int.TryParse(args[2], out var n) ? n : 30; return Diagnostics.DbSmoke.RunReadSmoke(args[1], max); } if (args.Length >= 3 && args[0] == "--db-save-smoke") { return Diagnostics.DbSmoke.RunSaveSmoke(args[1], args[2]); } if (args.Length >= 3 && args[0] == "--db-find") { return Diagnostics.DbSmoke.RunFind(args[1], args[2]); } if (args.Length >= 3 && args[0] == "--db-columns") { return Diagnostics.DbSmoke.RunColumns(args[1], args[2]); } if (args.Length >= 3 && args[0] == "--db-row") { return Diagnostics.DbSmoke.RunRow(args[1], args[2]); } if (args.Length >= 2 && args[0] == "--db-tags") { return Diagnostics.DbSmoke.RunTagUsageReport(args[1]); } if (args.Length >= 2 && args[0] == "--db-phase3") { return Diagnostics.DbSmoke.RunPhase3Report(args[1]); } if (args.Length >= 2 && args[0] == "--db-dtf") { return Diagnostics.DbSmoke.RunDataTableFieldReport(args[1]); } if (args.Length >= 2 && args[0] == "--db-lines") { return Diagnostics.DbSmoke.RunLineReport(args[1]); } if (args.Length >= 2 && args[0] == "--db-props") { return Diagnostics.DbSmoke.RunPropUsageReport(args[1]); } if (args.Length >= 2 && args[0] == "--db-shtmst") { return Diagnostics.DbSmoke.RunShtMstReport(args[1]); } if (args.Length >= 3 && args[0] == "--db-sample") { return Diagnostics.DbSmoke.RunSample(args[1], args[2]); } if (args.Length >= 3 && args[0] == "--db-word-smoke") { return Diagnostics.DbSmoke.RunWordSmoke(args[1], args[2]); } if (args.Length >= 3 && args[0] == "--db-spd") { return Diagnostics.DbSmoke.RunSpreadDump(args[1], args[2]); } if (args.Length >= 2 && args[0] == "--db-colors") { return Diagnostics.DbSmoke.RunColorReport(args[1]); } if (args.Length >= 2 && args[0] == "--db-pageprops") { return Diagnostics.DbSmoke.RunPagePropsReport(args[1]); } if (args.Length >= 3 && args[0] == "--db-xml") { return Diagnostics.DbSmoke.RunDesignXml(args[1], args[2]); } if (args.Length >= 3 && args[0] == "--db-render") { return RunDbRenderSmoke(args[1], args[2]); } if (args.Length >= 2 && args[0] == "--db-gate") { return Diagnostics.DbSmoke.RunGateReport(args[1]); } if (args.Length >= 2 && args[0] == "--db-cls") { return Diagnostics.DbSmoke.RunClassReport(args[1]); } if (args.Length >= 3 && args[0] == "--db-tables") { return Diagnostics.DbSmoke.RunTableSearch(args[1], args[2]); } if (args.Length >= 2 && args[0] == "--query-popup") { return Diagnostics.DbSmoke.RunQueryPopupCheck(args[1]); } if (args.Length >= 2 && args[0] == "--db-trial") { return Diagnostics.DbSmoke.RunQueryTrial(args[1]); } if (args.Length >= 2 && args[0] == "--dialog-shots") { return Diagnostics.DialogShots.Run(args[1]); } if (args.Length >= 2 && args[0] == "--snap-shots") { return Diagnostics.SnapShots.Run(args[1]); } if (args.Length >= 1 && args[0] == "--maxrect") { return Diagnostics.MaximizeCheck.Run(args.Length >= 2 ? args[1] : null); } // 진단 모드에서는 모달을 띄우지 않는다 — 스크립트 옵션 오타 하나로 무인 실행이 여기서 // 영원히 멈춘다. WinExe 라 콘솔이 없어 stderr 는 호출자가 리다이렉트할 때만 보이므로 // 로그를 함께 남긴다(--db-render 가 .err.txt 로 남기는 것과 같은 이유). Services.AppLog.Warn($"알 수 없는 진단 옵션입니다: {args[0]}"); Console.Error.WriteLine($"알 수 없는 진단 옵션입니다: {args[0]}"); return 2; } /// 첫 페이지 오프스크린 렌더 → PNG (렌더 파이프라인 자동 검증) private static int RunRenderSmoke(string xmlPath, string pngPath) { try { var store = new Data.Stores.XmlFileFormStore(); var document = store.LoadFile(xmlPath); var page = DocumentMapper.CreatePage(document.Pages[0], 0); var view = new PageView { DataContext = page }; var size = new Size(page.WidthDip, page.HeightDip); view.Measure(size); view.Arrange(new Rect(size)); view.UpdateLayout(); var bitmap = new RenderTargetBitmap( (int)Math.Ceiling(size.Width), (int)Math.Ceiling(size.Height), 96, 96, PixelFormats.Pbgra32); bitmap.Render(view); var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap)); using var stream = File.Create(pngPath); encoder.Save(stream); Console.WriteLine($"렌더 스모크 완료: {pngPath} ({size.Width}x{size.Height}, " + $"페이지 {document.Pages.Count}, 경고 {document.Meta.ReadWarnings.Count})"); return 0; } catch (Exception ex) { Console.Error.WriteLine($"렌더 스모크 실패: {ex}"); return 1; } } /// /// DB 서식 오프스크린 렌더 → PNG (Spread 격자 주입 경로 포함 검증). /// 페이지가 여럿이면 이름_p1.png, 이름_p2.png … 로 전부 뽑는다 — /// 운영 서식 128건이 다중 페이지라 첫 장만 봐서는 검증이 되지 않는다. /// private static int RunDbRenderSmoke(string shtCod, string pngPath) { try { var dataBusiness = new DataBusiness.FormDesignDataBusiness(); var document = dataBusiness.OpenFromDb(shtCod); if (document is null) { File.WriteAllText(pngPath + ".err.txt", $"활성 디자인 없음: {shtCod}"); return 1; } var designer = new ViewModels.DesignerViewModel(document, dataBusiness.LoadSpreadGrids(shtCod)); var directory = Path.GetDirectoryName(pngPath) ?? "."; var stem = Path.GetFileNameWithoutExtension(pngPath); for (var i = 0; i < designer.Pages.Count; i++) { var page = designer.Pages[i]; var view = new PageView { DataContext = page }; var size = new Size(page.WidthDip, page.HeightDip); view.Measure(size); view.Arrange(new Rect(size)); view.UpdateLayout(); var bitmap = new RenderTargetBitmap( (int)Math.Ceiling(size.Width), (int)Math.Ceiling(size.Height), 96, 96, PixelFormats.Pbgra32); bitmap.Render(view); var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap)); var target = designer.Pages.Count == 1 ? pngPath : Path.Combine(directory, $"{stem}_p{i + 1}.png"); using var stream = File.Create(target); encoder.Save(stream); } Console.WriteLine($"{shtCod}: 페이지 {designer.Pages.Count} 렌더 완료"); return 0; } catch (Exception ex) { File.WriteAllText(pngPath + ".err.txt", ex.ToString()); return 1; } } }