795 lines
32 KiB
Markdown
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` — 태스크 간 시그니처 일치 확인함.
|