# 윈도우 데스크톱 런처 (WebView2 + 2_frontend) 구현 계획 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** `2_frontend` React SPA 를 WPF+WebView2 창에 담아, 전역 단축키(`Ctrl+Alt+Space`)로 소환하는 윈도우 런처 셸을 만든다. **Architecture:** V1(`d:\project\021.code-assistant\3_windowsApp\`)의 런처 껍데기(`CodeAssist.Shell`)를 이식하고, 챗/인증 네이티브 로직은 버린다. WebView2 는 DEBUG=vite dev(핫리로드)/RELEASE=가상호스트(dist)로 React 앱을 로드. 상태·통신은 전부 React 가 `/api` 로 직접 처리하므로 닷넷은 순수 셸. **Tech Stack:** .NET 8 (`net8.0-windows`), WPF, `Microsoft.Web.WebView2`, `H.NotifyIcon.Wpf`(트레이), xUnit(테스트). Win32 P/Invoke(핫키·포그라운드). ## Global Constraints - 대상 프레임워크: `net8.0-windows` (모든 프로젝트 동일) - `Nullable` enable, `ImplicitUsings` enable (모든 프로젝트) - 코드 주석: 한글·반말 톤 (CLAUDE.md 0번) - 신규 코드는 전부 `3_windowsApp/` 아래. `2_frontend/` 는 **절대 수정 금지**(그대로 담기만 함). - 이식원(참고 전용, 수정하지 말 것): `d:\project\021.code-assistant\3_windowsApp\` - 전역 단축키: `Ctrl+Alt+Space` (modifiers `0x0001|0x0002`, vk `0x20`) - vite dev 포트: `15173` (`2_frontend/vite.config.ts` 의 `server.port` 와 반드시 일치) - **스코프 밖(다음 단계)**: Entra/MSAL 데스크톱 로그인, RELEASE `/api` 프록시, 브릿지(paste/hide/resize), Core 프로젝트. Core 는 나중에 되살릴 때 V1 `CodeAssist.Core` 참고. - 사전조건: WebView2 Evergreen 런타임 설치돼 있어야 함(Win11 기본 포함). `node`/`npm` PATH 에 있어야 DEBUG 동작. --- ### Task 1: 솔루션 + Shell 골격 + 창 위치 로직 (TDD) `WindowPlacement`(순수 로직)와 `JsonWindowPlacementStore`(파일 IO)를 TDD 로 이식한다. 나머지 Win32/UI 글루는 Task 2~5 에서 빌드·실행으로 검증(유닛테스트 불가 영역). **Files:** - Create: `3_windowsApp/CodeAssist.sln` - Create: `3_windowsApp/CodeAssist.Shell/CodeAssist.Shell.csproj` - Create: `3_windowsApp/CodeAssist.Shell/Window/WindowPlacement.cs` - Create: `3_windowsApp/CodeAssist.Shell/Window/IWindowPlacementStore.cs` - Create: `3_windowsApp/CodeAssist.Shell/Window/JsonWindowPlacementStore.cs` - Create: `3_windowsApp/CodeAssist.Tests/CodeAssist.Tests.csproj` - Create: `3_windowsApp/CodeAssist.Tests/WindowPlacementTests.cs` - Create: `3_windowsApp/CodeAssist.Tests/JsonWindowPlacementStoreTests.cs` - Modify: `.gitignore` (루트 — bin/obj 제외) **Interfaces:** - Produces: - `record WindowPlacement(double Left, double Top, double Width, double Height)` + `bool IsVisibleWithin(double vsLeft, double vsTop, double vsWidth, double vsHeight)` - `interface IWindowPlacementStore { WindowPlacement? Load(); void Save(WindowPlacement placement); }` - `class JsonWindowPlacementStore : IWindowPlacementStore`, 생성자 `JsonWindowPlacementStore(string? path = null)` - [ ] **Step 1: 솔루션·프로젝트 생성** ```bash cd D:/project/021.code-assistant-v2/3_windowsApp dotnet new sln -n CodeAssist dotnet new classlib -n CodeAssist.Shell -f net8.0-windows dotnet new xunit -n CodeAssist.Tests -f net8.0-windows # classlib 기본 Class1.cs 제거 rm CodeAssist.Shell/Class1.cs rm CodeAssist.Tests/UnitTest1.cs dotnet sln add CodeAssist.Shell/CodeAssist.Shell.csproj CodeAssist.Tests/CodeAssist.Tests.csproj dotnet add CodeAssist.Tests/CodeAssist.Tests.csproj reference CodeAssist.Shell/CodeAssist.Shell.csproj ``` - [ ] **Step 2: Shell csproj 를 아래로 교체** (`UseWPF` — Clipboard·Window 타입 때문에 Shell 도 WPF 참조) `3_windowsApp/CodeAssist.Shell/CodeAssist.Shell.csproj`: ```xml net8.0-windows true enable enable ``` - [ ] **Step 3: 실패하는 테스트 작성** (WindowPlacement) `3_windowsApp/CodeAssist.Tests/WindowPlacementTests.cs`: ```csharp using CodeAssist.Shell.Window; using Xunit; namespace CodeAssist.Tests; public class WindowPlacementTests { private const double VsL = 0, VsT = 0, VsW = 1920, VsH = 1080; [Fact] public void IsVisibleWithin_fully_inside_true() => Assert.True(new WindowPlacement(100, 100, 640, 520).IsVisibleWithin(VsL, VsT, VsW, VsH)); [Fact] public void IsVisibleWithin_fully_offscreen_false() => Assert.False(new WindowPlacement(3000, 3000, 640, 520).IsVisibleWithin(VsL, VsT, VsW, VsH)); [Fact] public void IsVisibleWithin_tiny_sliver_false() // 20px 만 걸침(<80) => Assert.False(new WindowPlacement(1900, 100, 640, 520).IsVisibleWithin(VsL, VsT, VsW, VsH)); [Fact] public void IsVisibleWithin_enough_overlap_true() // 120px 걸침(>=80) => Assert.True(new WindowPlacement(1800, 100, 640, 520).IsVisibleWithin(VsL, VsT, VsW, VsH)); } ``` - [ ] **Step 4: 컴파일 실패 확인** Run: `dotnet test 3_windowsApp/CodeAssist.Tests` Expected: FAIL — `WindowPlacement` 타입 없음(빌드 에러). - [ ] **Step 5: WindowPlacement 이식** (V1 `CodeAssist.Shell/Window/WindowPlacement.cs` 와 동일) `3_windowsApp/CodeAssist.Shell/Window/WindowPlacement.cs`: ```csharp namespace CodeAssist.Shell.Window; /// 창의 마지막 위치·크기. 화면 밖 여부는 IsVisibleWithin 으로 판정. public sealed record WindowPlacement(double Left, double Top, double Width, double Height) { // 복원 시 최소 이만큼은 화면 안에 보여야 "찾을 수 있다"(드래그 가능)고 본다. private const double MinVisibleWidth = 80; private const double MinVisibleHeight = 30; /// 이 창 사각형이 가상 화면(모든 모니터 합집합)과 충분히 겹쳐 보이는지. public bool IsVisibleWithin(double vsLeft, double vsTop, double vsWidth, double vsHeight) { var overlapW = Math.Min(Left + Width, vsLeft + vsWidth) - Math.Max(Left, vsLeft); var overlapH = Math.Min(Top + Height, vsTop + vsHeight) - Math.Max(Top, vsTop); return overlapW >= MinVisibleWidth && overlapH >= MinVisibleHeight; } } ``` - [ ] **Step 6: 테스트 통과 확인** Run: `dotnet test 3_windowsApp/CodeAssist.Tests` Expected: PASS (4 passed). - [ ] **Step 7: JsonWindowPlacementStore 실패 테스트 작성** `3_windowsApp/CodeAssist.Tests/JsonWindowPlacementStoreTests.cs`: ```csharp using System; using System.IO; using CodeAssist.Shell.Window; using Xunit; namespace CodeAssist.Tests; public class JsonWindowPlacementStoreTests { private static string TempFile() => Path.Combine(Path.GetTempPath(), "ca-test-" + Guid.NewGuid().ToString("N") + ".json"); [Fact] public void Load_missing_file_returns_null() { var store = new JsonWindowPlacementStore(TempFile()); Assert.Null(store.Load()); } [Fact] public void Save_then_Load_roundtrips() { var path = TempFile(); try { var store = new JsonWindowPlacementStore(path); store.Save(new WindowPlacement(10, 20, 640, 520)); var loaded = store.Load(); Assert.Equal(new WindowPlacement(10, 20, 640, 520), loaded); } finally { File.Delete(path); } } [Fact] public void Load_corrupt_json_returns_null() { var path = TempFile(); try { File.WriteAllText(path, "{ not valid json"); Assert.Null(new JsonWindowPlacementStore(path).Load()); } finally { File.Delete(path); } } } ``` - [ ] **Step 8: 실패 확인** Run: `dotnet test 3_windowsApp/CodeAssist.Tests` Expected: FAIL — `JsonWindowPlacementStore`, `IWindowPlacementStore` 타입 없음. - [ ] **Step 9: 인터페이스 + 구현 이식** (V1 동일 파일들) `3_windowsApp/CodeAssist.Shell/Window/IWindowPlacementStore.cs`: ```csharp namespace CodeAssist.Shell.Window; /// 창 위치·크기 저장소(로컬 파일). public interface IWindowPlacementStore { WindowPlacement? Load(); void Save(WindowPlacement placement); } ``` `3_windowsApp/CodeAssist.Shell/Window/JsonWindowPlacementStore.cs`: ```csharp using System.IO; using System.Text.Json; namespace CodeAssist.Shell.Window; /// %LocalAppData%\CodeAssist\window.json 에 평문 JSON 으로 저장. 이 PC 로컬 전용. public sealed class JsonWindowPlacementStore : IWindowPlacementStore { private readonly string _path; private static readonly JsonSerializerOptions Opts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, }; public JsonWindowPlacementStore(string? path = null) => _path = path ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CodeAssist", "window.json"); public WindowPlacement? Load() { if (!File.Exists(_path)) return null; try { return JsonSerializer.Deserialize(File.ReadAllText(_path), Opts); } catch (JsonException) { return null; } } public void Save(WindowPlacement placement) { try { Directory.CreateDirectory(Path.GetDirectoryName(_path)!); File.WriteAllText(_path, JsonSerializer.Serialize(placement, Opts)); } catch (Exception) { /* 위치 저장은 best-effort — 실패해도 흐름 막지 않음 */ } } } ``` - [ ] **Step 10: 전체 테스트 통과 확인** Run: `dotnet test 3_windowsApp/CodeAssist.Tests` Expected: PASS (7 passed). - [ ] **Step 11: .gitignore 에 bin/obj 추가** 루트 `.gitignore` 에 아래 없으면 추가(있으면 skip): ``` 3_windowsApp/**/bin/ 3_windowsApp/**/obj/ 3_windowsApp/.vs/ ``` - [ ] **Step 12: 커밋** ```bash git add 3_windowsApp/CodeAssist.sln 3_windowsApp/CodeAssist.Shell 3_windowsApp/CodeAssist.Tests .gitignore git commit -m "feat(win): 솔루션 골격 + 창 위치 저장 로직(TDD)" ``` --- ### Task 2: Shell 런처 원시기능 이식 (핫키·단일인스턴스·트레이·창베이스·vite런처) Win32/트레이/프로세스 글루라 유닛테스트 대상 아님 — **빌드 성공**으로 검증. 아래 파일들은 V1 원본과 **동일**하게 이식(네임스페이스 그대로). **Files:** - Create: `3_windowsApp/CodeAssist.Shell/Platform/IHotKeyService.cs` - Create: `3_windowsApp/CodeAssist.Shell/Platform/HotKeyService.cs` - Create: `3_windowsApp/CodeAssist.Shell/Platform/ISingleInstanceGuard.cs` - Create: `3_windowsApp/CodeAssist.Shell/Platform/SingleInstanceGuard.cs` - Create: `3_windowsApp/CodeAssist.Shell/Platform/ViteDevServer.cs` - Create: `3_windowsApp/CodeAssist.Shell/Tray/ITrayIconHost.cs` - Create: `3_windowsApp/CodeAssist.Shell/Tray/TrayIconHost.cs` - Create: `3_windowsApp/CodeAssist.Shell/Window/FramelessPaletteWindow.cs` **Interfaces:** - Produces: - `interface IHotKeyService { bool Register(IntPtr hwnd, uint modifiers, uint vk); void ProcessMessage(int msg); event Action? HotKeyPressed; }` + `class HotKeyService : IHotKeyService, IDisposable` - `interface ISingleInstanceGuard { bool TryAcquire(string name); }` + `class SingleInstanceGuard : ISingleInstanceGuard, IDisposable` - `interface ITrayIconHost { void Show(string tooltip); void Notify(string title, string message); event Action? OpenRequested; event Action? ExitRequested; }` + `class TrayIconHost : ITrayIconHost, IDisposable` - `class FramelessPaletteWindow : System.Windows.Window` (기본 생성자) - `class ViteDevServer : IDisposable` — `void Start(string webDir)`, `Task WaitUntilReadyAsync(int port, TimeSpan timeout, CancellationToken ct = default)` - [ ] **Step 1: 5개 원시기능 파일을 V1 에서 그대로 복사** 아래 원본을 내용 그대로 복사(네임스페이스·코드 무수정): - `IHotKeyService.cs`, `HotKeyService.cs` ← V1 `CodeAssist.Shell/Platform/` - `ISingleInstanceGuard.cs`, `SingleInstanceGuard.cs` ← V1 `CodeAssist.Shell/Platform/` - `ViteDevServer.cs` ← V1 `CodeAssist.Shell/Platform/` - `ITrayIconHost.cs`, `TrayIconHost.cs` ← V1 `CodeAssist.Shell/Tray/` > 원본 경로: `d:\project\021.code-assistant\3_windowsApp\CodeAssist.Shell\...`. 파일 내용은 이미 확인됨(이 계획 작성 시점 기준). 복사 후 임의 수정 금지. - [ ] **Step 2: FramelessPaletteWindow 이식** (V1 동일) `3_windowsApp/CodeAssist.Shell/Window/FramelessPaletteWindow.cs`: ```csharp using System.Windows; namespace CodeAssist.Shell.Window; /// 표준 윈도우 창 베이스(제목표시줄·크기조절). 핫키/트레이로 소환, X(닫기)는 파생 클래스에서 숨김 처리. public class FramelessPaletteWindow : System.Windows.Window { public FramelessPaletteWindow() { Title = "CodeAssist"; WindowStyle = WindowStyle.SingleBorderWindow; ResizeMode = ResizeMode.CanResize; ShowInTaskbar = true; WindowStartupLocation = WindowStartupLocation.CenterScreen; Background = System.Windows.Media.Brushes.White; } } ``` - [ ] **Step 3: Shell 빌드 확인** Run: `dotnet build 3_windowsApp/CodeAssist.Shell` Expected: 빌드 성공 (0 Error). H.NotifyIcon.Wpf 복원됨. - [ ] **Step 4: 커밋** ```bash git add 3_windowsApp/CodeAssist.Shell git commit -m "feat(win): Shell 런처 원시기능 이식(핫키·단일인스턴스·트레이·vite런처)" ``` --- ### Task 3: App 프로젝트 + WebHostView (React 로더) WPF exe 진입 프로젝트를 만들고, WebView2 에 `2_frontend` 를 로드하는 뷰를 넣는다. V1 `WebChatView` 에서 챗/인증/브릿지 전부 제거한 축약판. **Files:** - Create: `3_windowsApp/CodeAssist.App/CodeAssist.App.csproj` - Create: `3_windowsApp/CodeAssist.App/App.xaml` - Create: `3_windowsApp/CodeAssist.App/App.xaml.cs` (이 태스크선 최소 스텁 — Task 5 에서 채움) - Create: `3_windowsApp/CodeAssist.App/Views/WebHostView.xaml` - Create: `3_windowsApp/CodeAssist.App/Views/WebHostView.xaml.cs` - Modify: `3_windowsApp/CodeAssist.sln` (App 프로젝트 추가) **Interfaces:** - Consumes: `CodeAssist.Shell.Platform.ViteDevServer` (Task 2) - Produces: `UserControl CodeAssist.App.Views.WebHostView` (기본 생성자, Loaded 시 자동 로드) - [ ] **Step 1: App 프로젝트 생성 + 참조 배선** ```bash cd D:/project/021.code-assistant-v2/3_windowsApp dotnet new wpf -n CodeAssist.App -f net8.0-windows rm CodeAssist.App/MainWindow.xaml CodeAssist.App/MainWindow.xaml.cs dotnet sln add CodeAssist.App/CodeAssist.App.csproj dotnet add CodeAssist.App/CodeAssist.App.csproj reference CodeAssist.Shell/CodeAssist.Shell.csproj dotnet add CodeAssist.App/CodeAssist.App.csproj package Microsoft.Web.WebView2 --version 1.0.4022.49 ``` - [ ] **Step 2: App csproj 를 아래로 교체** (WinExe + wwwroot content) `3_windowsApp/CodeAssist.App/CodeAssist.App.csproj`: ```xml WinExe net8.0-windows enable enable true ``` - [ ] **Step 3: App.xaml 교체** (StartupUri 제거, 창 숨겨도 안 죽게 OnExplicitShutdown) `3_windowsApp/CodeAssist.App/App.xaml`: ```xml ``` - [ ] **Step 4: App.xaml.cs 최소 스텁** (Task 5 에서 본체 채움 — 지금은 빌드만 되게) `3_windowsApp/CodeAssist.App/App.xaml.cs`: ```csharp using System.Windows; namespace CodeAssist.App; public partial class App : Application { } ``` - [ ] **Step 5: WebHostView.xaml 작성** `3_windowsApp/CodeAssist.App/Views/WebHostView.xaml`: ```xml ``` - [ ] **Step 6: WebHostView.xaml.cs 작성** (V1 WebChatView 에서 브릿지·챗·리사이즈 전부 제거) `3_windowsApp/CodeAssist.App/Views/WebHostView.xaml.cs`: ```csharp using System.IO; using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Controls; using Microsoft.Web.WebView2.Core; namespace CodeAssist.App.Views; /// /// WebView2 안에 2_frontend React 앱을 띄우는 호스트. /// - DEBUG: ViteDevServer 로 2_frontend 의 npm run dev 를 띄우고 localhost:15173 을 물림(핫리로드). /// - RELEASE: 출력 폴더의 wwwroot(2_frontend/dist 복사본)를 가상 호스트로 물림. /// (주의: RELEASE 는 /api 프록시가 없어 백엔드 호출 안 됨 — auth 스코프와 함께 다음 단계.) /// 초기화/네비 과정을 temp\codeassist-webview.log 에 남기고, 실패 시 에러 HTML 표시. /// public partial class WebHostView : UserControl { private const int DevPort = 15173; // 2_frontend/vite.config.ts 의 server.port 와 일치 private static readonly string LogPath = Path.Combine(Path.GetTempPath(), "codeassist-webview.log"); #if DEBUG private readonly CodeAssist.Shell.Platform.ViteDevServer _vite = new(); #endif public WebHostView() { InitializeComponent(); Loaded += OnLoaded; #if DEBUG Unloaded += (_, _) => _vite.Dispose(); #endif } private async void OnLoaded(object sender, RoutedEventArgs e) { try { Log("OnLoaded 시작"); // UserDataFolder 명시(exe 옆이 쓰기 불가일 때 초기화 실패 방지). 초기화 전에만 설정 가능. Web.CreationProperties = new CoreWebView2CreationProperties { UserDataFolder = Path.Combine(Path.GetTempPath(), "CodeAssist.WebView2"), }; await Web.EnsureCoreWebView2Async(); Log("CoreWebView2 준비됨"); Web.NavigationCompleted += (_, args) => Log($"NavigationCompleted success={args.IsSuccess} status={args.WebErrorStatus}"); var settings = Web.CoreWebView2.Settings; settings.AreDefaultContextMenusEnabled = false; settings.IsZoomControlEnabled = false; #if DEBUG settings.AreDevToolsEnabled = true; string webDir = ResolveFrontendDir(); Log($"webDir={webDir} port={DevPort} (존재={Directory.Exists(webDir)})"); _vite.Start(webDir); bool ready = await _vite.WaitUntilReadyAsync(DevPort, TimeSpan.FromSeconds(30)); Log($"vite ready={ready}"); if (ready) Web.CoreWebView2.Navigate($"http://localhost:{DevPort}"); else Web.CoreWebView2.NavigateToString(ErrorHtml( "vite dev 서버가 30초 안에 안 떴음.", $"webDir: {webDir}\n수동 확인: 그 폴더에서 npm run dev")); #else settings.AreDevToolsEnabled = false; string wwwroot = Path.Combine(AppContext.BaseDirectory, "wwwroot"); Log($"wwwroot={wwwroot} (존재={Directory.Exists(wwwroot)})"); Web.CoreWebView2.SetVirtualHostNameToFolderMapping( "appassets.example", wwwroot, CoreWebView2HostResourceAccessKind.Allow); Web.CoreWebView2.Navigate("https://appassets.example/index.html"); #endif Log("navigate 호출됨"); } catch (Exception ex) { Log("예외: " + ex); try { Web.CoreWebView2?.NavigateToString(ErrorHtml("WebView2 초기화 실패", ex.ToString())); } catch { /* CoreWebView2 자체가 없으면 표시 방법도 없음 — 로그로만 */ } } } private static string ErrorHtml(string title, string detail) => $"" + $"

{System.Net.WebUtility.HtmlEncode(title)}

" + $"
{System.Net.WebUtility.HtmlEncode(detail)}
" + $"

로그: {System.Net.WebUtility.HtmlEncode(LogPath)}

"; private static void Log(string msg) { try { File.AppendAllText(LogPath, $"{DateTime.Now:HH:mm:ss.fff} {msg}{Environment.NewLine}"); } catch { /* 로그 실패는 무시 */ } } #if DEBUG // dev: 이 파일 위치에서 리포 루트의 2_frontend 를 역산. // Views → CodeAssist.App → 3_windowsApp → → 2_frontend private static string ResolveFrontendDir([CallerFilePath] string thisFile = "") { string viewsDir = Path.GetDirectoryName(thisFile)!; return Path.GetFullPath(Path.Combine(viewsDir, "..", "..", "..", "2_frontend")); } #endif } ``` - [ ] **Step 7: App 빌드 확인** Run: `dotnet build 3_windowsApp/CodeAssist.App` Expected: 빌드 성공 (0 Error). - [ ] **Step 8: 커밋** ```bash git add 3_windowsApp/CodeAssist.App 3_windowsApp/CodeAssist.sln git commit -m "feat(win): App 프로젝트 + WebHostView(2_frontend WebView2 로더)" ``` --- ### Task 4: PaletteWindow (호스트 창 + 위치기억 + 숨김처리) `WebHostView` 를 담는 실제 창. 위치·크기 복원/저장, X→숨김, (RELEASE) blur→숨김. **Files:** - Create: `3_windowsApp/CodeAssist.App/Views/PaletteWindow.xaml` - Create: `3_windowsApp/CodeAssist.App/Views/PaletteWindow.xaml.cs` **Interfaces:** - Consumes: `FramelessPaletteWindow`(Task 2), `IWindowPlacementStore`/`WindowPlacement`(Task 1), `WebHostView`(Task 3) - Produces: `class PaletteWindow : FramelessPaletteWindow`, 생성자 `PaletteWindow(IWindowPlacementStore placementStore)`, 속성 `bool AllowClose` - [ ] **Step 1: PaletteWindow.xaml 작성** `3_windowsApp/CodeAssist.App/Views/PaletteWindow.xaml`: ```xml ``` - [ ] **Step 2: PaletteWindow.xaml.cs 작성** (V1 에서 챗 의존성 제거, blur/close-to-hide 되살림) `3_windowsApp/CodeAssist.App/Views/PaletteWindow.xaml.cs`: ```csharp using System.ComponentModel; using System.Windows; using CodeAssist.Shell.Window; namespace CodeAssist.App.Views; public partial class PaletteWindow : FramelessPaletteWindow { /// 트레이 '종료' 등 진짜 끌 때만 true. 평소 X 는 숨김 처리. public bool AllowClose { get; set; } private readonly IWindowPlacementStore _placementStore; public PaletteWindow(IWindowPlacementStore placementStore) { InitializeComponent(); _placementStore = placementStore; RestorePlacement(); // Show 전에 위치·크기 복원 IsVisibleChanged += (_, _) => { if (!IsVisible) SaveCurrentPlacement(); }; #if !DEBUG // 포커스 잃으면 자동 숨김(wox 방식). DEBUG 선 끔 — DevTools 열 때마다 창이 숨어 개발 불가. Deactivated += (_, _) => { if (!AllowClose) Hide(); }; #endif } /// 저장된 위치·크기가 화면 안이면 복원. 없거나 화면 밖이면 CenterScreen 유지. private void RestorePlacement() { var saved = _placementStore.Load(); if (saved is null) return; if (!saved.IsVisibleWithin( SystemParameters.VirtualScreenLeft, SystemParameters.VirtualScreenTop, SystemParameters.VirtualScreenWidth, SystemParameters.VirtualScreenHeight)) return; WindowStartupLocation = WindowStartupLocation.Manual; Left = saved.Left; Top = saved.Top; Width = saved.Width; Height = saved.Height; } /// 현재 위치·크기 저장. 최소화/최대화·이상값이면 skip. private void SaveCurrentPlacement() { if (WindowState != WindowState.Normal) return; if (Width <= 0 || Height <= 0) return; if (double.IsNaN(Left) || double.IsNaN(Top)) return; // CenterScreen 미표시 창은 좌표 NaN _placementStore.Save(new WindowPlacement(Left, Top, Width, Height)); } // X(닫기)는 종료 대신 숨김. 트레이 '종료'가 AllowClose=true 로 진짜 종료. protected override void OnClosing(CancelEventArgs e) { SaveCurrentPlacement(); if (!AllowClose) { e.Cancel = true; Hide(); } base.OnClosing(e); } } ``` - [ ] **Step 3: 빌드 확인** Run: `dotnet build 3_windowsApp/CodeAssist.App` Expected: 빌드 성공 (0 Error). - [ ] **Step 4: 커밋** ```bash git add 3_windowsApp/CodeAssist.App/Views git commit -m "feat(win): PaletteWindow — 위치기억 + X/blur 숨김 처리" ``` --- ### Task 5: App 배선 (단일인스턴스·핫키·트레이·토글) + 실행 검증 셸을 하나로 잇는다. GUI 최종 동작이라 **수동 실행**으로 검증(성공 기준 = 설계 §9). **Files:** - Modify: `3_windowsApp/CodeAssist.App/App.xaml.cs` (Task 3 스텁 → 본체) **Interfaces:** - Consumes: `SingleInstanceGuard`, `HotKeyService`, `TrayIconHost`(Task 2), `JsonWindowPlacementStore`(Task 1), `PaletteWindow`(Task 4) - [ ] **Step 1: App.xaml.cs 본체 작성** (DI 없이 직접 배선 — 셸 서비스 소수라 new 로 충분) `3_windowsApp/CodeAssist.App/App.xaml.cs`: ```csharp using System.Windows; using System.Windows.Interop; using CodeAssist.App.Views; using CodeAssist.Shell.Platform; using CodeAssist.Shell.Tray; using CodeAssist.Shell.Window; namespace CodeAssist.App; public partial class App : Application { private const string MutexName = "CodeAssist-v2-9a1f2b6c"; // MOD_ALT(0x1) | MOD_CONTROL(0x2), VK_SPACE(0x20) private const uint ModCtrlAlt = 0x0001 | 0x0002; private const uint VkSpace = 0x20; private SingleInstanceGuard _guard = null!; private HotKeyService _hotkeys = null!; private TrayIconHost _tray = null!; private PaletteWindow? _palette; protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); // 단일 인스턴스 — 두 번째면 조용히 종료 _guard = new SingleInstanceGuard(); if (!_guard.TryAcquire(MutexName)) { Shutdown(); return; } // 빈 팔레트 창 준비(아직 안 띄움) — 핫키용 HWND 확보 _palette = new PaletteWindow(new JsonWindowPlacementStore()); var helper = new WindowInteropHelper(_palette); helper.EnsureHandle(); HwndSource.FromHwnd(helper.Handle)!.AddHook(WndProc); // 전역 핫키 — 실패해도 죽지 말고 트레이로 안내 _hotkeys = new HotKeyService(); _hotkeys.HotKeyPressed += () => Dispatcher.Invoke(TogglePalette); bool ok = _hotkeys.Register(helper.Handle, ModCtrlAlt, VkSpace); // 트레이 상주 _tray = new TrayIconHost(); _tray.OpenRequested += () => Dispatcher.Invoke(ShowPalette); _tray.ExitRequested += () => Dispatcher.Invoke(() => { if (_palette is not null) _palette.AllowClose = true; // X 가로채기 풀고 진짜 종료 Shutdown(); }); _tray.Show("CodeAssist"); if (!ok) _tray.Notify("단축키 등록 실패", "Ctrl+Alt+Space 가 선점됨 — 트레이 '열기'로 호출"); } private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) { _hotkeys.ProcessMessage(msg); return IntPtr.Zero; } private void TogglePalette() { if (_palette is null) return; if (_palette.IsVisible) _palette.Hide(); else ShowPalette(); } private void ShowPalette() { if (_palette is null) return; _palette.Show(); _palette.Activate(); } protected override void OnExit(ExitEventArgs e) { _hotkeys?.Dispose(); _tray?.Dispose(); _guard?.Dispose(); base.OnExit(e); } } ``` - [ ] **Step 2: 빌드 확인** Run: `dotnet build 3_windowsApp/CodeAssist.App` Expected: 빌드 성공 (0 Error). - [ ] **Step 3: 실행 검증** (수동 — GUI) 사전: `2_frontend` 에서 의존성 설치돼 있어야 함(`cd 2_frontend && npm install` 한 번). 백엔드는 없어도 됨(React 렌더까지만 확인). Run: `dotnet run --project 3_windowsApp/CodeAssist.App` 확인(설계 §9 성공 기준): - [ ] 시작 시 창 안 뜨고 트레이 아이콘만 상주 - [ ] `Ctrl+Alt+Space` → 창 뜸 → 다시 누르면 숨음 - [ ] 창 안에 2_frontend React 앱이 렌더됨(로그인/챗 화면). vite 핫리로드 동작(2_frontend 코드 고치면 반영) - [ ] 창 X 클릭 → 종료 아니라 숨김 / 트레이 우클릭 '열기' → 다시 뜸 - [ ] 트레이 우클릭 '종료' → 앱 완전 종료(트레이 아이콘 사라짐) - [ ] 앱 켠 채로 한 번 더 `dotnet run` → 두 번째 인스턴스 즉시 종료(단일 인스턴스) - [ ] 창 위치/크기 옮기고 숨겼다 다시 열면 그 위치·크기로 복원 문제 시 로그 확인: `%TEMP%\codeassist-webview.log`, `%TEMP%\codeassist-vite.log` - [ ] **Step 4: 커밋** ```bash git add 3_windowsApp/CodeAssist.App/App.xaml.cs git commit -m "feat(win): App 배선(단일인스턴스·핫키·트레이·토글) — v1 셸 완성" ``` --- ## 완료 후 - `specs/`(spec-kit) 안 쓰고 이 계획 하나로 진행(B 스코프 단일 셸이라 분해 불필요). - v1 셸 검증되면 다음 단계 후보(설계 §7·§8): RELEASE `/api` 프록시 + dist 패키징 + Entra 로그인 + 브릿지(paste/hide/resize). 그때 V1 `Core`·`WebBridgeProtocol` 참고. ## Self-Review (작성자 점검 결과) - **Spec coverage:** 설계 §4 동작흐름→Task5, §5 단축키/창→Task2·4·5, §6 A/B로딩→Task3, §9 성공기준→Task5 Step3 로 전부 매핑됨. §7(RELEASE /api)·§8(제외항목)은 의도적으로 다음 단계. - **Placeholder scan:** 코드 스텁(App.xaml.cs Task3)은 Task5 에서 전체 교체됨을 명시 — 미완성 방치 아님. 그 외 TBD/TODO 없음. - **Type consistency:** `IWindowPlacementStore.Load/Save`, `WindowPlacement(Left,Top,Width,Height)`, `HotKeyService.Register/ProcessMessage/HotKeyPressed`, `TrayIconHost.Show/Notify/OpenRequested/ExitRequested`, `PaletteWindow(IWindowPlacementStore)`+`AllowClose`, `ViteDevServer.Start/WaitUntilReadyAsync` — 태스크 간 시그니처 일치 확인함.