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 ? settings.Settings.Llm.DetailedLogRetentionDays : 3; 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(); }); } /// 현재 선택된 텍스트를 가져옵니다 (Ctrl+C 시뮬레이션). /// 후크 스레드에서 호출됩니다 (UI 스레드 아님). 클립보드 접근은 STA 스레드가 필요합니다. 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()); // 클립보드 업데이트 대기 (앱마다 다름) 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; } } /// AI 텍스트 명령을 AX Agent 대화로 전달합니다. 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)); }); } /// ChatWindow 등 외부에서 설정 창을 여는 공개 메서드. public void OpenSettingsFromChat() => Dispatcher.Invoke(() => { OpenSettings(); _settingsWindow?.SelectAgentSettingsTab(); }); /// AX Agent 창을 열고 전용 설정창을 바로 표시합니다. public void OpenAgentSettingsInChat() { Dispatcher.Invoke(() => { OpenAiChat(); _chatWindow?.OpenAgentSettingsFromExternal(); }); } /// AX Agent 창 열기 (트레이 메뉴 등에서 호출). 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 { left, top }; _settings.Save(); } }; _dockBar.Show(); var (dockLeft, dockTop) = ResolveDockBarPosition(launcher); _dockBar.ApplySettings( launcher?.DockBarOpacity ?? 0.92, dockLeft, dockTop, launcher?.DockBarRainbowGlow ?? false); } /// 독 바를 현재 설정으로 즉시 새로고침합니다. 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(settings); services.AddSingleton(settings); // 하위 호환: 구체 타입 직접 주입도 허용 services.AddSingleton(); services.AddSingleton(appState); services.AddSingleton(appState); services.AddSingleton(chatSessionState); services.AddSingleton(memoryService); // ── Transient: 요청마다 새 인스턴스 ────────────────────────────── services.AddTransient(sp => new LlmService(sp.GetRequiredService())); services.AddSingleton(sp => new ModelRouterService(sp.GetRequiredService())); }); LogService.Info("DI 컨테이너 초기화 완료"); } }