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

32 KiB

윈도우 데스크톱 런처 (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.tsserver.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: 솔루션·프로젝트 생성

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:

<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:

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:

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:

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:

namespace CodeAssist.Shell.Window;

/// <summary>창 위치·크기 저장소(로컬 파일).</summary>
public interface IWindowPlacementStore
{
    WindowPlacement? Load();
    void Save(WindowPlacement placement);
}

3_windowsApp/CodeAssist.Shell/Window/JsonWindowPlacementStore.cs:

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: 커밋
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 : IDisposablevoid 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:

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: 커밋
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 프로젝트 생성 + 참조 배선

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:

<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:

<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:

using System.Windows;

namespace CodeAssist.App;

public partial class App : Application
{
}
  • Step 5: WebHostView.xaml 작성

3_windowsApp/CodeAssist.App/Views/WebHostView.xaml:

<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:

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: 커밋
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:

<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:

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: 커밋
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:

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: 커밋
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 — 태스크 간 시그니처 일치 확인함.