41 KiB
UbisamBase 플랫폼 사용 가이드 (AI/개발자 참고용)
이 문서는 다른 AI 세션이나 개발자가 이 플랫폼으로 새 프로그램을 만들거나 기존 코드를 이식할 때
빠르게 참고할 수 있도록 정리한 기술 레퍼런스입니다. 사람이 읽는 슬라이드 버전은
doc/UbisamBase_플랫폼_사용_매뉴얼.pptx를 참고하세요.
0. 구조
UbisamBase.Core 공통 서비스 + Shell UI 라이브러리 (여기 손대면 플랫폼 전체가 바뀜)
UbisamBase.Shell 고정 호스트. 같은 폴더의 IAppSetup 구현 dll을 리플렉션으로 로드
UbisamBase.Launcher 실행 진입점. 실행할 때마다 D:\UbisamPlatform\bin을 자기 폴더로 복사한 뒤
Shell을 같은 프로세스 안에서 띄운다 (F5도, 사용자 실행도 이걸로 시작)
소비 프로젝트 UbisamBase.Core를 참조하고 IAppSetup만 구현하는 실제 프로그램
저장소 위치: Core/Shell 소스는 D:\업무\03_개발\Source\용접기\08_UbisamPlatform
저장소에 있다. 소비 프로젝트(예제 UbisamBase.App)는 08_UbisamBase 저장소에 있다.
Core/Shell을 참조하는 방식(모든 프로그램이 D드라이브 배포 폴더 하나를 본다)의 전체 설명은 10-2 참고 — 아래는
그 요약이자, 새 프로젝트를 만드는 순간부터 바로 따라 할 수 있는 실전 절차다.
0-1. 새 소비 프로젝트 만들기 — 생성 직후 플랫폼 연결
프로젝트 생성 버전 / 설정
이 플랫폼은 .NET Framework 4.8(net48) 전용이다. Visual Studio의 "새 프로젝트 만들기"
마법사로 시작한다면:
| 선택 항목 | 값 |
|---|---|
| 대상 프레임워크 | .NET Framework 4.8 (netcoreapp/net6+ 아님) |
| 프로젝트 형식 | 클래스 라이브러리 (WPF 앱 템플릿 아님 — 실행은 UbisamBase.Launcher.exe가 하고, 이 프로젝트는 그 안에서 Shell이 리플렉션으로 로드하는 dll일 뿐이다) |
| 플랫폼 | Any CPU만 (<Platforms>에 x64를 넣지 않는다 — 출력 폴더가 bin\x64\...로 바뀌어 F5 실행 경로와 어긋난다) |
| SDK 스타일 | 새 SDK 스타일 csproj (<Project Sdk="Microsoft.NET.Sdk">) |
VS의 클래스 라이브러리(.NET Framework) 템플릿에는 WPF 활성화 옵션이 따로 없고, 아래에 정리한
CopyPlatformRuntime 타겟 등 이 플랫폼 특유의 설정도 마법사가 만들어주지 않는다. 그래서
실무적으로는 둘 중 하나를 쓴다:
- (권장) 기존 소비 프로젝트를 복사해서 시작 —
08_UbisamBase\src\UbisamBase.App폴더 전체를 복사하고 폴더/파일 이름,AssemblyName/RootNamespace만 바꾼다. 아래 csproj의 모든 설정이 이미 정답 상태로 들어있다. - 처음부터 만들기 — 빈 폴더에 아래
.csproj를 그대로 붙여넣는다.
.csproj 템플릿
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net48</TargetFramework>
<UseWPF>true</UseWPF>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>MyApp</RootNamespace>
<AssemblyName>MyApp</AssemblyName>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<!-- 플랫폼 배포 폴더. 소스 코드가 없는 컴파일된 dll/exe만 있다(.pdb/.cs 없음).
플랫폼 원본(08_UbisamPlatform)을 빌드하면 여기에 자동 반영된다. 모든 프로그램이 이 값을
그대로 쓴다 - 플랫폼을 고치는 개발자도 마찬가지(10-2 참고). -->
<UbisamPlatformDir>D:\UbisamPlatform\bin\</UbisamPlatformDir>
<!-- F5(디버그 시작) 시 Launcher.exe가 실행되도록 지정 — 이 프로젝트 자체는 dll이라 직접 실행 불가.
Launcher는 실행할 때마다 D:\UbisamPlatform\bin의 최신 내용을 이 폴더로 복사한 뒤 Shell을
같은 프로세스에서 띄운다. 그래서 다시 빌드하지 않아도 최신 플랫폼으로 뜨고, 이 프로젝트에
찍은 중단점도 정상으로 걸린다.
주의: 여기서 $(TargetDir)를 쓰면 안 된다. 이 시점엔 아직 비어 있어서 VS가
"UbisamBase.Launcher.exe 프로그램을 시작할 수 없습니다 / 파일을 찾을 수 없습니다"로 실패한다. -->
<StartAction>Program</StartAction>
<StartProgram>$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\UbisamBase.Launcher.exe</StartProgram>
<StartWorkingDirectory>$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\</StartWorkingDirectory>
<!-- VS의 "빠른 최신 상태 확인"이 아래 CopyPlatformRuntime 복사를 추적 못 해서
소스 변경 없으면 빌드를 건너뛰려 하는 문제를 막는다 -->
<DisableFastUpToDateCheck>true</DisableFastUpToDateCheck>
</PropertyGroup>
<ItemGroup>
<!-- 프로젝트 참조가 아니라 배포된 dll을 그대로 가져다 쓰는 파일 참조 -->
<Reference Include="UbisamBase.Core">
<HintPath>$(UbisamPlatformDir)UbisamBase.Core.dll</HintPath>
<Private>false</Private>
</Reference>
<!-- IAppSetup.RegisterServices(IServiceCollection) 구현에 필요 -->
<Reference Include="Microsoft.Extensions.DependencyInjection.Abstractions">
<HintPath>$(UbisamPlatformDir)Microsoft.Extensions.DependencyInjection.Abstractions.dll</HintPath>
<Private>false</Private>
</Reference>
<!-- [ObservableProperty]/[RelayCommand]를 쓰려면 반드시 PackageReference로 받아야 한다
(소스 제너레이터가 필요해서 dll 파일 참조만으로는 안 됨) -->
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.2" />
</ItemGroup>
<!-- 차트를 쓴다면 이 4개도 같은 방식(HintPath, Private=false)으로 추가:
LiveChartsCore, LiveChartsCore.SkiaSharpView, LiveChartsCore.SkiaSharpView.WPF, SkiaSharp -->
<!-- 플랫폼 런타임 전체(Shell.exe + Core.dll + nuget 런타임)를 이 프로젝트 출력 폴더로 복사 -->
<Target Name="CopyPlatformRuntime" BeforeTargets="Build">
<ItemGroup>
<_PlatformFiles Include="$(UbisamPlatformDir)**\*.*" />
</ItemGroup>
<MakeDir Directories="$(TargetDir)" Condition="!Exists('$(TargetDir)')" />
<Copy SourceFiles="@(_PlatformFiles)" DestinationFiles="@(_PlatformFiles->'$(TargetDir)%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
</Project>
확인 순서
dotnet build(또는 VS 빌드) — 출력 폴더bin\Debug\net48\(※bin\x64\...가 아니어야 함)에UbisamBase.Launcher.exe,UbisamBase.Launcher.exe.config,UbisamBase.Shell.exe,UbisamBase.Core.dll, 이 프로젝트의 dll이 같이 생겼는지 확인.- 아직
IAppSetup을 구현하지 않았으면 F5 시 "IAppSetup을 구현한 dll을 … 찾지 못했습니다" 예외로 멈춘다 — 이 메시지가 나오면 참조·복사·실행 경로 연결은 성공한 것이다. - 다음(0-2)에서
IAppSetup을 구현하면 실제 화면이 붙는다. - F5로 뜬 상태에서 작업 관리자를 보면
UbisamBase.Launcher프로세스 하나만 있어야 한다.UbisamBase.Shell이 별도 프로세스로 따로 떠 있으면 옛 Launcher(별도 프로세스 방식)라서 디버거가 안 붙고 중단점이 안 걸린다 →D:\UbisamPlatform\bin을 최신 배포본으로 교체. - (선택) 이 저장소에
.slnx솔루션이 있다면<Project Path="src/MyApp/MyApp.csproj" />를 추가해 VS 솔루션 탐색기에 노출시킨다. 이때<Platform Name="x64" />같은 x64 구성은 넣지 않는다.
자주 겪는 실행 오류
| 증상 | 원인 | 조치 |
|---|---|---|
F5 시 "UbisamBase.Launcher.exe 프로그램을 시작할 수 없습니다 / 지정된 파일을 찾을 수 없습니다" |
StartProgram에 $(TargetDir)를 썼다 — PropertyGroup 시점엔 비어 있어 파일명만 남는다 |
위 템플릿처럼 $(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\ 절대경로로 |
빌드 출력이 bin\x64\Debug\net48\로 나온다 |
csproj <Platforms>에 x64가 들어갔거나 솔루션이 x64로 매핑됨 |
csproj에서 <Platforms> 삭제, .slnx의 x64 Platform 항목 삭제, 구성 관리자 활성 플랫폼 = Any CPU |
| VS 상단에 "알 수 없는 프로젝트 구성 매핑이 있습니다" 배너 | .slnx에 프로젝트에 없는 플랫폼(x64) 매핑이 남음 |
.slnx에서 해당 <Platform .../> 삭제 후 VS 재시작 |
| 켜졌다가 꺼지고 새 창으로 다시 뜬다 / 중단점이 안 걸린다 | 배포본의 Launcher가 옛 버전(Shell을 별도 프로세스로 띄움) | D:\UbisamPlatform\bin 최신화 후 재빌드 |
실행 직후 창 없이 조용히 종료 (이벤트 뷰어에 FileLoadException) |
UbisamBase.Launcher.exe.config에 바인딩 리디렉트가 없음(옛 배포본) |
D:\UbisamPlatform\bin 최신화 — 플랫폼을 빌드하면 Shell 설정이 Launcher 설정으로 복사된다(다른 PC면 최신 배포 폴더를 통째로 다시 복사) |
| 컴파일 에러 "…형식이 없습니다", "…정의가 포함되어 있지 않습니다" | 코드가 기대하는 플랫폼 API가 배포본에 없다 | 배포본 최신화. 그래도 없으면 플랫폼에 추가할지, 코드에서 뺄지 결정 |
0-2. IAppSetup 구현
새 프로그램 = IAppSetup 구현 클래스 하나 + 필요한 View/ViewModel. 나머지(테마, 네비게이션,
설정 저장, 로그, 대화상자, 토스트, 잠금 등)는 전부 플랫폼이 제공합니다.
public class AppSetup : IAppSetup
{
public string GetAppName() => "내 프로그램 이름"; // 상단 브랜딩 바에 표시
public void RegisterViews(ITabManager manager)
{
manager.AddMain<HomeView, HomeViewModel>(Icon.Home, ViewIds.Home, "홈");
manager.AddSub<DetailView, DetailViewModel>(ViewIds.Home, "상세");
}
public void RegisterSettings(ITabManager manager)
{
manager.AddSettingsSub<MySettings>(TabManager.SettingsTabKey, "내 설정");
}
public void RegisterServices(IServiceCollection services)
{
services.AddSingleton<MyLegacyService>(); // 기존 코드를 여기서 DI에 등록
}
}
AddMain/AddSub로 등록한 View는 DI가 자동으로 ViewModel을 만들어 DataContext에 연결합니다.
ViewModel 생성자에 필요한 서비스(플랫폼 제공이든 RegisterServices로 직접 등록한 것이든)를
선언하기만 하면 주입됩니다 — ActivatorUtilities.CreateInstance 기반이라 별도 배선이 필요 없습니다.
1. 설정 화면
1-1. 자동 생성 (View 없이)
public class MySettings
{
[Category("장비")]
[DisplayName("측정값 개수")]
public int MeasureCount { get; set; }
[Category("장비")]
public bool UseAutoSave { get; set; }
[Category("데이터")]
public ObservableCollection<Row> Rows { get; set; } = new();
}
manager.AddSettingsSub<MySettings>(TabManager.SettingsTabKey, "내 설정");
- 프로퍼티 타입 → 컨트롤 자동 매핑:
bool→체크박스,int/long/short→정수 텍스트박스,float/double/decimal→소수 텍스트박스,DateTime→달력,string→텍스트박스,ObservableCollection<T>→DataGrid. [Category("말머리")]같은 것끼리 화면에서 그룹으로 묶임 (없으면 말머리 없는 그룹).[DisplayName("이름")]생략 시 프로퍼티 이름 그대로 표시.[Browsable(false)]면 화면에서 숨김.- 저장 파일:
D:\UbisamConfig\Settings\{클래스이름}.json— 어느 탭에 붙여도 클래스가 같으면 같은 파일 공유.
1-2. 직접 만든 View + 수동 등록
View를 직접 그리고 싶으면 ISettingsService를 주입받아 등록합니다.
public MyViewModel(ISettingsService settingsService)
{
settings = settingsService.RegisterSettings("MyKey", new MySettingsClass());
}
// settingsService.Save("MyKey"); / settingsService.Reload("MyKey");
1-3. 저장/새로고침 버튼 (플랫폼 자동 지원)
모든 설정 화면 우상단에 파란색 저장/새로고침 버튼이 동일하게 표시됩니다. 직접 만든 화면에서도
SettingsToolbar 컴포넌트를 붙이면 됩니다 — DataContext를 상속받아 호스트 ViewModel의
RefreshCommand/SaveCommand([RelayCommand] private void Refresh()/Save())에 바인딩됩니다.
<local:SettingsToolbar DockPanel.Dock="Top" Margin="0,0,0,16"/>
저장/새로고침 성공·실패 시 Success/Error 토스트가 자동으로 뜨도록 구현부에서
MessageUtil.ShowSavedToast() / MessageUtil.ShowErrorToast(...)를 호출하는 패턴을 따르세요
(플랫폼이 등록한 기본 4개 설정 화면이 이미 이 패턴입니다).
2. 로깅 — UbisamBase.Core.Logging
private readonly Logger log = new Logger("MyFeature"); // DI 없이 어디서든 생성 가능
log.V("verbose"); log.D("debug"); log.I("info");
log.W("warning"); log.E("error"); log.E("error", ex);
- 호출 즉시
D:\UbisamConfig\Log\yyyyMMdd.log에 기록 + 설정 > 로그 탭(LogService.Current.RecentEntries)에 실시간 반영. - 버튼 클릭 자동 로깅:
ButtonClickLogger.Install()이 부팅 시 한 번 걸려 있어서 (EventManager.RegisterClassHandler(typeof(ButtonBase), ButtonBase.ClickEvent, ...)), 플랫폼 전체(Core + 모든 소비 프로그램)의 모든Button/ToggleButton클릭이 코드 한 줄 없이"UI"태그로 자동 기록됩니다. 버튼마다 로그 코드를 넣을 필요가 없습니다. - 로그 뷰어: DataGrid, 레벨 필터(ALL = 전부, 특정 레벨 = 정확히 일치), 검색(LIKE, 부분 포함), 우클릭 복사.
3. 대화상자 / 토스트 — UbisamBase.Core.Messaging
// 대화상자 — OS MessageBox 대신 테마 적용된 MessageDialog가 자동으로 뜬다
MessageUtil.ShowInfo("알림", "저장되었습니다.");
MessageUtil.ShowError("오류", "저장하지 못했습니다.");
bool yes = MessageUtil.ShowYesNo("확인", "계속하시겠습니까?");
// 토스트 — 3초 후 자동 소멸, 화면 끝에서 슬라이드 인/아웃
MessageUtil.ShowToast("정보 메시지");
MessageUtil.ShowSuccessToast("저장되었습니다.");
MessageUtil.ShowWarningToast("값이 허용 범위를 벗어났습니다.");
MessageUtil.ShowErrorToast("장비 통신이 끊어졌습니다.");
토스트 표시 위치(좌상단/좌하단/우상단/우하단)는 설정 > UI Setting에서 사용자가 바꿀 수 있습니다
(UiSettingsService.ToastPosition).
4. 차트 — UbisamBase.Core.Charting.ChartTheme (LiveCharts2)
public ISeries[] TrendSeries { get; private set; }
public Axis[] TrendXAxes { get; private set; }
public Axis[] TrendYAxes { get; private set; }
public MyViewModel()
{
Rebuild();
ChartTheme.ThemeChanged += Rebuild; // 뷰가 사라질 때 -= 로 해제할 것
}
private void Rebuild()
{
TrendSeries = new ISeries[] {
ChartTheme.ReferenceLine(baseline, "기준"),
ChartTheme.Line(measured, "측정")
};
TrendXAxes = new[] { ChartTheme.XAxis(v => $"{v}s") };
TrendYAxes = new[] { ChartTheme.YAxis("0", min: 0, max: 100) };
OnPropertyChanged(nameof(TrendSeries)); // 등등
}
<lvc:CartesianChart Series="{Binding TrendSeries}" XAxes="{Binding TrendXAxes}"
YAxes="{Binding TrendYAxes}" Background="Transparent" Height="200"/>
Background="Transparent"를 꼭 지정 — 기본값은 흰 배경이라 다크 테마에서 튐.- 툴팁·범례는 따로 입혀야 한다 — 지정하지 않으면 LiveCharts 기본(밝은 바탕 + 검은 글자)이 나와
다크 테마에서 튄다. 차트를 만든 뒤와 테마가 바뀐 뒤에
ChartTheme.Apply(chart)를 한 번 부른다 (CartesianChart/PieChart둘 다 있음).
4-1. 차트 종류별 — 무엇을 언제 쓰나
| 만들기 | 생김새 | 쓰는 자리 |
|---|---|---|
Line(values, name) |
accent 선 2px, 보간 없음, 면적 12% | 단일 측정값 추이(기본) |
Line(values, name, i) |
팔레트 i번 선, 면적 없음 | 여러 계열을 한 그래프에 겹칠 때 |
ReferenceLine(values, name) |
회색 파선, hover 없음 | 기준·목표·규격 상하한. 측정선보다 먼저 넣어야 아래에 깔린다 |
SmoothLine(values, name, i) |
곡선 보간(0.65) | 추세만 보여줄 때. 공정 측정값에는 쓰지 말 것 — 없던 중간값을 지어낸다 |
Area(values, name, i) |
선 + 채움 24% | 누적량·점유량처럼 "쌓인 양" |
StepLine(values, name, i) |
계단선 | ON/OFF·단계값처럼 다음 값까지 유지되는 신호 |
Column(values, name[, i]) |
각진 세로 막대, 채움 85% | 항목별 비교(수량·횟수) |
StackedColumn(values, name, i) |
누적 세로 막대 | 전체 대비 구성비를 시간축으로 |
Bar(values, name[, i]) |
가로 막대 | 항목 이름이 길어 세로 막대 라벨이 겹칠 때(설비명·불량 유형) |
Pie(values[, names]) |
원형, 조각 사이 판 색 경계 | 구성비. 조각 6개 이하일 때만 |
Donut(values[, names]) |
가운데 빈 원형 | 구성비 + 가운데에 총계 표시 |
Gauge(percent) |
0~100 도넛 게이지 | 가동률·달성률 한 값 |
Scatter(points, name, i) |
점 9px, 판 색 테두리 | 두 값의 상관 |
- 색은 직접 고르지 않는다. 단일 계열은 accent 하나, 여러 계열은
seriesIndex를 0,1,2…로 넘기면Ubisam.Brush.Chart.Series1~6팔레트를 순서대로 쓴다(라이트/다크 각각 따로 정의돼 있다). 7개째부터는 색이 처음으로 돌아가므로, 계열이 그보다 많으면 차트를 나누는 편이 낫다. - 팔레트 색이 필요하면
ChartTheme.SeriesColor(i)로 꺼내 쓴다(범례 표식 등). - 소비 프로젝트에서 차트를 직접 쓰려면
LiveChartsCore,LiveChartsCore.SkiaSharpView,LiveChartsCore.SkiaSharpView.WPF,SkiaSharp— 4개를$(UbisamPlatformDir)에서 HintPath로 참조 추가해야 함(TestApp.csproj 예시 참고).
5. 화면 잠금 — UbisamBase.Core.Locking
- 하단 네비 "화면 잠금" →
LockConfirmDialog가 뜨고, 거기서 입력한 아이디/비밀번호가 그 순간 해제 키가 됨(고정 계정 저장 방식 아님).LockService.Current.Validator를 그 자리에서 교체. LockOverlay가 화면 전체를 불투명하게 덮음(Esc로 못 나감), 3회 실패 시 입력 잠김 → 구석의 "강제 잠금 해제" 버튼만 예외(LockService.Current.ForceUnlock(), 인증 없음).- 코드에서 직접 잠그려면:
LockService.Current.Lock()(주의: 이 경우Validator를 미리 설정해두지 않으면 아무도 못 풂 — 보통은LockConfirmDialog.ShowAndLock()을 통해서만 잠금). - 잠글 때 입력값은 평문으로
D:\UbisamConfig\ScreenSaver\last-lock.json에 기록됨(요청 사양).
6. 스크린 세이버 — UbisamBase.Core.ScreenSaving.ScreenSaverService
화면 잠금과 달리 인증 없이 코드에서 자유롭게 켜고 끕니다 — 특정 작업 중 사용자 조작을 막을 때 사용.
ScreenSaverService.Current.Show("작업을 진행 중입니다..."); // 즉시 켜기
ScreenSaverService.Current.Hide(); // 즉시 끄기
// 시간을 넘기면, 그 시간이 지나도 서비스가 스스로 끄지 않고 이벤트만 올린다 —
// 실제로 끌지는 구독한 쪽이 결정한다(항상 자유롭게 켜고 끌 수 있다는 원칙 유지).
ScreenSaverService.Current.TimerElapsed += OnElapsed;
ScreenSaverService.Current.Show("잠시만요...", seconds: 5);
void OnElapsed()
{
ScreenSaverService.Current.TimerElapsed -= OnElapsed;
ScreenSaverService.Current.Hide();
}
반투명 스크림 + 화면 가운데 accent색 큰 글자로 Show()에 넘긴 문구가 표시됩니다.
7. 이벤트 버스 — UbisamBase.Core.Messaging.IEventBus
문자열 subject 기반 Pub/Sub. AddUbisamBaseCore에서 이미 DI 싱글턴으로 등록되어 있음.
public MyViewModel(IEventBus bus)
{
subscription = bus.Subscribe("Order.Completed", OnOrderCompleted);
}
private void OnOrderCompleted(EventMessage msg)
{
// msg.Subject, msg.Param, msg.From
}
// 다른 곳에서:
bus.Publish(new EventMessage("Order.Completed", orderId, from: "OrderService"));
// 화면이 사라질 때
subscription.Dispose();
8. 타임아웃 관리자 — UbisamBase.Core.Utils.TimeOutManager
문자열 키 기반 타이머 모음. DI 싱글턴으로 등록되어 있음.
public MyViewModel(TimeOutManager timers) { _timers = timers; }
_timers["Ch1"].Start(limitMs: 5000);
if (_timers["Ch1"].IsTimeout()) { /* 5초 초과 */ }
long ms = _timers["Ch1"].End();
_timers.Remove("Ch1");
키는 미리 정의할 필요 없음(인덱서 접근 시 없으면 자동 생성). Start/End 시 "TimeUtil" 태그로
자동 로그가 남습니다.
8-1. 발표자 모드 — UbisamBase.Core.Shell.PlatformChrome
플랫폼이 그리는 껍데기(상단 브랜딩 바 · 네비게이션 탭 바 · 서브탭 줄)를 한 번에 숨긴다. 발표 자료를 띄울 때처럼 모듈 화면만 꽉 차게 보여주고 싶을 때 쓴다.
using UbisamBase.Core.Shell;
PlatformChrome.IsVisible = false; // 플랫폼 껍데기 전부 숨김 (발표자 모드)
PlatformChrome.IsVisible = true; // 원래대로
PlatformChrome.Toggle(); // 뒤집기 — 버튼/단축키에 걸어 쓰기 좋다
- 기본값은
true. 저장하지 않는다 — 앱을 다시 켜면 항상 보이는 상태로 시작한다. - 숨기면 종료 · 화면 잠금 · 로그 · 캡처 버튼도 같이 사라진다. 다시 켤 방법(모듈 화면의 버튼이나 키 입력)을 반드시 모듈 쪽에 만들어 둬야 한다.
- 숨기면 컨텐츠 여백(26,22)도 0이 된다 — 껍데기를 걷었는데 액자처럼 테두리가 남지 않는다.
- 토스트 메시지, 화면 잠금, 스크린 세이버 오버레이는 이 스위치와 무관하게 그대로 뜬다.
- 바뀔 때마다
VisibleChanged이벤트가 온다. 설정의 "상단 바 접기"나 다른 모듈이 바꾼 경우에도 오므로, 발표 모드를 나갈 때 타이머를 멈추거나 띄워둔 창을 닫는 정리는 여기서 한다.
PlatformChrome.VisibleChanged += OnChromeChanged;
Unloaded += (_, _) => PlatformChrome.VisibleChanged -= OnChromeChanged; // 정적 이벤트라 해제 필수
private void OnChromeChanged(object? sender, bool visible)
{
if (visible) { /* 발표 모드 종료 정리 — 타이머 정지, 발표자 창 닫기 등 */ }
}
8-2. 보조 모니터 — UbisamBase.Core.Shell.Displays
발표자 보기처럼 "본 창이 없는 쪽 모니터"에 창을 띄울 때 쓴다. 모듈이 System.Windows.Forms의
Screen을 직접 읽으면 실제 픽셀이 나와서, 배율(DPI)이 걸린 환경에서 DIP 환산을 빠뜨리면 창이
엉뚱한 자리에 뜬다 — 그 환산까지 여기서 처리한다.
var area = Displays.SecondaryWorkArea(Window.GetWindow(this)); // 모니터가 하나면 null
if (area is Rect r)
{
var win = new PresentWindow { WindowStartupLocation = WindowStartupLocation.Manual };
win.Left = r.Left; win.Top = r.Top; win.Width = r.Width; win.Height = r.Height;
win.Show();
}
Displays.HasSecondary— 모니터가 두 대 이상인지.Displays.WorkArea(owner)— owner 창이 있는 모니터의 작업 영역(DIP).- 값은 전부 작업표시줄을 뺀 작업 영역이고 WPF 좌표(DIP)다. 모니터가 셋 이상이면 owner가 없는 것 중 첫 번째(주 모니터 우선)를 돌려준다.
8-3. 닫기 확인 — UbisamBase.Core.Modules.ICloseGuard
저장하지 않은 변경이 있을 때 창이 그냥 닫히는 것을 막는 표준 통로. 모듈 View나 그 ViewModel이
구현하면 셸이 닫기 전에 물어본다. 셸 창의 Closing에 직접 붙지 말 것 — 모듈이 여럿이면
서로 Cancel/Close를 걸어 충돌한다.
public partial class DeckViewModel : ObservableObject, ICloseGuard
{
public async Task<bool> CanCloseAsync()
{
if (!IsDirty) return true;
return await AskUserAsync("저장하지 않은 변경이 있습니다. 닫을까요?");
}
}
- 한 번이라도 연 탭만 물어본다(아직 안 연 탭은 화면을 만들지 않는다).
- 하나라도 false를 돌려주면 거기서 멈추고 창은 닫히지 않는다(뒤의 화면에는 묻지 않는다).
- 하단 "종료" 버튼도 이 경로를 탄다. 반대로
Application.Current.Shutdown()을 직접 부르면 이 확인을 건너뛴다 — 모듈에서는 쓰지 말 것. - UI 설정의 "상단 바 접기"는 사용자가 저장해 두는 별개 취향이다. 둘 중 하나라도 숨기라고 하면 상단 바는 숨는다.
9. 테마 · UI 컨벤션
Ubisam.Brush.*,Ubisam.Style.*로 시작하는 리소스가 다크/라이트 테마 전체를 정의 (src/UbisamBase.Core/Themes/Colors.Dark.xaml,Colors.Light.xaml,Controls.xaml).TextBox/ComboBox/CheckBox/Button/ToggleButton/DataGrid는 전부 암시적 스타일 (x:Key없음) — 그냥<Button>,<TextBox>를 쓰면 자동으로 테마 적용됨. 예외 처리하려면Style="{x:Null}".- 새 창(Window)을 별도로 띄울 땐(대화상자 등)
Window.Resources에ShellStyles.xaml을 다시 merge해야 하고, 루트 컨테이너에TextElement.Foreground="{DynamicResource Ubisam.Brush.ContentForeground}"를 꼭 걸어야 함 — 안 그러면 스타일 없는 텍스트가 검정으로 보임(이 세션에서 실제로 겪은 버그). - 테마가 바뀌면
Application.Current.Resources.MergedDictionaries에서Colors.*.xaml을 갈아끼우는 방식(UiSettingsService.ApplyTheme) — DynamicResource로 참조해야 실시간 반영됨 (StaticResource는 최초 1회만 고정).
9-1. 설정 > UI Setting 항목 (UiSettingsService)
| 화면 항목 | 속성 | 설명 |
|---|---|---|
| 테마 | ThemeMode |
시스템 설정 따르기 / 라이트 / 다크 |
| 네비게이션 위치 | NavPosition |
왼쪽 / 오른쪽 / 하단 |
| 상단 바 접기 | TopBarCollapsed |
상단 브랜딩 바(로고·장비명·시계와 로그/캡처/모드 변경 버튼)를 숨긴다 |
| 토스트 메시지 위치 | ToastPosition |
좌상단 / 우상단 / 좌하단 / 우하단 |
| 화면 배율 | ChromeScale |
상단 바·탭 바 배율. BaselineChromeScale(1.2)이 화면상 "100%" |
| 언어 | LanguageService.CurrentCulture |
등록된 언어 리소스가 있을 때만 보인다 |
- 바꾸면 화면에 즉시 반영되고, "저장"을 눌러야
<설정 루트>\Settings\UiSettingsService.json에 남아 다음 실행에도 유지된다("새로고침"은 저장된 값으로 되돌린다). - 상단 바 접기는 사용자가 저장해 두는 취향이고, 8-1의
PlatformChrome(발표자 모드)은 코드에서 잠깐 껐다 켜는 모드다. 둘 중 하나라도 숨기라고 하면 상단 바는 숨는다.
9-2. 창 배치 (키오스크 / 창모드)
- 시작하면 키오스크(작업표시줄까지 덮는 전체화면). 상단 바의 "모드 변경" 버튼이나 Ctrl+Shift+F로 창모드와 오간다. Topmost는 걸지 않는다 — 현장의 다른 경보 창이 가려지면 안 되기 때문.
- 키오스크는 모니터 경계를 창 크기로 쓰되, 만들어진 창을 실측해 모니터보다 큰 만큼 되돌린다
(
ShellWindow.FitToMonitor). 화면 배율이 100%가 아닌 환경(예: 2560×1600 / 150%)에서 창이 20~30px 크게 만들어져 하단 네비게이션 탭 글자가 잘리던 문제 대응이다. 하단이 잘려 보이면 이 지점을 먼저 본다.
9-3. WebView2를 쓰는 모듈 (참고)
- WebView2는 WPF의 앞뒤 순서를 따르지 않는다. 자기 자식 윈도우에 직접 그리기 때문에 WebView2
위에 WPF 버튼을 올려도 보이지 않는다. 겹쳐 쓰려면 아래쪽 WebView2를
Visibility.Hidden으로 내린다. - 사용자 데이터 폴더를 모듈마다 나눈다. 같은 폴더를 쓰면 쿠키·로컬 저장소가 섞인다
(예: UbisamDeck은
D:\UbisamConfig\WebView2). - WebView2 안쪽은 크로미움이라 플랫폼 테마(WPF 스타일)가 닿지 않는다. 스크롤바 같은 것은
그 웹 페이지 CSS에서 직접 맞춰야 한다(
::-webkit-scrollbar).
10. 기존 C# 코드를 이 플랫폼에 연동하는 방법
10-1. 어디에 둘지 먼저 정한다
| 상황 | 두는 곳 |
|---|---|
| 여러 프로그램이 공유해서 쓸 공통 기능 | UbisamBase.Core에 새 폴더/네임스페이스로 옮기고 DI 싱글턴 등록 |
| 이 프로그램(소비 프로젝트)만 쓰는 기능 | 소비 프로젝트 안에 그대로 두고 RegisterServices에서 등록 |
공통으로 옮길 경우, TimeOutManager를 옮긴 실제 예시:
src/UbisamBase.Core/Utils/TimeOutManager.cs로 파일 복사, 네임스페이스를UbisamBase.Core.Utils로 변경.- 외부 의존성 정리 — 원래 코드가
Aurora.Api.Logger같은 이 플랫폼에 없는 타입을 쓰고 있으면, 시그니처가 호환되는 플랫폼 타입으로 바꿔치기한다. (Logger(string tag),.I/.W/.E(string)시그니처가 동일해서using Aurora.Api;→using UbisamBase.Core.Logging;한 줄 교체로 끝났다.) 호환 안 되면 그 부분만 새로 짠다. src/UbisamBase.Core/DependencyInjection/ServiceCollectionExtensions.cs의AddUbisamBaseCore에services.AddSingleton<TimeOutManager>();추가.- 이제 어떤 ViewModel이든 생성자에
TimeOutManager를 선언하면 주입받는다.
10-2. 소비 프로젝트에서 플랫폼을 참조하는 방식 — 모두 D드라이브 배포 폴더 하나
플랫폼 원본 소스는 D:\업무\03_개발\Source\용접기\08_UbisamPlatform에 있고, 그걸 빌드한
결과물은 D:\UbisamPlatform\bin에 모인다. 플랫폼을 쓰는 모든 프로그램은 이 배포 폴더만 본다 —
플랫폼을 고치는 개발자의 테스트 프로그램(TestApp)도 예외 없이 같다.
소비 프로젝트(App, TestApp, MergeHub 등)는 UbisamBase.Core를 ProjectReference가 아니라 파일
Reference로, 항상 D:\UbisamPlatform\bin\을 가리켜 참조한다:
<UbisamPlatformDir>D:\UbisamPlatform\bin\</UbisamPlatformDir>
<Reference Include="UbisamBase.Core">
<HintPath>$(UbisamPlatformDir)UbisamBase.Core.dll</HintPath>
<Private>false</Private>
</Reference>
전체 흐름
08_UbisamPlatform 빌드 (Debug/Release 아무거나)
└─ Directory.Build.targets가 결과물을 D:\UbisamPlatform\bin 에 반영
(.pdb 제외, Shell 설정 → Launcher 설정 복사, README 갱신)
소비 프로젝트 실행 (F5 또는 exe 더블클릭)
└─ UbisamBase.Launcher.exe
1) D:\UbisamPlatform\bin 전체를 자기 폴더로 복사
2) 같은 폴더의 Shell을 같은 프로세스 안에서 로드
3) Shell이 같은 폴더의 IAppSetup 구현 dll(소비 프로젝트)을 찾아 화면을 띄움
- 플랫폼을 고쳤으면 플랫폼만 빌드하고 소비 프로젝트를 실행하면 된다. 소비 프로젝트를 다시 빌드할 필요가 없다(빌드 절차가 없는 사용자 PC에서도 배포 폴더만 바뀌면 다음 실행부터 반영).
- Launcher가 Shell을 같은 프로세스에서 띄우므로 VS 디버거가 그대로 붙어 소비 프로젝트 중단점이 걸린다.
- 소비 프로젝트를 빌드할 때도
CopyPlatformRuntime타겟이 배포 폴더 전체를 출력 폴더로 복사한다. Launcher.exe 자체가 출력 폴더에 처음 자리 잡게 하는 용도다. - Debug 빌드도 바로 반영되므로, 고치다 만 코드도 빌드하는 순간 이 PC의 모든 플랫폼 프로그램에 적용된다.
- Core만 빌드하면 Core.dll만, Shell을 빌드하면 Shell 출력 전체가 반영된다. 헷갈리면 솔루션
(
UbisamBase.Platform.slnx) 단위로 빌드한다.
깨끗한 배포본 — scripts\publish-platform.ps1
자동 반영은 덮어쓰기만 하므로, 의존 패키지를 빼는 등으로 쓰지 않게 된 옛 dll이 배포 폴더에 남는다. 다른 PC에 넘기기 전처럼 깨끗한 Release 배포본이 필요하면 이 스크립트를 실행한다:
powershell -ExecutionPolicy Bypass -File scripts\publish-platform.ps1
1) D:\UbisamPlatform\bin 을 비운다
2) UbisamBase.Platform.slnx 를 Release로 빌드 → Directory.Build.targets가 폴더를 다시 채움
3) 필수 파일(Core.dll, Shell.exe, Launcher.exe, Launcher 설정, README) 확인
4) Launcher 설정에 바인딩 리디렉트가 있는지 확인
— Launcher 프로세스 안에서 Shell이 돌기 때문에 실제로 적용되는 건 Launcher 쪽 설정이다.
이게 없으면 실행 직후 FileLoadException으로 조용히 죽는다.
5) .pdb/.cs가 남아 있으면 에러로 중단 (안전장치)
다른 PC에서 쓰려면 D:\UbisamPlatform\bin 폴더를 통째로 그 PC의 같은 위치에 복사한다.
외부 NuGet 패키지가 새로 필요한 경우(예: LiveChartsCore)도 Core에 PackageReference를
추가하면 빌드 시 그 dll들이 배포 폴더에 자동으로 포함된다. 소비 프로젝트가 컴파일 타임에 그
타입을 쓰려면 소비 프로젝트 쪽에도 $(UbisamPlatformDir)해당dll.dll을 가리키는 파일
Reference를 추가해야 한다.
10-3. 체크리스트
- 네임스페이스를
UbisamBase.Core.*(공통) 또는 소비 프로젝트 네임스페이스(전용)로 정리했는가 - 이 플랫폼에 없는 외부 타입(로거, 설정 저장 등)을 플랫폼 대응 타입으로 교체했는가
- 공통이면
AddUbisamBaseCore에 등록, 전용이면IAppSetup.RegisterServices에 등록했는가 - 플랫폼(Core/Shell/Launcher)을 빌드했는가 (빌드하면
D:\UbisamPlatform\bin에 자동 반영) UbisamPlatformDir이D:\UbisamPlatform\bin\이고,StartProgram이$(MSBuildProjectDirectory)기반 절대경로로 Launcher를 가리키는가- 플랫폼이 Any CPU인가 (csproj
<Platforms>,.slnx에 x64 없음) - F5로 떴을 때
UbisamBase.Launcher프로세스 하나만 있고, 중단점이 걸리는가 - (외부 NuGet이 새로 필요하면) 소비 프로젝트에도 컴파일 타임 참조를 추가했는가
11. 저장 위치 요약
| 경로 | 내용 |
|---|---|
D:\UbisamConfig\Settings\ |
설정 클래스별 JSON |
D:\UbisamConfig\Log\ |
날짜별 로그 (yyyyMMdd.log) |
D:\UbisamConfig\ScreenSaver\ |
화면 잠금 시 마지막 아이디/비밀번호 |
여러 프로그램이 이 플랫폼으로 만들어져도 전부 D:\UbisamConfig 한 곳에 모입니다.
12. 플랫폼 자동 업데이트 (깃)
프로그램을 켤 때 깃 저장소의 업데이트 파일과 지금 깔린 플랫폼(D:\UbisamPlatform\bin)을 비교해,
새 버전이면 받아서 켠다. 확인과 내려받기는 Launcher가 Shell을 띄우기 전에 한다.
12-1. 동작 순서
설정에서 켜져 있나? → 아니오면 끝
인터넷이 연결됐나? → 아니오면 끝(조용히)
저장소에 새 커밋이 있나?(git ls-remote) → 없으면 끝
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자격 증명이 저장돼 있어야 한다.
12-2. 쓰는 쪽 설정 — 설정 > UI Setting > "플랫폼 업데이트"
| 항목 | 값 |
|---|---|
| 사용 / 사용 안 함 | 업데이트 확인 기능 자체를 켜고 끈다(기본: 사용 안 함) |
| 자동으로 업데이트 / 업데이트할지 물어보기 | 새 버전을 찾았을 때 묻지 않고 받을지 |
| 저장소 주소 | 플랫폼 저장소 URL. 비어 있으면 확인하지 않는다 |
값은 D:\UbisamPlatform\update-settings.json에 저장된다(배포 폴더 바깥 — 업데이트가 bin을
통째로 덮어쓰기 때문). 스키마는 아래와 같고, Core(PlatformUpdateSettings)와
Launcher(PlatformUpdater.Settings)가 같은 이름으로 읽고 쓴다. 이름을 바꾸면 양쪽을 같이 고칠 것.
{
"Enabled": true,
"AutoUpdate": false,
"RepositoryUrl": "https://.../UbisamPlatform.git",
"Branch": "main",
"DistPath": "dist",
"InstalledVersion": "2026-09-21 21:53:23",
"InstalledCommit": "a1b2c3..."
}
InstalledVersion/InstalledCommit은 Launcher가 기록한다(손대지 않는다).
12-3. 내는 쪽 — 새 버전 배포하기
scripts\publish-platform.ps1실행 —D:\UbisamPlatform\bin을 비우고 Release로 다시 채운 뒤, 저장소의dist\폴더를 그 내용과 똑같이 맞춘다(robocopy /MIR).git add dist && git commit && git push.- 다른 PC는 다음 실행 때 새 커밋을 보고 업데이트한다.
- 버전 기준은
platform-version.json의version(= 배포 시각)이다. 빌드할 때마다Directory.Build.targets가D:\UbisamPlatform\bin에 찍고, 그 파일이dist에 함께 올라간다. - 소스만 바꾼 커밋(=
dist가 그대로)에는 아무에게도 업데이트를 묻지 않는다. - 업데이트는 덮어쓰기다. 쓰지 않게 된 옛 파일까지 정리하려면
publish-platform.ps1로bin을 비우고 다시 채운 뒤dist를 올리면 된다.
13. 사내 API 호출 — UbisamBase.Core.Net.ApiService
토큰이 필요한 사내 API를 앱이 대신 호출해 응답 본문(JSON 문자열)만 돌려준다. 화면(WebView2 등)이 직접 부르면 토큰이 그 문서에 섞여 들어가므로, 앱이 부르고 값만 넘기는 구조를 공용으로 둔 것이다. 브라우저에서 막히는 CORS도 이 경로로는 문제가 되지 않는다.
using UbisamBase.Core.Net;
var json = await ApiService.Current.FetchAsync(url); // 실패하면 예외
var (ok, body, error) = await ApiService.Current.TryFetchAsync(url); // 예외 대신 결과로 받기
- 인증 헤더는 서비스 안에서 붙는다 — 부르는 쪽은 토큰을 몰라도 된다.
- 같은 주소를
MinIntervalMs(기본 1초) 안에 다시 부르면 서버를 때리지 않고 직전 응답을 돌려준다. 화면 갱신 주기가 짧아도 호출 제한에 걸리지 않게 하기 위한 것이다. - 로그에는 주소의
?앞부분만 남긴다(쿼리에 토큰이 섞인 경우 대비). 응답 본문도 남기지 않는다.
13-1. 설정 — D:\UbisamConfig\Settings\ApiSettings.json
{
"AuthMode": "BearerHeader",
"HeaderName": "X-Api-Key",
"Token": "...",
"MinIntervalMs": 1000,
"TimeoutSeconds": 10
}
| AuthMode | 붙는 헤더 |
|---|---|
None |
없음(사내망 IP 제한 등) |
BearerHeader |
Authorization: Bearer {Token} |
CustomHeader |
{HeaderName}: {Token} |
Cookie |
Cookie: {Token} |
토큰은 이 파일에만 있다. 내보낸 HTML·.ub 같은 산출물에는 들어가지 않는다.
13-2. 내보낸 파일에서는 인증 API를 못 부른다
앱 밖으로 내보낸 HTML은 혼자 도는 파일이라 이 서비스가 없다. 그래서:
- 앱 안에서 볼 때 →
ApiService로 최신 값 - 내보낸 파일을 남에게 줄 때 → 인증이 필요한 API는 호출 불가, 내보낼 때 담긴 마지막 값이 보인다 (인증이 없는 공개 API라면 내보낸 파일에서도 실시간으로 돈다)
이는 제약이라기보다 의도된 동작이다 — 토큰이 파일에 들어가지 않기 때문이다.