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 3aa85c1..d2c32e7 100644
Binary files a/dist/UbisamBase.Core.dll and b/dist/UbisamBase.Core.dll differ
diff --git a/dist/UbisamBase.Launcher.exe b/dist/UbisamBase.Launcher.exe
index 31f1f29..27d8f67 100644
Binary files a/dist/UbisamBase.Launcher.exe and b/dist/UbisamBase.Launcher.exe differ
diff --git a/dist/UbisamBase.Shell.exe b/dist/UbisamBase.Shell.exe
index 4f2e26d..a4be496 100644
Binary files a/dist/UbisamBase.Shell.exe and b/dist/UbisamBase.Shell.exe differ
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; // 넘기지 못하면 그냥 이대로 실행한다 — 프로그램이 안 켜지는 것보다 낫다.
+ }
+ }
}