Files
AX-Copilot-Codex/src/AxCopilot/App.xaml.cs
lacvet 3747a92c12 분석 로그를 1MB 롤링과 14일 보관 기준으로 정리한다
- app/perf/audit/workflow 로그에 공통 RollingTextLogStore를 적용해 날짜별 파일이 1MB를 넘지 않도록 오래된 내용을 밀어내며 저장한다.

- 공통 로그, 성능 로그, 감사 로그는 14일 보관으로 맞추고 워크플로우 상세 로그는 기존 설정을 따르되 최대 14일 상한을 적용한다.

- RollingTextLogStoreTests 3건을 추가해 파일 크기 상한과 오래된 파일/날짜 디렉터리 정리 동작을 검증한다.

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_logroll\\ -p:IntermediateOutputPath=obj\\verify_logroll\\ 경고 0 / 오류 0

- 검증: dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter RollingTextLogStoreTests -p:OutputPath=bin\\verify_logroll_tests\\ -p:IntermediateOutputPath=obj\\verify_logroll_tests\\ 통과 3 (기존 WorkspaceContextGeneratorTests.cs(76) nullable 경고 1건 유지)
2026-04-14 18:51:55 +09:00

1058 lines
48 KiB
C#

using System.Windows;
using System.Windows.Forms;
using Microsoft.Win32;
using Microsoft.Extensions.DependencyInjection;
using AxCopilot.Core;
using AxCopilot.Handlers;
using AxCopilot.Services;
using AxCopilot.ViewModels;
using AxCopilot.Views;
namespace AxCopilot;
public partial class App : System.Windows.Application
{
private System.Threading.Mutex? _singleInstanceMutex;
private InputListener? _inputListener;
private LauncherWindow? _launcher;
private NotifyIcon? _trayIcon;
private Views.TrayMenuWindow? _trayMenu;
private SettingsService? _settings;
private SettingsWindow? _settingsWindow;
private PluginHost? _pluginHost;
private SchedulerService? _schedulerService;
private ClipboardHistoryService? _clipboardHistory;
private DockBarWindow? _dockBar;
private FileDialogWatcher? _fileDialogWatcher;
private volatile IndexService? _indexService;
private int _indexWarmupStarted;
public IndexService? IndexService => _indexService;
public SettingsService? SettingsService => _settings;
public ClipboardHistoryService? ClipboardHistoryService => _clipboardHistory;
private SessionTrackingService? _sessionTracking;
private WorktimeReminderService? _worktimeReminder;
private ScreenCaptureHandler? _captureHandler;
private AgentMemoryService? _memoryService;
private ChatSessionStateService? _chatSessionState;
private AppStateService? _appState;
public AgentMemoryService? MemoryService => _memoryService;
public ChatSessionStateService? ChatSessionState => _chatSessionState;
public AppStateService? AppState => _appState;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// ─── 보안: 디버거/디컴파일러 감지 (Release 빌드만 활성) ───
Security.AntiTamper.Check();
// 전역 예외 핸들러 — 미처리 예외 시 로그 + 메시지 (크래시 방지)
DispatcherUnhandledException += (_, ex) =>
{
LogService.Error($"미처리 예외: {ex.Exception}");
CustomMessageBox.Show(
$"오류가 발생했습니다:\n{ex.Exception.Message}\n\n로그 폴더를 확인하세요.",
"AX Copilot 오류", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error);
ex.Handled = true;
};
// ─── 중복 실행 방지 ──────────────────────────────────────────────────
_singleInstanceMutex = new System.Threading.Mutex(
initiallyOwned: true, "Global\\AXCopilot_SingleInstance", out bool createdNew);
if (!createdNew)
{
_singleInstanceMutex.Dispose();
_singleInstanceMutex = null;
CustomMessageBox.Show("AX Copilot가 이미 실행 중입니다.\n트레이 아이콘을 확인하세요.",
"AX Copilot", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Information);
Shutdown();
return;
}
LogService.Info("=== AX Copilot 시작 ===");
// ─── 서비스 초기화 ────────────────────────────────────────────────────
_settings = new SettingsService();
var settings = _settings;
settings.Load();
// ─── 워크플로우 상세 로그 초기화 ─────────────────────────────────────
WorkflowLogService.IsEnabled = settings.Settings.Llm.EnableDetailedLog;
WorkflowLogService.RetentionDays = settings.Settings.Llm.DetailedLogRetentionDays > 0
? Math.Min(settings.Settings.Llm.DetailedLogRetentionDays, 14) : 14;
WorkflowLogService.IsRawLogEnabled = settings.Settings.Llm.EnableRawLlmLog;
// ─── 대화 보관/디스크 정리 (제품화 하드닝) ───────────────────────────
try
{
var chatStorage = new ChatStorageService();
var retentionDays = settings.Settings.CleanupPeriodDays;
if (retentionDays > 0)
{
var purgedExpired = chatStorage.PurgeExpired(retentionDays);
if (purgedExpired > 0)
LogService.Info($"대화 보관 정책 정리 완료: {purgedExpired}개 삭제 (보관 {retentionDays}일)");
}
var purgedForDisk = chatStorage.PurgeForDiskSpace();
if (purgedForDisk > 0)
LogService.Info($"디스크 보호 정리 완료: {purgedForDisk}개 삭제");
}
catch (Exception ex)
{
LogService.Warn($"대화 보관 정리 중 오류: {ex.Message}");
}
// 업데이트/마이그레이션 안내 메시지박스는 표시하지 않음.
// 언어 초기화
L10n.SetLanguage(settings.Settings.Launcher.Language);
// 첫 실행 시 자동 시작 등록 (레지스트리에 아직 없으면)
if (!IsAutoStartEnabled())
SetAutoStart(true);
_memoryService = new AgentMemoryService();
_memoryService.Load(settings.Settings.Llm.WorkFolder);
_chatSessionState = new ChatSessionStateService();
_chatSessionState.Load(settings);
_appState = new AppStateService();
_appState.AttachChatSession(_chatSessionState);
_appState.LoadFromSettings(settings);
// ─── DI 컨테이너 초기화 (기존 인스턴스 등록, 점진적 전환) ──────────────
ConfigureDependencyInjection(settings, _memoryService, _chatSessionState, _appState);
_indexService = new IndexService(settings);
var indexService = _indexService;
// 캐시 로드를 백그라운드에서 실행 — UI 스레드 블록 방지
// FuzzyEngine은 IndexEntriesChanged 이벤트를 구독하여 캐시 로드 완료 시 자동 반영됨
_ = Task.Run(() =>
{
indexService.LoadCachedIndex();
if (indexService.HasCachedIndexLoaded)
indexService.StartWatchers();
});
var fuzzyEngine = new FuzzyEngine(indexService);
var commandResolver = new CommandResolver(fuzzyEngine, settings);
var contextManager = new ContextManager(settings);
// ─── 세션 추적 / 사용 통계 ────────────────────────────────────────────
_sessionTracking = new SessionTrackingService();
// ─── 잠금 해제 알림 서비스 ────────────────────────────────────────────
_worktimeReminder = new WorktimeReminderService(settings, Dispatcher);
// ─── 클립보드 히스토리 서비스 ──────────────────────────────────────────
_clipboardHistory = new ClipboardHistoryService(settings);
// ─── 빌트인 핸들러 등록 ──────────────────────────────────────────────
commandResolver.RegisterHandler(new CalculatorHandler());
commandResolver.RegisterHandler(new SystemCommandHandler(settings));
commandResolver.RegisterHandler(new SnippetHandler(settings));
commandResolver.RegisterHandler(new ClipboardHistoryHandler(_clipboardHistory));
commandResolver.RegisterHandler(new WorkspaceHandler(contextManager, settings));
commandResolver.RegisterHandler(new UrlAliasHandler(settings));
commandResolver.RegisterHandler(new FolderAliasHandler(settings));
commandResolver.RegisterHandler(new BatchHandler(settings));
commandResolver.RegisterHandler(new ClipboardHandler(settings));
commandResolver.RegisterHandler(new BookmarkHandler());
commandResolver.RegisterHandler(new WebSearchHandler(settings));
commandResolver.RegisterHandler(new ProcessHandler());
commandResolver.RegisterHandler(new MediaHandler());
commandResolver.RegisterHandler(new SystemInfoHandler());
commandResolver.RegisterHandler(new StarInfoHandler()); // * 단축키 (info와 동일)
commandResolver.RegisterHandler(new EmojiHandler());
commandResolver.RegisterHandler(new ColorHandler());
commandResolver.RegisterHandler(new RecentFilesHandler());
commandResolver.RegisterHandler(new NoteHandler());
commandResolver.RegisterHandler(new UninstallHandler());
commandResolver.RegisterHandler(new PortHandler());
commandResolver.RegisterHandler(new EnvHandler());
commandResolver.RegisterHandler(new JsonHandler());
commandResolver.RegisterHandler(new EncodeHandler());
commandResolver.RegisterHandler(new SnapHandler());
_captureHandler = new ScreenCaptureHandler(settings);
commandResolver.RegisterHandler(_captureHandler);
commandResolver.RegisterHandler(new ColorPickHandler());
commandResolver.RegisterHandler(new DateCalcHandler());
commandResolver.RegisterHandler(new ServiceHandler(_clipboardHistory));
commandResolver.RegisterHandler(new ClipboardPipeHandler());
commandResolver.RegisterHandler(new JournalHandler());
commandResolver.RegisterHandler(new RoutineHandler());
commandResolver.RegisterHandler(new BatchTextHandler());
commandResolver.RegisterHandler(new DiffHandler(_clipboardHistory));
commandResolver.RegisterHandler(new WindowSwitchHandler());
commandResolver.RegisterHandler(new RunHandler());
commandResolver.RegisterHandler(new TextStatsHandler());
commandResolver.RegisterHandler(new FavoriteHandler());
commandResolver.RegisterHandler(new RenameHandler());
commandResolver.RegisterHandler(new MonitorHandler());
commandResolver.RegisterHandler(new ScaffoldHandler());
commandResolver.RegisterHandler(new EverythingHandler());
commandResolver.RegisterHandler(new HelpHandler(settings));
commandResolver.RegisterHandler(new ChatHandler(settings));
commandResolver.RegisterHandler(new QuickLinkHandler(settings));
commandResolver.RegisterHandler(new TagHandler());
commandResolver.RegisterHandler(new NotifHandler());
commandResolver.RegisterHandler(new PomoHandler());
commandResolver.RegisterHandler(new FileBrowserHandler());
commandResolver.RegisterHandler(new HotkeyHandler(settings));
commandResolver.RegisterHandler(new OcrHandler());
commandResolver.RegisterHandler(new SessionHandler(settings));
commandResolver.RegisterHandler(new BatchRenameHandler());
_schedulerService = new SchedulerService(settings);
_schedulerService.Refresh();
commandResolver.RegisterHandler(new ScheduleHandler(settings));
commandResolver.RegisterHandler(new MacroHandler(settings));
commandResolver.RegisterHandler(new ContextHandler());
commandResolver.RegisterHandler(new GitHandler());
commandResolver.RegisterHandler(new RegexHandler());
commandResolver.RegisterHandler(new TimeZoneHandler());
commandResolver.RegisterHandler(new NetDiagHandler());
commandResolver.RegisterHandler(new FileHashHandler());
commandResolver.RegisterHandler(new ZipHandler());
commandResolver.RegisterHandler(new EventLogHandler());
commandResolver.RegisterHandler(new SshHandler(settings));
commandResolver.RegisterHandler(new PasswordGenHandler());
commandResolver.RegisterHandler(new SubnetHandler());
commandResolver.RegisterHandler(new CleanHandler());
commandResolver.RegisterHandler(new BaseConvertHandler());
commandResolver.RegisterHandler(new XmlHandler());
commandResolver.RegisterHandler(new UuidHandler());
commandResolver.RegisterHandler(new CertHandler());
commandResolver.RegisterHandler(new LoremHandler());
commandResolver.RegisterHandler(new CsvHandler());
commandResolver.RegisterHandler(new JwtHandler());
commandResolver.RegisterHandler(new CronHandler());
commandResolver.RegisterHandler(new UnicodeHandler());
commandResolver.RegisterHandler(new HttpTesterHandler());
commandResolver.RegisterHandler(new HostsHandler());
commandResolver.RegisterHandler(new MorseHandler());
commandResolver.RegisterHandler(new StartupHandler());
commandResolver.RegisterHandler(new DnsQueryHandler());
commandResolver.RegisterHandler(new PathHandler());
commandResolver.RegisterHandler(new DriveHandler());
commandResolver.RegisterHandler(new AgeHandler());
commandResolver.RegisterHandler(new WolHandler());
commandResolver.RegisterHandler(new RegHandler());
commandResolver.RegisterHandler(new TipHandler());
commandResolver.RegisterHandler(new FontHandler());
commandResolver.RegisterHandler(new WslHandler());
commandResolver.RegisterHandler(new CurrencyHandler());
commandResolver.RegisterHandler(new BmiHandler());
commandResolver.RegisterHandler(new MdHandler());
commandResolver.RegisterHandler(new PingHandler());
commandResolver.RegisterHandler(new DockerHandler());
commandResolver.RegisterHandler(new TodoHandler());
commandResolver.RegisterHandler(new TableHandler());
commandResolver.RegisterHandler(new UnitHandler());
commandResolver.RegisterHandler(new NumHandler());
commandResolver.RegisterHandler(new YamlHandler());
commandResolver.RegisterHandler(new GitignoreHandler());
commandResolver.RegisterHandler(new SqlHandler());
commandResolver.RegisterHandler(new TextCaseHandler());
commandResolver.RegisterHandler(new AspectHandler());
commandResolver.RegisterHandler(new AbbrHandler());
commandResolver.RegisterHandler(new CalcHandler());
commandResolver.RegisterHandler(new TimerHandler());
commandResolver.RegisterHandler(new IpInfoHandler());
commandResolver.RegisterHandler(new NpmHandler());
commandResolver.RegisterHandler(new HexHandler());
commandResolver.RegisterHandler(new RandHandler());
commandResolver.RegisterHandler(new StrHandler());
commandResolver.RegisterHandler(new PermHandler());
commandResolver.RegisterHandler(new TomlHandler());
commandResolver.RegisterHandler(new LogHandler());
commandResolver.RegisterHandler(new PsHandler());
commandResolver.RegisterHandler(new KeyHandler());
commandResolver.RegisterHandler(new ProcHandler());
commandResolver.RegisterHandler(new XlHandler());
commandResolver.RegisterHandler(new PipHandler());
commandResolver.RegisterHandler(new FormHandler());
commandResolver.RegisterHandler(new CalHandler());
commandResolver.RegisterHandler(new LeaveHandler());
commandResolver.RegisterHandler(new WorkTimeHandler());
commandResolver.RegisterHandler(new FixHandler());
commandResolver.RegisterHandler(new SpellHandler());
commandResolver.RegisterHandler(new ContactHandler());
commandResolver.RegisterHandler(new RemindHandler());
commandResolver.RegisterHandler(new PhraseHandler());
commandResolver.RegisterHandler(new TodayHandler());
commandResolver.RegisterHandler(new VolHandler());
commandResolver.RegisterHandler(new QrHandler());
commandResolver.RegisterHandler(new MeetHandler());
commandResolver.RegisterHandler(new BrightHandler());
commandResolver.RegisterHandler(new PasteHandler(_clipboardHistory));
commandResolver.RegisterHandler(new PkgHandler());
commandResolver.RegisterHandler(new ApHandler());
commandResolver.RegisterHandler(new DictHandler());
commandResolver.RegisterHandler(new FlowHandler());
// ─── 플러그인 로드 ────────────────────────────────────────────────────
var pluginHost = new PluginHost(settings, commandResolver);
pluginHost.LoadAll();
_pluginHost = pluginHost;
// ─── 런처 윈도우 ──────────────────────────────────────────────────────
var vm = new LauncherViewModel(commandResolver, settings);
_launcher = new LauncherWindow(vm)
{
OpenSettingsAction = OpenSettings,
SendToChatAction = msg =>
{
OpenAiChat();
_chatWindow?.SendInitialMessage(msg);
}
};
// ─── 클립보드 히스토리 초기화 (메시지 펌프 시작 직후 — 런처 표시 불필요) ──
Dispatcher.BeginInvoke(
() => _clipboardHistory?.Initialize(),
System.Windows.Threading.DispatcherPriority.ApplicationIdle);
// ─── 런처 인덱스 캐시/감시 초기화 ─────────────────────────────────────
// 앱 시작 시엔 저장된 캐시를 즉시 로드하고, 파일 감시는 증분 반영 위주로 유지합니다.
// 무거운 전체 재색인은 실제 검색이 시작될 때 한 번만 보강 실행합니다.
// ─── 글로벌 훅 + 스니펫 확장기 ───────────────────────────────────────
_inputListener = new InputListener();
_inputListener.HotkeyTriggered += OnHotkeyTriggered;
_inputListener.CaptureHotkeyTriggered += OnCaptureHotkeyTriggered;
_inputListener.HookFailed += OnHookFailed;
// 설정에 저장된 핫키로 초기화 (기본: Alt+Space)
_inputListener.UpdateHotkey(settings.Settings.Hotkey);
// 글로벌 캡처 단축키 초기화
_inputListener.UpdateCaptureHotkey(
settings.Settings.ScreenCapture.GlobalHotkey,
settings.Settings.ScreenCapture.GlobalHotkeyEnabled);
var snippetExpander = new SnippetExpander(settings);
_inputListener.KeyFilter = snippetExpander.HandleKey;
_inputListener.Start();
// ─── 시스템 트레이 ─────────────────────────────────────────────────────
SetupTrayIcon(pluginHost, settings);
// ─── 파일 대화상자 감지 ──────────────────────────────────────────────
_fileDialogWatcher = new FileDialogWatcher();
_fileDialogWatcher.FileDialogOpened += (_, hwnd) =>
{
Dispatcher.Invoke(() =>
{
if (_launcher == null || _launcher.IsVisible) return;
if (_settings?.Settings.Launcher.EnableFileDialogIntegration != true) return;
WindowTracker.Capture();
ShowLauncherWindow();
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input,
() => _launcher.SetInputText("cd "));
});
};
UpdateFileDialogWatcherState();
settings.SettingsChanged += (_, _) =>
{
Dispatcher.BeginInvoke(() =>
{
UpdateFileDialogWatcherState();
_schedulerService?.Refresh();
});
};
// 독 바 자동 표시
if (settings.Settings.Launcher.DockBarAutoShow)
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Loaded, () => ToggleDockBar());
LogService.Info($"초기화 완료. {settings.Settings.Hotkey}로 실행하세요.");
}
private void OnHotkeyTriggered(object? sender, EventArgs e)
{
// 런처가 열리기 전 현재 활성 창을 저장 (SnapHandler, ScreenCaptureHandler용)
WindowTracker.Capture();
// 선택 텍스트 감지는 UI 스레드 밖에서 수행해야 SendInput이 정상 처리됨
var launcherSettings = _settings?.Settings.Launcher;
var enableTextAction = launcherSettings?.EnableTextAction == true;
// ── 텍스트 감지 활성: 런처를 먼저 열고, 텍스트 감지를 비동기로 처리 ──
string? selectedText = enableTextAction ? TryGetSelectedText() : null;
// BeginInvoke 사용 — Dispatcher.Invoke 데드락 방지
Dispatcher.BeginInvoke(() =>
{
// 런처 토글(닫기)
if (_launcher?.IsVisible == true)
{
_launcher.Hide();
return;
}
// 텍스트 감지 비활성 또는 선택 텍스트 없음
if (!enableTextAction || string.IsNullOrWhiteSpace(selectedText))
{
if (_launcher == null) return;
UsageStatisticsService.RecordLauncherOpen();
ShowLauncherWindow();
return;
}
// 텍스트 감지 활성 + 선택 텍스트 있음 → 텍스트 액션 처리
var enabledCmds = launcherSettings?.TextActionCommands ?? new();
// 활성 명령이 1개뿐이면 팝업 없이 바로 실행
if (enabledCmds.Count == 1)
{
var directAction = TextActionPopup.AvailableCommands
.FirstOrDefault(c => c.Key == enabledCmds[0]);
if (!string.IsNullOrEmpty(directAction.Key))
{
var actionResult = enabledCmds[0] switch
{
"translate" => TextActionPopup.ActionResult.Translate,
"summarize" => TextActionPopup.ActionResult.Summarize,
"grammar" => TextActionPopup.ActionResult.GrammarFix,
"explain" => TextActionPopup.ActionResult.Explain,
"rewrite" => TextActionPopup.ActionResult.Rewrite,
_ => TextActionPopup.ActionResult.None,
};
if (actionResult != TextActionPopup.ActionResult.None)
{
ExecuteTextAction(actionResult, selectedText);
return;
}
}
}
// 여러 개 → 팝업 표시
var popup = new TextActionPopup(selectedText, enabledCmds);
popup.Closed += (_, _) =>
{
switch (popup.SelectedAction)
{
case TextActionPopup.ActionResult.OpenLauncher:
UsageStatisticsService.RecordLauncherOpen();
ShowLauncherWindow();
break;
case TextActionPopup.ActionResult.None:
break;
default:
ExecuteTextAction(popup.SelectedAction, popup.SelectedText);
break;
}
};
popup.Show();
});
}
/// <summary>현재 선택된 텍스트를 가져옵니다 (Ctrl+C 시뮬레이션).</summary>
/// <remarks>후크 스레드에서 호출됩니다 (UI 스레드 아님). 클립보드 접근은 STA 스레드가 필요합니다.</remarks>
private string? TryGetSelectedText()
{
try
{
// 클립보드는 STA 스레드에서만 접근 가능 → Dispatcher로 읽기
string? prevText = null;
bool hadText = false;
Dispatcher.Invoke(() =>
{
hadText = System.Windows.Clipboard.ContainsText();
prevText = hadText ? System.Windows.Clipboard.GetText() : null;
System.Windows.Clipboard.Clear();
});
System.Threading.Thread.Sleep(30);
// Ctrl+C 시뮬레이션 (후크 스레드에서 → 대상 앱이 처리)
var inputs = new[]
{
new NativeMethods.INPUT { type = 1, u = new NativeMethods.InputUnion { ki = new NativeMethods.KEYBDINPUT { wVk = 0x11 } } },
new NativeMethods.INPUT { type = 1, u = new NativeMethods.InputUnion { ki = new NativeMethods.KEYBDINPUT { wVk = 0x43 } } },
new NativeMethods.INPUT { type = 1, u = new NativeMethods.InputUnion { ki = new NativeMethods.KEYBDINPUT { wVk = 0x43, dwFlags = 0x0002 } } },
new NativeMethods.INPUT { type = 1, u = new NativeMethods.InputUnion { ki = new NativeMethods.KEYBDINPUT { wVk = 0x11, dwFlags = 0x0002 } } },
};
NativeMethods.SendInput((uint)inputs.Length, inputs, System.Runtime.InteropServices.Marshal.SizeOf<NativeMethods.INPUT>());
// 클립보드 업데이트 대기 (앱마다 다름)
System.Threading.Thread.Sleep(80);
// 클립보드에서 새 텍스트 확인 (STA 필요)
string? selected = null;
Dispatcher.Invoke(() =>
{
if (System.Windows.Clipboard.ContainsText())
{
var copied = System.Windows.Clipboard.GetText();
if (copied != prevText && !string.IsNullOrWhiteSpace(copied))
selected = copied;
}
// 클립보드 복원
if (selected != null && prevText != null)
System.Windows.Clipboard.SetText(prevText);
else if (selected != null && !hadText)
System.Windows.Clipboard.Clear();
});
return selected;
}
catch
{
return null;
}
}
/// <summary>AI 텍스트 명령을 AX Agent 대화로 전달합니다.</summary>
private void ExecuteTextAction(TextActionPopup.ActionResult action, string text)
{
var prompt = action switch
{
TextActionPopup.ActionResult.Translate => $"다음 텍스트를 번역해줘:\n\n{text}",
TextActionPopup.ActionResult.Summarize => $"다음 텍스트를 요약해줘:\n\n{text}",
TextActionPopup.ActionResult.GrammarFix => $"다음 텍스트의 문법을 교정해줘:\n\n{text}",
TextActionPopup.ActionResult.Explain => $"다음 텍스트를 쉽게 설명해줘:\n\n{text}",
TextActionPopup.ActionResult.Rewrite => $"다음 텍스트를 다시 작성해줘:\n\n{text}",
_ => text
};
// ! 프리픽스로 AX Agent에 전달
ShowLauncherWindow();
_launcher?.SetInputText($"! {prompt}");
}
private static class NativeMethods
{
[System.Runtime.InteropServices.DllImport("user32.dll")]
public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
public struct INPUT { public uint type; public InputUnion u; }
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Explicit)]
public struct InputUnion { [System.Runtime.InteropServices.FieldOffset(0)] public KEYBDINPUT ki; }
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
public struct KEYBDINPUT { public ushort wVk; public ushort wScan; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }
}
private void OnCaptureHotkeyTriggered(object? sender, EventArgs e)
{
// 런처 없이 직접 캡처 실행
WindowTracker.Capture();
var mode = _settings?.Settings.ScreenCapture.GlobalHotkeyMode ?? "screen";
Dispatcher.Invoke(async () =>
{
if (_captureHandler == null) return;
try
{
await _captureHandler.CaptureDirectAsync(mode);
}
catch (Exception ex)
{
LogService.Error($"글로벌 캡처 실패: {ex.Message}");
}
});
}
private void OnHookFailed(object? sender, EventArgs e)
{
Dispatcher.Invoke(() =>
{
_trayIcon?.ShowBalloonTip(3000, "AX Copilot",
$"글로벌 단축키 등록에 실패했습니다.\n다른 앱과 {_settings?.Settings.Hotkey ?? ""}가 충돌하고 있을 수 있습니다.",
ToolTipIcon.Warning);
});
}
private void SetupTrayIcon(PluginHost pluginHost, SettingsService settings)
{
var version = typeof(App).Assembly.GetName().Version;
var versionText = version == null
? "AX Copilot v.0.0.0"
: $"AX Copilot v.{version.Major}.{version.Minor}.{version.Build}";
_trayIcon = new NotifyIcon
{
Text = "AX Copilot",
Visible = true,
Icon = LoadAppIcon()
};
// ─── WPF 커스텀 트레이 메뉴 구성 ──────────────────────────────────
_trayMenu = new Views.TrayMenuWindow();
_trayMenu
.AddHeader(versionText)
.AddItem("\uE7C5", "AX Commander 호출하기", () =>
Dispatcher.Invoke(ShowLauncherWindow), hint: "double-click")
.AddItem("\uE8BD", "AX Agent 대화하기", () =>
Dispatcher.Invoke(OpenAiChat), out var aiTrayItem, hint: "click")
.AddItem("\uE8A7", "독 바 표시", () =>
Dispatcher.Invoke(() => ToggleDockBar()))
.AddSeparator()
.AddItem("\uE72C", "플러그인 재로드", () =>
{
pluginHost.Reload();
_trayIcon!.ShowBalloonTip(2000, "AX Copilot", "플러그인이 재로드되었습니다.", ToolTipIcon.None);
})
.AddItem("\uE838", "로그 폴더 열기", () =>
{
var logDir = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AxCopilot", "logs");
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("explorer.exe", logDir)
{ UseShellExecute = true });
})
.AddItem("\uE9D9", "사용 통계", () =>
Dispatcher.Invoke(() => new StatisticsWindow().Show()))
.AddItem("\uE736", "사용 가이드 문서보기", () =>
{
try { Dispatcher.Invoke(() => new Views.GuideViewerWindow().Show()); }
catch (Exception ex) { LogService.Error($"사용 가이드 열기 실패: {ex.Message}"); }
})
.AddSeparator()
.AddToggleItem("\uE82F", "Windows 시작 시 자동 실행", IsAutoStartEnabled(),
isChecked => SetAutoStart(isChecked), out _, out _)
.AddSeparator()
.AddItem("\uE713", "설정", () =>
Dispatcher.Invoke(OpenSettings))
.AddItem("\uE946", "개발 정보", () =>
Dispatcher.Invoke(() => new AboutWindow().Show()))
.AddItem("\uE711", "종료", () =>
{
_inputListener?.Dispose();
_trayIcon?.Dispose();
Shutdown();
});
// AI 기능 활성화 여부에 따라 메뉴 항목 가시성 동적 업데이트
_trayMenu.Opening += () =>
{
aiTrayItem.Visibility = settings.Settings.AiEnabled
? System.Windows.Visibility.Visible
: System.Windows.Visibility.Collapsed;
};
// 좌클릭 → 대화창, 더블클릭 → 런처, 우클릭 → 메뉴
// MouseClick은 버튼 정보를 제공하므로 Click 대신 사용 — Click은 모든 버튼에 발생하여 우클릭 메뉴와 충돌
System.Windows.Forms.Timer? _trayClickTimer = null;
_trayIcon.MouseClick += (_, e) =>
{
if (e.Button == System.Windows.Forms.MouseButtons.Right)
{
// 우클릭: 컨텍스트 메뉴 표시 (싱글클릭 타이머 간섭 방지)
_trayClickTimer?.Stop();
_trayClickTimer?.Dispose();
_trayClickTimer = null;
Dispatcher.Invoke(() => _trayMenu?.ShowWithUpdate());
return;
}
if (e.Button != System.Windows.Forms.MouseButtons.Left) return;
// 싱글/더블 클릭 구분: WinForms NotifyIcon은 DoubleClick 시에도 Click을 먼저 발생시키므로
// 타이머를 사용하여 더블클릭 대기 후 싱글 클릭 액션을 실행
_trayClickTimer?.Stop();
_trayClickTimer?.Dispose();
_trayClickTimer = new System.Windows.Forms.Timer { Interval = SystemInformation.DoubleClickTime };
_trayClickTimer.Tick += (s, _) =>
{
_trayClickTimer?.Stop();
_trayClickTimer?.Dispose();
_trayClickTimer = null;
// 싱글 클릭: 대화창 열기
Dispatcher.BeginInvoke(() =>
{
if (settings.Settings.AiEnabled)
OpenAiChat();
else
ShowLauncherWindow();
});
};
_trayClickTimer.Start();
};
_trayIcon.MouseDoubleClick += (_, e) =>
{
if (e.Button != System.Windows.Forms.MouseButtons.Left) return;
// 싱글 클릭 타이머 취소 → 런처만 열림
_trayClickTimer?.Stop();
_trayClickTimer?.Dispose();
_trayClickTimer = null;
Dispatcher.BeginInvoke(ShowLauncherWindow);
};
// 타이머/알람 풍선 알림 서비스 연결
NotificationService.Initialize((title, msg) =>
{
Dispatcher.Invoke(() =>
_trayIcon?.ShowBalloonTip(4000, title, msg, ToolTipIcon.None));
});
}
/// <summary>ChatWindow 등 외부에서 설정 창을 여는 공개 메서드.</summary>
public void OpenSettingsFromChat() => Dispatcher.Invoke(() =>
{
OpenSettings();
_settingsWindow?.SelectAgentSettingsTab();
});
/// <summary>AX Agent 창을 열고 전용 설정창을 바로 표시합니다.</summary>
public void OpenAgentSettingsInChat()
{
Dispatcher.Invoke(() =>
{
OpenAiChat();
_chatWindow?.OpenAgentSettingsFromExternal();
});
}
/// <summary>AX Agent 창 열기 (트레이 메뉴 등에서 호출).</summary>
private Views.ChatWindow? _chatWindow;
public void EnsureIndexWarmupStarted()
{
if (_indexService == null) return;
if (Interlocked.Exchange(ref _indexWarmupStarted, 1) == 1) return;
_ = Task.Run(async () =>
{
try
{
await _indexService.BuildAsync().ConfigureAwait(false);
_indexService.StartWatchers();
}
catch (Exception ex)
{
LogService.Warn($"런처 인덱스 초기화 실패: {ex.Message}");
}
});
}
private void ShowLauncherWindow()
{
if (_launcher == null) return;
_launcher.Show();
}
private void OpenAiChat()
{
if (_settings == null) return;
if (_chatWindow == null)
{
_chatWindow = new Views.ChatWindow(_settings);
}
_chatWindow.Show();
_chatWindow.Activate();
}
public void ToggleDockBar()
{
if (_dockBar != null && _dockBar.IsVisible)
{
_dockBar.Hide();
return;
}
if (_dockBar == null)
{
_dockBar = new DockBarWindow();
_dockBar.OnQuickSearch = query =>
{
if (_launcher == null) return;
ShowLauncherWindow();
_launcher.Activate(); // 독 바 뒤가 아닌 전면에 표시
if (!string.IsNullOrEmpty(query))
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input,
() => _launcher.SetInputText(query));
};
_dockBar.OnCapture = async () =>
{
WindowTracker.Capture();
if (_captureHandler != null)
await _captureHandler.CaptureDirectAsync("region");
};
_dockBar.OnOpenAgent = () =>
{
if (_launcher == null) return;
ShowLauncherWindow();
Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Input,
() => _launcher.SetInputText("!"));
};
}
var launcher = _settings?.Settings.Launcher;
var dockItems = launcher?.DockBarItems ?? new() { "launcher", "clipboard", "capture", "agent", "clock", "cpu" };
_dockBar.BuildFromSettings(dockItems);
_dockBar.OnPositionChanged = (deviceName, left, top) =>
{
if (_settings != null && !string.IsNullOrWhiteSpace(deviceName))
{
_settings.Settings.Launcher.DockBarLeft = left;
_settings.Settings.Launcher.DockBarTop = top;
_settings.Settings.Launcher.MonitorDockPositions[deviceName] = new List<double> { left, top };
_settings.Save();
}
};
_dockBar.Show();
var (dockLeft, dockTop) = ResolveDockBarPosition(launcher);
_dockBar.ApplySettings(
launcher?.DockBarOpacity ?? 0.92,
dockLeft,
dockTop,
launcher?.DockBarRainbowGlow ?? false);
}
/// <summary>독 바를 현재 설정으로 즉시 새로고침합니다.</summary>
public void RefreshDockBar()
{
if (_dockBar == null || !_dockBar.IsVisible) return;
var launcher = _settings?.Settings.Launcher;
var dockItems = launcher?.DockBarItems ?? new() { "launcher", "clipboard", "capture", "agent", "clock", "cpu" };
_dockBar.BuildFromSettings(dockItems);
var (dockLeft, dockTop) = ResolveDockBarPosition(launcher);
_dockBar.ApplySettings(
launcher?.DockBarOpacity ?? 0.92,
dockLeft,
dockTop,
launcher?.DockBarRainbowGlow ?? false);
}
private (double Left, double Top) ResolveDockBarPosition(AxCopilot.Models.LauncherSettings? launcher)
{
if (launcher == null)
return (-1, -1);
var currentScreen = Screen.FromPoint(System.Windows.Forms.Cursor.Position);
var currentDeviceName = currentScreen.DeviceName;
if (launcher.MonitorDockPositions.TryGetValue(currentDeviceName, out var stored)
&& stored is { Count: >= 2 }
&& IsDockPositionValid(currentScreen, stored[0], stored[1]))
{
launcher.DockBarLeft = stored[0];
launcher.DockBarTop = stored[1];
return (stored[0], stored[1]);
}
var left = launcher.DockBarLeft;
var top = launcher.DockBarTop;
if (left >= 0 && top >= 0)
{
var matchedScreen = Screen.AllScreens.FirstOrDefault(screen => IsDockPositionValid(screen, left, top));
if (matchedScreen != null)
return (left, top);
}
launcher.DockBarLeft = -1;
launcher.DockBarTop = -1;
try { _settings?.Save(); } catch { }
return (-1, -1);
}
private bool IsDockPositionValid(Screen screen, double left, double top)
{
if (left < 0 || top < 0)
return false;
var width = _dockBar?.ActualWidth > 0 ? _dockBar.ActualWidth : 420;
var height = _dockBar?.ActualHeight > 0 ? _dockBar.ActualHeight : 56;
var area = screen.WorkingArea;
return left >= area.Left
&& top >= area.Top
&& left + width <= area.Right
&& top + height <= area.Bottom;
}
private void OpenSettings()
{
if (_settingsWindow != null && _settingsWindow.IsVisible)
{
_settingsWindow.Activate();
return;
}
if (_settings == null || _launcher == null) return;
var vm = new ViewModels.SettingsViewModel(_settings);
// 미리보기 콜백: 현재 편집 중인 색상(vm.ColorRows)으로 런처에 즉시 반영
void PreviewCallback(string themeKey)
{
if (themeKey == "custom")
{
var tempColors = new AxCopilot.Models.CustomThemeColors();
foreach (var row in vm.ColorRows)
{
var prop = typeof(AxCopilot.Models.CustomThemeColors).GetProperty(row.Property);
prop?.SetValue(tempColors, row.Hex);
}
_launcher.ApplyTheme(themeKey, tempColors);
}
else
{
_launcher.ApplyTheme(themeKey, _settings.Settings.Launcher.CustomTheme);
}
}
// 취소/X 닫기 콜백: 파일에 저장된 원본 설정으로 복원
void RevertCallback()
{
_launcher.ApplyTheme(
_settings.Settings.Launcher.Theme ?? "system",
_settings.Settings.Launcher.CustomTheme);
}
_settingsWindow = new Views.SettingsWindow(vm, PreviewCallback, RevertCallback)
{
// 핫키 녹화 중 글로벌 핫키 일시 정지
SuspendHotkeyCallback = suspend =>
{
if (_inputListener != null)
_inputListener.SuspendHotkey = suspend;
}
};
// 저장 완료 시 InputListener 핫키 갱신 + 알림 타이머 재시작
vm.SaveCompleted += (_, _) =>
{
if (_inputListener != null && _settings != null)
{
_inputListener.UpdateHotkey(_settings.Settings.Hotkey);
_inputListener.UpdateCaptureHotkey(
_settings.Settings.ScreenCapture.GlobalHotkey,
_settings.Settings.ScreenCapture.GlobalHotkeyEnabled);
}
_worktimeReminder?.RestartTimer();
_chatWindow?.RefreshFromSavedSettings();
};
_settingsWindow.Show();
}
// ─── 자동 시작 레지스트리 헬퍼 ──────────────────────────────────────────
private static System.Drawing.Icon LoadAppIcon()
{
// DPI 인식 아이콘 크기 (기본 16 → 고DPI에서 20/24/32)
var iconSize = System.Windows.Forms.SystemInformation.SmallIconSize;
// 1) 파일 시스템에서 로드 (개발 환경)
try
{
var exeDir = System.IO.Path.GetDirectoryName(Environment.ProcessPath)
?? AppContext.BaseDirectory;
var path = System.IO.Path.Combine(exeDir, "Assets", "icon.ico");
if (System.IO.File.Exists(path))
return new System.Drawing.Icon(path, iconSize);
}
catch { }
// 2) 내장 리소스에서 로드 (PublishSingleFile 배포)
try
{
var uri = new Uri("pack://application:,,,/Assets/icon.ico");
var stream = System.Windows.Application.GetResourceStream(uri)?.Stream;
if (stream != null)
return new System.Drawing.Icon(stream, iconSize);
}
catch { }
return System.Drawing.SystemIcons.Application;
}
private const string AutoRunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string AutoRunName = "AxCopilot";
private static bool IsAutoStartEnabled()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(AutoRunKey, writable: false);
return key?.GetValue(AutoRunName) != null;
}
catch { return false; }
}
private static void SetAutoStart(bool enable)
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(AutoRunKey, writable: true);
if (key == null) return;
if (enable)
{
var exePath = Environment.ProcessPath
?? System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName
?? string.Empty;
if (!string.IsNullOrEmpty(exePath))
key.SetValue(AutoRunName, $"\"{exePath}\"");
}
else
{
key.DeleteValue(AutoRunName, throwOnMissingValue: false);
}
}
catch (Exception ex)
{
LogService.Warn($"자동 시작 레지스트리 설정 실패: {ex.Message}");
}
}
protected override void OnExit(ExitEventArgs e)
{
_chatWindow?.ForceClose(); // 미리 생성된 ChatWindow 진짜 닫기
_inputListener?.Dispose();
_clipboardHistory?.Dispose();
_fileDialogWatcher?.Dispose();
_indexService?.Dispose();
_schedulerService?.Dispose();
_sessionTracking?.Dispose();
_worktimeReminder?.Dispose();
_trayIcon?.Dispose();
try { _singleInstanceMutex?.ReleaseMutex(); } catch { }
_singleInstanceMutex?.Dispose();
LogService.Info("=== AX Copilot 종료 ===");
base.OnExit(e);
}
private void UpdateFileDialogWatcherState()
{
if (_fileDialogWatcher == null || _settings == null)
return;
if (_settings.Settings.Launcher.EnableFileDialogIntegration)
{
_fileDialogWatcher.Start();
return;
}
_fileDialogWatcher.Stop();
}
// ─── DI 컨테이너 구성 ─────────────────────────────────────────────────────
// 기존 수동 생성 인스턴스를 DI 컨테이너에 등록합니다.
// 향후 ViewModel/Window도 DI에서 resolve할 수 있도록 점진 확장합니다.
private static void ConfigureDependencyInjection(
SettingsService settings,
AgentMemoryService memoryService,
ChatSessionStateService chatSessionState,
AppStateService appState)
{
ServiceLocator.Configure(services =>
{
// ── Singleton: 기존 인스턴스 등록 ────────────────────────────────
services.AddSingleton<ISettingsService>(settings);
services.AddSingleton(settings); // 하위 호환: 구체 타입 직접 주입도 허용
services.AddSingleton<IChatStorageService, ChatStorageService>();
services.AddSingleton<IAppStateService>(appState);
services.AddSingleton(appState);
services.AddSingleton(chatSessionState);
services.AddSingleton(memoryService);
// ── Transient: 요청마다 새 인스턴스 ──────────────────────────────
services.AddTransient<ILlmService>(sp => new LlmService(sp.GetRequiredService<SettingsService>()));
services.AddSingleton<IModelRouterService>(sp => new ModelRouterService(sp.GetRequiredService<SettingsService>()));
});
LogService.Info("DI 컨테이너 초기화 완료");
}
}