메인 창은 WindowStyle=None + WindowChrome 으로 크롬을 직접 그린다. 그 조합에서 OS 는 최대화 사각형을 작업영역이 아니라 모니터 전체를 기준으로 잡고 리사이즈 프레임만큼 더 부풀린다. 실측으로 창이 (-7,-7)~(1927,1087) — 작업영역 (0,0)~(1920,1032) 를 55px 넘겨 작업표시줄이 통째로 가려졌다. 표준 크롬 창에는 없는 문제라 WindowStyle=None 창에만 건다. WM_GETMINMAXINFO 훅 하나로 답한다(Services/MaximizeToWorkArea.cs). 레이아웃·XAML·테마는 건드리지 않았다. 최대화 상태에서 콘텐츠가 밀려 보인다면 답은 Margin 보정이 아니라 이 훅의 계산이 틀린 것이다 — 창 사각형이 정확히 작업영역이면 잘릴 곳이 없다. 같이 처리한 것들: · 최소 크기가 최대화를 이기는 경우. OS 는 최대화 크기를 최소 트래킹 크기까지 되밀어 올린다. 그 값은 우리 뒤에 도는 WPF 가 MinWidth/MinHeight(940x640 DIP)로 채우므로, 작업영역이 그보다 작으면 창이 도로 부풀어 작업표시줄을 다시 덮는다 — 1366x768 을 125% 로 쓰면 최소 높이 800px 에 작업영역 720px 다. 그 경우에만 WPF 의 처리를 막고 트래킹 크기를 직접 채운다. 화면에 안 들어가는 최소 크기는 최소 크기 구실을 못 한다. 보통은 handled=false 로 두어 MinWidth/MinHeight 가 그대로 산다. · 최대화 중 상단 6px 리사이즈 띠. 전에는 창이 화면 밖으로 밀려 있어 그 띠가 안 보였다. 이제 타이틀바 위로 올라와 닫기 버튼 윗변이 먹혔다(실측 y=1·3·5 에서 HTTOP). 최대화된 창은 어차피 가장자리로 크기를 못 바꾸므로 그동안 띠를 걷는다. 여백 보정이 아니라 히트테스트 영역 보정이다 — 창 사각형은 그대로 작업영역이다. · 자동 숨김 작업표시줄. 자동 숨김이면 작업영역이 모니터 전체와 같아지고, 그대로 꽉 채우면 셸이 전체 화면 앱으로 보아 가장자리 호버에 반응하지 않는다 — 작업표시줄이 영영 안 나온다. 네 변을 각각 ABM_GETAUTOHIDEBAREX 로 보고 1px 씩 물러선다. 폴백은 '한 변도 못 찾았을 때'가 아니라 '그 변을 못 찾았을 때' 돈다(좌측 서드파티 바 하나 때문에 하단 작업표시줄이 구제받지 못하던 구조였다). 폴백이 쓰는 두 API 는 주 작업표시줄 전용이라 보조 모니터는 못 덮는다 — 주석을 그렇게 바로잡았다. · 재부착 가드. 최대화 중에 두 번 붙으면 그때의 0 을 '복원 시 두께'로 기억해 복원해도 가장자리를 끌 수 없는 창이 됐다. 진단 종료 코드가 죽어 있었다. 기본 ShutdownMode 는 OnLastWindowClose 라, 창을 띄웠다 전부 닫는 진단은 마지막 창이 닫히는 순간 WPF 가 먼저 Shutdown(0) 을 걸고 뒤이은 Shutdown(코드) 인자를 무시한다 — 리포트에 '실패 1건' 이 찍혀도 프로세스는 0 을 돌려줬다(--maxrect 첫 실행이 그랬다). --edit-smoke·--db-smoke 도 같은 구멍이었다. 진단 분기 진입부에서 OnExplicitShutdown 으로 바꿨다. 같은 자리에서 진단 모드 UI 예외도 모달 대신 로그+Shutdown(3) 으로 돌린다 — '진단 모드에서는 모달을 띄우지 않는다'는 규칙을 그보다 먼저 등록되는 예외 핸들러가 깨고 있었다. 신규 진단 --maxrect (10건). 기대값을 베껴 적지 않고 성질을 단언한다 — 모니터 안에 들어가는가, 자동 숨김 없는 변은 작업영역과 같은가, 있는 변은 물러섰는가, 작업표시줄과 겹치지 않는가, 최소 크기가 최대화 크기를 넘지 않는가. 전사(轉寫)하면 같은 착각을 양쪽이 공유하고 양보 폭을 1→2 로 바꾸는 순간 거짓 실패가 난다. 보내는 버퍼는 센티넬로 채운다 — 0 으로 두면 기대값이 (0,0) 인 모니터에서 훅이 아무것도 안 써도 통과한다(실제로 한 번 그렇게 통과했다). 창을 실제로 띄워 최대화·복원까지 해 OS 가 답을 지키는지와 StateChanged 배선까지 본다. 그 경로에서 화면이 한 번 번쩍인다. 실측(1920x1080·96DPI·하단 고정 작업표시줄): 최대화 (-7,-7)~(1927,1087) → (0,0)~(1920,1032), 침범 55px → 0px 복원 1440x920 유지, 최소 크기 940x640 유지 최대화 상단 히트테스트 HTTOP → HTCAPTION 자동 숨김·다중 모니터·125%/150% 배율은 이 개발기에서 재현할 수 없다. 그 환경에서 --maxrect 를 한 번 돌려 봐야 한다. 게이트: 테스트 230/230, --edit-smoke 0건, --query-popup 17/17, --db-smoke 3000 diff 0·예외 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일, --dialog-shots 51개, --maxrect 10/10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
374 lines
16 KiB
C#
374 lines
16 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 서식생성기 애플리케이션 진입점.
|
|
/// 명령행: --render-smoke <입력.xml> <출력.png> — 첫 페이지를 오프스크린 렌더해 PNG 저장(검증용) 후 종료.
|
|
/// </summary>
|
|
public partial class App : Application
|
|
{
|
|
/// <summary>
|
|
/// 기동 — 진단 모드 분기, 아니면 HIS 세션을 확정한 뒤 메인 셸 표시.
|
|
/// HIS 기동 규약: <c>SheetMe.Designer.exe "UidCod,ComNum,ShtCod"</c> (콤마 구분 단일 인자).
|
|
/// </summary>
|
|
/// <summary>Dispatcher 예외 폭주 차단용 — 10초 내 5회면 강제 종료(무한 팝업 루프 방지)</summary>
|
|
private readonly Queue<DateTime> recentCrashes = new();
|
|
|
|
/// <summary>진단 플래그로 기동했는가 — 이 모드에서는 어떤 경로로도 모달을 띄우지 않는다</summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// UI 스레드 미처리 예외 — 기록 후 계속 진행한다. 편집 중 문서를 예외 하나로 잃지 않게 하려는 것이며,
|
|
/// 같은 예외가 반복되면(10초 내 5회) 무한 팝업 루프이므로 강제 종료한다.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>진단 플래그 분기 — 종료 코드를 반환한다(호출부가 Shutdown 처리)</summary>
|
|
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 >= 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;
|
|
}
|
|
|
|
/// <summary>첫 페이지 오프스크린 렌더 → PNG (렌더 파이프라인 자동 검증)</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// DB 서식 오프스크린 렌더 → PNG (Spread 격자 주입 경로 포함 검증).
|
|
/// 페이지가 여럿이면 <c>이름_p1.png</c>, <c>이름_p2.png</c> … 로 전부 뽑는다 —
|
|
/// 운영 서식 128건이 다중 페이지라 첫 장만 봐서는 검증이 되지 않는다.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
}
|