Files
CODE_ASSISTANT/docs/superpowers/plans/2026-08-04-windows-webview-launcher.md
T
2026-09-16 17:22:14 +09:00

795 lines
32 KiB
Markdown

# 윈도우 데스크톱 런처 (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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="H.NotifyIcon.Wpf" Version="2.1.3" />
</ItemGroup>
</Project>
```
- [ ] **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;
/// <summary>창의 마지막 위치·크기. 화면 밖 여부는 IsVisibleWithin 으로 판정.</summary>
public sealed record WindowPlacement(double Left, double Top, double Width, double Height)
{
// 복원 시 최소 이만큼은 화면 안에 보여야 "찾을 수 있다"(드래그 가능)고 본다.
private const double MinVisibleWidth = 80;
private const double MinVisibleHeight = 30;
/// <summary>이 창 사각형이 가상 화면(모든 모니터 합집합)과 충분히 겹쳐 보이는지.</summary>
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;
/// <summary>창 위치·크기 저장소(로컬 파일).</summary>
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;
/// <summary>%LocalAppData%\CodeAssist\window.json 에 평문 JSON 으로 저장. 이 PC 로컬 전용.</summary>
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<WindowPlacement>(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<bool> 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;
/// <summary>표준 윈도우 창 베이스(제목표시줄·크기조절). 핫키/트레이로 소환, X(닫기)는 파생 클래스에서 숨김 처리.</summary>
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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\CodeAssist.Shell\CodeAssist.Shell.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4022.49" />
</ItemGroup>
<ItemGroup>
<!-- RELEASE 배포용: 2_frontend/dist 를 여기 wwwroot 로 복사해두면 WebView2 가 가상호스트로 물림.
(dist 복사 + /api 프록시는 다음 단계 — v1 은 DEBUG 로 검증) -->
<Content Include="wwwroot\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
```
- [ ] **Step 3: App.xaml 교체** (StartupUri 제거, 창 숨겨도 안 죽게 OnExplicitShutdown)
`3_windowsApp/CodeAssist.App/App.xaml`:
```xml
<Application x:Class="CodeAssist.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
ShutdownMode="OnExplicitShutdown" />
```
- [ ] **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
<UserControl x:Class="CodeAssist.App.Views.WebHostView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf">
<wv2:WebView2 x:Name="Web"/>
</UserControl>
```
- [ ] **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;
/// <summary>
/// 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 표시.
/// </summary>
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)
=> $"<html><body style='font-family:Segoe UI;padding:24px'>" +
$"<h2 style='color:#c0392b'>{System.Net.WebUtility.HtmlEncode(title)}</h2>" +
$"<pre style='white-space:pre-wrap;color:#444'>{System.Net.WebUtility.HtmlEncode(detail)}</pre>" +
$"<p style='color:#888'>로그: {System.Net.WebUtility.HtmlEncode(LogPath)}</p></body></html>";
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 → <repo루트> → 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
<shell:FramelessPaletteWindow
x:Class="CodeAssist.App.Views.PaletteWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:CodeAssist.Shell.Window;assembly=CodeAssist.Shell"
xmlns:views="clr-namespace:CodeAssist.App.Views"
Width="960" Height="680">
<views:WebHostView x:Name="Web"/>
</shell:FramelessPaletteWindow>
```
- [ ] **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
{
/// <summary>트레이 '종료' 등 진짜 끌 때만 true. 평소 X 는 숨김 처리.</summary>
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
}
/// <summary>저장된 위치·크기가 화면 안이면 복원. 없거나 화면 밖이면 CenterScreen 유지.</summary>
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;
}
/// <summary>현재 위치·크기 저장. 최소화/최대화·이상값이면 skip.</summary>
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` — 태스크 간 시그니처 일치 확인함.