Compare commits
10 Commits
5e5ef8c36f
...
9af4bf3bdb
| Author | SHA1 | Date | |
|---|---|---|---|
| 9af4bf3bdb | |||
| 6991dfdfcf | |||
| 00b3754063 | |||
| a5411d13b0 | |||
| 649599de48 | |||
| 83d207462f | |||
| 7dbac78c6e | |||
| 7d48090af6 | |||
| e788684bd9 | |||
| d11f428045 |
2
dist/README.txt
vendored
2
dist/README.txt
vendored
@ -1,5 +1,5 @@
|
||||
UbisamBase 플랫폼 배포 폴더 (빌드할 때 자동 생성 - 직접 고치지 마세요)
|
||||
갱신 시각 : 2026-09-21 21:55:55
|
||||
갱신 시각 : 2026-09-21 22:44:28
|
||||
빌드 구성 : Release
|
||||
원본 소스 : D:\업무\03_개발\Source\용접기\08_UbisamPlatform\
|
||||
----
|
||||
|
||||
BIN
dist/UbisamBase.Core.dll
vendored
BIN
dist/UbisamBase.Core.dll
vendored
Binary file not shown.
BIN
dist/UbisamBase.Launcher.exe
vendored
BIN
dist/UbisamBase.Launcher.exe
vendored
Binary file not shown.
BIN
dist/UbisamBase.Shell.exe
vendored
BIN
dist/UbisamBase.Shell.exe
vendored
Binary file not shown.
2
dist/platform-version.json
vendored
2
dist/platform-version.json
vendored
@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2026-09-21 21:55:55",
|
||||
"version": "2026-09-21 22:44:28",
|
||||
"configuration": "Release"
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# 변경 이력
|
||||
# 변경 이력
|
||||
|
||||
| 날짜 | 내용 |
|
||||
| --- | --- |
|
||||
@ -13,3 +13,21 @@
|
||||
| 2026-09-21 | `ICloseGuard` 추가 — 저장 안 한 변경 확인을 셸이 단일 통로로 처리(하단 "종료"도 이 경로) (건의 4) |
|
||||
| 2026-09-21 | 플랫폼 스크롤바 색 조정(트랙=테마 판 색, 막대=accent) |
|
||||
| 2026-09-21 | 플랫폼 자동 업데이트 추가 — Launcher가 켤 때 깃 저장소의 `dist`와 비교해 새 버전이면 받아서 실행(UI Setting에서 사용/자동 여부 선택) |
|
||||
| 2026-09-21 | 업데이트 안내창을 플랫폼 디자인으로 교체(Launcher 자체 XAML, 시스템 테마 따름) |
|
||||
| 2026-09-21 | `ApiService` 추가 — 사내 API를 앱이 대신 호출(토큰은 설정 파일에만, 호출 간격 제한·타임아웃 포함) |
|
||||
| 2026-09-21 | 차트 종류별 디자인 정리 — 면적·계단선·누적/가로 막대·원형·도넛·산점 추가, 계열 팔레트(Chart.Series1~6)와 툴팁·범례 테마 적용 |
|
||||
| 2026-09-21 | 업데이트 진행 창 추가 — 런처를 누르자마자 확인/받기 상태와 진행률을 보여주고, 다 받은 뒤 설치 여부를 묻는다 |
|
||||
| 2026-09-21 | Shell.exe를 직접 실행하면 런처로 넘기도록 — 진입점을 런처 하나로 통일(업데이트 확인 누락 방지) |
|
||||
| 2026-09-21 | 런처 창(진행·업데이트 안내)이 앱의 테마 설정(다크/라이트)을 따르도록 — 윈도우 설정만 보던 것을 수정 |
|
||||
| 2026-09-21 | `PlatformChrome.FullBleed` 첨부 속성 추가 — 화면을 꽉 쓰는 모듈이 셸 여백을 끌 수 있게 (건의 6) |
|
||||
| 2026-09-28 | 하단 탭 줄 항목을 한 단계 작게(74 → 61 DIP) — 여백 13/14→9/10 · 아이콘 22→20 · 아이콘 아래 틈 7→5 · 글자 13.5→12.5. 배율 1.2 에서 89 → 73px, 그만큼 모듈 화면이 넓어진다(MergeHub 12차 C-2). **모든 소비 프로그램의 탭 줄 높이가 함께 바뀐다** |
|
||||
| 2026-09-23 | `MessageUtil.ShowChoice` 추가 — 세 갈래로 묻는 창(저장하고 계속 · 저장하지 않고 계속 · 취소), Esc·창 닫기는 취소 |
|
||||
| 2026-09-23 | 덮는 창을 본 창의 **실제 자리** 가운데로(`OwnerCentering`) — 전체화면일 때 세로로 어긋나던 것 수정 |
|
||||
| 2026-09-23 | D 드라이브가 없는 PC 대응(`ConfigLocation`) — `%LOCALAPPDATA%\UbisamConfig` 로 넘어가고, 두 자리 다 못 쓰면 까닭을 적은 안내창 |
|
||||
| 2026-09-23 | 켤 때 터지면 창 없이 죽지 않고 안내창(로그 자리 포함)을 띄우고 끝내도록 |
|
||||
| 2026-09-23 | 셸 창 모양 기억(`ShellWindowPlacement.json`) — 마지막 모드(전체화면·창모드)와 창모드 자리·크기 |
|
||||
| 2026-09-23 | 켤 때 기억한 자리를 모니터 안으로(`ShellPlacementMath.FitToMonitors`) — 모니터를 떼거나 배율이 바뀌어도 창을 잡을 수 있게 |
|
||||
| 2026-09-23 | 최대화 크기를 그 모니터에 맞춤(`NeedsMaximizeFix`) — 배율이 크게 다른 모니터에서 오른쪽·아래가 잘리던 것 수정 |
|
||||
| 2026-09-23 | 탭 줄 접기 추가 — 하단 [탭 접기]와 `F11`, 접으면 얇은 띠만 남고 띠를 누르면 펴짐(`UiSettingsService.NavCollapsed`) |
|
||||
| 2026-09-23 | 트레이 추가 — 최소화하면 알림 영역으로 내려가고, 왼쪽 단추로 되살리기 · 오른쪽 단추로 [열기]/[종료](WinForms 의존 없이 `Shell_NotifyIcon`) |
|
||||
| 2026-09-23 | `SettingsService.RegisterSettings` 에 같은 키 중복 등록 방지 — 코드가 쓰는 객체와 설정 화면이 고치는 객체가 갈라지던 문제 |
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# UbisamBase 플랫폼 사용 가이드 (AI/개발자 참고용)
|
||||
# UbisamBase 플랫폼 사용 가이드 (AI/개발자 참고용)
|
||||
|
||||
이 문서는 다른 AI 세션이나 개발자가 이 플랫폼으로 새 프로그램을 만들거나 기존 코드를 이식할 때
|
||||
빠르게 참고할 수 있도록 정리한 기술 레퍼런스입니다. 사람이 읽는 슬라이드 버전은
|
||||
@ -256,6 +256,13 @@ MessageUtil.ShowInfo("알림", "저장되었습니다.");
|
||||
MessageUtil.ShowError("오류", "저장하지 못했습니다.");
|
||||
bool yes = MessageUtil.ShowYesNo("확인", "계속하시겠습니까?");
|
||||
|
||||
// 세 갈래로 묻기 — 예/아니오로 가를 수 없는 자리("저장하고 계속 · 저장하지 않고 계속 · 취소").
|
||||
// 단추 글은 부르는 쪽이 정하고, Esc · 창 닫기는 Cancel 이다.
|
||||
DialogChoice choice = MessageUtil.ShowChoice(
|
||||
"저장하지 않은 편집",
|
||||
"Unit0.cs 에 저장하지 않은 편집이 있습니다.",
|
||||
"저장하고 계속", "저장하지 않고 계속");
|
||||
|
||||
// 토스트 — 3초 후 자동 소멸, 화면 끝에서 슬라이드 인/아웃
|
||||
MessageUtil.ShowToast("정보 메시지");
|
||||
MessageUtil.ShowSuccessToast("저장되었습니다.");
|
||||
@ -266,6 +273,12 @@ MessageUtil.ShowErrorToast("장비 통신이 끊어졌습니다.");
|
||||
토스트 표시 위치(좌상단/좌하단/우상단/우하단)는 설정 > UI Setting에서 사용자가 바꿀 수 있습니다
|
||||
(`UiSettingsService.ToastPosition`).
|
||||
|
||||
**왜 세 갈래가 필요한가** — 예/아니오로는 "버리고 가기"와 "그만두기"가 구분되지 않습니다.
|
||||
아니오를 눌렀을 때 그대로 넘어가 버리면 그것이 곧 잃는 것입니다.
|
||||
|
||||
덮는 창(대화상자)은 뜬 뒤에 **본 창의 실제 자리 가운데**로 맞춰집니다(`OwnerCentering`).
|
||||
WPF `CenterOwner`는 본 창이 전체화면일 때 "복원 자리"를 기준으로 삼아 세로로 어긋났습니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 차트 — `UbisamBase.Core.Charting.ChartTheme` (LiveCharts2)
|
||||
@ -299,7 +312,32 @@ private void Rebuild()
|
||||
```
|
||||
|
||||
- **`Background="Transparent"`를 꼭 지정** — 기본값은 흰 배경이라 다크 테마에서 튐.
|
||||
- `ChartTheme.Column(values, name)`, `ChartTheme.Gauge(percent)`(도넛 게이지, `ISeries[]` 반환)도 있음.
|
||||
- **툴팁·범례는 따로 입혀야 한다** — 지정하지 않으면 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 예시 참고).
|
||||
@ -460,6 +498,54 @@ public partial class DeckViewModel : ObservableObject, ICloseGuard
|
||||
이 확인을 건너뛴다 — 모듈에서는 쓰지 말 것.
|
||||
- UI 설정의 "상단 바 접기"는 사용자가 저장해 두는 별개 취향이다. 둘 중 하나라도 숨기라고 하면 상단 바는 숨는다.
|
||||
|
||||
## 8-4. 화면을 꽉 쓰는 모듈 — `PlatformChrome.FullBleed`
|
||||
|
||||
셸은 모듈 화면 둘레에 여백(26,22)을 준다. 설정 화면이나 폼처럼 글을 읽는 화면에는 그 여백이
|
||||
있어야 보기 좋지만, 편집기·지도·카메라처럼 **자기 안에서 이미 칸을 나누는 모듈**은 여백이 한 겹 더
|
||||
붙으면 화면만 좁아지고 테두리처럼 보인다. 그런 모듈은 View에 첨부 속성 하나만 붙이면 된다.
|
||||
|
||||
```xml
|
||||
<UserControl x:Class="MyModule.Views.EditorView"
|
||||
xmlns:shell="clr-namespace:UbisamBase.Core.Shell;assembly=UbisamBase.Core"
|
||||
shell:PlatformChrome.FullBleed="True">
|
||||
```
|
||||
|
||||
- 기본은 여백 있음(False). 이 화면이 떠 있는 동안만 여백이 사라지고, 다른 탭으로 옮기면 그 탭의
|
||||
설정을 따른다.
|
||||
- 발표자 모드(`PlatformChrome.IsVisible = false`)에서는 이 값과 무관하게 여백이 0이다.
|
||||
- 코드에서 바꿔도 된다 — `PlatformChrome.SetFullBleed(view, true)`. 떠 있는 동안 바꿔도 셸이 다시
|
||||
계산한다(`FullBleedChanged`).
|
||||
- **비주얼 트리를 거슬러 올라가 Border의 Padding을 직접 0으로 만들지 말 것.** 플랫폼 레이아웃이
|
||||
바뀌면 그대로 깨진다 — 이 속성이 그 자리를 대신한다.
|
||||
|
||||
---
|
||||
|
||||
## 8-5. 창 모양 — 전체화면 · 창모드 · 최대화 · 트레이
|
||||
|
||||
셸 창의 모양은 모듈이 손댈 것이 없습니다. 플랫폼이 알아서 하고, 사용자가 고른 것을 기억합니다.
|
||||
|
||||
| 하는 일 | 어디서 | 기억하는 곳 |
|
||||
| --- | --- | --- |
|
||||
| 전체화면 ↔ 창모드 | 상단 바 오른쪽 단추, `Ctrl+Shift+F` | `ShellWindowPlacement.json` |
|
||||
| 창모드 자리·크기 | 마우스로 옮기고 끌기 | 같은 파일(닫을 때 씀) |
|
||||
| 최소화 → 트레이 | 하단 [바탕화면], 창 최소화 | — |
|
||||
| 탭 줄 접기 | 하단 [탭 접기], `F11` | `UiSettingsService.NavCollapsed` |
|
||||
|
||||
- **켤 때** — 지난번에 창모드로 껐으면 그 자리·크기로, 아니면 전체화면으로 뜬다. 기억한 자리가
|
||||
지금 모니터 밖이면(모니터를 떼거나 해상도·배율이 바뀜) **가장 많이 겹치는 모니터 안으로** 민다.
|
||||
어느 모니터에도 안 걸치면 주 모니터 가운데다. 파일이 없거나 깨졌으면 전체화면 — 못 켜지는 일은 없다.
|
||||
- **최대화** — 배율이 크게 다른 모니터(주 150% 옆 300%)에서는 창이 모니터보다 크게 잡혀 오른쪽·아래가
|
||||
잘렸다(재현: 3840×2160 모니터에서 창 5120×2186). 최대화된 뒤 한 번 다시 놓아 맞춘다
|
||||
(`ShellPlacementMath.NeedsMaximizeFix`). 이미 맞으면 손대지 않는다.
|
||||
- **트레이** — 최소화하면 작업 표시줄이 아니라 알림 영역(트레이)으로 내려간다. 아이콘을 왼쪽 단추로
|
||||
누르면 **내려가기 전 모양 그대로** 되살아나고, 오른쪽 단추로 누르면 [열기] · [종료] 가 나온다.
|
||||
[종료]는 창을 되살린 뒤 평소 경로로 닫으므로 `ICloseGuard` 확인을 건너뛰지 않는다.
|
||||
아이콘은 실행 파일의 것을 쓴다 — 프로그램마다 자기 아이콘이 나온다. WinForms `NotifyIcon` 을 쓰지 않고
|
||||
`Shell_NotifyIcon` 을 직접 부른다(플랫폼에 WinForms 의존을 더하지 않으려고).
|
||||
윈도우 11 은 처음 보는 트레이 아이콘을 **숨김 영역**에 넣는다 — 안 보이면 `^`(숨겨진 아이콘 표시) 안에 있다.
|
||||
- **탭 줄 접기** — 접으면 얇은 띠만 남고, 띠를 누르거나 `F11` 로 다시 편다. 접힘은 사용자 취향이라
|
||||
UI 설정 파일에 그 항목만 따로 쓴다(설정 화면에서 저장하지 않은 값은 건드리지 않는다).
|
||||
|
||||
## 9. 테마 · UI 컨벤션
|
||||
|
||||
- `Ubisam.Brush.*`, `Ubisam.Style.*`로 시작하는 리소스가 다크/라이트 테마 전체를 정의
|
||||
@ -618,11 +704,18 @@ Reference를 추가해야 한다.
|
||||
| 경로 | 내용 |
|
||||
| --- | --- |
|
||||
| `D:\UbisamConfig\Settings\` | 설정 클래스별 JSON |
|
||||
| `D:\UbisamConfig\Settings\ShellWindowPlacement.json` | 셸 창의 마지막 모양(전체화면 여부·창모드 자리) |
|
||||
| `D:\UbisamConfig\Log\` | 날짜별 로그 (`yyyyMMdd.log`) |
|
||||
| `D:\UbisamConfig\ScreenSaver\` | 화면 잠금 시 마지막 아이디/비밀번호 |
|
||||
|
||||
여러 프로그램이 이 플랫폼으로 만들어져도 전부 `D:\UbisamConfig` 한 곳에 모입니다.
|
||||
|
||||
**D 드라이브가 없는 PC** — `D:\UbisamConfig` 를 만들 수 없으면(드라이브가 없거나, 같은 이름 파일이
|
||||
있거나, 권한이 없으면) `%LOCALAPPDATA%\UbisamConfig` 에 씁니다(`ConfigLocation`). 어디에 쓰는지는
|
||||
로그 첫 줄에 남습니다. 두 자리 모두 못 쓰면 **까닭을 적은 안내창**을 띄우고 끝냅니다 — 예전에는
|
||||
창 없는 프로세스만 남아 "켜지지 않는다"로 보였습니다. 설정 폴더 자리는 `UbisamAppBuilder.ConfigRoot`
|
||||
로 읽습니다(상수가 아니라 켤 때 정해지는 값이라 하드코딩하지 말 것).
|
||||
|
||||
---
|
||||
|
||||
## 12. 플랫폼 자동 업데이트 (깃)
|
||||
@ -640,9 +733,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` 자격 증명이 저장돼 있어야 한다.
|
||||
@ -686,3 +787,54 @@ Launcher(`PlatformUpdater.Settings`)가 같은 이름으로 읽고 쓴다. **이
|
||||
- 업데이트는 **덮어쓰기**다. 쓰지 않게 된 옛 파일까지 정리하려면 `publish-platform.ps1`로 `bin`을
|
||||
비우고 다시 채운 뒤 `dist`를 올리면 된다.
|
||||
|
||||
---
|
||||
|
||||
## 13. 사내 API 호출 — `UbisamBase.Core.Net.ApiService`
|
||||
|
||||
토큰이 필요한 사내 API를 **앱이 대신 호출해** 응답 본문(JSON 문자열)만 돌려준다. 화면(WebView2 등)이
|
||||
직접 부르면 토큰이 그 문서에 섞여 들어가므로, 앱이 부르고 값만 넘기는 구조를 공용으로 둔 것이다.
|
||||
브라우저에서 막히는 CORS도 이 경로로는 문제가 되지 않는다.
|
||||
|
||||
```csharp
|
||||
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`
|
||||
|
||||
```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라면 내보낸 파일에서도 실시간으로 돈다)
|
||||
|
||||
이는 제약이라기보다 의도된 동작이다 — 토큰이 파일에 들어가지 않기 때문이다.
|
||||
|
||||
|
||||
65
src/UbisamBase.Core/Bootstrap/ConfigLocation.cs
Normal file
65
src/UbisamBase.Core/Bootstrap/ConfigLocation.cs
Normal file
@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace UbisamBase.Core.Bootstrap;
|
||||
|
||||
/// <summary>
|
||||
/// 설정 · 로그 · 화면 잠금 기록을 둘 자리를 정한다.
|
||||
///
|
||||
/// 보통은 <see cref="Preferred"/>(<c>D:\UbisamConfig</c>) 다 — 여러 프로그램이 이 플랫폼으로 만들어져도
|
||||
/// 한 곳에 모인다. <b>D 드라이브가 없거나 그 자리에 폴더를 만들 수 없으면</b> <see cref="Fallback"/>
|
||||
/// (<c>%LOCALAPPDATA%\UbisamConfig</c>)에 쓴다(MergeHub 11차 B-2, 2026-09-22 사용자 결정).
|
||||
///
|
||||
/// 예전에는 D:\ 고정이었고 폴더를 못 만들 때 감싼 곳이 없었다. 셸이 켜지다 말고 공용 예외 창을 띄운 뒤
|
||||
/// <b>창 없는 프로세스만 남았다</b>(D:\UbisamConfig 자리에 같은 이름 파일을 두어 같은 줄에서 재현).
|
||||
/// D 드라이브가 있는 PC 에서는 예전과 똑같다.
|
||||
/// </summary>
|
||||
public static class ConfigLocation
|
||||
{
|
||||
/// <summary>늘 먼저 쓰는 자리.</summary>
|
||||
public const string Preferred = @"D:\UbisamConfig";
|
||||
|
||||
/// <summary>먼저 쓰는 자리를 못 쓸 때의 자리 - Windows 계정마다 따로다.</summary>
|
||||
public static string Fallback =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "UbisamConfig");
|
||||
|
||||
/// <summary>
|
||||
/// 쓸 자리를 고른다.
|
||||
/// </summary>
|
||||
/// <param name="tryCreate">자리를 만들어 보고, 되면 null · 안 되면 까닭을 돌려준다(<see cref="TryCreate"/>).</param>
|
||||
/// <returns>고른 자리와, 먼저 쓰는 자리를 못 쓴 까닭(썼으면 null).</returns>
|
||||
/// <exception cref="IOException">두 자리 모두 못 쓸 때 - 두 까닭을 함께 적는다.</exception>
|
||||
public static (string Root, string? FallbackReason) Choose(string preferred, string fallback, Func<string, string?> tryCreate)
|
||||
{
|
||||
var first = tryCreate(preferred);
|
||||
if (first is null) return (preferred, null);
|
||||
|
||||
var second = tryCreate(fallback);
|
||||
if (second is null) return (fallback, first);
|
||||
|
||||
throw new IOException($"설정 폴더를 만들 수 없습니다.{Environment.NewLine}" +
|
||||
$"{preferred} : {first}{Environment.NewLine}" +
|
||||
$"{fallback} : {second}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 그 자리에 Settings 폴더를 만들어 본다. 되면 null, 안 되면 까닭.
|
||||
/// 드라이브가 없으면 DirectoryNotFoundException, 같은 이름 파일이 있으면 IOException, 권한이 없으면 UnauthorizedAccessException 이다.
|
||||
/// </summary>
|
||||
public static string? TryCreate(string root)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(root, "Settings"));
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException)
|
||||
{
|
||||
return ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>다른 자리를 쓰게 됐을 때 로그에 남길 글.</summary>
|
||||
public static string FallbackNote(string preferred, string used, string reason) =>
|
||||
$"{preferred} 를 쓸 수 없어 {used} 에 설정 · 로그를 둡니다 ({reason})";
|
||||
}
|
||||
31
src/UbisamBase.Core/Bootstrap/StartupMessages.cs
Normal file
31
src/UbisamBase.Core/Bootstrap/StartupMessages.cs
Normal file
@ -0,0 +1,31 @@
|
||||
using System;
|
||||
|
||||
namespace UbisamBase.Core.Bootstrap;
|
||||
|
||||
/// <summary>
|
||||
/// 셸이 사람에게 보이는 실패 글(MergeHub 11차 B-2).
|
||||
///
|
||||
/// 예전 글은 켜지다 만 때에도 "프로그램은 계속 쓸 수 있습니다" · "로그 파일에 남았습니다" 라고 했다.
|
||||
/// 본 창이 안 떴으니 쓸 것이 없었고, 로그는 그보다 뒤에 준비되므로 아무 데도 안 남았다.
|
||||
/// 그래서 <b>켜는 중 실패</b>와 <b>쓰는 중 실패</b>를 나누고, 로그는 준비됐을 때만 자리를 댄다.
|
||||
/// </summary>
|
||||
public static class StartupMessages
|
||||
{
|
||||
/// <summary>본 창이 뜨기 전에 실패했을 때. 이 글을 보인 뒤 프로그램을 끝낸다.</summary>
|
||||
public static string StartFailed(string reason, string? logDirectory) =>
|
||||
"프로그램을 켜지 못해 끝냅니다." + Environment.NewLine +
|
||||
Environment.NewLine +
|
||||
reason + LogHint(logDirectory);
|
||||
|
||||
/// <summary>쓰는 도중 한 동작이 실패했을 때. 프로그램은 계속 돈다.</summary>
|
||||
public static string ActionFailed(string reason, string? logDirectory) =>
|
||||
"작업 중 문제가 생겨 이 동작을 멈췄습니다. 프로그램은 계속 쓸 수 있습니다." + Environment.NewLine +
|
||||
Environment.NewLine +
|
||||
reason + LogHint(logDirectory);
|
||||
|
||||
/// <summary>로그가 준비됐을 때만 자리를 댄다 - 못 남긴 것을 남았다고 하지 않는다.</summary>
|
||||
public static string LogHint(string? logDirectory) =>
|
||||
string.IsNullOrEmpty(logDirectory)
|
||||
? string.Empty
|
||||
: Environment.NewLine + Environment.NewLine + "자세한 내용은 " + logDirectory + " 아래 로그 파일에 남았습니다.";
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Windows;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@ -24,17 +24,32 @@ namespace UbisamBase.Core.Bootstrap;
|
||||
public static class UbisamAppBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// 설정 JSON(Settings 폴더)이 저장되는 고정 위치. exe가 어디서 실행되든, 여러 프로그램이
|
||||
/// 이 플랫폼으로 만들어져도 항상 D:\UbisamConfig\Settings\ 아래에 모인다. 폴더가 없으면 자동 생성된다.
|
||||
/// 설정 JSON(Settings 폴더) · 로그가 저장되는 자리. 여러 프로그램이 이 플랫폼으로 만들어져도 한 곳에 모인다.
|
||||
///
|
||||
/// 보통은 <c>D:\UbisamConfig</c> 다. <b>D 드라이브가 없거나 거기에 폴더를 만들 수 없으면</b>
|
||||
/// <c>%LOCALAPPDATA%\UbisamConfig</c> 를 쓴다(<see cref="ConfigLocation"/>).
|
||||
/// <b>상수가 아니라 속성</b>이다 - 상수는 소비 프로그램에 글자로 박혀, 다시 빌드하지 않으면 옛 자리를 계속 본다.
|
||||
/// <see cref="Run"/> 전에는 <see cref="ConfigLocation.Preferred"/> 다.
|
||||
/// </summary>
|
||||
public const string ConfigRoot = @"D:\UbisamConfig";
|
||||
public static string ConfigRoot { get; private set; } = ConfigLocation.Preferred;
|
||||
|
||||
/// <summary><see cref="ConfigLocation.Preferred"/> 를 못 쓰고 다른 자리를 쓴 까닭. 썼으면 null.</summary>
|
||||
public static string? ConfigFallbackReason { get; private set; }
|
||||
|
||||
public static IServiceProvider Run(string basePath, Action<IServiceCollection>? configureServices = null)
|
||||
{
|
||||
var uiSettings = new UiSettingsService();
|
||||
uiSettings.Initialize(ConfigRoot);
|
||||
// 자리를 먼저 고르고 로그부터 준비한다 - 뒤에서 무엇이 실패해도 까닭이 파일에 남는다.
|
||||
(ConfigRoot, ConfigFallbackReason) = ConfigLocation.Choose(
|
||||
ConfigLocation.Preferred, ConfigLocation.Fallback, ConfigLocation.TryCreate);
|
||||
|
||||
LogService.Current.Initialize(ConfigRoot);
|
||||
if (ConfigFallbackReason is not null)
|
||||
{
|
||||
new Logger("Platform").W(ConfigLocation.FallbackNote(ConfigLocation.Preferred, ConfigRoot, ConfigFallbackReason));
|
||||
}
|
||||
|
||||
var uiSettings = new UiSettingsService();
|
||||
uiSettings.Initialize(ConfigRoot);
|
||||
ButtonClickLogger.Install();
|
||||
FullscreenHotkeyInstaller.Install();
|
||||
|
||||
|
||||
@ -2,7 +2,9 @@ using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using LiveChartsCore;
|
||||
using LiveChartsCore.Defaults;
|
||||
using LiveChartsCore.SkiaSharpView;
|
||||
using LiveChartsCore.SkiaSharpView.WPF;
|
||||
using LiveChartsCore.SkiaSharpView.Painting;
|
||||
using LiveChartsCore.SkiaSharpView.Painting.Effects;
|
||||
using SkiaSharp;
|
||||
@ -117,4 +119,204 @@ public static class ChartTheme
|
||||
LabelsPaint = new SolidColorPaint(Muted) { SKTypeface = BodyTypeface },
|
||||
SeparatorsPaint = new SolidColorPaint(GridLine, 1)
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 시리즈 색
|
||||
// 단일 계열 차트는 지금까지처럼 accent 하나만 쓴다. 여러 계열을 한 그래프에 그릴 때만
|
||||
// 아래 팔레트를 순서대로 쓴다 — 색을 화면마다 고르면 프로그램마다 톤이 달라진다.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>계열 순번(0부터)에 해당하는 색. 6개를 넘어가면 처음으로 돌아간다.</summary>
|
||||
public static SKColor SeriesColor(int index)
|
||||
=> ReadColor("Ubisam.Brush.Chart.Series" + ((Math.Abs(index) % 6) + 1));
|
||||
|
||||
/// <summary>꺾은선 — 여러 계열용. 색만 팔레트에서 가져오고 나머지는 단일 계열과 같다(두께 2, 보간 없음).</summary>
|
||||
public static ISeries Line(double[] values, string name, int seriesIndex)
|
||||
{
|
||||
var color = SeriesColor(seriesIndex);
|
||||
return new LineSeries<double>
|
||||
{
|
||||
Values = values,
|
||||
Name = name,
|
||||
Stroke = new SolidColorPaint(color, 2),
|
||||
Fill = null,
|
||||
GeometrySize = 0,
|
||||
LineSmoothness = 0,
|
||||
AnimationsSpeed = AnimationSpeed
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>부드러운 꺾은선 — 추세용. 공정 측정값처럼 "튄 지점"이 중요한 데이터에는 쓰지 말 것
|
||||
/// (보간이 실제로 없던 중간값을 지어낸다).</summary>
|
||||
public static ISeries SmoothLine(double[] values, string name, int seriesIndex = 0)
|
||||
{
|
||||
var color = SeriesColor(seriesIndex);
|
||||
return new LineSeries<double>
|
||||
{
|
||||
Values = values,
|
||||
Name = name,
|
||||
Stroke = new SolidColorPaint(color, 2),
|
||||
Fill = null,
|
||||
GeometrySize = 0,
|
||||
LineSmoothness = 0.65,
|
||||
AnimationsSpeed = AnimationSpeed
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>면적 — 누적량·점유량처럼 "쌓인 양"을 보여줄 때. 겹쳐 그릴 수 있도록 채움을 24%로 둔다.</summary>
|
||||
public static ISeries Area(double[] values, string name, int seriesIndex = 0)
|
||||
{
|
||||
var color = SeriesColor(seriesIndex);
|
||||
return new LineSeries<double>
|
||||
{
|
||||
Values = values,
|
||||
Name = name,
|
||||
Stroke = new SolidColorPaint(color, 2),
|
||||
Fill = new SolidColorPaint(color.WithAlpha(0x3D)),
|
||||
GeometrySize = 0,
|
||||
LineSmoothness = 0,
|
||||
AnimationsSpeed = AnimationSpeed
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>계단선 — ON/OFF나 단계값처럼 "다음 값까지 그 상태가 유지되는" 신호용.
|
||||
/// 이런 데이터를 꺾은선으로 그리면 없는 중간값이 있는 것처럼 보인다.</summary>
|
||||
public static ISeries StepLine(double[] values, string name, int seriesIndex = 0)
|
||||
{
|
||||
var color = SeriesColor(seriesIndex);
|
||||
return new StepLineSeries<double>
|
||||
{
|
||||
Values = values,
|
||||
Name = name,
|
||||
Stroke = new SolidColorPaint(color, 2),
|
||||
Fill = null,
|
||||
GeometrySize = 0,
|
||||
AnimationsSpeed = AnimationSpeed
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>세로 막대 — 여러 계열용. 각진 모서리는 단일 계열과 같다.</summary>
|
||||
public static ISeries Column(double[] values, string name, int seriesIndex)
|
||||
=> new ColumnSeries<double>
|
||||
{
|
||||
Values = values,
|
||||
Name = name,
|
||||
Fill = new SolidColorPaint(SeriesColor(seriesIndex).WithAlpha(217)),
|
||||
Rx = 0,
|
||||
Ry = 0,
|
||||
AnimationsSpeed = AnimationSpeed
|
||||
};
|
||||
|
||||
/// <summary>누적 막대 — 전체 대비 구성비를 볼 때. 같은 차트에 넣은 누적 막대끼리 쌓인다.</summary>
|
||||
public static ISeries StackedColumn(double[] values, string name, int seriesIndex)
|
||||
=> new StackedColumnSeries<double>
|
||||
{
|
||||
Values = values,
|
||||
Name = name,
|
||||
Fill = new SolidColorPaint(SeriesColor(seriesIndex).WithAlpha(217)),
|
||||
Rx = 0,
|
||||
Ry = 0,
|
||||
AnimationsSpeed = AnimationSpeed
|
||||
};
|
||||
|
||||
/// <summary>가로 막대 — 항목 이름이 길어 세로 막대에서 라벨이 겹칠 때(설비명·불량 유형 등).</summary>
|
||||
public static ISeries Bar(double[] values, string name, int seriesIndex = 0)
|
||||
=> new RowSeries<double>
|
||||
{
|
||||
Values = values,
|
||||
Name = name,
|
||||
Fill = new SolidColorPaint(SeriesColor(seriesIndex).WithAlpha(217)),
|
||||
Rx = 0,
|
||||
Ry = 0,
|
||||
AnimationsSpeed = AnimationSpeed
|
||||
};
|
||||
|
||||
/// <summary>원형 — 구성비. 조각은 팔레트 순서대로, 경계는 판 색으로 얇게 갈라 서로 붙어 보이지 않게 한다.
|
||||
/// 조각이 6개를 넘으면 색이 돌아오므로 그때는 가로 막대(Bar)가 낫다.</summary>
|
||||
public static ISeries[] Pie(double[] values, string[] names = null)
|
||||
=> BuildPie(values, names, innerRadius: 0);
|
||||
|
||||
/// <summary>도넛 — 원형과 같되 가운데를 비워 총계·제목을 얹을 수 있다.</summary>
|
||||
public static ISeries[] Donut(double[] values, string[] names = null)
|
||||
=> BuildPie(values, names, innerRadius: 52);
|
||||
|
||||
private static ISeries[] BuildPie(double[] values, string[] names, double innerRadius)
|
||||
{
|
||||
var series = new ISeries[values.Length];
|
||||
|
||||
for (var i = 0; i < values.Length; i++)
|
||||
{
|
||||
series[i] = new PieSeries<double>
|
||||
{
|
||||
Values = new[] { values[i] },
|
||||
Name = names != null && i < names.Length ? names[i] : string.Empty,
|
||||
Fill = new SolidColorPaint(SeriesColor(i)),
|
||||
Stroke = new SolidColorPaint(Surface, 2),
|
||||
InnerRadius = innerRadius,
|
||||
AnimationsSpeed = AnimationSpeed
|
||||
};
|
||||
}
|
||||
|
||||
return series;
|
||||
}
|
||||
|
||||
/// <summary>산점 — 두 값의 상관을 볼 때. 점이 겹쳐도 개수를 셀 수 있도록 판 색 테두리를 두른다.</summary>
|
||||
public static ISeries Scatter(ObservablePoint[] points, string name, int seriesIndex = 0)
|
||||
{
|
||||
var color = SeriesColor(seriesIndex);
|
||||
return new ScatterSeries<ObservablePoint>
|
||||
{
|
||||
Values = points,
|
||||
Name = name,
|
||||
Fill = new SolidColorPaint(color.WithAlpha(0xC8)),
|
||||
Stroke = new SolidColorPaint(Surface, 1),
|
||||
GeometrySize = 9,
|
||||
AnimationsSpeed = AnimationSpeed
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 툴팁 · 범례 — 차트 컨트롤에 직접 넣어야 하는 값들.
|
||||
// 지정하지 않으면 LiveCharts 기본(밝은 바탕 + 검은 글자)이 나와서 다크 테마에서 튄다.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public static SolidColorPaint TooltipTextPaint => new SolidColorPaint(Text) { SKTypeface = BodyTypeface };
|
||||
|
||||
public static SolidColorPaint TooltipBackgroundPaint => new SolidColorPaint(Surface);
|
||||
|
||||
public static SolidColorPaint LegendTextPaint => new SolidColorPaint(Muted) { SKTypeface = BodyTypeface };
|
||||
|
||||
public static SolidColorPaint LegendBackgroundPaint => new SolidColorPaint(Surface);
|
||||
|
||||
/// <summary>툴팁·범례 색을 이 차트에 입힌다. 차트를 만든 뒤와 테마가 바뀐 뒤에 한 번씩 불러주면 된다.</summary>
|
||||
public static void Apply(CartesianChart chart)
|
||||
{
|
||||
if (chart == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
chart.TooltipTextPaint = TooltipTextPaint;
|
||||
chart.TooltipBackgroundPaint = TooltipBackgroundPaint;
|
||||
chart.TooltipTextSize = 12;
|
||||
chart.LegendTextPaint = LegendTextPaint;
|
||||
chart.LegendBackgroundPaint = LegendBackgroundPaint;
|
||||
chart.LegendTextSize = 12;
|
||||
}
|
||||
|
||||
/// <summary>원형·도넛 차트에도 같은 처리.</summary>
|
||||
public static void Apply(PieChart chart)
|
||||
{
|
||||
if (chart == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
chart.TooltipTextPaint = TooltipTextPaint;
|
||||
chart.TooltipBackgroundPaint = TooltipBackgroundPaint;
|
||||
chart.TooltipTextSize = 12;
|
||||
chart.LegendTextPaint = LegendTextPaint;
|
||||
chart.LegendBackgroundPaint = LegendBackgroundPaint;
|
||||
chart.LegendTextSize = 12;
|
||||
}
|
||||
}
|
||||
|
||||
35
src/UbisamBase.Core/Converters/ContentPaddingConverter.cs
Normal file
35
src/UbisamBase.Core/Converters/ContentPaddingConverter.cs
Normal file
@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
using UbisamBase.Core.Shell;
|
||||
|
||||
namespace UbisamBase.Core.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// 셸 컨텐츠 영역의 여백을 정한다. 값 두 개를 받는다 — (1) 플랫폼 껍데기가 보이는지,
|
||||
/// (2) 지금 올라와 있는 모듈 화면.
|
||||
///
|
||||
/// 발표자 모드(껍데기 숨김)이거나 그 화면이 <see cref="PlatformChrome.FullBleedProperty"/>를 켜 뒀으면
|
||||
/// 여백 0, 그 외에는 <see cref="Default"/>.
|
||||
/// </summary>
|
||||
public class ContentPaddingConverter : IMultiValueConverter
|
||||
{
|
||||
/// <summary>평소 여백. ShellStyles.xaml에서 지정한다.</summary>
|
||||
public Thickness Default { get; set; }
|
||||
|
||||
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
var chromeVisible = values.Length > 0 && values[0] is bool visible && visible;
|
||||
if (!chromeVisible)
|
||||
{
|
||||
return new Thickness(0);
|
||||
}
|
||||
|
||||
var content = values.Length > 1 ? values[1] as DependencyObject : null;
|
||||
return content != null && PlatformChrome.GetFullBleed(content) ? new Thickness(0) : Default;
|
||||
}
|
||||
|
||||
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
@ -29,6 +29,9 @@ public partial class LockConfirmDialog : Window
|
||||
{
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
}
|
||||
|
||||
// 전체화면 셸 위에서는 CenterOwner 가 어긋난다 - 뜬 뒤 실제 자리로 맞춘다.
|
||||
ContentRendered += (_, _) => Shell.OwnerCentering.CenterOnOwner(this);
|
||||
}
|
||||
|
||||
private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) => DragMove();
|
||||
|
||||
20
src/UbisamBase.Core/Messaging/DialogChoice.cs
Normal file
20
src/UbisamBase.Core/Messaging/DialogChoice.cs
Normal file
@ -0,0 +1,20 @@
|
||||
namespace UbisamBase.Core.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// 세 갈래로 묻는 창에서 사용자가 고른 것(<see cref="MessageUtil.ShowChoice"/>).
|
||||
///
|
||||
/// 예/아니오만으로는 가를 수 없는 자리에 쓴다 - "저장하고 계속 · 버리고 계속 · 취소" 처럼
|
||||
/// <b>계속할지</b> 와 <b>저장할지</b> 를 한 번에 물어야 할 때. 단추 글은 부르는 쪽이 정하고,
|
||||
/// 여기서는 세 자리(주 동작 · 보조 동작 · 그만두기)만 가른다.
|
||||
/// </summary>
|
||||
public enum DialogChoice
|
||||
{
|
||||
/// <summary>그만둔다. Esc · 창 닫기도 이것이다.</summary>
|
||||
Cancel,
|
||||
|
||||
/// <summary>주 동작(오른쪽 채운 단추).</summary>
|
||||
Primary,
|
||||
|
||||
/// <summary>보조 동작(가운데 단추).</summary>
|
||||
Secondary
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
<Window x:Class="UbisamBase.Core.Messaging.MessageDialog"
|
||||
<Window x:Class="UbisamBase.Core.Messaging.MessageDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Width="420"
|
||||
@ -43,6 +43,14 @@
|
||||
BorderThickness="0,1,0,0"
|
||||
Padding="16,12">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<!-- 세 갈래로 물을 때만 보인다(예: 저장하고 계속 · 버리고 계속 · 취소). 왼쪽이 "그만두기" 쪽. -->
|
||||
<Button x:Name="TertiaryButton"
|
||||
Content="취소"
|
||||
MinWidth="84"
|
||||
Padding="14,7"
|
||||
Margin="0,0,8,0"
|
||||
Visibility="Collapsed"
|
||||
Click="Tertiary_Click"/>
|
||||
<Button x:Name="SecondaryButton"
|
||||
Content="아니오"
|
||||
MinWidth="84"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System.Windows;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
|
||||
@ -19,6 +19,9 @@ public partial class MessageDialog : Window
|
||||
|
||||
private bool result;
|
||||
|
||||
/// <summary>세 갈래로 물었을 때 무엇을 골랐는지. 두 갈래(예/아니오)에서는 쓰지 않는다.</summary>
|
||||
private DialogChoice choice = DialogChoice.Cancel;
|
||||
|
||||
private MessageDialog(string title, string message, DialogKind kind, bool showSecondary)
|
||||
{
|
||||
InitializeComponent();
|
||||
@ -49,6 +52,9 @@ public partial class MessageDialog : Window
|
||||
{
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||||
}
|
||||
|
||||
// CenterOwner 는 본 창이 전체화면일 때 "복원 자리" 를 기준으로 삼아 어긋난다 - 뜬 뒤 실제 자리로 맞춘다.
|
||||
ContentRendered += (_, _) => Shell.OwnerCentering.CenterOnOwner(this);
|
||||
}
|
||||
|
||||
private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) => DragMove();
|
||||
@ -56,12 +62,21 @@ public partial class MessageDialog : Window
|
||||
private void Primary_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
result = true;
|
||||
choice = DialogChoice.Primary;
|
||||
DialogResult = true;
|
||||
}
|
||||
|
||||
private void Secondary_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
result = false;
|
||||
choice = DialogChoice.Secondary;
|
||||
DialogResult = false;
|
||||
}
|
||||
|
||||
private void Tertiary_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
result = false;
|
||||
choice = DialogChoice.Cancel;
|
||||
DialogResult = false;
|
||||
}
|
||||
|
||||
@ -73,6 +88,7 @@ public partial class MessageDialog : Window
|
||||
}
|
||||
|
||||
result = false;
|
||||
choice = DialogChoice.Cancel; // Esc 는 늘 "그만두기"
|
||||
DialogResult = false;
|
||||
e.Handled = true;
|
||||
}
|
||||
@ -89,4 +105,20 @@ public partial class MessageDialog : Window
|
||||
dialog.ShowDialog();
|
||||
return dialog.result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 세 갈래로 묻는다. 예/아니오로 가를 수 없는 자리에 쓴다 - 대표적으로 "저장하고 계속 · 버리고 계속 · 취소".
|
||||
/// 예/아니오 두 갈래로 물으면 사용자가 [아니오] 를 "버리고 계속" 으로 읽을지 "그만두기" 로 읽을지 알 수 없다.
|
||||
/// 단추 글은 부르는 쪽이 정한다. Esc · 창 닫기는 늘 <see cref="DialogChoice.Cancel"/>.
|
||||
/// </summary>
|
||||
public static DialogChoice ShowChoice(string title, string message, string primary, string secondary, string cancel)
|
||||
{
|
||||
var dialog = new MessageDialog(title, message, DialogKind.Question, showSecondary: true);
|
||||
dialog.PrimaryButton.Content = primary;
|
||||
dialog.SecondaryButton.Content = secondary;
|
||||
dialog.TertiaryButton.Content = cancel;
|
||||
dialog.TertiaryButton.Visibility = Visibility.Visible;
|
||||
dialog.ShowDialog();
|
||||
return dialog.choice;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using UbisamBase.Core.Logging;
|
||||
using UbisamBase.Core.Logging;
|
||||
|
||||
namespace UbisamBase.Core.Messaging;
|
||||
|
||||
@ -12,6 +12,14 @@ public static class MessageUtil
|
||||
|
||||
public static bool ShowYesNo(string title, string message) => MessageDialog.ShowYesNo(title, message);
|
||||
|
||||
/// <summary>
|
||||
/// 세 갈래로 묻는다 - "저장하고 계속 · 버리고 계속 · 취소" 처럼 예/아니오로 가를 수 없는 자리.
|
||||
/// 단추 글은 부르는 쪽이 정한다. Esc · 창 닫기는 <see cref="DialogChoice.Cancel"/>.
|
||||
/// </summary>
|
||||
public static DialogChoice ShowChoice(string title, string message,
|
||||
string primary = "예", string secondary = "아니오", string cancel = "취소")
|
||||
=> MessageDialog.ShowChoice(title, message, primary, secondary, cancel);
|
||||
|
||||
public static void ShowSaveSuccessDialog() => ShowInfo("저장", "저장되었습니다.");
|
||||
|
||||
public static void ShowSaveFailedDialog() => ShowError("저장 실패", "저장하지 못했습니다.");
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
namespace UbisamBase.Core.Modules;
|
||||
namespace UbisamBase.Core.Modules;
|
||||
|
||||
/// <summary>
|
||||
/// AddMain에 넘길 수 있는 아이콘 목록. Themes/Icons.xaml에 등록된 Geometry 리소스와 이름이 1:1로 대응한다
|
||||
@ -41,5 +41,8 @@ public enum Icon
|
||||
Desktop,
|
||||
Camera,
|
||||
Fullscreen,
|
||||
FullscreenExit
|
||||
FullscreenExit,
|
||||
|
||||
/// <summary>탭 줄 접기.</summary>
|
||||
CollapseNav
|
||||
}
|
||||
|
||||
229
src/UbisamBase.Core/Net/ApiService.cs
Normal file
229
src/UbisamBase.Core/Net/ApiService.cs
Normal file
@ -0,0 +1,229 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using UbisamBase.Core.Bootstrap;
|
||||
using UbisamBase.Core.Logging;
|
||||
|
||||
namespace UbisamBase.Core.Net;
|
||||
|
||||
/// <summary>인증을 어떻게 붙이는지.</summary>
|
||||
public enum ApiAuthMode
|
||||
{
|
||||
/// <summary>인증 없음(사내망 IP 제한 등). 주소만으로 부른다.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Authorization: Bearer {토큰}</summary>
|
||||
BearerHeader,
|
||||
|
||||
/// <summary>{HeaderName}: {토큰} — 사내 API가 고유 헤더를 쓰는 경우.</summary>
|
||||
CustomHeader,
|
||||
|
||||
/// <summary>Cookie: {토큰} — 세션 쿠키를 그대로 넘기는 경우.</summary>
|
||||
Cookie
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사내 API를 대신 호출해 응답 본문(JSON 문자열)만 돌려주는 공용 서비스.
|
||||
///
|
||||
/// <para><b>왜 플랫폼에 있나</b> — 토큰이 필요한 API는 화면(WebView2 등)에서 직접 부르면 토큰이 그
|
||||
/// 문서에 섞여 들어간다. 앱이 대신 부르고 <b>숫자만</b> 넘기면 토큰은 앱 설정 파일에만 남는다.
|
||||
/// 내보낸 HTML·.ub 같은 파일에는 절대 들어가지 않는다. 브라우저에서 막히는 CORS도 우회된다.</para>
|
||||
///
|
||||
/// <para>토큰은 <c>D:\UbisamConfig\Settings\ApiSettings.json</c>에만 저장되고, 로그에도 남기지 않는다.</para>
|
||||
/// </summary>
|
||||
public sealed class ApiService
|
||||
{
|
||||
private static readonly Logger Log = new("Api");
|
||||
private static readonly HttpClient Http = new();
|
||||
private static ApiService? current;
|
||||
|
||||
private readonly Dictionary<string, CacheEntry> cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static ApiService Current => current ??= new ApiService();
|
||||
|
||||
public ApiSettings Settings { get; private set; } = ApiSettings.Load();
|
||||
|
||||
/// <summary>설정 파일을 다시 읽는다(설정 화면에서 토큰을 바꾼 뒤 등).</summary>
|
||||
public void ReloadSettings() => Settings = ApiSettings.Load();
|
||||
|
||||
/// <summary>
|
||||
/// 주소를 호출해 응답 본문을 그대로 돌려준다. 인증 헤더는 설정에 따라 이 안에서 붙으므로
|
||||
/// 부르는 쪽(모듈·편집기)은 토큰을 몰라도 된다.
|
||||
///
|
||||
/// 같은 주소를 <see cref="ApiSettings.MinIntervalMs"/> 안에 다시 부르면 서버를 때리지 않고
|
||||
/// 직전 응답을 돌려준다 — 화면 갱신 주기가 짧아도 호출 제한에 걸리지 않게 하기 위한 것이다.
|
||||
/// 실패하면 예외를 던진다(메시지는 사용자에게 보여줘도 되는 내용만 담는다).
|
||||
/// </summary>
|
||||
public async Task<string> FetchAsync(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
throw new ArgumentException("주소가 비어 있습니다.", nameof(url));
|
||||
}
|
||||
|
||||
if (TryGetCached(url, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
ApplyAuth(request);
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(Math.Max(1, Settings.TimeoutSeconds)));
|
||||
using var response = await Http.SendAsync(request, cts.Token).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
// 본문에 토큰이 되비칠 수 있으므로 상태 코드만 남긴다.
|
||||
Log.W($"API 호출 실패 ({(int)response.StatusCode}) : {Safe(url)}");
|
||||
throw new InvalidOperationException($"서버가 {(int)response.StatusCode} 응답을 돌려줬습니다.");
|
||||
}
|
||||
|
||||
lock (cache)
|
||||
{
|
||||
cache[url] = new CacheEntry(body, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Log.W($"API 호출 시간 초과 : {Safe(url)}");
|
||||
throw new InvalidOperationException($"{Settings.TimeoutSeconds}초 안에 응답이 오지 않았습니다.");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
Log.W($"API 호출 실패 : {Safe(url)} — {ex.Message}");
|
||||
throw new InvalidOperationException("서버에 연결하지 못했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>예외 대신 성공 여부로 받고 싶을 때. 실패하면 <paramref name="error"/>에 사유가 담긴다.</summary>
|
||||
public async Task<(bool Ok, string Body, string Error)> TryFetchAsync(string url)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (true, await FetchAsync(url).ConfigureAwait(false), string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, string.Empty, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetCached(string url, out string body)
|
||||
{
|
||||
lock (cache)
|
||||
{
|
||||
if (cache.TryGetValue(url, out var entry) &&
|
||||
(DateTime.UtcNow - entry.At).TotalMilliseconds < Math.Max(0, Settings.MinIntervalMs))
|
||||
{
|
||||
body = entry.Body;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
body = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ApplyAuth(HttpRequestMessage request)
|
||||
{
|
||||
var token = Settings.Token;
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (Settings.AuthMode)
|
||||
{
|
||||
case ApiAuthMode.BearerHeader:
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + token);
|
||||
break;
|
||||
|
||||
case ApiAuthMode.CustomHeader:
|
||||
var name = string.IsNullOrWhiteSpace(Settings.HeaderName) ? "X-Api-Key" : Settings.HeaderName;
|
||||
request.Headers.TryAddWithoutValidation(name, token);
|
||||
break;
|
||||
|
||||
case ApiAuthMode.Cookie:
|
||||
request.Headers.TryAddWithoutValidation("Cookie", token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>로그에 남길 주소 — 쿼리에 토큰이 섞여 있을 수 있으므로 물음표 앞까지만 남긴다.</summary>
|
||||
private static string Safe(string url)
|
||||
{
|
||||
var index = url.IndexOf('?');
|
||||
return index < 0 ? url : url.Substring(0, index) + "?...";
|
||||
}
|
||||
|
||||
private readonly struct CacheEntry
|
||||
{
|
||||
public CacheEntry(string body, DateTime at)
|
||||
{
|
||||
Body = body;
|
||||
At = at;
|
||||
}
|
||||
|
||||
public string Body { get; }
|
||||
|
||||
public DateTime At { get; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>ApiService 설정. 토큰이 들어 있으므로 앱 설정 폴더에만 저장한다.</summary>
|
||||
public sealed class ApiSettings
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
public static string FilePath => Path.Combine(UbisamAppBuilder.ConfigRoot, "Settings", "ApiSettings.json");
|
||||
|
||||
public ApiAuthMode AuthMode { get; set; } = ApiAuthMode.None;
|
||||
|
||||
/// <summary>AuthMode가 CustomHeader일 때 쓸 헤더 이름.</summary>
|
||||
public string HeaderName { get; set; } = "X-Api-Key";
|
||||
|
||||
/// <summary>토큰/키/쿠키 값. 이 파일 밖으로 나가지 않는다.</summary>
|
||||
public string Token { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>같은 주소를 이 시간 안에 다시 부르면 서버 대신 직전 응답을 쓴다(호출 제한 대비).</summary>
|
||||
public int MinIntervalMs { get; set; } = 1000;
|
||||
|
||||
public int TimeoutSeconds { get; set; } = 10;
|
||||
|
||||
public static ApiSettings Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(FilePath))
|
||||
{
|
||||
return JsonSerializer.Deserialize<ApiSettings>(File.ReadAllText(FilePath), JsonOptions) ?? new ApiSettings();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 손상된 파일은 무시하고 기본값(인증 없음)으로 간다.
|
||||
}
|
||||
|
||||
return new ApiSettings();
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
var dir = Path.GetDirectoryName(FilePath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, JsonOptions));
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
@ -33,6 +33,17 @@ public sealed class SettingsService : ISettingsService
|
||||
|
||||
public T RegisterSettings<T>(string key, T defaultInstance) where T : class
|
||||
{
|
||||
// 같은 키를 두 번 등록하면 이미 있는 객체를 그대로 돌려준다.
|
||||
//
|
||||
// 그러지 않으면 인스턴스가 갈라진다 - 소비 프로그램 코드가 들고 쓰는 객체와
|
||||
// 설정 화면이 고치는 객체가 서로 달라져서, 저장을 눌러도 프로그램 동작은 그대로이고
|
||||
// 다시 켜야만 반영된다. 코드에서 설정을 쓰면서 설정 탭도 노출하는 흔한 구조에서
|
||||
// 반드시 걸리는 문제다.
|
||||
if (items.TryGetValue(key, out var registered) && registered is T existing)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var path = FilePath(key);
|
||||
T instance = defaultInstance;
|
||||
|
||||
|
||||
60
src/UbisamBase.Core/Settings/UiSettingsFile.cs
Normal file
60
src/UbisamBase.Core/Settings/UiSettingsFile.cs
Normal file
@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace UbisamBase.Core.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// <c>UiSettingsService.json</c> 에서 <b>한 항목만</b> 읽고 고쳐 쓴다(MergeHub 11차 F-4).
|
||||
///
|
||||
/// 아래 탭 줄 접기는 누를 때마다 바로 기억해야 한다. 그런데 <see cref="UiSettingsService.Save"/> 는
|
||||
/// 지금 메모리의 값을 통째로 쓰므로, 설정 화면에서 바꾸고 아직 [저장] 하지 않은 테마 · 배율까지 함께 저장된다.
|
||||
/// 그래서 파일의 다른 값(모르는 항목 포함)은 그대로 두고 이 항목만 바꾼다.
|
||||
/// </summary>
|
||||
public static class UiSettingsFile
|
||||
{
|
||||
public const string NavCollapsedKey = "NavCollapsed";
|
||||
|
||||
private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true };
|
||||
|
||||
/// <summary>아래 탭 줄을 접어 둔 상태인지. 항목이 없거나 글이 망가졌으면 펼침(false).</summary>
|
||||
public static bool ReadNavCollapsed(string? json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Parse(json) is JsonObject obj &&
|
||||
obj[NavCollapsedKey] is JsonValue value &&
|
||||
value.TryGetValue(out bool collapsed) && collapsed;
|
||||
}
|
||||
catch (Exception e) when (e is JsonException || e is InvalidOperationException || e is FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 접기 항목만 바꾼 글. 글이 없거나 망가졌으면 그 항목만 든 새 글 - 망가진 파일은 어차피
|
||||
/// <see cref="UiSettingsService.Initialize"/> 가 무시하고 기본값을 쓴다.
|
||||
/// </summary>
|
||||
public static string WithNavCollapsed(string? json, bool collapsed)
|
||||
{
|
||||
JsonObject obj;
|
||||
try
|
||||
{
|
||||
obj = Parse(json) as JsonObject ?? new JsonObject();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
obj = new JsonObject();
|
||||
}
|
||||
|
||||
obj[NavCollapsedKey] = collapsed;
|
||||
return obj.ToJsonString(Indented);
|
||||
}
|
||||
|
||||
private static JsonNode? Parse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
return JsonNode.Parse(json!.TrimStart(''));
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Windows;
|
||||
@ -65,6 +65,11 @@ public partial class UiSettingsService : ObservableObject
|
||||
/// <summary>XAML에서 쓰기 편하게 뒤집어 둔 값 — 상단 바를 보여야 하면 true.</summary>
|
||||
public bool TopBarVisible => !TopBarCollapsed;
|
||||
|
||||
// 탭 줄(네비게이션)을 접어 두었는지. 상단 바와 같은 까닭으로 "접힘"을 true 로 둔다 —
|
||||
// 이 항목이 없는 이전 버전 json 은 false(펼침)로 읽혀 지금까지와 같은 모습이 된다.
|
||||
[ObservableProperty]
|
||||
private bool navCollapsed;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
private string? filePath;
|
||||
|
||||
@ -90,6 +95,7 @@ public partial class UiSettingsService : ObservableObject
|
||||
NavPosition = snapshot.NavPosition;
|
||||
ToastPosition = snapshot.ToastPosition;
|
||||
TopBarCollapsed = snapshot.TopBarCollapsed;
|
||||
NavCollapsed = snapshot.NavCollapsed;
|
||||
}
|
||||
}
|
||||
catch
|
||||
@ -119,11 +125,35 @@ public partial class UiSettingsService : ObservableObject
|
||||
ChromeScale = ChromeScale,
|
||||
NavPosition = NavPosition,
|
||||
ToastPosition = ToastPosition,
|
||||
TopBarCollapsed = TopBarCollapsed
|
||||
TopBarCollapsed = TopBarCollapsed,
|
||||
NavCollapsed = NavCollapsed
|
||||
};
|
||||
File.WriteAllText(filePath, JsonSerializer.Serialize(snapshot, JsonOptions));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 탭 줄 접힘만 파일에 바로 쓴다. 접고 펴는 것은 누를 때마다 기억해야 하는데,
|
||||
/// <see cref="Save"/> 는 지금 메모리 값을 통째로 쓰므로 설정 화면에서 바꾸고 아직 [저장] 하지 않은
|
||||
/// 테마 · 배율까지 함께 써진다. 그래서 이 항목만 고쳐 쓴다(<see cref="UiSettingsFile"/>).
|
||||
/// </summary>
|
||||
public void SaveNavCollapsed()
|
||||
{
|
||||
if (filePath == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.Exists(filePath) ? File.ReadAllText(filePath) : null;
|
||||
File.WriteAllText(filePath, UiSettingsFile.WithNavCollapsed(json, NavCollapsed));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// 접힘 상태는 덤이다 - 못 써도 프로그램은 그대로 돈다.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>디스크에 저장된 값으로 다시 불러온다 — UI Setting 화면의 "새로고침" 버튼이 호출한다.</summary>
|
||||
public void Reload()
|
||||
{
|
||||
@ -142,6 +172,7 @@ public partial class UiSettingsService : ObservableObject
|
||||
NavPosition = snapshot.NavPosition;
|
||||
ToastPosition = snapshot.ToastPosition;
|
||||
TopBarCollapsed = snapshot.TopBarCollapsed;
|
||||
NavCollapsed = snapshot.NavCollapsed;
|
||||
}
|
||||
}
|
||||
catch
|
||||
@ -159,6 +190,8 @@ public partial class UiSettingsService : ObservableObject
|
||||
public NavPosition NavPosition { get; set; }
|
||||
public ToastPosition ToastPosition { get; set; }
|
||||
public bool TopBarCollapsed { get; set; }
|
||||
|
||||
public bool NavCollapsed { get; set; }
|
||||
}
|
||||
|
||||
private static void ApplyTheme(ThemeMode mode)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
@ -92,6 +92,22 @@ public static class Displays
|
||||
return new Rect(device.Left / scaleX, device.Top / scaleY, device.Width / scaleX, device.Height / scaleY);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 모든 모니터의 작업 영역(작업표시줄 제외)을 Win32 좌표 그대로. 주 모니터가 맨 앞이다.
|
||||
/// 셸이 기억한 창 자리를 화면 안으로 되돌릴 때 쓴다(<see cref="ShellPlacementMath.FitToMonitors"/>) -
|
||||
/// 그 계산은 창 손잡이에서 읽은 Win32 좌표를 다루므로 DIP 로 바꾸지 않는다.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<Rect> WorkAreasDevice()
|
||||
{
|
||||
var areas = new List<Rect>();
|
||||
foreach (var monitor in GetMonitors())
|
||||
{
|
||||
areas.Add(monitor.Work);
|
||||
}
|
||||
|
||||
return areas;
|
||||
}
|
||||
|
||||
private readonly struct MonitorInfo
|
||||
{
|
||||
public MonitorInfo(IntPtr handle, Rect work)
|
||||
|
||||
82
src/UbisamBase.Core/Shell/OwnerCentering.cs
Normal file
82
src/UbisamBase.Core/Shell/OwnerCentering.cs
Normal file
@ -0,0 +1,82 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace UbisamBase.Core.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// 덮는 창(확인 · 잠금 확인 같은 대화상자)을 <b>본 창의 실제 자리</b> 가운데에 놓는다.
|
||||
///
|
||||
/// WPF 의 <c>CenterOwner</c> 는 본 창이 최대화면 그 창의 "복원 자리" 를 기준으로 삼는다. 셸 창은 전체화면(kiosk)에서
|
||||
/// 작업 표시줄까지 덮으므로 그 둘이 어긋나, 확인 창이 세로로 작업 표시줄 절반만큼 위에 떴다.
|
||||
/// 창 손잡이에서 실제 사각형을 읽어 맞춘다 - 보통 창에서는 <c>CenterOwner</c> 와 같은 자리다.
|
||||
/// 본 창을 못 찾으면 아무것도 하지 않는다(WPF 가 정한 자리를 그대로 둔다).
|
||||
/// </summary>
|
||||
internal static class OwnerCentering
|
||||
{
|
||||
/// <summary>창이 떠서 크기가 정해진 뒤 한 번 부른다(예: <c>ContentRendered</c>).</summary>
|
||||
public static void CenterOnOwner(Window dialog)
|
||||
{
|
||||
if (dialog is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var owner = FindOwner(dialog);
|
||||
if (owner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ownerRect = MonitorHelper.GetWindowBounds(owner);
|
||||
if (ownerRect.IsEmpty || ownerRect.Width <= 0 || ownerRect.Height <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var scale = VisualTreeHelper.GetDpi(dialog).DpiScaleX;
|
||||
if (scale <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var width = dialog.ActualWidth > 0 ? dialog.ActualWidth : dialog.Width;
|
||||
var height = dialog.ActualHeight > 0 ? dialog.ActualHeight : dialog.Height;
|
||||
if (double.IsNaN(width) || double.IsNaN(height) || width <= 0 || height <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 본 창 사각형은 Win32 좌표, 창 자리(Left · Top)는 DIP 다. 배율로 한 번 나눠 맞춘다.
|
||||
dialog.Left = (ownerRect.Left + (ownerRect.Width - (width * scale)) / 2) / scale;
|
||||
dialog.Top = (ownerRect.Top + (ownerRect.Height - (height * scale)) / 2) / scale;
|
||||
}
|
||||
|
||||
/// <summary>맞출 본 창. 켤 때 셸보다 먼저 뜨는 창도 있어 Owner · MainWindow · 떠 있는 셸 순으로 찾는다.</summary>
|
||||
private static Window? FindOwner(Window dialog)
|
||||
{
|
||||
if (dialog.Owner is { IsVisible: true } owner && !ReferenceEquals(owner, dialog))
|
||||
{
|
||||
return owner;
|
||||
}
|
||||
|
||||
if (Application.Current?.MainWindow is { IsVisible: true } main && !ReferenceEquals(main, dialog))
|
||||
{
|
||||
return main;
|
||||
}
|
||||
|
||||
if (Application.Current is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (Window window in Application.Current.Windows)
|
||||
{
|
||||
if (window is ShellWindow { IsVisible: true } shell && !ReferenceEquals(shell, dialog))
|
||||
{
|
||||
return shell;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
|
||||
namespace UbisamBase.Core.Shell;
|
||||
|
||||
@ -38,6 +39,35 @@ public static class PlatformChrome
|
||||
/// <summary>보임/숨김을 뒤집는다 — 발표 중 단축키나 버튼에 걸어 쓰기 좋다.</summary>
|
||||
public static void Toggle() => IsVisible = !IsVisible;
|
||||
|
||||
/// <summary>
|
||||
/// 모듈 화면에 붙이는 첨부 속성. True면 셸이 그 화면 둘레의 여백(26,22)을 주지 않는다.
|
||||
///
|
||||
/// 편집기·지도·카메라처럼 화면을 꽉 쓰는 모듈은 자기 안에서 이미 칸을 나누고 각 칸이 자기 여백을
|
||||
/// 갖는다. 거기에 바깥 여백이 한 겹 더 붙으면 화면만 좁아지고 테두리처럼 보인다. 반대로 설정
|
||||
/// 화면이나 폼은 여백이 있어야 읽기 좋으므로, 기본은 여백 있음이고 필요한 모듈만 끈다.
|
||||
///
|
||||
/// <code><UserControl ... shell:PlatformChrome.FullBleed="True"></code>
|
||||
/// </summary>
|
||||
public static readonly DependencyProperty FullBleedProperty =
|
||||
DependencyProperty.RegisterAttached(
|
||||
"FullBleed",
|
||||
typeof(bool),
|
||||
typeof(PlatformChrome),
|
||||
new PropertyMetadata(false, OnFullBleedChanged));
|
||||
|
||||
public static void SetFullBleed(DependencyObject element, bool value)
|
||||
=> element.SetValue(FullBleedProperty, value);
|
||||
|
||||
public static bool GetFullBleed(DependencyObject element)
|
||||
=> element != null && (bool)element.GetValue(FullBleedProperty);
|
||||
|
||||
/// <summary>화면이 떠 있는 동안 값을 바꿔도 셸이 여백을 다시 계산하도록 알린다
|
||||
/// (첨부 속성은 셸의 바인딩에서 직접 감시할 수 없다).</summary>
|
||||
public static event EventHandler? FullBleedChanged;
|
||||
|
||||
private static void OnFullBleedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
=> FullBleedChanged?.Invoke(d, EventArgs.Empty);
|
||||
|
||||
/// <summary>보임/숨김이 바뀔 때마다 새 값(true=보임)과 함께 알린다. 모듈이 자기가 바꾸지 않은
|
||||
/// 변화(설정의 "상단 바 접기", 다른 모듈, 단축키)에도 반응할 수 있게 하는 통로다 —
|
||||
/// 발표 모드를 나갈 때 타이머를 멈추거나 띄워둔 창을 닫는 정리에 쓴다.
|
||||
|
||||
156
src/UbisamBase.Core/Shell/ShellPlacement.cs
Normal file
156
src/UbisamBase.Core/Shell/ShellPlacement.cs
Normal file
@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Windows;
|
||||
|
||||
namespace UbisamBase.Core.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// 셸 창의 마지막 모드(전체화면 · 창모드)와 창모드일 때의 자리 · 크기.
|
||||
/// <c>{설정 폴더}\Settings\ShellWindowPlacement.json</c> 에 둔다.
|
||||
///
|
||||
/// UI 설정 파일과 따로 두는 까닭 — UI 설정은 [저장] 을 눌러야 쓰는데 창 자리는 닫을 때마다 쓴다.
|
||||
/// 한 파일에 담으면 저장하지 않은 UI 설정까지 함께 써진다.
|
||||
/// 파일이 없거나 깨졌으면 <b>지금까지처럼 전체화면</b>으로 켠다.
|
||||
/// </summary>
|
||||
internal sealed class ShellPlacement
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
/// <summary>닫을 때 전체화면(kiosk)이었는지.</summary>
|
||||
public bool Fullscreen { get; set; } = true;
|
||||
|
||||
/// <summary>창모드일 때의 자리 · 크기(Win32 좌표). 한 번도 창모드로 쓰지 않았으면 0 이다.</summary>
|
||||
public double Left { get; set; }
|
||||
|
||||
public double Top { get; set; }
|
||||
|
||||
public double Width { get; set; }
|
||||
|
||||
public double Height { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool HasWindowRect => Width > 0 && Height > 0;
|
||||
|
||||
[JsonIgnore]
|
||||
public Rect WindowRect => new Rect(Left, Top, Width, Height);
|
||||
|
||||
public static string? PathIn(string? configRoot) =>
|
||||
string.IsNullOrWhiteSpace(configRoot) ? null : Path.Combine(configRoot, "Settings", "ShellWindowPlacement.json");
|
||||
|
||||
/// <summary>읽는다. 없거나 깨졌으면 null - 부르는 쪽이 전체화면으로 켠다.</summary>
|
||||
public static ShellPlacement? Load(string? path)
|
||||
{
|
||||
if (path is null || !File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<ShellPlacement>(File.ReadAllText(path), JsonOptions);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
return null; // 깨진 파일 때문에 못 켜지면 안 된다
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>쓴다. 못 써도 조용히 넘어간다 - 창 자리는 덤이다.</summary>
|
||||
public void Save(string? path)
|
||||
{
|
||||
if (path is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var folder = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(folder))
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
}
|
||||
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(this, JsonOptions));
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 창 자리 계산. 화면 없이 검사할 수 있게 순수 함수로 둔다.
|
||||
/// </summary>
|
||||
internal static class ShellPlacementMath
|
||||
{
|
||||
/// <summary>창모드 최소 크기(Win32 좌표). 이보다 작게 기억돼 있어도 이만큼은 띄운다.</summary>
|
||||
public const double MinWidth = 900;
|
||||
|
||||
public const double MinHeight = 600;
|
||||
|
||||
/// <summary>
|
||||
/// 기억한 창 자리를 켤 때 놓을 자리. <b>가장 많이 겹치는 모니터</b>의 작업 영역 안으로 밀고, 모니터보다 크면 줄인다.
|
||||
///
|
||||
/// 모니터를 떼거나 해상도 · 배율이 바뀌면 기억한 자리가 화면 밖일 수 있다 - 그러면 창을 아예 못 잡는다.
|
||||
/// 겹치는 모니터가 없으면(예: 없어진 모니터에 있던 창) 주 모니터 가운데로 보낸다.
|
||||
/// </summary>
|
||||
public static Rect FitToMonitors(Rect saved, IReadOnlyList<Rect> workAreas)
|
||||
{
|
||||
if (workAreas is null || workAreas.Count == 0)
|
||||
{
|
||||
return saved;
|
||||
}
|
||||
|
||||
var best = workAreas[0];
|
||||
var bestOverlap = -1.0;
|
||||
foreach (var work in workAreas)
|
||||
{
|
||||
var overlap = Overlap(saved, work);
|
||||
if (overlap > bestOverlap)
|
||||
{
|
||||
bestOverlap = overlap;
|
||||
best = work;
|
||||
}
|
||||
}
|
||||
|
||||
// 어느 모니터와도 겹치지 않는다 - 첫 모니터(주 모니터) 가운데로.
|
||||
if (bestOverlap <= 0)
|
||||
{
|
||||
var w0 = Math.Min(Math.Max(saved.Width, MinWidth), best.Width);
|
||||
var h0 = Math.Min(Math.Max(saved.Height, MinHeight), best.Height);
|
||||
return new Rect(best.Left + (best.Width - w0) / 2, best.Top + (best.Height - h0) / 2, w0, h0);
|
||||
}
|
||||
|
||||
var width = Math.Min(Math.Max(saved.Width, MinWidth), best.Width);
|
||||
var height = Math.Min(Math.Max(saved.Height, MinHeight), best.Height);
|
||||
var left = Clamp(saved.Left, best.Left, best.Right - width);
|
||||
var top = Clamp(saved.Top, best.Top, best.Bottom - height);
|
||||
return new Rect(left, top, width, height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 최대화한 창이 그 모니터에 꼭 맞는지. 다르면 부르는 쪽이 모니터 크기로 다시 놓는다.
|
||||
///
|
||||
/// 배율이 크게 다른 모니터(주 150% 옆 300%)에서 최대화하면 창이 모니터보다 커져 오른쪽 · 아래가 화면 밖으로 나갔다
|
||||
/// (재현: 3840×2160 모니터에서 5120×2186). 최대화된 뒤 한 번 다시 놓아 바로잡는다.
|
||||
/// 이미 맞으면 하지 않는다 - 다시 놓기가 되풀이되지 않게.
|
||||
/// </summary>
|
||||
public static bool NeedsMaximizeFix(Rect window, Rect monitor) =>
|
||||
monitor.Width > 0 && monitor.Height > 0 &&
|
||||
(Math.Abs(window.Left - monitor.Left) > 0.5 || Math.Abs(window.Top - monitor.Top) > 0.5 ||
|
||||
Math.Abs(window.Width - monitor.Width) > 0.5 || Math.Abs(window.Height - monitor.Height) > 0.5);
|
||||
|
||||
private static double Overlap(Rect a, Rect b)
|
||||
{
|
||||
var width = Math.Min(a.Right, b.Right) - Math.Max(a.Left, b.Left);
|
||||
var height = Math.Min(a.Bottom, b.Bottom) - Math.Max(a.Top, b.Top);
|
||||
return width <= 0 || height <= 0 ? 0 : width * height;
|
||||
}
|
||||
|
||||
private static double Clamp(double value, double min, double max) =>
|
||||
max < min ? min : Math.Max(min, Math.Min(value, max));
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
<Window x:Class="UbisamBase.Core.Shell.ShellWindow"
|
||||
<Window x:Class="UbisamBase.Core.Shell.ShellWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:messaging="clr-namespace:UbisamBase.Core.Messaging"
|
||||
@ -142,6 +142,47 @@
|
||||
</Border>
|
||||
|
||||
<!-- 네비게이션: 기본은 하단(NavPosition.Bottom). 좌/우 전환은 코드비하인드가 처리 -->
|
||||
<!-- 탭 줄을 접었을 때 남는 얇은 띠. 누르거나 F11 로 다시 편다.
|
||||
NavList 보다 먼저 두어야 같은 쪽에 붙을 때 창 가장자리를 차지한다. 자리 · 방향은 ApplyNavPosition 이 정한다. -->
|
||||
<Button x:Name="NavExpandStrip"
|
||||
DockPanel.Dock="Bottom"
|
||||
Visibility="Collapsed"
|
||||
MinHeight="0" MinWidth="0" Padding="0"
|
||||
Click="NavExpandStrip_Click"
|
||||
Cursor="Hand"
|
||||
ToolTip="탭 줄 펴기 (F11)"
|
||||
Focusable="False">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd"
|
||||
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
|
||||
BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.HoverBackground}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
<StackPanel x:Name="NavExpandContent" Orientation="Horizontal">
|
||||
<Path x:Name="NavExpandArrow"
|
||||
Data="M1,7 L6,2 L11,7"
|
||||
Stroke="{DynamicResource Ubisam.Brush.Sidebar.Foreground}"
|
||||
StrokeThickness="1.5" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round"
|
||||
Width="12" Height="9" Stretch="None" VerticalAlignment="Center"
|
||||
RenderTransformOrigin="0.5,0.5">
|
||||
<Path.RenderTransform>
|
||||
<RotateTransform x:Name="NavExpandArrowTurn" Angle="0"/>
|
||||
</Path.RenderTransform>
|
||||
</Path>
|
||||
<TextBlock x:Name="NavExpandLabel" Text="탭 펴기 · F11" FontSize="10" Margin="6,0,0,0"
|
||||
Foreground="{DynamicResource Ubisam.Brush.Sidebar.Foreground}" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<ListBox x:Name="NavList"
|
||||
DockPanel.Dock="Bottom"
|
||||
Visibility="{Binding Path=(shell:PlatformChrome.IsVisible), Converter={StaticResource BoolToVis}}"
|
||||
@ -161,8 +202,14 @@
|
||||
|
||||
<!-- 모듈이 마운트되기 전에는 사선 해치가 빈 슬롯을 표시, 마운트되면 단색 배경.
|
||||
여백은 발표자 모드에서 0이 된다 — 껍데기를 걷었는데 액자처럼 테두리가 남으면 안 된다. -->
|
||||
<Border Grid.Row="0"
|
||||
Padding="{Binding Path=(shell:PlatformChrome.IsVisible), Converter={StaticResource BoolToContentPadding}}">
|
||||
<Border x:Name="ContentBorder" Grid.Row="0">
|
||||
<Border.Padding>
|
||||
<!-- 발표자 모드이거나, 올라온 모듈 화면이 FullBleed면 여백 0. -->
|
||||
<MultiBinding Converter="{StaticResource ContentPadding}">
|
||||
<Binding Path="(shell:PlatformChrome.IsVisible)"/>
|
||||
<Binding Path="SelectedMainTab.CurrentContent"/>
|
||||
</MultiBinding>
|
||||
</Border.Padding>
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.ContentBackground}"/>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Threading.Tasks;
|
||||
@ -30,6 +30,17 @@ public partial class ShellWindow : Window
|
||||
private bool closeApproved;
|
||||
private bool closeAsking;
|
||||
|
||||
/// <summary>창모드일 때의 마지막 자리(Win32 좌표). 닫을 때 이 값을 기억한다.</summary>
|
||||
private Rect lastWindowedRect;
|
||||
|
||||
/// <summary>트레이 아이콘. 최소화해서 트레이로 내려갈 때 처음 만든다.</summary>
|
||||
private TrayIcon? tray;
|
||||
|
||||
private ContextMenu? trayMenu;
|
||||
|
||||
/// <summary>트레이로 내려가기 바로 전의 창 모양. 되살릴 때 그대로 돌려놓는다.</summary>
|
||||
private WindowState stateBeforeTray = WindowState.Normal;
|
||||
|
||||
public ShellWindow(ShellViewModel viewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
@ -38,8 +49,14 @@ public partial class ShellWindow : Window
|
||||
DataContext = viewModel;
|
||||
|
||||
viewModel.UiSettings.PropertyChanged += OnUiSettingsChanged;
|
||||
|
||||
// FullBleed는 첨부 속성이라 바인딩이 스스로 변화를 못 본다 — 바뀌면 여백을 다시 계산한다.
|
||||
PlatformChrome.FullBleedChanged += OnFullBleedChanged;
|
||||
Closed += (_, _) => PlatformChrome.FullBleedChanged -= OnFullBleedChanged;
|
||||
ApplyNavPosition(viewModel.UiSettings.NavPosition);
|
||||
ApplyToastPosition(viewModel.UiSettings.ToastPosition);
|
||||
Loaded += (_, _) => ApplyNavCollapsed(viewModel.UiSettings.NavCollapsed);
|
||||
PreviewKeyDown += ShellWindow_PreviewKeyDown;
|
||||
|
||||
// 하단 탭 바 = 등록된 실제 탭(MainTabs, 왼쪽에 모임) + "화면 잠금"/"바탕화면"/"종료" 같은
|
||||
// 동작형 항목(오른쪽 끝에 붙음, ShellStyles.xaml의 DockPanel.Dock=Right 트리거로 처리).
|
||||
@ -52,7 +69,9 @@ public partial class ShellWindow : Window
|
||||
// Shutdown()이 아니라 Close()다 — 그래야 ICloseGuard(저장 안 한 변경 확인)를 거친다.
|
||||
new NavActionItem("종료", UbisamBase.Core.Modules.Icon.Power, Close),
|
||||
new NavActionItem("바탕화면", UbisamBase.Core.Modules.Icon.Desktop, () => WindowState = WindowState.Minimized),
|
||||
new NavActionItem("화면 잠금", UbisamBase.Core.Modules.Icon.Lock, () => LockConfirmDialog.ShowAndLock())
|
||||
new NavActionItem("화면 잠금", UbisamBase.Core.Modules.Icon.Lock, () => LockConfirmDialog.ShowAndLock()),
|
||||
// 탭 줄 접기 - 콘텐츠를 세로(또는 가로)로 더 보이게. 접으면 얇은 띠가 남고, 띠나 F11 로 다시 편다.
|
||||
new NavActionItem("탭 접기", UbisamBase.Core.Modules.Icon.CollapseNav, () => SetNavCollapsed(true))
|
||||
};
|
||||
NavList.ItemsSource = composite;
|
||||
|
||||
@ -97,6 +116,8 @@ public partial class ShellWindow : Window
|
||||
/// <summary>셸 창이 닫히면 앱도 끝난다 — 로그 창 같은 보조 창이 떠 있어도 남지 않도록 명시적으로 종료한다.</summary>
|
||||
protected override void OnClosed(EventArgs e)
|
||||
{
|
||||
SavePlacement();
|
||||
tray?.Dispose();
|
||||
base.OnClosed(e);
|
||||
Application.Current?.Shutdown();
|
||||
}
|
||||
@ -173,7 +194,177 @@ public partial class ShellWindow : Window
|
||||
protected override void OnSourceInitialized(EventArgs e)
|
||||
{
|
||||
base.OnSourceInitialized(e);
|
||||
ApplyKiosk();
|
||||
|
||||
// 지난번에 창모드로 껐으면 그 자리 · 크기로, 아니면 지금까지처럼 전체화면으로 켠다.
|
||||
var saved = ShellPlacement.Load(ShellPlacement.PathIn(UbisamBase.Core.Bootstrap.UbisamAppBuilder.ConfigRoot));
|
||||
if (saved is { Fullscreen: false, HasWindowRect: true })
|
||||
{
|
||||
ApplyWindowed(saved.WindowRect);
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyKiosk();
|
||||
}
|
||||
|
||||
// 창모드에서 최대화(Win+↑ 등)하면 배율이 다른 모니터에서 화면 밖으로 넘칠 수 있다 - 그때 바로잡는다.
|
||||
StateChanged += (_, _) => { FitMaximizedToMonitor(); OnStateChanged(); };
|
||||
DpiChanged += (_, _) => FitMaximizedToMonitor();
|
||||
LocationChanged += (_, _) => RememberWindowedRect();
|
||||
SizeChanged += (_, _) => RememberWindowedRect();
|
||||
}
|
||||
|
||||
/// <summary>창모드로 떠 있는 동안의 자리를 들고 있는다. 닫을 때 이 값을 기억한다.</summary>
|
||||
private void RememberWindowedRect()
|
||||
{
|
||||
// 트레이로 내려간 동안(감춰짐)에는 창이 화면 밖(-32000)에 있다 - 그 자리를 기억하면 되살릴 때 못 찾는다.
|
||||
if (isKiosk || WindowState != WindowState.Normal || !IsVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var rect = MonitorHelper.GetWindowBounds(this);
|
||||
if (rect.IsEmpty || rect.Width <= 0 || rect.Height <= 0 || IsParked(rect))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lastWindowedRect = rect;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 최소화한 창이 잠깐 놓이는 자리(-32000, -32000)인지.
|
||||
///
|
||||
/// 최소화하는 동안 창이 먼저 저 자리로 옮겨지고 그 다음에 모양이 "최소화"로 바뀐다.
|
||||
/// 그 틈에 자리를 기억해 버리면 되살릴 때 화면 밖이라 어느 모니터에도 걸치지 않아,
|
||||
/// 창이 주 모니터 가운데 최소 크기로 쪼그라들었다.
|
||||
/// </summary>
|
||||
private static bool IsParked(Rect rect) => rect.Left <= -30000 || rect.Top <= -30000;
|
||||
|
||||
/// <summary>마지막 모드와 창모드 자리를 파일에 남긴다. 못 써도 그냥 넘어간다.</summary>
|
||||
private void SavePlacement()
|
||||
{
|
||||
var placement = new ShellPlacement { Fullscreen = isKiosk };
|
||||
if (lastWindowedRect.Width > 0 && lastWindowedRect.Height > 0)
|
||||
{
|
||||
placement.Left = lastWindowedRect.Left;
|
||||
placement.Top = lastWindowedRect.Top;
|
||||
placement.Width = lastWindowedRect.Width;
|
||||
placement.Height = lastWindowedRect.Height;
|
||||
}
|
||||
|
||||
placement.Save(ShellPlacement.PathIn(UbisamBase.Core.Bootstrap.UbisamAppBuilder.ConfigRoot));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 최대화한 창이 그 모니터보다 크면 모니터 크기로 다시 놓는다.
|
||||
///
|
||||
/// 배율이 크게 다른 모니터(주 150% 옆 300%)에서 최대화하면 창이 모니터보다 커져 오른쪽 · 아래가 잘렸다.
|
||||
/// 이미 맞으면 아무것도 하지 않아 다시 놓기가 되풀이되지 않는다.
|
||||
/// </summary>
|
||||
private void FitMaximizedToMonitor()
|
||||
{
|
||||
if (WindowState != WindowState.Maximized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var monitor = MonitorHelper.GetBounds(this);
|
||||
var window = MonitorHelper.GetWindowBounds(this);
|
||||
if (window.IsEmpty || !ShellPlacementMath.NeedsMaximizeFix(window, monitor))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WindowState = WindowState.Normal;
|
||||
Left = monitor.Left;
|
||||
Top = monitor.Top;
|
||||
Width = monitor.Width;
|
||||
Height = monitor.Height;
|
||||
FitToMonitor(monitor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 최소화하면 작업 표시줄 대신 트레이(알림 영역)로 내려간다.
|
||||
///
|
||||
/// 창을 <see cref="Window.Hide"/> 로 감추므로 작업 표시줄 단추도 함께 사라진다 - 되살리는 길은 트레이 아이콘뿐이라
|
||||
/// 아이콘을 먼저 올리고 나서 감춘다. [바탕화면] 단추로 내려도 같다.
|
||||
/// </summary>
|
||||
private void OnStateChanged()
|
||||
{
|
||||
if (WindowState != WindowState.Minimized)
|
||||
{
|
||||
// 최소화가 아닌 마지막 모양을 들고 있는다 - 트레이에서 되살릴 때 이 모양으로 돌아간다.
|
||||
stateBeforeTray = WindowState;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureTray().Show();
|
||||
Hide();
|
||||
}
|
||||
|
||||
/// <summary>트레이에서 창을 되살린다. 내려가기 전 모양(전체화면 · 최대화 · 창모드)으로 돌아간다.</summary>
|
||||
private void RestoreFromTray()
|
||||
{
|
||||
Show();
|
||||
|
||||
if (isKiosk)
|
||||
{
|
||||
ApplyKiosk();
|
||||
}
|
||||
else if (stateBeforeTray == WindowState.Maximized)
|
||||
{
|
||||
WindowState = WindowState.Maximized;
|
||||
FitMaximizedToMonitor();
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyWindowed(lastWindowedRect, fit: false);
|
||||
}
|
||||
|
||||
Activate();
|
||||
tray?.Hide();
|
||||
}
|
||||
|
||||
private TrayIcon EnsureTray()
|
||||
{
|
||||
if (tray != null)
|
||||
{
|
||||
return tray;
|
||||
}
|
||||
|
||||
tray = new TrayIcon(string.IsNullOrWhiteSpace(Title) ? "Ubisam" : Title);
|
||||
tray.Opened += (_, _) => RestoreFromTray();
|
||||
tray.MenuRequested += (_, _) => ShowTrayMenu();
|
||||
return tray;
|
||||
}
|
||||
|
||||
/// <summary>트레이 아이콘 오른쪽 단추 메뉴 - [열기] · [종료].</summary>
|
||||
private void ShowTrayMenu()
|
||||
{
|
||||
if (trayMenu == null)
|
||||
{
|
||||
var open = new MenuItem { Header = "열기" };
|
||||
open.Click += (_, _) => RestoreFromTray();
|
||||
var quit = new MenuItem { Header = "종료" };
|
||||
quit.Click += (_, _) =>
|
||||
{
|
||||
// 닫기 확인(ICloseGuard)을 거치려면 창이 떠 있어야 한다 - 되살린 뒤 평소처럼 닫는다.
|
||||
RestoreFromTray();
|
||||
Close();
|
||||
};
|
||||
|
||||
trayMenu = new ContextMenu();
|
||||
trayMenu.Items.Add(open);
|
||||
trayMenu.Items.Add(quit);
|
||||
}
|
||||
|
||||
trayMenu.Placement = System.Windows.Controls.Primitives.PlacementMode.MousePoint;
|
||||
trayMenu.IsOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>작업표시줄까지 덮는 전체화면. Topmost는 걸지 않는다 — 현장의 다른 경보/알림 창이
|
||||
@ -224,19 +415,50 @@ public partial class ShellWindow : Window
|
||||
/// <summary>이동/리사이즈 가능한 일반 창. WindowStyle은 계속 None(프레임리스, 브랜딩 바와
|
||||
/// 일관)이지만 ResizeMode=CanResizeWithGrip이면 WPF가 가장자리에 OS 리사이즈 히트영역을
|
||||
/// 그대로 붙여준다 — 직접 구현할 필요가 없다. 이동은 브랜딩 바 드래그(BrandBar_MouseLeftButtonDown)로.</summary>
|
||||
private void ApplyWindowed()
|
||||
private void ApplyWindowed() => ApplyWindowed(lastWindowedRect);
|
||||
|
||||
/// <summary>
|
||||
/// 이동 · 크기 조절이 되는 창모드. 기억한 자리가 있으면 그 자리로 - 없으면 지금까지처럼 1200×760 가운데.
|
||||
///
|
||||
/// 기억한 자리는 <b>모니터 안으로</b> 되돌려 놓는다(<see cref="ShellPlacementMath.FitToMonitors"/>).
|
||||
/// 모니터를 떼거나 해상도 · 배율이 바뀌면 그 자리가 화면 밖일 수 있어, 그대로 놓으면 창을 잡을 수 없다.
|
||||
/// </summary>
|
||||
/// <param name="fit">
|
||||
/// 기억한 자리를 모니터 안으로 밀지. 켤 때는 참이다 - 지난번과 모니터 · 배율이 다를 수 있다.
|
||||
/// 트레이에서 되살릴 때는 거짓 - 몇 초 전 그 자리이므로 그대로 놓아야 크기가 달라지지 않는다.
|
||||
/// </param>
|
||||
private void ApplyWindowed(Rect remembered, bool fit = true)
|
||||
{
|
||||
isKiosk = false;
|
||||
ResizeMode = ResizeMode.CanResizeWithGrip;
|
||||
WindowState = WindowState.Normal;
|
||||
Width = 1200;
|
||||
Height = 760;
|
||||
|
||||
var work = SystemParameters.WorkArea;
|
||||
Left = work.Left + Math.Max(0, (work.Width - Width) / 2);
|
||||
Top = work.Top + Math.Max(0, (work.Height - Height) / 2);
|
||||
if (remembered.Width > 0 && remembered.Height > 0)
|
||||
{
|
||||
var target = fit ? ShellPlacementMath.FitToMonitors(remembered, Displays.WorkAreasDevice()) : remembered;
|
||||
var actual = MonitorHelper.GetWindowBounds(this);
|
||||
var scaleX = actual.IsEmpty || Width <= 0 ? 1 : actual.Width / Width;
|
||||
var scaleY = actual.IsEmpty || Height <= 0 ? 1 : actual.Height / Height;
|
||||
if (scaleX <= 0) scaleX = 1;
|
||||
if (scaleY <= 0) scaleY = 1;
|
||||
|
||||
Width = target.Width / scaleX;
|
||||
Height = target.Height / scaleY;
|
||||
Left = target.Left / scaleX;
|
||||
Top = target.Top / scaleY;
|
||||
}
|
||||
else
|
||||
{
|
||||
Width = 1200;
|
||||
Height = 760;
|
||||
|
||||
var work = SystemParameters.WorkArea;
|
||||
Left = work.Left + Math.Max(0, (work.Width - Width) / 2);
|
||||
Top = work.Top + Math.Max(0, (work.Height - Height) / 2);
|
||||
}
|
||||
|
||||
Topmost = false;
|
||||
|
||||
RememberWindowedRect();
|
||||
SetFullscreenButtonState(toWindowed: false);
|
||||
}
|
||||
|
||||
@ -313,6 +535,9 @@ public partial class ShellWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFullBleedChanged(object? sender, EventArgs e)
|
||||
=> BindingOperations.GetMultiBindingExpression(ContentBorder, Border.PaddingProperty)?.UpdateTarget();
|
||||
|
||||
private void OnUiSettingsChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(UiSettingsService.NavPosition))
|
||||
@ -323,6 +548,48 @@ public partial class ShellWindow : Window
|
||||
{
|
||||
ApplyToastPosition(((UiSettingsService)sender!).ToastPosition);
|
||||
}
|
||||
else if (e.PropertyName == nameof(UiSettingsService.NavCollapsed))
|
||||
{
|
||||
ApplyNavCollapsed(((UiSettingsService)sender!).NavCollapsed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>탭 줄을 접거나 편다. 누를 때마다 그 항목만 파일에 남긴다(설정 화면의 저장하지 않은 값은 건드리지 않는다).</summary>
|
||||
private void SetNavCollapsed(bool collapsed)
|
||||
{
|
||||
if (viewModel.UiSettings.NavCollapsed == collapsed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
viewModel.UiSettings.NavCollapsed = collapsed;
|
||||
viewModel.UiSettings.SaveNavCollapsed();
|
||||
}
|
||||
|
||||
private void NavExpandStrip_Click(object sender, RoutedEventArgs e) => SetNavCollapsed(false);
|
||||
|
||||
/// <summary>F11 로 접고 편다. 다른 키 조합(Ctrl · Alt · Shift)은 모듈 몫으로 둔다.</summary>
|
||||
private void ShellWindow_PreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.F11 || Keyboard.Modifiers != ModifierKeys.None)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SetNavCollapsed(!viewModel.UiSettings.NavCollapsed);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void ApplyNavCollapsed(bool collapsed)
|
||||
{
|
||||
NavList.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible;
|
||||
NavExpandStrip.Visibility = collapsed ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
if (!collapsed && viewModel.UiSettings.NavPosition == NavPosition.Bottom)
|
||||
{
|
||||
// 접혀 있는 동안에는 폭이 0 이라 밑줄 자리를 잴 수 없다. 편 뒤 배치가 끝나면 다시 맞춘다.
|
||||
Dispatcher.BeginInvoke(new Action(() => MoveTabIndicator(animate: false)), System.Windows.Threading.DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>토스트 오버레이의 정렬/여백을 UiSettings.ToastPosition에 맞춘다. ToastList는
|
||||
@ -410,6 +677,25 @@ public partial class ShellWindow : Window
|
||||
NavList.Width = isHorizontal ? double.NaN : 140;
|
||||
NavList.Height = double.NaN;
|
||||
|
||||
// 접었을 때의 띠도 같은 쪽에 붙인다. 아래면 가로 띠(글자 포함), 옆이면 세로 띠(화살표만).
|
||||
DockPanel.SetDock(NavExpandStrip, DockPanel.GetDock(NavList));
|
||||
NavExpandStrip.Height = isHorizontal ? 16 : double.NaN;
|
||||
NavExpandStrip.Width = isHorizontal ? double.NaN : 16;
|
||||
NavExpandStrip.BorderThickness = position switch
|
||||
{
|
||||
NavPosition.Right => new Thickness(1, 0, 0, 0),
|
||||
NavPosition.Bottom => new Thickness(0, 1, 0, 0),
|
||||
_ => new Thickness(0, 0, 1, 0)
|
||||
};
|
||||
NavExpandLabel.Visibility = isHorizontal ? Visibility.Visible : Visibility.Collapsed;
|
||||
NavExpandContent.Orientation = isHorizontal ? Orientation.Horizontal : Orientation.Vertical;
|
||||
NavExpandArrowTurn.Angle = position switch
|
||||
{
|
||||
NavPosition.Right => -90, // 왼쪽으로 펼친다
|
||||
NavPosition.Bottom => 0, // 위로 펼친다
|
||||
_ => 90 // 오른쪽으로 펼친다
|
||||
};
|
||||
|
||||
if (isHorizontal)
|
||||
{
|
||||
Dispatcher.BeginInvoke(new Action(() => MoveTabIndicator(animate: false)));
|
||||
|
||||
199
src/UbisamBase.Core/Shell/TrayIcon.cs
Normal file
199
src/UbisamBase.Core/Shell/TrayIcon.cs
Normal file
@ -0,0 +1,199 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
|
||||
namespace UbisamBase.Core.Shell;
|
||||
|
||||
/// <summary>
|
||||
/// 작업 표시줄 오른쪽 알림 영역(트레이)에 아이콘을 둔다. 창을 최소화하면 작업 표시줄 대신 여기로 내려간다.
|
||||
///
|
||||
/// WinForms 의 <c>NotifyIcon</c> 을 쓰지 않는다 - 플랫폼(Core)에 WinForms 의존을 더하면 이 플랫폼으로 만든
|
||||
/// 모든 프로그램이 함께 지고 간다. 대신 <c>Shell_NotifyIcon</c> 을 직접 부르고, 마우스 알림을 받을
|
||||
/// <b>메시지 전용 창</b> 하나만 만든다(화면에 보이지 않는다).
|
||||
///
|
||||
/// 아이콘은 실행 파일의 것을 그대로 쓴다 - 프로그램마다 자기 아이콘이 나온다. 못 가져오면 윈도 기본 아이콘.
|
||||
/// </summary>
|
||||
internal sealed class TrayIcon : IDisposable
|
||||
{
|
||||
private const int WM_USER = 0x0400;
|
||||
private const int TrayCallbackMessage = WM_USER + 1;
|
||||
private const int WM_LBUTTONUP = 0x0202;
|
||||
private const int WM_LBUTTONDBLCLK = 0x0203;
|
||||
private const int WM_RBUTTONUP = 0x0205;
|
||||
|
||||
private const int NIM_ADD = 0x0;
|
||||
private const int NIM_MODIFY = 0x1;
|
||||
private const int NIM_DELETE = 0x2;
|
||||
private const int NIF_MESSAGE = 0x1;
|
||||
private const int NIF_ICON = 0x2;
|
||||
private const int NIF_TIP = 0x4;
|
||||
|
||||
private readonly HwndSource source;
|
||||
private NOTIFYICONDATA data;
|
||||
private bool shown;
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>트레이 아이콘을 왼쪽 단추로 눌렀다(창을 다시 보일 때).</summary>
|
||||
public event EventHandler? Opened;
|
||||
|
||||
/// <summary>트레이 아이콘을 오른쪽 단추로 눌렀다(메뉴를 띄울 때).</summary>
|
||||
public event EventHandler? MenuRequested;
|
||||
|
||||
public TrayIcon(string tooltip)
|
||||
{
|
||||
// 보이지 않는 메시지 전용 창. 트레이가 보내는 마우스 알림을 받는 자리다.
|
||||
source = new HwndSource(new HwndSourceParameters("UbisamTrayIcon")
|
||||
{
|
||||
Width = 0,
|
||||
Height = 0,
|
||||
WindowStyle = 0,
|
||||
ParentWindow = new IntPtr(-3), // HWND_MESSAGE
|
||||
});
|
||||
source.AddHook(OnMessage);
|
||||
|
||||
data = new NOTIFYICONDATA
|
||||
{
|
||||
cbSize = Marshal.SizeOf<NOTIFYICONDATA>(),
|
||||
hWnd = source.Handle,
|
||||
uID = 1,
|
||||
uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP,
|
||||
uCallbackMessage = TrayCallbackMessage,
|
||||
hIcon = LoadAppIcon(),
|
||||
szTip = Trim(tooltip),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>트레이에 아이콘을 올린다. 이미 있으면 글만 고친다.</summary>
|
||||
public void Show()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Shell_NotifyIcon(shown ? NIM_MODIFY : NIM_ADD, ref data);
|
||||
shown = true;
|
||||
}
|
||||
|
||||
/// <summary>트레이에서 아이콘을 내린다.</summary>
|
||||
public void Hide()
|
||||
{
|
||||
if (!shown || disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Shell_NotifyIcon(NIM_DELETE, ref data);
|
||||
shown = false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Hide();
|
||||
disposed = true;
|
||||
|
||||
if (data.hIcon != IntPtr.Zero)
|
||||
{
|
||||
DestroyIcon(data.hIcon);
|
||||
data.hIcon = IntPtr.Zero;
|
||||
}
|
||||
|
||||
source.RemoveHook(OnMessage);
|
||||
source.Dispose();
|
||||
}
|
||||
|
||||
private IntPtr OnMessage(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||
{
|
||||
if (msg != TrayCallbackMessage)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
switch (lParam.ToInt32())
|
||||
{
|
||||
case WM_LBUTTONUP:
|
||||
case WM_LBUTTONDBLCLK:
|
||||
Opened?.Invoke(this, EventArgs.Empty);
|
||||
handled = true;
|
||||
break;
|
||||
case WM_RBUTTONUP:
|
||||
MenuRequested?.Invoke(this, EventArgs.Empty);
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
/// <summary>실행 파일의 아이콘. 못 가져오면 윈도 기본 아이콘(IntPtr.Zero 면 트레이가 빈칸이 되므로 반드시 하나는 준다).</summary>
|
||||
private static IntPtr LoadAppIcon()
|
||||
{
|
||||
try
|
||||
{
|
||||
var exe = Process.GetCurrentProcess().MainModule?.FileName;
|
||||
if (exe is { Length: > 0 })
|
||||
{
|
||||
var handle = ExtractIcon(IntPtr.Zero, exe, 0);
|
||||
if (handle != IntPtr.Zero && handle != new IntPtr(1))
|
||||
{
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException || ex is System.ComponentModel.Win32Exception)
|
||||
{
|
||||
}
|
||||
|
||||
return LoadIcon(IntPtr.Zero, new IntPtr(32512)); // IDI_APPLICATION
|
||||
}
|
||||
|
||||
/// <summary>트레이 글은 128자까지다.</summary>
|
||||
private static string Trim(string text) =>
|
||||
string.IsNullOrEmpty(text) ? string.Empty : (text.Length <= 127 ? text : text.Substring(0, 127));
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct NOTIFYICONDATA
|
||||
{
|
||||
public int cbSize;
|
||||
public IntPtr hWnd;
|
||||
public int uID;
|
||||
public int uFlags;
|
||||
public int uCallbackMessage;
|
||||
public IntPtr hIcon;
|
||||
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||
public string szTip;
|
||||
|
||||
public int dwState;
|
||||
public int dwStateMask;
|
||||
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||
public string szInfo;
|
||||
|
||||
public int uVersion;
|
||||
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
|
||||
public string szInfoTitle;
|
||||
|
||||
public int dwInfoFlags;
|
||||
}
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern bool Shell_NotifyIcon(int message, ref NOTIFYICONDATA data);
|
||||
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr ExtractIcon(IntPtr instance, string exeFileName, int iconIndex);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr LoadIcon(IntPtr instance, IntPtr iconName);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool DestroyIcon(IntPtr icon);
|
||||
}
|
||||
@ -65,4 +65,14 @@
|
||||
<SolidColorBrush x:Key="Ubisam.Brush.Toast.Border" Color="#FF535B67"/>
|
||||
<DropShadowEffect x:Key="Ubisam.Effect.Toast" BlurRadius="18" ShadowDepth="4" Direction="270" Opacity="0.55" Color="#000000"/>
|
||||
|
||||
<!-- 차트 시리즈 색 — 여러 계열을 한 그래프에 그릴 때 쓰는 순서 고정 팔레트.
|
||||
1번은 회사 하늘색(단일 계열 차트는 이 색 하나만 쓴다), 2~6번은 색상환에서 충분히 떨어뜨려
|
||||
겹쳐 그려도 구분되게 골랐다. 다크에서는 채도를 낮추고 밝기를 올려 어두운 판 위에서 뜨게 한다. -->
|
||||
<SolidColorBrush x:Key="Ubisam.Brush.Chart.Series1" Color="#FF4DA6F5"/>
|
||||
<SolidColorBrush x:Key="Ubisam.Brush.Chart.Series2" Color="#FF4ECDC4"/>
|
||||
<SolidColorBrush x:Key="Ubisam.Brush.Chart.Series3" Color="#FFE0A63C"/>
|
||||
<SolidColorBrush x:Key="Ubisam.Brush.Chart.Series4" Color="#FFA98BE8"/>
|
||||
<SolidColorBrush x:Key="Ubisam.Brush.Chart.Series5" Color="#FF5FC97E"/>
|
||||
<SolidColorBrush x:Key="Ubisam.Brush.Chart.Series6" Color="#FFF07070"/>
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@ -60,4 +60,12 @@
|
||||
<SolidColorBrush x:Key="Ubisam.Brush.Toast.Border" Color="#FFD6D6D9"/>
|
||||
<DropShadowEffect x:Key="Ubisam.Effect.Toast" BlurRadius="12" ShadowDepth="3" Direction="270" Opacity="0.22" Color="#000000"/>
|
||||
|
||||
<!-- 차트 시리즈 색 — 다크와 같은 순서·같은 계열이되, 밝은 바탕에서 읽히도록 한 단계 진하다. -->
|
||||
<SolidColorBrush x:Key="Ubisam.Brush.Chart.Series1" Color="#FF1D82E8"/>
|
||||
<SolidColorBrush x:Key="Ubisam.Brush.Chart.Series2" Color="#FF16A79C"/>
|
||||
<SolidColorBrush x:Key="Ubisam.Brush.Chart.Series3" Color="#FFB8791A"/>
|
||||
<SolidColorBrush x:Key="Ubisam.Brush.Chart.Series4" Color="#FF7B5BC7"/>
|
||||
<SolidColorBrush x:Key="Ubisam.Brush.Chart.Series5" Color="#FF2E8B4F"/>
|
||||
<SolidColorBrush x:Key="Ubisam.Brush.Chart.Series6" Color="#FFC93B3B"/>
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<!-- Ubisam Base 아이콘 세트 — 24×24 그리드, 스트로크 렌더링(StrokeThickness 1.5).
|
||||
<!-- Ubisam Base 아이콘 세트 — 24×24 그리드, 스트로크 렌더링(StrokeThickness 1.5).
|
||||
Path의 Fill은 비우고 Stroke만 쓴다. 색은 상위 Foreground를 상속시키려면
|
||||
Stroke="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ListBoxItem}}" 처럼 묶으면 된다.
|
||||
아이콘을 추가할 때는 Lucide(https://lucide.dev)에서 같은 24px 그리드/스트로크 1.5 규칙으로 가져와
|
||||
@ -51,6 +51,8 @@
|
||||
<Geometry x:Key="Ubisam.Icon.Fullscreen">M4,9 L4,4 L9,4 M15,4 L20,4 L20,9 M20,15 L20,20 L15,20 M9,20 L4,20 L4,15</Geometry>
|
||||
<Geometry x:Key="Ubisam.Icon.FullscreenExit">M9,4 L9,9 L4,9 M15,4 L15,9 L20,9 M20,15 L15,15 L15,20 M4,15 L9,15 L9,20</Geometry>
|
||||
|
||||
<Geometry x:Key="Ubisam.Icon.CollapseNav">M6,8 L12,14 L18,8 M4,19.5 L20,19.5</Geometry>
|
||||
|
||||
<!-- 대화상자 아이콘 3종 -->
|
||||
<Geometry x:Key="Ubisam.Icon.Info">M3,12 A9,9 0 0 1 21,12 A9,9 0 0 1 3,12 Z M12,11 L12,16 M12,7.4 L12,7.5</Geometry>
|
||||
<Geometry x:Key="Ubisam.Icon.Question">M3,12 A9,9 0 0 1 21,12 A9,9 0 0 1 3,12 Z M9.3,9.3 A2.7,2.7 0 1 1 13.3,12.6 C12.5,13.1 12,13.6 12,14.8 M12,17.4 L12,17.5</Geometry>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:conv="clr-namespace:UbisamBase.Core.Converters"
|
||||
xmlns:sys="clr-namespace:System;assembly=mscorlib">
|
||||
@ -13,6 +13,8 @@
|
||||
<conv:AllBoolToVisibilityConverter x:Key="AllBoolToVis"/>
|
||||
<!-- 발표자 모드(PlatformChrome.IsVisible=false)에서는 컨텐츠 여백을 없애 화면을 꽉 채운다. -->
|
||||
<conv:BoolToThicknessConverter x:Key="BoolToContentPadding" True="26,22" False="0"/>
|
||||
<!-- 컨텐츠 여백 — 발표자 모드이거나 그 화면이 FullBleed면 0. -->
|
||||
<conv:ContentPaddingConverter x:Key="ContentPadding" Default="26,22"/>
|
||||
<conv:InverseBooleanConverter x:Key="InverseBool"/>
|
||||
<conv:FirstCharacterConverter x:Key="FirstCharConverter"/>
|
||||
<conv:ToastTypeToBrushConverter x:Key="ToastTypeToBrush"/>
|
||||
@ -96,12 +98,16 @@
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- 하단 네비게이션 아이템: 가운데 모임, 아이콘 위 + 라벨 아래. 선택 시 바닥 3px 밑줄(SelectionIndicator)만. -->
|
||||
<!-- 하단 네비게이션 아이템: 가운데 모임, 아이콘 위 + 라벨 아래. 선택 시 바닥 3px 밑줄(SelectionIndicator)만.
|
||||
|
||||
높이를 한 단계 줄였다(MergeHub 12차 C-2, 2026-09-28). 위아래 여백 13/14 → 9/10,
|
||||
아이콘 22 → 20, 아이콘 아래 틈 7 → 5, 글자 13.5 → 12.5 로 74 → 61 DIP(배율 1.2 에서 89 → 73px).
|
||||
줄인 만큼이 그대로 콘텐츠 높이가 된다 - 코드 칸이 한 줄 더 들어간다. -->
|
||||
<Style x:Key="Ubisam.Style.SidebarItem.Horizontal" TargetType="ListBoxItem">
|
||||
<Setter Property="Padding" Value="30,13,30,14"/>
|
||||
<Setter Property="Padding" Value="30,9,30,10"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.Foreground}"/>
|
||||
<Setter Property="FontFamily" Value="{StaticResource Ubisam.Font.Body}"/>
|
||||
<Setter Property="FontSize" Value="13.5"/>
|
||||
<Setter Property="FontSize" Value="12.5"/>
|
||||
<Setter Property="FontWeight" Value="Medium"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
@ -114,8 +120,8 @@
|
||||
Stroke="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}"
|
||||
StrokeThickness="1.5"
|
||||
StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round"
|
||||
Width="22" Height="22" Stretch="Uniform"
|
||||
Margin="0,0,0,7" HorizontalAlignment="Center"/>
|
||||
Width="20" Height="20" Stretch="Uniform"
|
||||
Margin="0,0,0,5" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Title}" HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@ -12,6 +12,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.IO.Compression.FileSystem" />
|
||||
<!-- 사내 API 호출(ApiService)용 — net48에서는 명시적으로 참조해야 HttpClient를 쓸 수 있다. -->
|
||||
<Reference Include="System.Net.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
91
src/UbisamBase.Launcher/LauncherTheme.cs
Normal file
91
src/UbisamBase.Launcher/LauncherTheme.cs
Normal file
@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace UbisamBase.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// 런처가 띄우는 창(진행 창·업데이트 안내창)의 밝기를 정한다.
|
||||
///
|
||||
/// 런처는 Core를 참조할 수 없어서(갱신 대상인 Core.dll을 물게 된다) UiSettingsService를 쓸 수 없지만,
|
||||
/// 그 서비스가 저장해 둔 파일은 그대로 읽을 수 있다. 사용자가 앱에서 다크로 해뒀는데 런처 창만
|
||||
/// 윈도우 설정(밝게)을 따라가면 혼자 하얗게 떠서 튄다 — 그래서 앱 설정을 먼저 보고,
|
||||
/// "시스템 설정 따르기"이거나 파일이 없을 때만 윈도우 설정을 본다.
|
||||
/// </summary>
|
||||
internal static class LauncherTheme
|
||||
{
|
||||
// 설정 폴더는 Core의 UbisamAppBuilder.ConfigRoot와 같은 고정 경로다(앱이 달라도 같은 폴더를 쓴다).
|
||||
private const string UiSettingsPath = @"D:\UbisamConfig\Settings\UiSettingsService.json";
|
||||
|
||||
/// <summary>지금 밝은 테마로 그려야 하는가.</summary>
|
||||
public static bool IsLight()
|
||||
{
|
||||
var mode = ReadThemeMode();
|
||||
|
||||
return mode switch
|
||||
{
|
||||
ThemeMode.Light => true,
|
||||
ThemeMode.Dark => false,
|
||||
_ => IsSystemLightTheme()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Core의 UbisamBase.Core.Settings.ThemeMode와 값·순서가 같아야 한다(파일에 숫자로 저장된다).</summary>
|
||||
private enum ThemeMode
|
||||
{
|
||||
System = 0,
|
||||
Light = 1,
|
||||
Dark = 2
|
||||
}
|
||||
|
||||
private static ThemeMode ReadThemeMode()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(UiSettingsPath))
|
||||
{
|
||||
return ThemeMode.System;
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(UiSettingsPath);
|
||||
|
||||
// 숫자로 저장된 경우(지금 방식)
|
||||
var number = Regex.Match(json, "\"ThemeMode\"\\s*:\\s*(\\d+)");
|
||||
if (number.Success && int.TryParse(number.Groups[1].Value, out var value) &&
|
||||
Enum.IsDefined(typeof(ThemeMode), value))
|
||||
{
|
||||
return (ThemeMode)value;
|
||||
}
|
||||
|
||||
// 이름으로 저장된 경우(나중에 문자열 변환기를 붙이더라도 깨지지 않게)
|
||||
var name = Regex.Match(json, "\"ThemeMode\"\\s*:\\s*\"(\\w+)\"");
|
||||
if (name.Success && Enum.TryParse<ThemeMode>(name.Groups[1].Value, ignoreCase: true, out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 읽지 못하면 시스템 설정을 따른다.
|
||||
}
|
||||
|
||||
return ThemeMode.System;
|
||||
}
|
||||
|
||||
/// <summary>윈도우가 밝은 테마인지. Core의 UiSettingsService.IsSystemLightTheme과 같은 판정이다.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
/// 새 버전이면 받아서 덮어쓴다. 순서는 이렇다.
|
||||
///
|
||||
/// <code>
|
||||
/// 설정에서 켜져 있나? → 인터넷 연결됐나? → 저장소에 새 커밋이 있나? → 그 안의 업데이트 파일이
|
||||
/// 지금 것보다 새 버전인가? → 자동 업데이트면 바로 받기 / 아니면 물어보고 "예"일 때만 받기
|
||||
/// 설정에서 켜져 있나? → 인터넷 연결됐나? → (진행 창 표시) 저장소에 새 커밋이 있나?
|
||||
/// → 업데이트 파일 받기(진행률 표시) → 지금 것보다 새 버전인가?
|
||||
/// → 자동 업데이트면 바로 설치 / 아니면 물어보고 "예"일 때만 설치
|
||||
/// </code>
|
||||
///
|
||||
/// 받는 동안 <see cref="ProgressWindow"/>를 띄운다 — 예전에는 아무것도 안 보여서 몇 초 동안
|
||||
/// 프로그램이 안 켜지는 것처럼 보였다. 설치 여부는 <b>다 받은 뒤에</b> 묻는다(그래야 "예"를 누른
|
||||
/// 다음에 또 기다리지 않는다).
|
||||
///
|
||||
/// 어느 단계에서 막히든(설정 꺼짐 · 네트워크 없음 · git 없음 · 저장소 접근 실패) 조용히 넘어가고
|
||||
/// 기존 파일로 그대로 실행한다 — 업데이트 때문에 프로그램이 안 켜지는 일은 없어야 한다.
|
||||
///
|
||||
@ -26,10 +33,32 @@ namespace UbisamBase.Launcher;
|
||||
internal static class PlatformUpdater
|
||||
{
|
||||
private const string SettingsPath = @"D:\UbisamPlatform\update-settings.json";
|
||||
// 업데이트 확인은 조용히 지나가는 것이 기본이라, 왜 안 떴는지 볼 방법이 하나는 있어야 한다.
|
||||
// 실행할 때마다 덮어쓰므로 파일이 쌓이지 않는다.
|
||||
private const string LogPath = @"D:\UbisamPlatform\update-log.txt";
|
||||
private const string VersionFileName = "platform-version.json";
|
||||
private const int LsRemoteTimeoutMs = 10000;
|
||||
private const int CloneTimeoutMs = 180000;
|
||||
|
||||
private static readonly StringBuilder LogBuffer = new StringBuilder();
|
||||
|
||||
/// <summary>확인 과정을 한 줄씩 남긴다(D:\UbisamPlatform\update-log.txt). 토큰이나 응답 내용은 남기지 않는다.</summary>
|
||||
private static void Note(string message)
|
||||
=> LogBuffer.AppendLine(DateTime.Now.ToString("HH:mm:ss") + " " + message);
|
||||
|
||||
private static void FlushLog()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 메모장·PowerShell에서 한글이 깨지지 않도록 BOM을 붙여 쓴다.
|
||||
File.WriteAllText(LogPath, LogBuffer.ToString(), new UTF8Encoding(true));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 로그를 못 써도 실행에는 지장이 없다.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>플랫폼 배포 폴더를 최신으로 만든다. 실패해도 예외를 밖으로 내보내지 않는다.</summary>
|
||||
public static void TryUpdate(string platformDir)
|
||||
{
|
||||
@ -39,103 +68,92 @@ internal static class PlatformUpdater
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Note("예외 : " + ex.Message);
|
||||
MessageBox.Show(
|
||||
"플랫폼 업데이트를 확인하는 중 문제가 발생했습니다:\n" + ex.Message + "\n\n기존 버전으로 계속 실행합니다.",
|
||||
"UbisamBase 업데이트", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
finally
|
||||
{
|
||||
FlushLog();
|
||||
}
|
||||
}
|
||||
|
||||
private static void Run(string platformDir)
|
||||
{
|
||||
var settings = Settings.Load(SettingsPath);
|
||||
Note($"시작 — 사용:{settings.Enabled} 자동:{settings.AutoUpdate} 주소:{(string.IsNullOrWhiteSpace(settings.RepositoryUrl) ? "(없음)" : settings.RepositoryUrl)}");
|
||||
|
||||
if (!settings.Enabled || string.IsNullOrWhiteSpace(settings.RepositoryUrl))
|
||||
{
|
||||
Note("설정이 꺼져 있거나 주소가 비어 있어 건너뜀");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) 인터넷 연결 확인 — 연결이 없으면 깃을 부르지도 않는다(오프라인 현장 PC).
|
||||
if (!NetworkInterface.GetIsNetworkAvailable())
|
||||
{
|
||||
Note("인터넷 연결 없음 — 건너뜀");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryRunGit("--version", null, LsRemoteTimeoutMs, out _))
|
||||
{
|
||||
Note("git을 찾지 못함 — 건너뜀");
|
||||
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(() =>
|
||||
{
|
||||
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);
|
||||
if (string.IsNullOrEmpty(remoteCommit) || remoteCommit == settings.InstalledCommit)
|
||||
{
|
||||
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 _))
|
||||
if (!settings.AutoUpdate && !UpdateDialog.Ask(download.LocalVersion, download.RemoteVersion))
|
||||
{
|
||||
return;
|
||||
Note("사용자가 \"나중에\"를 선택 — 다음 실행 때 다시 물어본다");
|
||||
return; // 기록을 남기지 않는다 — 다음에 켤 때 다시 물어본다.
|
||||
}
|
||||
|
||||
if (!TryRunGit($"sparse-checkout set \"{distName}\"", tempDir, CloneTimeoutMs, out _))
|
||||
{
|
||||
return;
|
||||
}
|
||||
var failed = CopyAll(download.DistDir, platformDir);
|
||||
Note($"업데이트 완료 — 바꾸지 못한 파일:{failed}개");
|
||||
|
||||
var remoteDist = Path.Combine(tempDir, distName.Replace('/', '\\'));
|
||||
if (!Directory.Exists(remoteDist))
|
||||
{
|
||||
// 저장소에 아직 업데이트 파일이 없다 — 이 커밋은 확인했다고 기록만 하고 넘어간다.
|
||||
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")이라 문자열 비교로 앞뒤가 가려진다.
|
||||
// 원격이 더 새것일 때만 업데이트한다 — 소스만 바뀐 커밋이면 확인 기록만 남긴다.
|
||||
if (string.IsNullOrEmpty(remoteVersion) || string.CompareOrdinal(remoteVersion, localVersion) <= 0)
|
||||
{
|
||||
settings.InstalledCommit = remoteCommit;
|
||||
settings.Save(SettingsPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) 자동 업데이트가 아니면 물어본다.
|
||||
if (!settings.AutoUpdate)
|
||||
{
|
||||
var answer = MessageBox.Show(
|
||||
"새 플랫폼 버전이 있습니다.\n\n" +
|
||||
"지금 버전 : " + (string.IsNullOrEmpty(localVersion) ? "(알 수 없음)" : localVersion) + "\n" +
|
||||
"새 버전 : " + remoteVersion + "\n\n" +
|
||||
"지금 업데이트하고 실행할까요?",
|
||||
"UbisamBase 업데이트", MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||||
|
||||
if (answer != MessageBoxResult.Yes)
|
||||
{
|
||||
// 기록을 남기지 않는다 — 다음에 켤 때 다시 물어본다.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var failed = CopyAll(remoteDist, platformDir);
|
||||
settings.InstalledCommit = remoteCommit;
|
||||
settings.InstalledVersion = remoteVersion;
|
||||
settings.InstalledCommit = download.Commit;
|
||||
settings.InstalledVersion = download.RemoteVersion;
|
||||
settings.Save(SettingsPath);
|
||||
|
||||
if (failed > 0)
|
||||
@ -148,10 +166,133 @@ internal static class PlatformUpdater
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempDir);
|
||||
TryDeleteDirectory(download.TempDir);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>원격을 확인하고, 새 버전이면 업데이트 파일을 임시 폴더에 받아 온다.
|
||||
/// 받을 것이 없으면 null. 화면 표시는 <paramref name="progress"/>로 알린다.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>받아둔 업데이트 파일 한 벌.</summary>
|
||||
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));
|
||||
|
||||
private static string FirstToken(string text)
|
||||
{
|
||||
var trimmed = (text ?? string.Empty).Trim();
|
||||
@ -211,7 +352,12 @@ internal static class PlatformUpdater
|
||||
return failed;
|
||||
}
|
||||
|
||||
private static bool TryRunGit(string arguments, string? workingDirectory, int timeoutMs, out string output)
|
||||
/// <summary>
|
||||
/// git을 한 번 실행한다. <paramref name="onPercent"/>를 주면 진행률(0~100)을 알려준다 —
|
||||
/// git은 진행률을 stderr에 줄바꿈 없이(\r) 흘리므로 줄 단위가 아니라 글자 단위로 읽는다.
|
||||
/// stdout과 stderr를 각각 다른 스레드에서 읽는 이유는, 한쪽 버퍼가 차서 서로 기다리는 것을 막기 위함이다.
|
||||
/// </summary>
|
||||
private static bool TryRunGit(string arguments, string? workingDirectory, int timeoutMs, out string output, Action<double>? onPercent = null)
|
||||
{
|
||||
output = string.Empty;
|
||||
|
||||
@ -239,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))
|
||||
{
|
||||
@ -256,6 +405,9 @@ internal static class PlatformUpdater
|
||||
return false;
|
||||
}
|
||||
|
||||
outReader.Join(2000);
|
||||
errReader.Join(2000);
|
||||
|
||||
output = stdout;
|
||||
return process.ExitCode == 0;
|
||||
}
|
||||
@ -265,6 +417,49 @@ internal static class PlatformUpdater
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>stderr를 글자 단위로 읽어 "…: 37%" 같은 진행률만 뽑아낸다.</summary>
|
||||
private static void ReadProgress(StreamReader reader, Action<double>? 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
|
||||
|
||||
63
src/UbisamBase.Launcher/ProgressWindow.xaml
Normal file
63
src/UbisamBase.Launcher/ProgressWindow.xaml
Normal file
@ -0,0 +1,63 @@
|
||||
<Window x:Class="UbisamBase.Launcher.ProgressWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Width="420"
|
||||
SizeToContent="Height"
|
||||
WindowStyle="None"
|
||||
ResizeMode="NoResize"
|
||||
ShowInTaskbar="False"
|
||||
Topmost="True"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
FontFamily="Barlow, Segoe UI"
|
||||
FontSize="14"
|
||||
MouseLeftButtonDown="Window_MouseLeftButtonDown">
|
||||
|
||||
<!-- 업데이트 안내창(UpdateDialog)과 같은 언어: 위 accent 띠, accent 칩 아이콘, 한 판 구성.
|
||||
버튼이 없다 — 확인/취소는 받기가 끝난 뒤 UpdateDialog에서 묻는다. -->
|
||||
<Border x:Name="RootBorder" BorderThickness="1">
|
||||
<DockPanel>
|
||||
<Border x:Name="AccentStrip" DockPanel.Dock="Top" Height="3"/>
|
||||
|
||||
<StackPanel Margin="26,22,26,22">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Border x:Name="IconChip" Width="40" Height="40" CornerRadius="20" VerticalAlignment="Top" Margin="0,0,14,0">
|
||||
<Path x:Name="IconPath"
|
||||
Data="M12,4 L12,14.5 M7.2,10 L12,15 L16.8,10 M5.5,19 L18.5,19"
|
||||
Width="20" Height="20"
|
||||
Stretch="Uniform"
|
||||
StrokeThickness="1.7"
|
||||
StrokeStartLineCap="Round"
|
||||
StrokeEndLineCap="Round"
|
||||
StrokeLineJoin="Round"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock x:Name="HeadlineText"
|
||||
Text="업데이트 확인 중"
|
||||
FontFamily="Barlow Condensed, Segoe UI"
|
||||
FontSize="19"
|
||||
FontWeight="SemiBold"/>
|
||||
<TextBlock x:Name="StatusText"
|
||||
Text="저장소에서 최신 버전을 확인하고 있습니다."
|
||||
FontSize="12.5"
|
||||
TextWrapping="Wrap"
|
||||
Margin="0,3,0,0"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 진행 막대 — 기본 ProgressBar 대신 직접 그린다(테마 색을 그대로 쓰기 위함). -->
|
||||
<Border x:Name="TrackBorder" Height="6" CornerRadius="3" Margin="0,18,0,0" HorizontalAlignment="Stretch">
|
||||
<Border x:Name="FillBorder" Height="6" CornerRadius="3" HorizontalAlignment="Left" Width="0"/>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="PercentText"
|
||||
Text="0%"
|
||||
FontSize="11.5"
|
||||
HorizontalAlignment="Right"
|
||||
Margin="0,7,0,0"/>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Window>
|
||||
96
src/UbisamBase.Launcher/ProgressWindow.xaml.cs
Normal file
96
src/UbisamBase.Launcher/ProgressWindow.xaml.cs
Normal file
@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
|
||||
namespace UbisamBase.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// 업데이트를 확인하고 받는 동안 보여주는 창. 런처를 누르자마자 떠서 "지금 뭘 하고 있는지"를 알린다
|
||||
/// (예전에는 받는 동안 아무것도 안 보여서 프로그램이 안 켜지는 것처럼 보였다).
|
||||
///
|
||||
/// 받기가 끝나면 이 창은 닫히고, 설치 여부는 UpdateDialog가 묻는다.
|
||||
/// 색은 UpdateDialog와 같은 방식으로(윈도우 밝게/어둡게) 직접 넣는다 — Core를 참조할 수 없기 때문.
|
||||
/// </summary>
|
||||
public partial class ProgressWindow : Window
|
||||
{
|
||||
private double percent;
|
||||
|
||||
public ProgressWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
ApplyTheme(LauncherTheme.IsLight());
|
||||
SizeChanged += (_, _) => UpdateFillWidth(animate: false);
|
||||
}
|
||||
|
||||
/// <summary>제목과 설명을 바꾼다. 어느 스레드에서 불러도 된다.</summary>
|
||||
public void SetStage(string headline, string status)
|
||||
=> Dispatcher.Invoke(() =>
|
||||
{
|
||||
HeadlineText.Text = headline;
|
||||
StatusText.Text = status;
|
||||
});
|
||||
|
||||
/// <summary>0~100. 뒤로 가지 않도록 지금 값보다 큰 값만 반영한다 — git이 단계마다 0%부터 다시 세기 때문.</summary>
|
||||
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 void Window_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) => DragMove();
|
||||
}
|
||||
142
src/UbisamBase.Launcher/UpdateDialog.xaml
Normal file
142
src/UbisamBase.Launcher/UpdateDialog.xaml
Normal file
@ -0,0 +1,142 @@
|
||||
<Window x:Class="UbisamBase.Launcher.UpdateDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Width="460"
|
||||
SizeToContent="Height"
|
||||
WindowStyle="None"
|
||||
ResizeMode="NoResize"
|
||||
ShowInTaskbar="False"
|
||||
Topmost="True"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
FontFamily="Barlow, Segoe UI"
|
||||
FontSize="14"
|
||||
MouseLeftButtonDown="Window_MouseLeftButtonDown"
|
||||
PreviewKeyDown="Window_PreviewKeyDown">
|
||||
|
||||
<!-- 제목줄 없이 한 판으로 가고, 위쪽 accent 띠 하나로 플랫폼 창임을 표시한다.
|
||||
Core의 리소스를 쓸 수 없어(참조하면 갱신 대상 Core.dll을 물게 된다) 색은 코드비하인드가
|
||||
시스템 테마(밝게/어둡게)에 맞춰 직접 넣는다. -->
|
||||
<Window.Resources>
|
||||
<Style x:Key="PrimaryButton" TargetType="Button">
|
||||
<Setter Property="MinWidth" Value="104"/>
|
||||
<Setter Property="Padding" Value="16,9"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="7" SnapsToDevicePixels="True">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" Margin="{TemplateBinding Padding}"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Opacity" Value="0.88"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsPressed" Value="True">
|
||||
<Setter TargetName="Bd" Property="Opacity" Value="0.72"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- 보조 동작은 테두리 없는 글자 버튼 — 눈이 "업데이트" 쪽으로 먼저 가게 한다. -->
|
||||
<Style x:Key="QuietButton" TargetType="Button">
|
||||
<Setter Property="MinWidth" Value="84"/>
|
||||
<Setter Property="Padding" Value="14,9"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="7" SnapsToDevicePixels="True">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" Margin="{TemplateBinding Padding}"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{Binding QuietHoverBrush, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<Border x:Name="RootBorder" BorderThickness="1">
|
||||
<DockPanel>
|
||||
<!-- 위쪽 accent 띠 -->
|
||||
<Border x:Name="AccentStrip" DockPanel.Dock="Top" Height="3"/>
|
||||
|
||||
<StackPanel Margin="26,24,26,20">
|
||||
|
||||
<!-- 제목줄: 아이콘 칩 + 제목/한 줄 설명 -->
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Border x:Name="IconChip" Width="44" Height="44" CornerRadius="22" VerticalAlignment="Top" Margin="0,0,16,0">
|
||||
<!-- 아래로 내려받는 화살표 -->
|
||||
<Path x:Name="IconPath"
|
||||
Data="M12,4 L12,14.5 M7.2,10 L12,15 L16.8,10 M5.5,19 L18.5,19"
|
||||
Width="22" Height="22"
|
||||
Stretch="Uniform"
|
||||
StrokeThickness="1.7"
|
||||
StrokeStartLineCap="Round"
|
||||
StrokeEndLineCap="Round"
|
||||
StrokeLineJoin="Round"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock x:Name="HeadlineText"
|
||||
Text="새 버전이 준비됐습니다"
|
||||
FontFamily="Barlow Condensed, Segoe UI"
|
||||
FontSize="21"
|
||||
FontWeight="SemiBold"/>
|
||||
<TextBlock x:Name="SubText"
|
||||
Text="플랫폼을 최신으로 바꾼 뒤 실행합니다."
|
||||
FontSize="13"
|
||||
Margin="0,3,0,0"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- 버전 비교: 지금 → 새것 -->
|
||||
<Border x:Name="VersionBox" CornerRadius="8" Padding="16,13" Margin="0,20,0,0">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||
<StackPanel>
|
||||
<TextBlock x:Name="CurrentLabel" Text="지금" FontSize="11" HorizontalAlignment="Center"/>
|
||||
<TextBlock x:Name="CurrentValue" FontSize="13" Margin="0,3,0,0" HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<Path x:Name="ArrowPath"
|
||||
Data="M4,10 L18,10 M13,5.5 L18,10 L13,14.5"
|
||||
Width="22" Height="20"
|
||||
Stretch="Uniform"
|
||||
StrokeThickness="1.6"
|
||||
StrokeStartLineCap="Round"
|
||||
StrokeEndLineCap="Round"
|
||||
StrokeLineJoin="Round"
|
||||
Margin="18,10,18,0"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock x:Name="NewLabel" Text="새 버전" FontSize="11" HorizontalAlignment="Center"/>
|
||||
<TextBlock x:Name="NewValue" FontSize="13" FontWeight="SemiBold" Margin="0,3,0,0" HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="FootnoteText"
|
||||
Text="나중에를 누르면 이번에는 지금 버전으로 실행하고, 다음에 다시 물어봅니다."
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Margin="2,14,0,0"/>
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,22,0,0">
|
||||
<Button x:Name="NoButton" Content="나중에" Style="{StaticResource QuietButton}" Margin="0,0,8,0" Click="No_Click"/>
|
||||
<Button x:Name="YesButton" Content="지금 업데이트" Style="{StaticResource PrimaryButton}" IsDefault="True" Click="Yes_Click"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Window>
|
||||
89
src/UbisamBase.Launcher/UpdateDialog.xaml.cs
Normal file
89
src/UbisamBase.Launcher/UpdateDialog.xaml.cs
Normal file
@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace UbisamBase.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// "새 버전이 준비됐습니다 — 지금 받을까요?" 대화상자.
|
||||
///
|
||||
/// 플랫폼(Core)의 색·글꼴을 따르되 리소스는 공유하지 않는다 — Launcher가 Core를 참조하면 갱신
|
||||
/// 대상인 Core.dll을 물고 있게 되어 덮어쓸 수 없기 때문이다. 그래서 색을 여기서 직접 넣는다.
|
||||
/// 테마는 윈도우 설정(밝게/어둡게)을 따른다. 이 시점에는 아직 앱의 UI 설정을 읽을 수 없다.
|
||||
/// </summary>
|
||||
public partial class UpdateDialog : Window
|
||||
{
|
||||
private UpdateDialog(string currentVersion, string newVersion)
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
CurrentValue.Text = string.IsNullOrEmpty(currentVersion) ? "(알 수 없음)" : currentVersion;
|
||||
NewValue.Text = newVersion;
|
||||
|
||||
ApplyTheme(LauncherTheme.IsLight());
|
||||
}
|
||||
|
||||
/// <summary>보조 버튼에 마우스를 올렸을 때 깔리는 색. 템플릿에서 바인딩해 쓴다.</summary>
|
||||
public Brush QuietHoverBrush { get; private set; } = Brushes.Transparent;
|
||||
|
||||
/// <summary>대화상자를 띄우고 "지금 업데이트"를 눌렀는지 돌려준다.</summary>
|
||||
public static bool Ask(string currentVersion, string newVersion)
|
||||
=> new UpdateDialog(currentVersion, newVersion).ShowDialog() == true;
|
||||
|
||||
private void ApplyTheme(bool light)
|
||||
{
|
||||
// Colors.Light.xaml / Colors.Dark.xaml 과 같은 값.
|
||||
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 accentFill = Brush(light ? "#FF1D82E8" : "#FF1E7FD4");
|
||||
var onAccent = Brush(light ? "#FFFFFFFF" : "#FFF2F7FC");
|
||||
var sunken = Brush(light ? "#FFF2F2F3" : "#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;
|
||||
SubText.Foreground = muted;
|
||||
|
||||
VersionBox.Background = sunken;
|
||||
CurrentLabel.Foreground = muted;
|
||||
CurrentValue.Foreground = muted;
|
||||
ArrowPath.Stroke = accent;
|
||||
NewLabel.Foreground = muted;
|
||||
NewValue.Foreground = foreground;
|
||||
FootnoteText.Foreground = muted;
|
||||
|
||||
YesButton.Background = accentFill;
|
||||
YesButton.Foreground = onAccent;
|
||||
NoButton.Foreground = muted;
|
||||
QuietHoverBrush = sunken;
|
||||
}
|
||||
|
||||
private static SolidColorBrush Brush(string hex)
|
||||
=> new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex));
|
||||
|
||||
/// <summary>제목줄이 없으므로 창 아무 데나 끌어서 옮길 수 있게 한다.</summary>
|
||||
private void Window_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) => DragMove();
|
||||
|
||||
private void Yes_Click(object sender, RoutedEventArgs e) => DialogResult = true;
|
||||
|
||||
private void No_Click(object sender, RoutedEventArgs e) => DialogResult = false;
|
||||
|
||||
private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
DialogResult = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using UbisamBase.Core.Bootstrap;
|
||||
|
||||
@ -10,10 +12,93 @@ namespace UbisamBase.Shell;
|
||||
/// </summary>
|
||||
public partial class App : Application
|
||||
{
|
||||
private const string LauncherFileName = "UbisamBase.Launcher.exe";
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
|
||||
UbisamAppBuilder.Run(AppDomain.CurrentDomain.BaseDirectory);
|
||||
if (RedirectToLauncher())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
UbisamAppBuilder.Run(AppDomain.CurrentDomain.BaseDirectory);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 켜는 중 실패는 "계속 쓸 수 있습니다" 가 아니다 - 본 창이 없으므로 까닭을 보이고 끝낸다.
|
||||
// 예전에는 공용 예외 창만 뜨고 창 없는 프로세스가 남았다(설정 폴더를 못 만들 때 실제로 그랬다).
|
||||
try
|
||||
{
|
||||
MessageBox.Show(StartupMessages.StartFailed(ex.Message, LogDirectoryOrNull()),
|
||||
"켜지 못했습니다", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 창조차 못 띄우는 상태 - 그래도 프로세스는 남기지 않는다.
|
||||
}
|
||||
|
||||
Shutdown(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>로그가 준비됐을 때만 자리를 댄다 - 못 남긴 것을 남았다고 하지 않는다.</summary>
|
||||
private static string? LogDirectoryOrNull()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = System.IO.Path.Combine(UbisamAppBuilder.ConfigRoot, "Log");
|
||||
return System.IO.Directory.Exists(dir) ? dir : null;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이 exe를 직접 실행한 경우 런처로 넘긴다.
|
||||
///
|
||||
/// 프로그램의 정식 진입점은 <b>항상 런처</b>다 — 런처만이 배포 폴더(D:\UbisamPlatform\bin)를
|
||||
/// 최신으로 맞추고 업데이트를 확인한다. 폴더에 exe가 둘 보여서 이걸 직접 누르는 일이 생기는데,
|
||||
/// 그러면 갱신 없이 옛 파일로 떠서 "왜 업데이트가 안 되지?"가 된다. 그래서 조용히 런처로 넘긴다.
|
||||
///
|
||||
/// 런처는 Shell을 <b>같은 프로세스에서</b> 로드하므로, 런처로 실행된 경우 이 프로세스의 exe
|
||||
/// 이름은 런처다 — 그때는 아무 일도 하지 않는다.
|
||||
/// </summary>
|
||||
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; // 넘기지 못하면 그냥 이대로 실행한다 — 프로그램이 안 켜지는 것보다 낫다.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user