From c99723181e028a8d8409ed33e44ad3fe7d384c80 Mon Sep 17 00:00:00 2001 From: ksj09406 Date: Wed, 23 Sep 2026 02:42:25 +0900 Subject: [PATCH] =?UTF-8?q?11=EC=B0=A8=20F-11:=20Git=20=EC=9B=90=EA=B2=A9?= =?UTF-8?q?=20URL=20=C2=B7=20=EA=B3=84=EC=A0=95=20=C2=B7=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - URL · 계정은 Git 탭에 적으면 설정에 남긴다(0.8초 모아 한 번) - 토큰은 fetch/pull/push/클론이 성공했을 때만 윈도 자격 증명 관리자에(GitCredentialStore, 서버마다 한 이름) - 켤 때 · 주소를 바꿀 때 그 서버 토큰만 가져오고, 다른 서버로 바꾸면 앞 서버 토큰은 치운다 - [토큰 지우기] 단추 · "저장됨" 표시, 인증 실패는 한글 안내(GitErrors.Friendly) Co-Authored-By: Claude Opus 5 --- doc/history/2026-09-18-11차.md | 44 +++++ doc/history/changelog.md | 1 + doc/진행상황.md | 2 +- src/MergeHub/Services/GitCredentialStore.cs | 188 ++++++++++++++++++++ src/MergeHub/Services/GitWorkspace.cs | 92 +++++++++- src/MergeHub/ViewModels/GitViewModel.cs | 58 ++++-- src/MergeHub/Views/GitView.xaml | 8 +- src/MergeHub/Views/GitView.xaml.cs | 33 ++++ 8 files changed, 408 insertions(+), 18 deletions(-) create mode 100644 src/MergeHub/Services/GitCredentialStore.cs diff --git a/doc/history/2026-09-18-11차.md b/doc/history/2026-09-18-11차.md index 6e61169..14f0e7e 100644 --- a/doc/history/2026-09-18-11차.md +++ b/doc/history/2026-09-18-11차.md @@ -168,6 +168,7 @@ A-1 에서 값으로 확인한 것 — 지운 파일은 "삭제되어 건너뜀 - F-8: 매뉴얼 되돌리기 설명 — 저장 · 저장 후 커밋 뒤에도 Undo/Redo(커밋은 그대로, 화면만), 편집기에 친 것 · 규칙 바꾸기도 이어짐, 요약 `· 저장됨`, [전체 되돌리기] 는 파일을 연 시점(저장했으면 화면만). Git 탭 충돌 화면 "이번 병합에서 푼 파일" + [다시 충돌로](git.exe 필요 — 알아 둘 한계에도). 숫자 24벌 1,593건. - F-9: 매뉴얼 설정 표 "저장 > 저장할 때 백업"(기본 끔) — 백업 자리 `{설정 폴더}\Backup\MergeHub\날짜\드라이브\원래 폴더\이름.날짜-시각.확장자` · 목록.txt, 모든 저장(병합 중 Save · 파일 단위 Accept · 폴더 복사 포함), 못 만들면 저장 안 함, 저절로 지우지 않음, ACTIONS [백업 폴더 열기]. 숫자 24벌 1,618건. - F-10: 매뉴얼 파일 비교 화면 설명과 그림을 새로 — 칸마다 자기 줄(빈 줄로 맞추지 않음) · 두 칸(3분할은 위 세 칸) 사이 잇는 선과 색 · 옮김 점선 · 가운데 기준 스크롤 · 차이 이동이 가운데로 · 지운 줄 되살리기 [>] 가 BASE 칸 끝. 상태 줄 `N행` 은 파일 줄 번호. 숫자 25벌 1,639건. +- F-11: 매뉴얼 Git 탭 원격 칸 — 원격 URL · 계정은 적으면 설정에 남고, 토큰은 fetch/pull/push 가 되면 윈도 자격 증명 관리자에 저장(설정 파일에는 토큰이 없음), 껐다 켜면 다시 묻지 않음, [토큰 지우기] · "저장됨" 표시, 인증 실패 안내 문장. 이 PC · 이 윈도 계정에만 저장된다는 것도. 숫자 25벌 1,654건. ## B-1. 배포 묶음을 다시 만들고 꼭 필요한 파일을 가림 (2026-09-22, 완료) @@ -925,6 +926,49 @@ libgit2 의 다듬기(`PrettifyMessages`)를 켜면 **손대지 않은 뒤 커 - 워드랩을 켜면 화면 밖 줄의 높이는 어림이라, 긴 파일을 멀리 건너뛴 직후에는 가운데 맞춤이 몇 줄 어긋날 수 있다(그려지면 바로잡힘 — 예전 방식에도 있던 한계). - 3분할 MERGED 와 위 칸 사이 선은 두지 않았다(사용자 결정). +## F-11. Git 계정 · URL · 토큰이 한 번 쓰면 저장되게 (2026-09-23, 완료) + +사용자 결정: 토큰은 **윈도 자격 증명 관리자에 저장**(평문 금지 — 지시서 조건), Git 탭에 적은 **원격 URL · 계정은 적으면 설정에 바로 남김**. + +### 고치기 전 (화면으로 확인, `f11-check.ps1` · 가짜 서버 `f11-server.ps1`) + +| 확인 | 결과 | +|---|---| +| 계정 · 토큰을 넣고 Fetch | 서버가 `tester:토큰` 을 받았다 — 보내는 것은 정상 | +| 설정 파일 | Git 탭에 적은 URL · 계정 · 토큰이 **아무것도 저장되지 않음**(설정 탭 "사내 Gitea" 값만 켤 때 읽음) | +| 껐다 켬 | 원격 URL · 계정 · 토큰 칸이 **모두 빔** | +| 그 상태로 Fetch | 인증 없이 보내고 실패. 로그는 영어로 `remote authentication required but no callback set` 뿐 | +| 설정 폴더 전체에서 토큰 글자 | 0곳(저장하지 않으니 당연) | + +### 고친 것 + +- `GitCredentialStore`(새) — 윈도 자격 증명 관리자(advapi32 `CredWrite`/`CredRead`/`CredDelete`, 사용자 계정 키로 암호화). 이름은 **서버마다 하나** `MergeHub:git:스킴://호스트[:포트]`(`GitCredentialKey.For`) — 같은 서버의 다른 저장소는 한 번만 넣으면 된다. SSH(`git@…`) · 로컬 경로 원격은 토큰을 쓰지 않아 저장하지 않는다. 못 읽거나 못 쓰면 조용히 넘어간다(토큰은 덤이다). +- **저장은 된 것만** — fetch · pull · push · 클론이 **성공했을 때** 그때의 계정 · 토큰을 맡긴다. 켤 때와 원격 주소를 바꿀 때 그 서버의 토큰을 가져와 토큰 칸에 채운다(PasswordBox 는 바인딩이 안 돼 화면 코드가 넣는다). +- 다른 서버로 주소를 바꾸면 **앞 서버에서 가져온 토큰은 치운다** — 남의 서버로 보내지 않는다. 사람이 직접 적은 토큰은 건드리지 않는다. +- **[토큰 지우기]** 단추와 "저장됨" 표시(맡겨 둔 것이 있을 때만). PC 를 넘기거나 토큰을 새로 받았을 때 쓴다. +- Git 탭 원격 칸의 **URL · 계정을 설정에 남긴다**(글자마다 쓰지 않게 0.8초 모았다가 한 번). +- 실패한 영어 말을 사람 말로(`GitErrors.Friendly`) — "원격이 계정 · 토큰을 요구합니다. Git 탭 원격 칸에 계정과 토큰을 넣고 다시 해 보세요." · "계정 또는 토큰이 맞지 않습니다…". 원래 영어 말은 괄호로 함께 남긴다. + +### 확인 (`f11-verify.ps1`, 127.0.0.1 가짜 사내 서버 — 인증이 없거나 토큰이 틀리면 401, 맞으면 `git http-backend` 로 진짜 fetch) + +| 경우 | 결과 | +|---|---| +| 계정 · 토큰 넣고 Fetch | `fetch 완료` · 서버가 받은 인증 `tester:f11-secret-…` · 자격 증명 관리자에 저장됨 · 화면에 "저장됨" | +| 설정 파일 | URL 있음 · 계정 있음 · **토큰 글자 없음** | +| 껐다 켬 | URL · 계정 칸이 채워져 있고 "저장됨" 표시 | +| 아무것도 안 넣고 Fetch | `fetch 완료` · 서버가 같은 토큰을 받음 — **다시 묻지 않는다** | +| [토큰 지우기] | 자격 증명 관리자에서 사라짐 · "저장됨" 꺼짐 | +| 그 뒤 Fetch | `[fetch] 실패: 계정 또는 토큰이 맞지 않습니다. Git 탭 원격 칸에서 다시 넣어 주세요. (too many redirects or authentication replays)` | +| 시험 뒷정리 | 시험이 만든 자격 증명 삭제 확인 | + +- 하네스 MergeSaveTest **+15건(335 → 350)** — 이름 규칙(서버마다 하나 · 저장소가 달라도 같음 · 대소문자 · 포트 · SSH · 로컬 경로 · 빈 주소 · 말이 안 되는 주소)과 실패한 말 옮기기(토큰 없음 · 틀림 · 401 · 인증과 무관 · 빈 말). Debug 빌드 경고 0 · 오류 0. +- 회귀: 저장 후 커밋 되돌리기(f8-git) · 병합 [다시 충돌로](f8-reopen) 그대로. + +### 남은 것 + +- 사내 Gitea 가 실제로 서면 그 서버로 한 번 확인한다(지금은 127.0.0.1 가짜 서버). +- 자격 증명은 **이 PC · 이 윈도 계정**에만 있다(DPAPI). 다른 PC 에서는 한 번 더 넣어야 한다. + ## F-12. C# 코드 스타일 맞춤의 기본값을 AURORA 로 (2026-09-18, 완료) 사용자: "기본값은 원본형태를 따르고 C#인 경우에 AURORA 스타일로 하자" → 물어서 정한 것은 **제품 기본값 AURORA · C# 파일만 · 저장 동작은 지금 그대로**(켜져 있으면 저장도 그 스타일로 쓴다). diff --git a/doc/history/changelog.md b/doc/history/changelog.md index 34da6cd..4cff66f 100644 --- a/doc/history/changelog.md +++ b/doc/history/changelog.md @@ -8,6 +8,7 @@ | 날짜 | 항목 | 커밋 | |---|---|---| +| 2026-09-23 | **11차 F-11** — Git 원격 인증: 껐다 켜면 URL · 계정 · 토큰이 모두 비어 매번 다시 넣어야 했다. URL · 계정은 Git 탭에 적으면 설정에 남기고(0.8초 모아 한 번), 토큰은 fetch · pull · push · 클론이 **성공했을 때만** 윈도 자격 증명 관리자에 맡긴다(`GitCredentialStore` — 서버마다 한 이름, 설정 파일에는 토큰 글자 없음, SSH · 로컬 경로는 저장 안 함). 켤 때 · 주소를 바꿀 때 그 서버 토큰만 가져오고, 다른 서버로 바꾸면 앞 서버 토큰은 치운다. [토큰 지우기] 단추 · "저장됨" 표시. 인증 실패는 영어 대신 무엇을 하라는 한글 문장으로 | 아래 | | 2026-09-22 | **11차 F-10** — Araxis 식 화면: 칸마다 자기 줄만(빈 줄로 높이를 맞추지 않음), 두 칸(3분할은 위 세 칸) 사이 틈에 바뀐 덩어리마다 사다리꼴 잇는 선 · 칸 안 경계선(한쪽에 없으면 굵은 선 하나) · 옮긴 덩어리는 청록 점선. 스크롤은 가운데 기준으로 따라옴(`PaneMapping` — 함께 있는 줄 가운데를 짝점, 사이는 비율). 속의 정렬 행은 그대로라 반영 · 되돌리기 · 차이 이동이 그대로 동작. 편집기에서 빈 줄을 없애 `StripFillers` 를 지움(편집기 줄 ↔ 행 표). 지운 줄 [>] 는 BASE 칸 끝, 차이 이동은 가운데로 | 아래 | | 2026-09-22 | **11차 F-9** — 저장할 때 백업: 설정 "저장할 때 백업"(기본 끔)을 켜면 모든 저장(두 파일 · mergetool · Save & Commit · 병합 중 Save · 파일 단위 Accept · 폴더 비교 복사) 직전에 지금 파일을 바이트 그대로 `{설정 폴더}\Backup\MergeHub\날짜\드라이브\원래 폴더\이름.날짜-시각.확장자` 로 복사(`FileBackup`, 날짜 폴더마다 목록.txt, 저절로 안 지움 — 사용자 결정). 원본 폴더 · 저장소 밖이라 폴더 비교 · 변경분 · git status 에 안 섞임. 못 만들면 저장하지 않음 | 아래 | | 2026-09-22 | **11차 F-8** — 저장한 뒤에도 Undo/Redo: 저장하는 길마다 되돌리기 목록을 비우던 것을 두고, "저장할 것 있음" 을 목록이 아니라 "지금 글이 마지막 저장과 다른가" 로(`SaveBaseline`). 커밋 뒤 새 HEAD 로 다시 읽기 · 비교 규칙 바꾸기 · 편집기에 친 뒤 재정렬처럼 줄을 다시 세울 때 이력을 글 단위로 옮겨 지킴(친 것도 한 걸음, 300걸음까지). 전체 되돌리기는 파일을 연 시점. 병합 중 푼 파일은 Git 탭 [다시 충돌로] 로 되돌림 — LibGit2Sharp 에 길이 없어 이 단추만 `git checkout -m`(사용자 결정), 해결 기록에서도 뺌 | 아래 | diff --git a/doc/진행상황.md b/doc/진행상황.md index 535ef2c..c52f1f9 100644 --- a/doc/진행상황.md +++ b/doc/진행상황.md @@ -380,7 +380,7 @@ | **완료** | F-8 저장한 뒤에도 되돌리기 — 저장 · 저장 후 커밋 뒤 Undo/Redo 유지(저장할 것 있음은 "마지막 저장과 다른가" 로), 줄을 다시 세울 때(커밋 뒤 새 HEAD · 규칙 바꾸기 · 친 뒤 재정렬) 이력을 글 단위로 옮겨 지킴, 친 것도 한 걸음. 전체 되돌리기는 파일을 연 시점. Git 탭 "푼 파일" + [다시 충돌로](이 단추만 git.exe, 사용자 결정). MergeSaveTest +21 | | **완료** | F-9 저장할 때 백업 — 설정(기본 끔)을 켜면 모든 저장(두 파일 · mergetool · Save & Commit · 병합 중 Save · 파일 단위 Accept · 폴더 복사) 직전에 지금 파일을 바이트 그대로 `{설정 폴더}\Backup\MergeHub\날짜\…` 에 모음(목록.txt, 저절로 안 지움). 못 만들면 저장 안 함. ACTIONS [백업 폴더 열기]. 하네스 +25 | | **완료** | F-10 칸마다 자기 줄 · 바뀐 덩어리를 잇는 선(Araxis 식, 사용자 요청으로 넓힘) — 빈 줄로 맞추지 않고 칸마다 자기 줄만, 틈에 사다리꼴 선 · 옮김 점선, 가운데 기준 스크롤, 차이 이동은 가운데로, 지운 줄 [>] 는 BASE 칸 끝. 3분할은 위 세 칸 사이. 편집기 빈 줄(StripFillers) 없앰. PaneLinkTest 17 새로 · EditorTest 14 → 18 | -| 대기 | F-11 Git 계정 · URL · 암호 저장 확인 (지금은 주소 · 계정만 저장, 토큰은 메모리) | +| **완료** | F-11 Git 계정 · URL · 토큰 저장 — 확인해 보니 껐다 켜면 셋 다 비었다. URL · 계정은 적으면 설정에 남기고, 토큰은 fetch/pull/push/클론이 성공하면 윈도 자격 증명 관리자에 저장(평문 아님 · 설정 파일에 글자 없음), 켤 때 그 서버 것만 가져옴. [토큰 지우기] · "저장됨" 표시 · 인증 실패 한글 안내. MergeSaveTest +15 | | **완료** | F-12 C# 코드 스타일 맞춤 기본값을 AURORA 로 (2026-09-18 사용자 결정) — 설정 없는 첫 실행에서 AURORA, 중괄호만 다른 C# 짝 "차이 없음" · 같은 모양의 C++ 짝은 "남은 차이 4줄"(원본 그대로). 저장 동작은 그대로. 하네스 StyleTest +12(90건) | --- diff --git a/src/MergeHub/Services/GitCredentialStore.cs b/src/MergeHub/Services/GitCredentialStore.cs new file mode 100644 index 0000000..669374e --- /dev/null +++ b/src/MergeHub/Services/GitCredentialStore.cs @@ -0,0 +1,188 @@ +using System; +using System.Runtime.InteropServices; +using System.Text; + +namespace MergeHub.Services +{ + /// + /// 원격 토큰을 어디에 어떤 이름으로 둘지, 실패한 말을 사람 말로 어떻게 옮길지(11차 F-11). 윈도 없이 검사한다. + /// + public static class GitCredentialKey + { + /// 자격 증명 이름 앞머리. 다른 프로그램 것과 섞이지 않게 둔다. + public const string Prefix = "MergeHub:git:"; + + /// + /// 이 원격 주소의 토큰을 둘 이름. 서버(호스트 · 포트)마다 따로 둔다 - 저장소가 달라도 같은 서버면 한 번만 넣으면 된다. + /// 주소를 읽을 수 없으면 null(저장하지 않는다 - 로컬 경로 원격에는 토큰이 필요 없다). + /// + public static string? For(string? remoteUrl) + { + if (string.IsNullOrWhiteSpace(remoteUrl)) return null; + + var text = remoteUrl!.Trim(); + + // git@서버:팀/저장소.git (SSH) 는 토큰을 쓰지 않는다 + if (text.StartsWith("git@", StringComparison.OrdinalIgnoreCase)) return null; + + if (!Uri.TryCreate(text, UriKind.Absolute, out var uri)) return null; + if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) return null; + if (string.IsNullOrEmpty(uri.Host)) return null; + + var port = uri.IsDefaultPort ? string.Empty : ":" + uri.Port; + return Prefix + uri.Scheme.ToLowerInvariant() + "://" + uri.Host.ToLowerInvariant() + port; + } + } + + /// Git 이 영어로 돌려주는 실패를 사람 말로(11차 F-11). + public static class GitErrors + { + /// + /// 인증이 필요하다는 말이면 무엇을 하라는 말로 바꾼다. 아니면 null - 부르는 쪽이 원래 말을 쓴다. + /// + public static string? Friendly(string? message) + { + if (string.IsNullOrWhiteSpace(message)) return null; + + var text = message!.ToLowerInvariant(); + + var needsLogin = text.Contains("authentication required") || + text.Contains("no callback set") || + text.Contains("401") || + text.Contains("too many redirects or authentication replays") || + text.Contains("authentication failed") || + text.Contains("unauthorized"); + if (!needsLogin) return null; + + var wrong = text.Contains("failed") || text.Contains("401") || text.Contains("unauthorized") || + text.Contains("replays"); + + return wrong + ? "계정 또는 토큰이 맞지 않습니다. Git 탭 원격 칸에서 다시 넣어 주세요." + : "원격이 계정 · 토큰을 요구합니다. Git 탭 원격 칸에 계정과 토큰을 넣고 다시 해 보세요."; + } + } + + /// + /// 원격 토큰을 윈도 자격 증명 관리자에 맡긴다(11차 F-11, 사용자 결정 - 평문으로 두지 않는다). + /// + /// 윈도가 사용자 계정 키로 암호화해 두므로(DPAPI) 다른 계정 · 다른 PC 에서는 읽히지 않고, 설정 파일에는 토큰 글자가 남지 않는다. + /// 이름은 서버마다 하나(). 못 읽거나 못 쓰면 조용히 넘어간다 - 토큰은 덤이고, + /// 없으면 사용자가 그때 넣으면 된다. + /// + public sealed class GitCredentialStore + { + private const int CRED_TYPE_GENERIC = 1; + private const int CRED_PERSIST_LOCAL_MACHINE = 2; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct CREDENTIAL + { + public int Flags; + public int Type; + public IntPtr TargetName; + public IntPtr Comment; + public long LastWritten; + public int CredentialBlobSize; + public IntPtr CredentialBlob; + public int Persist; + public int AttributeCount; + public IntPtr Attributes; + public IntPtr TargetAlias; + public IntPtr UserName; + } + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CredWriteW")] + private static extern bool CredWrite(ref CREDENTIAL credential, int flags); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CredReadW")] + private static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr); + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CredDeleteW")] + private static extern bool CredDelete(string target, int type, int flags); + + [DllImport("advapi32.dll")] + private static extern void CredFree(IntPtr buffer); + + /// 이 서버의 계정 · 토큰을 맡긴다. 맡겼으면 true. + public bool Save(string? remoteUrl, string? userName, string? token) + { + var target = GitCredentialKey.For(remoteUrl); + if (target is null || string.IsNullOrEmpty(token)) return false; + + var blob = Encoding.Unicode.GetBytes(token); + var targetPtr = Marshal.StringToCoTaskMemUni(target); + var userPtr = Marshal.StringToCoTaskMemUni(userName ?? string.Empty); + var blobPtr = Marshal.AllocCoTaskMem(blob.Length); + + try + { + Marshal.Copy(blob, 0, blobPtr, blob.Length); + var credential = new CREDENTIAL + { + Type = CRED_TYPE_GENERIC, + TargetName = targetPtr, + CredentialBlobSize = blob.Length, + CredentialBlob = blobPtr, + Persist = CRED_PERSIST_LOCAL_MACHINE, + UserName = userPtr, + }; + return CredWrite(ref credential, 0); + } + catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException) + { + return false; + } + finally + { + Marshal.FreeCoTaskMem(targetPtr); + Marshal.FreeCoTaskMem(userPtr); + Marshal.FreeCoTaskMem(blobPtr); + } + } + + /// 맡겨 둔 계정 · 토큰. 없으면 (null, null). + public (string? UserName, string? Token) Load(string? remoteUrl) + { + var target = GitCredentialKey.For(remoteUrl); + if (target is null) return (null, null); + + var ptr = IntPtr.Zero; + try + { + if (!CredRead(target, CRED_TYPE_GENERIC, 0, out ptr)) return (null, null); + + var credential = Marshal.PtrToStructure(ptr); + var user = credential.UserName == IntPtr.Zero ? null : Marshal.PtrToStringUni(credential.UserName); + var token = credential.CredentialBlob == IntPtr.Zero || credential.CredentialBlobSize <= 0 + ? null + : Marshal.PtrToStringUni(credential.CredentialBlob, credential.CredentialBlobSize / 2); + return (user, token); + } + catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException) + { + return (null, null); + } + finally + { + if (ptr != IntPtr.Zero) CredFree(ptr); + } + } + + /// 맡겨 둔 것을 지운다. 지웠으면 true(없었으면 false). + public bool Forget(string? remoteUrl) + { + var target = GitCredentialKey.For(remoteUrl); + if (target is null) return false; + + try + { + return CredDelete(target, CRED_TYPE_GENERIC, 0); + } + catch (Exception ex) when (ex is DllNotFoundException || ex is EntryPointNotFoundException) + { + return false; + } + } + } +} diff --git a/src/MergeHub/Services/GitWorkspace.cs b/src/MergeHub/Services/GitWorkspace.cs index f6c4e1e..a971db6 100644 --- a/src/MergeHub/Services/GitWorkspace.cs +++ b/src/MergeHub/Services/GitWorkspace.cs @@ -58,6 +58,7 @@ namespace MergeHub.Services RemoteName = string.IsNullOrWhiteSpace(Settings.DefaultRemoteName) ? "origin" : Settings.DefaultRemoteName; RemoteUrl = Settings.GiteaUrl; UserName = Settings.GiteaUserName; + LoadToken(); // 이 서버에 맡겨 둔 토큰이 있으면 가져온다(11차 F-11) Git.IgnoreWhitespace = Settings.IgnoreWhitespace; Git.WordLevelDiff = Settings.WordLevelDiff; @@ -140,6 +141,91 @@ namespace MergeHub.Services /// public void SaveSettings() => _settingsService.Save(nameof(MergeHubSettings)); + // ================= 원격 주소 · 계정 · 토큰 (11차 F-11) ================= + + /// 토큰을 맡겨 두는 곳 - 윈도 자격 증명 관리자(사용자 결정). 설정 파일에는 토큰 글자가 남지 않는다. + private readonly GitCredentialStore _tokens = new GitCredentialStore(); + + /// 주소 · 계정을 설정에 적는 것을 잠깐 모아 둔다 - 글자 하나마다 파일을 쓰지 않으려고. + private System.Threading.Timer? _remoteSaveTimer; + + partial void OnRemoteUrlChanged(string value) + { + QueueRemoteSave(); + LoadToken(); // 서버가 바뀌면 그 서버에 맡겨 둔 토큰으로 + } + + partial void OnUserNameChanged(string value) => QueueRemoteSave(); + + private void QueueRemoteSave() + { + _remoteSaveTimer?.Dispose(); + _remoteSaveTimer = new System.Threading.Timer(_ => OnUi(SaveRemoteSettings), null, 800, System.Threading.Timeout.Infinite); + } + + /// Git 탭 원격 칸에 적은 주소 · 계정을 설정에 남긴다(11차 F-11 - 껐다 켜도 남는다). + private void SaveRemoteSettings() + { + var url = RemoteUrl ?? string.Empty; + var user = UserName ?? string.Empty; + if (Settings.GiteaUrl == url && Settings.GiteaUserName == user) return; + + Settings.GiteaUrl = url; + Settings.GiteaUserName = user; + SaveSettings(); + } + + /// 지금 토큰 칸에 든 것이 어느 서버에서 가져온 것인지. 주소를 바꾸면 남의 서버로 보내지 않게 치운다. + private string? _tokenFrom; + + /// + /// 이 서버에 맡겨 둔 계정 · 토큰을 가져온다. 사람이 직접 적어 둔 토큰은 건드리지 않는다 - + /// 다른 서버에서 가져왔던 토큰만 치운다(다른 서버로 보내면 안 된다). + /// + public void LoadToken() + { + var key = GitCredentialKey.For(RemoteUrl); + if (_tokenFrom is not null && _tokenFrom != key) + { + Password = string.Empty; // 앞 서버에서 가져온 토큰이다 + _tokenFrom = null; + } + + HasSavedToken = false; + var (user, token) = _tokens.Load(RemoteUrl); + if (string.IsNullOrEmpty(token)) return; + + Password = token!; + _tokenFrom = key; + if (string.IsNullOrWhiteSpace(UserName) && !string.IsNullOrWhiteSpace(user)) UserName = user!; + HasSavedToken = true; + } + + /// 원격 일(fetch · pull · push · 클론)이 잘 끝났다 - 그때의 계정 · 토큰을 맡겨 둔다. + public void RememberToken() + { + if (string.IsNullOrEmpty(Password)) return; + if (!_tokens.Save(RemoteUrl, UserName, Password)) return; + + HasSavedToken = true; + _tokenFrom = GitCredentialKey.For(RemoteUrl); + AppendLog($"토큰을 윈도 자격 증명 관리자에 저장했습니다({GitCredentialKey.For(RemoteUrl)}). 다음에 켤 때 다시 넣지 않아도 됩니다."); + } + + /// 맡겨 둔 토큰을 지운다. 화면의 토큰 칸도 비운다. + public bool ForgetToken() + { + var had = _tokens.Forget(RemoteUrl); + Password = string.Empty; + HasSavedToken = false; + _tokenFrom = null; + if (had) AppendLog($"저장한 토큰을 지웠습니다({GitCredentialKey.For(RemoteUrl)})."); + return had; + } + + /// 이 서버의 토큰을 맡겨 두었는지. 화면의 안내 · [저장한 토큰 지우기] 단추가 본다. + [ObservableProperty] private bool _hasSavedToken; + /// 저장소 상태가 다시 읽힐 때마다 발생. 화면들이 자기 패널을 갱신한다. public event Action? Refreshed; @@ -295,8 +381,10 @@ namespace MergeHub.Services _logger.E($"[{operation}] 실패", ex); OnUi(() => { - AppendLog($"[{operation}] 실패: {ex.Message}"); - MessageUtil.ShowErrorToast($"{operation} 실패: {ex.Message}"); + // 인증이 필요하다는 영어 말은 무엇을 하라는 말로 바꿔 준다(11차 F-11). + var said = GitErrors.Friendly(ex.Message) is { } friendly ? $"{friendly} ({ex.Message})" : ex.Message; + AppendLog($"[{operation}] 실패: {said}"); + MessageUtil.ShowErrorToast($"{operation} 실패: {said}"); }); return false; } diff --git a/src/MergeHub/ViewModels/GitViewModel.cs b/src/MergeHub/ViewModels/GitViewModel.cs index ba7ea5d..cb402d6 100644 --- a/src/MergeHub/ViewModels/GitViewModel.cs +++ b/src/MergeHub/ViewModels/GitViewModel.cs @@ -201,11 +201,15 @@ namespace MergeHub.ViewModels private bool CanClone() => !string.IsNullOrWhiteSpace(_workspace.RemoteUrl); [RelayCommand(CanExecute = nameof(CanClone))] - private Task CloneAsync() => _workspace.RunAndRefreshAsync("클론", () => + private async Task CloneAsync() { - _workspace.ApplyCredentials(); - _workspace.Git.Clone(_workspace.RemoteUrl, _workspace.RepositoryPath); - }); + var ok = await _workspace.RunAndRefreshAsync("클론", () => + { + _workspace.ApplyCredentials(); + _workspace.Git.Clone(_workspace.RemoteUrl, _workspace.RepositoryPath); + }); + if (ok) _workspace.RememberToken(); // 된 토큰만 맡긴다(11차 F-11) + } [RelayCommand(CanExecute = nameof(IsOpen))] private Task AddRemoteAsync() => _workspace.RunAndRefreshAsync("원격 추가", () => @@ -224,12 +228,27 @@ namespace MergeHub.ViewModels () => _workspace.Git.RemoveRemote(_workspace.RemoteName)); [RelayCommand(CanExecute = nameof(IsOpen))] - private Task FetchAsync() => _workspace.RunAndRefreshAsync("fetch", () => + private async Task FetchAsync() { - _workspace.ApplyCredentials(); - _workspace.Git.Fetch(_workspace.RemoteName); - _workspace.MarkFetched(); - }); + var ok = await _workspace.RunAndRefreshAsync("fetch", () => + { + _workspace.ApplyCredentials(); + _workspace.Git.Fetch(_workspace.RemoteName); + _workspace.MarkFetched(); + }); + if (ok) _workspace.RememberToken(); + } + + private bool CanForgetToken() => _workspace.HasSavedToken || !string.IsNullOrEmpty(_workspace.Password); + + /// 맡겨 둔 토큰을 지운다(11차 F-11). 다른 사람에게 PC 를 넘길 때 · 토큰을 새로 받았을 때. + [RelayCommand] + private void ForgetToken() + { + var had = _workspace.ForgetToken(); + MessageUtil.ShowSuccessToast(had ? "저장한 토큰을 지웠습니다." : "저장해 둔 토큰이 없습니다. 토큰 칸만 비웠습니다."); + RaiseCanExecuteChanged(); + } [RelayCommand(CanExecute = nameof(IsOpen))] private Task PullAsync() @@ -237,7 +256,12 @@ namespace MergeHub.ViewModels // 목록은 화면 스레드의 것이라 작업에 들어가기 전에 읽어 둔다. var tracked = _workspace.Branches.FirstOrDefault(b => b.IsCurrentHead)?.TrackedBranch; - return _workspace.RunAndRefreshAsync("pull", () => + return PullCoreAsync(tracked); + } + + private async Task PullCoreAsync(string? tracked) + { + var ok = await _workspace.RunAndRefreshAsync("pull", () => { _workspace.ApplyCredentials(); var result = _workspace.Git.Pull(_workspace.RemoteName); @@ -247,14 +271,19 @@ namespace MergeHub.ViewModels // 예전에는 여기서 아무것도 안 해 주석만 다른 충돌까지 사람에게 물었고, 알림도 커밋 메시지도 없었다. AfterMerge(result, MergeNames.PullMergeMessage(tracked, _workspace.CurrentBranch)); }); + if (ok) _workspace.RememberToken(); } [RelayCommand(CanExecute = nameof(IsOpen))] - private Task PushAsync() => _workspace.RunAndRefreshAsync("push", () => + private async Task PushAsync() { - _workspace.ApplyCredentials(); - _workspace.Git.Push(remoteName: _workspace.RemoteName); - }); + var ok = await _workspace.RunAndRefreshAsync("push", () => + { + _workspace.ApplyCredentials(); + _workspace.Git.Push(remoteName: _workspace.RemoteName); + }); + if (ok) _workspace.RememberToken(); + } [RelayCommand(CanExecute = nameof(IsOpen))] private Task RefreshAsync() => _workspace.RefreshAsync(); @@ -1093,6 +1122,7 @@ namespace MergeHub.ViewModels private void RaiseCanExecuteChanged() { ReopenConflictCommand.NotifyCanExecuteChanged(); + ForgetTokenCommand.NotifyCanExecuteChanged(); OpenCommand.NotifyCanExecuteChanged(); CloneCommand.NotifyCanExecuteChanged(); AddRemoteCommand.NotifyCanExecuteChanged(); diff --git a/src/MergeHub/Views/GitView.xaml b/src/MergeHub/Views/GitView.xaml index 24a9811..815c3d4 100644 --- a/src/MergeHub/Views/GitView.xaml +++ b/src/MergeHub/Views/GitView.xaml @@ -122,11 +122,17 @@ - + + +