From a5411d13b020f03f2e15938103dc1aab192a9cae Mon Sep 17 00:00:00 2001 From: choiminyeong Date: Mon, 21 Sep 2026 22:36:26 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=EC=B0=BD=20=EC=B6=94=EA=B0=80=20+=20Shell?= =?UTF-8?q?.exe=20=EC=A7=81=EC=A0=91=20=EC=8B=A4=ED=96=89=20=EC=8B=9C=20?= =?UTF-8?q?=EB=9F=B0=EC=B2=98=EB=A1=9C=20=EB=84=98=EA=B8=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- dist/README.txt | 2 +- dist/UbisamBase.Core.dll | Bin 681472 -> 681472 bytes dist/UbisamBase.Launcher.exe | Bin 102912 -> 111104 bytes dist/UbisamBase.Shell.exe | Bin 86016 -> 86528 bytes dist/platform-version.json | 2 +- doc/history/changelog.md | 2 + doc/platform-guide.md | 10 +- src/UbisamBase.Launcher/PlatformUpdater.cs | 298 ++++++++++++++---- src/UbisamBase.Launcher/ProgressWindow.xaml | 63 ++++ .../ProgressWindow.xaml.cs | 112 +++++++ src/UbisamBase.Shell/App.xaml.cs | 52 +++ 11 files changed, 468 insertions(+), 73 deletions(-) create mode 100644 src/UbisamBase.Launcher/ProgressWindow.xaml create mode 100644 src/UbisamBase.Launcher/ProgressWindow.xaml.cs diff --git a/dist/README.txt b/dist/README.txt index 45689cb..86810a6 100644 --- a/dist/README.txt +++ b/dist/README.txt @@ -1,5 +1,5 @@ UbisamBase 플랫폼 배포 폴더 (빌드할 때 자동 생성 - 직접 고치지 마세요) -갱신 시각 : 2026-09-21 22:26:56 +갱신 시각 : 2026-09-21 22:36:26 빌드 구성 : Release 원본 소스 : D:\업무\03_개발\Source\용접기\08_UbisamPlatform\ ---- diff --git a/dist/UbisamBase.Core.dll b/dist/UbisamBase.Core.dll index 3aa85c12e0e529a897a207fa52f76c1ce393892e..d2c32e79e2b615e578f0c3dd0e6460712f93a887 100644 GIT binary patch delta 146 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 DLMrbS delta 8908 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 diff --git a/dist/UbisamBase.Shell.exe b/dist/UbisamBase.Shell.exe index 4f2e26d27e8194fc4127a34fb55b3d8432f59935..a4be4963e8c9251b3a4d854e0e16fabeeeae0a33 100644 GIT binary patch delta 2269 zcmZ`*Yiv}<75--K-MjWK-0ZH6?HC&tHeN3V8|=bSlbW_EOEUI@%w4xB%=`d9fC&tajxi`58=4L~D_O+vG~)3YxzmH_&1-QkOp_W1_*&$vQBiRmQr(VLSyPaVzO)b1_MVJgK?tw zW<#lnGK>;By3J(c+CVBsImP@!9!(D}PqCuVIK-)G`v`V5*5jx8m0XYvpDt z9nEfw8Z7sI4yjf`Ql^%3XkIcGNYWB@T}*&0s#Y!|zyw9Z28jo#OC~129so?{FPo1l z$>XjaY$>-fMo3zzc8*=!$|2U+;noq|6>5<#bA?Q;^goenYPSs_aSX_tK!gzznTl5^ z9Vn{>E8M5EV(wS0^i!?dxH;r1Dd z7o2Uv-{tGl##vK2?_vlGn`Hwl>(^cZZTS!yU?X8Do6pco6{Biag|Q@__Y*_tB}VXIjYA6t^uz?Q9f!4ioS49@ zmWMTFbs{g~lw8ll`lGoKSI}g94zJ_5xsA9PJMk9wV6Yb=^X@FR^WYP}_G#{i7}3{Kar*p~ z!TE)|s^oiZR*QT%*t8EWVq`IvAp1N18Fd@TRSItD$zE*Q?&hmS=V4?Hmn+V6&+tx6 zmvgRHEKFj5&Mj4(Qf1W5=AAtDcoT(@LMe|g7K#oIIFGcPua;#ImP18At&eWt9vLs|HNS| zTbc5IQ1?pDyiUJ^S+7ubOW0j4Av0C2 zucPZ7*qkjpgF3eB9fr#Pao77QpX7Ta*83lZnwm#>L8~YshbcJlcr6{?3CI6)XnW+y z-08EK{KGq68NB-2eNSA!<3AcsPwX<{u)~nC342-WOCpvH1P$AkJkOY8#t|!+5VjqJ zA#FQa4S<79O4?>z2=xlaoY+w_C}KmwxDE^cO-GzO(J0$%XF;75b61|46J=_ND!OvG-5djW_H@@zH~FJbfd&Lwq1+Hw6C& D5{a&@ delta 1553 zcmY*ZTWpj?6#mZqyMNn%%XYiHut0%rx0mfDWlPam>q}`9s1@5#3KoLU))pI)Lc3_t zz^*odDujR&B^3#1AyItLq=_Xy5RCP~q^3S-qA!GmX!=6>KzRWZ{bqUzp6q;czB6ae zoO5ROPha<^ulX;Gm87@N{e~sEhY|!N0fcFIL9F$3;^NKSYI8y>DficG0(R-1f*Rc| z72dfJJ)#_8{h?G#zWz=ssDlB2$ zr=IT}P)@ZzfoLqS4*Jcn3bg}j79Dfe=Ju6ox8_-dp|tv+eAVH%G{MwpD}DL0BtczF~t z$x8^kam*=OVfUGeVWt})bTj!f>a3lt*fNL33cGnj-A_p!mt2%%r}Gx^ro2NeQYYz9 zgfu#$=AhG&2TV%v$_g7LJG#Qg$WklJteszBCdn(7P2($7Np@>-4G-Cql){{)pWum{ zXP!@1HoB$)x_>EIc%THiL>Fs_9_ol8G!P?bvi5G`dK|O*31SQ%SiRL^k3D)Ep4xThW8RoDSj{wTEbqsX!I(NG$^R2(`$=W#T$~PmJM5Vl{q6GwKnQ zW?n{A`mEl!q&K2XMy+MkT1L^xS2STQ6V@_8%edS@sswB09z#WmZ75^6I;n$U4H`=e zd}%h~e{!D}Z(j4V>|=^Xp0>S)U+i0X+P06cXnBvF;}+{J@*hPpO$Xk@7+?CLm>uO> zGS}*|%;@BJ=6=zh*Pz?I+s@A8M|5MLO^*jAb!Yao!KNhty}iei{kwXG5B2MBvl}Ca z`jZE?_Kftm3=a4AC5MJj*r2VOF3q<|u|B*i9=fC>>)b$1`|Y1z{o;p>^Aq~|s`}C$ zP87iaRJbcx5bl=neqV-!o7||9U~Uheh7^UHbRxJh;<)Sm%H=Opxylz6@%stu{rXDq zoa<)~0bQAM&7Uc}%*j7l4?C3D1p5;jMLz?E7YFSLdrk@oY0e(9JVO^VF z5~>^Db9uv&N87J{_Wrqdrj=5 n(W4iO3)MhE#|!?x6l#~5(O}_JuuvX1sIkOUZimdv#YDz`folO1 diff --git a/dist/platform-version.json b/dist/platform-version.json index 71e6d80..d3ccb19 100644 --- a/dist/platform-version.json +++ b/dist/platform-version.json @@ -1,4 +1,4 @@ { -"version": "2026-09-21 22:26:56", +"version": "2026-09-21 22:36:26", "configuration": "Release" } diff --git a/doc/history/changelog.md b/doc/history/changelog.md index a5a0542..d53fa56 100644 --- a/doc/history/changelog.md +++ b/doc/history/changelog.md @@ -16,3 +16,5 @@ | 2026-09-21 | 업데이트 안내창을 플랫폼 디자인으로 교체(Launcher 자체 XAML, 시스템 테마 따름) | | 2026-09-21 | `ApiService` 추가 — 사내 API를 앱이 대신 호출(토큰은 설정 파일에만, 호출 간격 제한·타임아웃 포함) | | 2026-09-21 | 차트 종류별 디자인 정리 — 면적·계단선·누적/가로 막대·원형·도넛·산점 추가, 계열 팔레트(Chart.Series1~6)와 툴팁·범례 테마 적용 | +| 2026-09-21 | 업데이트 진행 창 추가 — 런처를 누르자마자 확인/받기 상태와 진행률을 보여주고, 다 받은 뒤 설치 여부를 묻는다 | +| 2026-09-21 | Shell.exe를 직접 실행하면 런처로 넘기도록 — 진입점을 런처 하나로 통일(업데이트 확인 누락 방지) | diff --git a/doc/platform-guide.md b/doc/platform-guide.md index a7268e2..2dfcff2 100644 --- a/doc/platform-guide.md +++ b/doc/platform-guide.md @@ -665,9 +665,17 @@ dist의 버전이 지금 것보다 새것인가? → 아니면 커밋만 자동 업데이트인가? → 예: 바로 받기 / 아니오: "업데이트할까요?" 물어보고 예일 때만 받기 ``` +- 확인·받기는 몇 초가 걸리므로 그동안 **진행 창**이 뜬다(확인 중 → 새 버전을 찾았습니다 → 받기 완료, + 진행률은 git이 흘리는 값을 그대로 읽어 표시한다). 받기가 **끝난 뒤에** 설치 여부를 묻는다 — + "예"를 누르고 또 기다리지 않게 하기 위함이다. - 어느 단계에서 막히든(설정 꺼짐 · 네트워크 없음 · git 미설치 · 저장소 접근 실패) **조용히 넘어가고 기존 버전으로 실행한다.** 업데이트 때문에 프로그램이 안 켜지는 일은 없어야 하기 때문이다. -- 물어봤을 때 "아니오"를 고르면 아무것도 기록하지 않는다 — 다음에 켤 때 다시 물어본다. +- 물어봤을 때 "나중에"를 고르면 아무것도 기록하지 않는다 — 다음에 켤 때 다시 물어본다. +- 확인 과정은 `D:\UbisamPlatform\update-log.txt`에 한 줄씩 남는다(실행할 때마다 덮어씀). + "왜 업데이트 창이 안 뜨지?" 싶을 때 이 파일을 먼저 본다. +- **프로그램의 진입점은 항상 런처다.** 앱 폴더에는 `UbisamBase.Shell.exe`도 같이 있지만 그것을 직접 + 실행하면 갱신 없이 옛 파일로 뜨게 되므로, Shell은 자기가 직접 실행된 것을 알아채면 조용히 런처를 + 띄우고 종료한다(`App.RedirectToLauncher`). 바로가기를 만들 때도 런처를 가리켜야 한다. - 받는 것은 `dist` 폴더뿐이다(`--depth 1 --filter=blob:none --sparse`). 소스 이력은 받지 않는다. - git 자격 증명 창이 뜨지 않게 막아 두었다(`GIT_TERMINAL_PROMPT=0`). 사내 비공개 저장소라면 그 PC에서 한 번은 `git` 자격 증명이 저장돼 있어야 한다. diff --git a/src/UbisamBase.Launcher/PlatformUpdater.cs b/src/UbisamBase.Launcher/PlatformUpdater.cs index 7b0dcb9..8cda912 100644 --- a/src/UbisamBase.Launcher/PlatformUpdater.cs +++ b/src/UbisamBase.Launcher/PlatformUpdater.cs @@ -4,7 +4,9 @@ using System.IO; using System.Net.NetworkInformation; using System.Text; using System.Text.RegularExpressions; +using System.Threading; using System.Windows; +using System.Windows.Threading; namespace UbisamBase.Launcher; @@ -13,10 +15,15 @@ namespace UbisamBase.Launcher; /// 새 버전이면 받아서 덮어쓴다. 순서는 이렇다. /// /// -/// 설정에서 켜져 있나? → 인터넷 연결됐나? → 저장소에 새 커밋이 있나? → 그 안의 업데이트 파일이 -/// 지금 것보다 새 버전인가? → 자동 업데이트면 바로 받기 / 아니면 물어보고 "예"일 때만 받기 +/// 설정에서 켜져 있나? → 인터넷 연결됐나? → (진행 창 표시) 저장소에 새 커밋이 있나? +/// → 업데이트 파일 받기(진행률 표시) → 지금 것보다 새 버전인가? +/// → 자동 업데이트면 바로 설치 / 아니면 물어보고 "예"일 때만 설치 /// /// +/// 받는 동안 를 띄운다 — 예전에는 아무것도 안 보여서 몇 초 동안 +/// 프로그램이 안 켜지는 것처럼 보였다. 설치 여부는 다 받은 뒤에 묻는다(그래야 "예"를 누른 +/// 다음에 또 기다리지 않는다). +/// /// 어느 단계에서 막히든(설정 꺼짐 · 네트워크 없음 · git 없음 · 저장소 접근 실패) 조용히 넘어가고 /// 기존 파일로 그대로 실행한다 — 업데이트 때문에 프로그램이 안 켜지는 일은 없어야 한다. /// @@ -96,80 +103,57 @@ internal static class PlatformUpdater return; // git이 깔려 있지 않은 PC — 업데이트 기능만 조용히 쉰다. } - // 2) 업데이트 할 게 있는가 — 원격 브랜치의 최신 커밋이 마지막에 설치한 커밋과 같으면 끝. - var branch = string.IsNullOrWhiteSpace(settings.Branch) ? "main" : settings.Branch.Trim(); - if (!TryRunGit($"ls-remote \"{settings.RepositoryUrl}\" \"refs/heads/{branch}\"", null, LsRemoteTimeoutMs, out var lsRemote)) + // 여기서부터 몇 초가 걸릴 수 있다 — 무엇을 하고 있는지 창으로 보여준다. + var progress = new ProgressWindow(); + progress.Show(); + + Download? download = null; + + // 받기는 배경 스레드에서 하고, 이 스레드는 DispatcherFrame으로 화면을 계속 그린다. + // (Application.Run을 쓸 수 없다 — Shell이 나중에 자기 Application을 만든다. Dispatcher를 + // 종료시키는 방식도 안 된다 — 같은 스레드에서 Shell이 계속 돌아야 하기 때문이다.) + var frame = new DispatcherFrame(); + var worker = new Thread(() => { - Note("원격 확인 실패(주소·권한·네트워크) — 건너뜀"); - return; // 주소가 틀렸거나 접근 권한이 없다 — 실행을 막지 않는다. + try + { + download = Prepare(platformDir, settings, progress); + } + catch (Exception ex) + { + Note("받는 중 예외 : " + ex.Message); + } + finally + { + progress.Dispatcher.BeginInvoke(new Action(() => frame.Continue = false)); + } + }) + { + IsBackground = true + }; + + worker.Start(); + Dispatcher.PushFrame(frame); + progress.Close(); + + if (download == null) + { + return; // 할 일이 없거나 중간에 막혔다 — 이유는 로그에 남아 있다. } - var remoteCommit = FirstToken(lsRemote); - Note($"원격 커밋:{Short(remoteCommit)} / 설치된 커밋:{Short(settings.InstalledCommit)}"); - - if (string.IsNullOrEmpty(remoteCommit) || remoteCommit == settings.InstalledCommit) - { - Note("새 커밋 없음 — 끝"); - return; - } - - var tempDir = Path.Combine(Path.GetTempPath(), "UbisamPlatformUpdate_" + Guid.NewGuid().ToString("N")); try { - var distName = string.IsNullOrWhiteSpace(settings.DistPath) ? "dist" : settings.DistPath.Trim().Replace('\\', '/'); - - // 업데이트 파일만 얕게 받는다 — 소스 전체 이력을 받지 않는다. - if (!TryRunGit( - $"clone --depth 1 --branch \"{branch}\" --filter=blob:none --sparse \"{settings.RepositoryUrl}\" \"{tempDir}\"", - null, CloneTimeoutMs, out _)) - { - Note("업데이트 파일을 내려받지 못함(clone 실패) — 건너뜀"); - return; - } - - if (!TryRunGit($"sparse-checkout set \"{distName}\"", tempDir, CloneTimeoutMs, out _)) - { - Note("업데이트 폴더를 꺼내지 못함(sparse-checkout 실패) — 건너뜀"); - return; - } - - var remoteDist = Path.Combine(tempDir, distName.Replace('/', '\\')); - if (!Directory.Exists(remoteDist)) - { - Note($"저장소에 {distName} 폴더가 없음 — 커밋만 기록"); - // 저장소에 아직 업데이트 파일이 없다 — 이 커밋은 확인했다고 기록만 하고 넘어간다. - settings.InstalledCommit = remoteCommit; - settings.Save(SettingsPath); - return; - } - - var remoteVersion = ReadVersion(Path.Combine(remoteDist, VersionFileName)); - var localVersion = ReadVersion(Path.Combine(platformDir, VersionFileName)); - - // 버전은 배포 시각("yyyy-MM-dd HH:mm:ss")이라 문자열 비교로 앞뒤가 가려진다. - // 원격이 더 새것일 때만 업데이트한다 — 소스만 바뀐 커밋이면 확인 기록만 남긴다. - Note($"원격 버전:{remoteVersion} / 지금 버전:{localVersion}"); - - if (string.IsNullOrEmpty(remoteVersion) || string.CompareOrdinal(remoteVersion, localVersion) <= 0) - { - Note("더 새 버전이 아님 — 커밋만 기록"); - settings.InstalledCommit = remoteCommit; - settings.Save(SettingsPath); - return; - } - - // 3) 자동 업데이트가 아니면 물어본다. - if (!settings.AutoUpdate && !UpdateDialog.Ask(localVersion, remoteVersion)) + if (!settings.AutoUpdate && !UpdateDialog.Ask(download.LocalVersion, download.RemoteVersion)) { Note("사용자가 \"나중에\"를 선택 — 다음 실행 때 다시 물어본다"); - // 기록을 남기지 않는다 — 다음에 켤 때 다시 물어본다. - return; + return; // 기록을 남기지 않는다 — 다음에 켤 때 다시 물어본다. } - var failed = CopyAll(remoteDist, platformDir); + var failed = CopyAll(download.DistDir, platformDir); Note($"업데이트 완료 — 바꾸지 못한 파일:{failed}개"); - settings.InstalledCommit = remoteCommit; - settings.InstalledVersion = remoteVersion; + + settings.InstalledCommit = download.Commit; + settings.InstalledVersion = download.RemoteVersion; settings.Save(SettingsPath); if (failed > 0) @@ -182,10 +166,130 @@ internal static class PlatformUpdater } finally { - TryDeleteDirectory(tempDir); + TryDeleteDirectory(download.TempDir); } } + /// 원격을 확인하고, 새 버전이면 업데이트 파일을 임시 폴더에 받아 온다. + /// 받을 것이 없으면 null. 화면 표시는 로 알린다. + private static Download? Prepare(string platformDir, Settings settings, ProgressWindow progress) + { + progress.SetStage("업데이트 확인 중", "저장소에서 최신 버전을 확인하고 있습니다."); + progress.SetPercent(6); + + var branch = string.IsNullOrWhiteSpace(settings.Branch) ? "main" : settings.Branch.Trim(); + if (!TryRunGit($"ls-remote \"{settings.RepositoryUrl}\" \"refs/heads/{branch}\"", null, LsRemoteTimeoutMs, out var lsRemote)) + { + Note("원격 확인 실패(주소·권한·네트워크) — 건너뜀"); + return null; + } + + var remoteCommit = FirstToken(lsRemote); + Note($"원격 커밋:{Short(remoteCommit)} / 설치된 커밋:{Short(settings.InstalledCommit)}"); + + if (string.IsNullOrEmpty(remoteCommit) || remoteCommit == settings.InstalledCommit) + { + Note("새 커밋 없음 — 끝"); + progress.SetStage("최신 상태입니다", "받을 새 버전이 없습니다."); + progress.SetPercent(100); + Thread.Sleep(400); // 창이 깜빡이고 사라지지 않도록 잠깐 보여준다. + return null; + } + + progress.SetStage("새 버전을 찾았습니다", "업데이트 파일을 불러오고 있습니다."); + progress.SetPercent(12); + + var tempDir = Path.Combine(Path.GetTempPath(), "UbisamPlatformUpdate_" + Guid.NewGuid().ToString("N")); + var distName = string.IsNullOrWhiteSpace(settings.DistPath) ? "dist" : settings.DistPath.Trim().Replace('\\', '/'); + var keepTemp = false; + + try + { + // 업데이트 파일만 얕게 받는다 — 소스 전체 이력을 받지 않는다. + // --progress를 줘야 진행률이 나온다(출력이 리디렉트되면 git은 기본적으로 조용하다). + if (!TryRunGit( + $"clone --progress --depth 1 --branch \"{branch}\" --filter=blob:none --sparse \"{settings.RepositoryUrl}\" \"{tempDir}\"", + null, CloneTimeoutMs, out _, p => progress.SetPercent(12 + (p * 0.58)))) + { + Note("업데이트 파일을 내려받지 못함(clone 실패) — 건너뜀"); + return null; + } + + progress.SetPercent(70); + + if (!TryRunGit($"sparse-checkout set \"{distName}\"", tempDir, CloneTimeoutMs, out _, p => progress.SetPercent(70 + (p * 0.22)))) + { + Note("업데이트 폴더를 꺼내지 못함(sparse-checkout 실패) — 건너뜀"); + return null; + } + + progress.SetPercent(94); + + var remoteDist = Path.Combine(tempDir, distName.Replace('/', '\\')); + if (!Directory.Exists(remoteDist)) + { + Note($"저장소에 {distName} 폴더가 없음 — 커밋만 기록"); + settings.InstalledCommit = remoteCommit; + settings.Save(SettingsPath); + return null; + } + + var remoteVersion = ReadVersion(Path.Combine(remoteDist, VersionFileName)); + var localVersion = ReadVersion(Path.Combine(platformDir, VersionFileName)); + Note($"원격 버전:{remoteVersion} / 지금 버전:{localVersion}"); + + // 버전은 배포 시각("yyyy-MM-dd HH:mm:ss")이라 문자열 비교로 앞뒤가 가려진다. + // 원격이 더 새것일 때만 설치한다 — 소스만 바뀐 커밋이면 확인 기록만 남긴다. + if (string.IsNullOrEmpty(remoteVersion) || string.CompareOrdinal(remoteVersion, localVersion) <= 0) + { + Note("더 새 버전이 아님 — 커밋만 기록"); + settings.InstalledCommit = remoteCommit; + settings.Save(SettingsPath); + progress.SetStage("최신 상태입니다", "받을 새 버전이 없습니다."); + progress.SetPercent(100); + Thread.Sleep(400); + return null; + } + + progress.SetStage("받기 완료", "설치할지 확인합니다."); + progress.SetPercent(100); + Thread.Sleep(350); // 100%가 눈에 보이도록 잠깐 둔다. + + keepTemp = true; // 설치까지 쓰므로 여기서 지우지 않는다. + return new Download(tempDir, remoteDist, remoteCommit, remoteVersion, localVersion); + } + finally + { + if (!keepTemp) + { + TryDeleteDirectory(tempDir); + } + } + } + + /// 받아둔 업데이트 파일 한 벌. + private sealed class Download + { + public Download(string tempDir, string distDir, string commit, string remoteVersion, string localVersion) + { + TempDir = tempDir; + DistDir = distDir; + Commit = commit; + RemoteVersion = remoteVersion; + LocalVersion = localVersion; + } + + public string TempDir { get; } + + public string DistDir { get; } + + public string Commit { get; } + + public string RemoteVersion { get; } + + public string LocalVersion { get; } + } + private static string Short(string commit) => string.IsNullOrEmpty(commit) ? "(없음)" : commit.Substring(0, Math.Min(7, commit.Length)); @@ -248,7 +352,12 @@ internal static class PlatformUpdater return failed; } - private static bool TryRunGit(string arguments, string? workingDirectory, int timeoutMs, out string output) + /// + /// git을 한 번 실행한다. 를 주면 진행률(0~100)을 알려준다 — + /// git은 진행률을 stderr에 줄바꿈 없이(\r) 흘리므로 줄 단위가 아니라 글자 단위로 읽는다. + /// stdout과 stderr를 각각 다른 스레드에서 읽는 이유는, 한쪽 버퍼가 차서 서로 기다리는 것을 막기 위함이다. + /// + private static bool TryRunGit(string arguments, string? workingDirectory, int timeoutMs, out string output, Action? onPercent = null) { output = string.Empty; @@ -276,8 +385,11 @@ internal static class PlatformUpdater return false; } - var stdout = process.StandardOutput.ReadToEnd(); - process.StandardError.ReadToEnd(); + var stdout = string.Empty; + var outReader = new Thread(() => stdout = process.StandardOutput.ReadToEnd()) { IsBackground = true }; + var errReader = new Thread(() => ReadProgress(process.StandardError, onPercent)) { IsBackground = true }; + outReader.Start(); + errReader.Start(); if (!process.WaitForExit(timeoutMs)) { @@ -293,6 +405,9 @@ internal static class PlatformUpdater return false; } + outReader.Join(2000); + errReader.Join(2000); + output = stdout; return process.ExitCode == 0; } @@ -302,6 +417,49 @@ internal static class PlatformUpdater } } + /// stderr를 글자 단위로 읽어 "…: 37%" 같은 진행률만 뽑아낸다. + private static void ReadProgress(StreamReader reader, Action? onPercent) + { + try + { + var line = new StringBuilder(); + + while (true) + { + var read = reader.Read(); + if (read < 0) + { + break; + } + + var ch = (char)read; + if (ch == '\r' || ch == '\n') + { + if (onPercent != null && line.Length > 0) + { + var match = Regex.Match(line.ToString(), @"(\d{1,3})%"); + if (match.Success && double.TryParse(match.Groups[1].Value, out var value)) + { + onPercent(value); + } + } + + line.Clear(); + continue; + } + + if (line.Length < 200) + { + line.Append(ch); + } + } + } + catch + { + // 읽기가 끊겨도 실행에는 지장이 없다. + } + } + private static void TryDeleteDirectory(string dir) { try diff --git a/src/UbisamBase.Launcher/ProgressWindow.xaml b/src/UbisamBase.Launcher/ProgressWindow.xaml new file mode 100644 index 0000000..8c8298e --- /dev/null +++ b/src/UbisamBase.Launcher/ProgressWindow.xaml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/UbisamBase.Launcher/ProgressWindow.xaml.cs b/src/UbisamBase.Launcher/ProgressWindow.xaml.cs new file mode 100644 index 0000000..e99e5b3 --- /dev/null +++ b/src/UbisamBase.Launcher/ProgressWindow.xaml.cs @@ -0,0 +1,112 @@ +using System; +using System.Windows; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Animation; +using Microsoft.Win32; + +namespace UbisamBase.Launcher; + +/// +/// 업데이트를 확인하고 받는 동안 보여주는 창. 런처를 누르자마자 떠서 "지금 뭘 하고 있는지"를 알린다 +/// (예전에는 받는 동안 아무것도 안 보여서 프로그램이 안 켜지는 것처럼 보였다). +/// +/// 받기가 끝나면 이 창은 닫히고, 설치 여부는 UpdateDialog가 묻는다. +/// 색은 UpdateDialog와 같은 방식으로(윈도우 밝게/어둡게) 직접 넣는다 — Core를 참조할 수 없기 때문. +/// +public partial class ProgressWindow : Window +{ + private double percent; + + public ProgressWindow() + { + InitializeComponent(); + ApplyTheme(IsSystemLightTheme()); + SizeChanged += (_, _) => UpdateFillWidth(animate: false); + } + + /// 제목과 설명을 바꾼다. 어느 스레드에서 불러도 된다. + public void SetStage(string headline, string status) + => Dispatcher.Invoke(() => + { + HeadlineText.Text = headline; + StatusText.Text = status; + }); + + /// 0~100. 뒤로 가지 않도록 지금 값보다 큰 값만 반영한다 — git이 단계마다 0%부터 다시 세기 때문. + public void SetPercent(double value) + => Dispatcher.Invoke(() => + { + var clamped = Math.Max(0, Math.Min(100, value)); + if (clamped <= percent) + { + return; + } + + percent = clamped; + PercentText.Text = ((int)percent) + "%"; + UpdateFillWidth(animate: true); + }); + + private void UpdateFillWidth(bool animate) + { + var target = Math.Max(0, TrackBorder.ActualWidth * percent / 100.0); + + if (!animate) + { + FillBorder.BeginAnimation(WidthProperty, null); + FillBorder.Width = target; + return; + } + + // 숫자가 툭툭 튀지 않도록 짧게 이어 붙인다. + FillBorder.BeginAnimation(WidthProperty, new DoubleAnimation(target, TimeSpan.FromMilliseconds(220)) + { + EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }, + FillBehavior = FillBehavior.HoldEnd + }); + } + + private void ApplyTheme(bool light) + { + var background = Brush(light ? "#FFFFFFFF" : "#FF2A2E34"); + var foreground = Brush(light ? "#FF1D1F20" : "#FFE8EAED"); + var border = Brush(light ? "#FFD6D6D9" : "#FF3A3F47"); + var muted = Brush(light ? "#FF98989B" : "#FF8A9099"); + var accent = Brush(light ? "#FF2196F3" : "#FF4DA6F5"); + var track = Brush(light ? "#FFEDEDEF" : "#FF23262B"); + var chip = Brush(light ? "#142196F3" : "#1F4DA6F5"); + + Background = background; + Foreground = foreground; + RootBorder.BorderBrush = border; + AccentStrip.Background = accent; + IconChip.Background = chip; + IconPath.Stroke = accent; + HeadlineText.Foreground = foreground; + StatusText.Foreground = muted; + PercentText.Foreground = muted; + TrackBorder.Background = track; + FillBorder.Background = accent; + } + + private static SolidColorBrush Brush(string hex) + => new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)); + + private static bool IsSystemLightTheme() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey( + @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"); + + return key?.GetValue("AppsUseLightTheme") is not int value || value != 0; + } + catch + { + return true; + } + } + + private void Window_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) => DragMove(); +} diff --git a/src/UbisamBase.Shell/App.xaml.cs b/src/UbisamBase.Shell/App.xaml.cs index 11a9706..0d78d2f 100644 --- a/src/UbisamBase.Shell/App.xaml.cs +++ b/src/UbisamBase.Shell/App.xaml.cs @@ -1,4 +1,6 @@ using System; +using System.Diagnostics; +using System.IO; using System.Windows; using UbisamBase.Core.Bootstrap; @@ -10,10 +12,60 @@ namespace UbisamBase.Shell; /// public partial class App : Application { + private const string LauncherFileName = "UbisamBase.Launcher.exe"; + protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); + if (RedirectToLauncher()) + { + return; + } + UbisamAppBuilder.Run(AppDomain.CurrentDomain.BaseDirectory); } + + /// + /// 이 exe를 직접 실행한 경우 런처로 넘긴다. + /// + /// 프로그램의 정식 진입점은 항상 런처다 — 런처만이 배포 폴더(D:\UbisamPlatform\bin)를 + /// 최신으로 맞추고 업데이트를 확인한다. 폴더에 exe가 둘 보여서 이걸 직접 누르는 일이 생기는데, + /// 그러면 갱신 없이 옛 파일로 떠서 "왜 업데이트가 안 되지?"가 된다. 그래서 조용히 런처로 넘긴다. + /// + /// 런처는 Shell을 같은 프로세스에서 로드하므로, 런처로 실행된 경우 이 프로세스의 exe + /// 이름은 런처다 — 그때는 아무 일도 하지 않는다. + /// + private bool RedirectToLauncher() + { + try + { + var baseDir = AppDomain.CurrentDomain.BaseDirectory; + var currentExe = Process.GetCurrentProcess().MainModule?.FileName; + + if (string.Equals(Path.GetFileName(currentExe), LauncherFileName, StringComparison.OrdinalIgnoreCase)) + { + return false; // 런처가 띄운 정상 경로 + } + + var launcherPath = Path.Combine(baseDir, LauncherFileName); + if (!File.Exists(launcherPath)) + { + return false; // 런처 없이 배포된 환경 — 그대로 실행한다 + } + + Process.Start(new ProcessStartInfo(launcherPath) + { + UseShellExecute = true, + WorkingDirectory = baseDir + }); + + Shutdown(); + return true; + } + catch + { + return false; // 넘기지 못하면 그냥 이대로 실행한다 — 프로그램이 안 켜지는 것보다 낫다. + } + } }