[Phase52] 7개 파일 추가 분리 — 14개 파일로 재구성
LlmService.Streaming: - LlmService.Streaming.cs: 516 → 256줄 (Ollama/OpenAI 유지) - LlmService.GeminiClaude.cs (신규): Gemini+Claude 스트리밍 (~260줄) DocxSkill: - DocxSkill.cs: 543 → 158줄 (ExecuteAsync 유지) - DocxSkill.Builders.cs (신규): 11개 문서 빌더 헬퍼 (290줄) ChartSkill: - ChartSkill.cs: 537 → 174줄 (ExecuteAsync+RenderChart 유지) - ChartSkill.Renderers.cs (신규): 7개 차트 렌더러+헬퍼+Dataset (280줄) ScreenCaptureHandler: - ScreenCaptureHandler.cs: 637 → 241줄 (기본 캡처 유지) - ScreenCaptureHandler.Helpers.cs (신규): 스크롤/영역 캡처+헬퍼 (310줄) SystemInfoHandler: - SystemInfoHandler.cs: 509 → 352줄 - SystemInfoHandler.Helpers.cs (신규): 8개 헬퍼+StarInfoHandler (161줄) AppSettings: - AppSettings.cs: 564 → 309줄 (AppSettings/Launcher/CustomTheme 유지) - AppSettings.Models.cs (신규): 14개 설정 모델 클래스 (233줄) SkillEditorWindow: - SkillEditorWindow.xaml.cs: 528 → 303줄 - SkillEditorWindow.PreviewSave.cs (신규): 미리보기+저장+로드 (226줄) 빌드: 경고 0, 오류 0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
411
src/AxCopilot/Handlers/ScreenCaptureHandler.Helpers.cs
Normal file
411
src/AxCopilot/Handlers/ScreenCaptureHandler.Helpers.cs
Normal file
@@ -0,0 +1,411 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows.Media.Imaging;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
public partial class ScreenCaptureHandler
|
||||
{
|
||||
// ─── 스크롤 캡처 + 영역 캡처 + 헬퍼 ────────────────────────────────────
|
||||
|
||||
private async Task CaptureScrollAsync(string timestamp, CancellationToken ct)
|
||||
{
|
||||
var hwnd = WindowTracker.PreviousWindow;
|
||||
if (hwnd == IntPtr.Zero || !IsWindow(hwnd))
|
||||
{
|
||||
NotificationService.Notify("AX Copilot", "캡처할 창이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 런처가 완전히 사라질 때까지 대기
|
||||
var launcherHwnd = GetLauncherHwnd();
|
||||
if (launcherHwnd != IntPtr.Zero)
|
||||
{
|
||||
for (int i = 0; i < 10 && IsWindowVisible(launcherHwnd); i++)
|
||||
await Task.Delay(50, ct);
|
||||
}
|
||||
|
||||
ShowWindow(hwnd, SW_RESTORE);
|
||||
SetForegroundWindow(hwnd);
|
||||
await Task.Delay(200, ct);
|
||||
|
||||
if (!GetWindowRect(hwnd, out var rect)) return;
|
||||
int w = rect.right - rect.left;
|
||||
int h = rect.bottom - rect.top;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
|
||||
// 스크롤 가능한 자식 창 찾기 (웹브라우저, 텍스트뷰어 등)
|
||||
var scrollTarget = FindScrollableChild(hwnd);
|
||||
|
||||
const int maxPages = 15;
|
||||
var frames = new List<Bitmap>();
|
||||
|
||||
// 첫 프레임
|
||||
frames.Add(CaptureWindow(hwnd, w, h, rect));
|
||||
|
||||
for (int i = 0; i < maxPages - 1; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Page Down 전송
|
||||
if (scrollTarget != IntPtr.Zero)
|
||||
SendMessage(scrollTarget, WM_VSCROLL, new IntPtr(SB_PAGEDOWN), IntPtr.Zero);
|
||||
else
|
||||
SendPageDown(hwnd);
|
||||
|
||||
await Task.Delay(ScrollDelayMs, ct);
|
||||
|
||||
// 현재 프레임 캡처
|
||||
if (!GetWindowRect(hwnd, out var newRect)) break;
|
||||
var frame = CaptureWindow(hwnd, w, h, newRect);
|
||||
|
||||
// 이전 프레임과 동일하면 끝 (스크롤 종료 감지)
|
||||
if (frames.Count > 0 && AreSimilar(frames[^1], frame))
|
||||
{
|
||||
frame.Dispose();
|
||||
break;
|
||||
}
|
||||
|
||||
frames.Add(frame);
|
||||
}
|
||||
|
||||
// 프레임들을 수직으로 이어 붙이기 (오버랩 제거)
|
||||
using var stitched = StitchFrames(frames, h);
|
||||
|
||||
// 각 프레임 해제
|
||||
foreach (var f in frames) f.Dispose();
|
||||
|
||||
CopyToClipboard(stitched);
|
||||
NotificationService.Notify("스크롤 캡처 완료", $"{stitched.Height}px · 클립보드에 복사되었습니다");
|
||||
}
|
||||
|
||||
// ─── 헬퍼: 창 캡처 (PrintWindow 우선, 실패 시 BitBlt 폴백) ──────────────
|
||||
|
||||
private static Bitmap CaptureWindow(IntPtr hwnd, int w, int h, RECT rect)
|
||||
{
|
||||
var bmp = new Bitmap(w, h, PixelFormat.Format32bppArgb);
|
||||
using var g = Graphics.FromImage(bmp);
|
||||
|
||||
// PrintWindow로 창 내용 캡처 (최소화된 창도 동작)
|
||||
var hdc = g.GetHdc();
|
||||
bool ok = PrintWindow(hwnd, hdc, 2); // PW_RENDERFULLCONTENT = 2
|
||||
g.ReleaseHdc(hdc);
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
// 폴백: 화면에서 BitBlt
|
||||
g.CopyFromScreen(rect.left, rect.top, 0, 0,
|
||||
new System.Drawing.Size(w, h), CopyPixelOperation.SourceCopy);
|
||||
}
|
||||
|
||||
return bmp;
|
||||
}
|
||||
|
||||
// ─── 헬퍼: 전체 화면 범위 ─────────────────────────────────────────────────
|
||||
|
||||
private static System.Drawing.Rectangle GetAllScreenBounds()
|
||||
{
|
||||
var screens = System.Windows.Forms.Screen.AllScreens;
|
||||
int minX = screens.Min(s => s.Bounds.X);
|
||||
int minY = screens.Min(s => s.Bounds.Y);
|
||||
int maxX = screens.Max(s => s.Bounds.Right);
|
||||
int maxY = screens.Max(s => s.Bounds.Bottom);
|
||||
return new System.Drawing.Rectangle(minX, minY, maxX - minX, maxY - minY);
|
||||
}
|
||||
|
||||
// ─── 헬퍼: 스크롤 가능 자식 창 찾기 ──────────────────────────────────────
|
||||
|
||||
private static IntPtr FindScrollableChild(IntPtr hwnd)
|
||||
{
|
||||
// 공통 스크롤 가능 클래스 탐색
|
||||
foreach (var cls in new[] { "Internet Explorer_Server", "Chrome_RenderWidgetHostHWND",
|
||||
"MozillaWindowClass", "RichEdit20W", "RICHEDIT50W",
|
||||
"TextBox", "EDIT" })
|
||||
{
|
||||
var child = FindWindowEx(hwnd, IntPtr.Zero, cls, null);
|
||||
if (child != IntPtr.Zero) return child;
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
// ─── 헬퍼: Page Down 키 전송 ─────────────────────────────────────────────
|
||||
|
||||
private static void SendPageDown(IntPtr hwnd)
|
||||
{
|
||||
SendMessage(hwnd, WM_KEYDOWN, new IntPtr(VK_NEXT), IntPtr.Zero);
|
||||
SendMessage(hwnd, WM_KEYUP, new IntPtr(VK_NEXT), IntPtr.Zero);
|
||||
}
|
||||
|
||||
// ─── 헬퍼: 두 비트맵이 유사한지 비교 (스크롤 종료 감지) ─────────────────
|
||||
// LockBits를 사용하여 GetPixel 대비 ~50× 빠르게 처리.
|
||||
|
||||
private static bool AreSimilar(Bitmap a, Bitmap b)
|
||||
{
|
||||
if (a.Width != b.Width || a.Height != b.Height) return false;
|
||||
|
||||
int startY = (int)(a.Height * 0.8);
|
||||
int w = a.Width;
|
||||
int h = a.Height;
|
||||
|
||||
var rectA = new System.Drawing.Rectangle(0, startY, w, h - startY);
|
||||
var rectB = new System.Drawing.Rectangle(0, startY, w, h - startY);
|
||||
|
||||
var dataA = a.LockBits(rectA, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
var dataB = b.LockBits(rectB, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
|
||||
try
|
||||
{
|
||||
int sameCount = 0;
|
||||
int totalSamples = 0;
|
||||
int stride = dataA.Stride;
|
||||
int sampleW = w / 16 + 1;
|
||||
int sampleH = (h - startY) / 8 + 1;
|
||||
|
||||
unsafe
|
||||
{
|
||||
byte* ptrA = (byte*)dataA.Scan0.ToPointer();
|
||||
byte* ptrB = (byte*)dataB.Scan0.ToPointer();
|
||||
|
||||
for (int sy = 0; sy < sampleH; sy++)
|
||||
{
|
||||
int row = sy * 8;
|
||||
if (row >= h - startY) break;
|
||||
for (int sx = 0; sx < sampleW; sx++)
|
||||
{
|
||||
int col = sx * 16;
|
||||
if (col >= w) break;
|
||||
int idx = row * stride + col * 4;
|
||||
if (Math.Abs(ptrA[idx] - ptrB[idx]) < 5 &&
|
||||
Math.Abs(ptrA[idx + 1] - ptrB[idx + 1]) < 5 &&
|
||||
Math.Abs(ptrA[idx + 2] - ptrB[idx + 2]) < 5)
|
||||
sameCount++;
|
||||
totalSamples++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return totalSamples > 0 && (double)sameCount / totalSamples > 0.97;
|
||||
}
|
||||
finally
|
||||
{
|
||||
a.UnlockBits(dataA);
|
||||
b.UnlockBits(dataB);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 헬퍼: 프레임 이어 붙이기 (증분만 추가) ──────────────────────────────
|
||||
|
||||
private static Bitmap StitchFrames(List<Bitmap> frames, int windowHeight)
|
||||
{
|
||||
if (frames.Count == 0)
|
||||
return new Bitmap(1, 1);
|
||||
if (frames.Count == 1)
|
||||
return new Bitmap(frames[0]);
|
||||
|
||||
int w = frames[0].Width;
|
||||
|
||||
// 각 프레임에서 새로운 부분의 시작 Y (오버랩 제외)
|
||||
var newPartStarts = new List<int>(); // 인덱스 1부터: frames[i]에서 오버랩 이후 시작 행
|
||||
var newPartHeights = new List<int>(); // 새로운 부분 높이
|
||||
|
||||
int totalHeight = windowHeight; // 첫 프레임은 전체 사용
|
||||
|
||||
for (int i = 1; i < frames.Count; i++)
|
||||
{
|
||||
int overlap = FindOverlap(frames[i - 1], frames[i]);
|
||||
int newStart = overlap > 0 ? overlap : windowHeight / 5; // 오버랩 감지 실패 시 상단 20% 제거
|
||||
int newH = windowHeight - newStart;
|
||||
if (newH <= 0) { newH = windowHeight / 4; newStart = windowHeight - newH; }
|
||||
newPartStarts.Add(newStart);
|
||||
newPartHeights.Add(newH);
|
||||
totalHeight += newH;
|
||||
}
|
||||
|
||||
var result = new Bitmap(w, totalHeight, PixelFormat.Format32bppArgb);
|
||||
using var g = Graphics.FromImage(result);
|
||||
|
||||
// 첫 프레임: 전체 그리기
|
||||
g.DrawImage(frames[0], 0, 0, w, windowHeight);
|
||||
|
||||
// 이후 프레임: 새로운 부분(증분)만 잘라서 붙이기
|
||||
int yPos = windowHeight;
|
||||
for (int i = 1; i < frames.Count; i++)
|
||||
{
|
||||
int srcY = newPartStarts[i - 1];
|
||||
int srcH = newPartHeights[i - 1];
|
||||
var srcRect = new System.Drawing.Rectangle(0, srcY, w, srcH);
|
||||
var dstRect = new System.Drawing.Rectangle(0, yPos, w, srcH);
|
||||
g.DrawImage(frames[i], dstRect, srcRect, System.Drawing.GraphicsUnit.Pixel);
|
||||
yPos += srcH;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── 헬퍼: 두 프레임 사이 오버랩 픽셀 수 계산 (다중 행 비교) ────────────
|
||||
|
||||
private static int FindOverlap(Bitmap prev, Bitmap next)
|
||||
{
|
||||
int w = Math.Min(prev.Width, next.Width);
|
||||
int h = prev.Height;
|
||||
if (h < 16 || w < 16) return 0;
|
||||
|
||||
int searchRange = (int)(h * 0.7); // 최대 70% 오버랩 탐색
|
||||
const int checkRows = 8; // 오버랩 후보당 검증할 행 수
|
||||
|
||||
var dataPrev = prev.LockBits(
|
||||
new System.Drawing.Rectangle(0, 0, prev.Width, prev.Height),
|
||||
ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
var dataNext = next.LockBits(
|
||||
new System.Drawing.Rectangle(0, 0, next.Width, next.Height),
|
||||
ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
|
||||
try
|
||||
{
|
||||
int stridePrev = dataPrev.Stride;
|
||||
int strideNext = dataNext.Stride;
|
||||
int bestOverlap = 0;
|
||||
|
||||
unsafe
|
||||
{
|
||||
byte* ptrPrev = (byte*)dataPrev.Scan0.ToPointer();
|
||||
byte* ptrNext = (byte*)dataNext.Scan0.ToPointer();
|
||||
|
||||
// 큰 오버랩부터 탐색 (스크롤은 보통 1페이지 미만)
|
||||
for (int overlap = searchRange; overlap > 8; overlap -= 2)
|
||||
{
|
||||
int prevStartY = h - overlap;
|
||||
if (prevStartY < 0) continue;
|
||||
|
||||
int totalMatch = 0;
|
||||
int totalCheck = 0;
|
||||
|
||||
// 오버랩 영역 내 여러 행을 검증
|
||||
for (int r = 0; r < checkRows; r++)
|
||||
{
|
||||
int rowInOverlap = r * (overlap / checkRows);
|
||||
int prevRow = prevStartY + rowInOverlap;
|
||||
int nextRow = rowInOverlap;
|
||||
if (prevRow >= h || nextRow >= next.Height) continue;
|
||||
|
||||
// 행 내 샘플 픽셀 비교
|
||||
for (int x = 4; x < w - 4; x += 12)
|
||||
{
|
||||
int idxP = prevRow * stridePrev + x * 4;
|
||||
int idxN = nextRow * strideNext + x * 4;
|
||||
if (idxP + 2 >= dataPrev.Height * stridePrev) continue;
|
||||
if (idxN + 2 >= dataNext.Height * strideNext) continue;
|
||||
|
||||
if (Math.Abs(ptrPrev[idxP] - ptrNext[idxN]) < 10 &&
|
||||
Math.Abs(ptrPrev[idxP + 1] - ptrNext[idxN + 1]) < 10 &&
|
||||
Math.Abs(ptrPrev[idxP + 2] - ptrNext[idxN + 2]) < 10)
|
||||
totalMatch++;
|
||||
totalCheck++;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCheck > 0 && (double)totalMatch / totalCheck > 0.80)
|
||||
{
|
||||
bestOverlap = overlap;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestOverlap;
|
||||
}
|
||||
finally
|
||||
{
|
||||
prev.UnlockBits(dataPrev);
|
||||
next.UnlockBits(dataNext);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 영역 선택 캡처 ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task CaptureRegionAsync(string timestamp, CancellationToken ct)
|
||||
{
|
||||
// 전체 화면을 먼저 캡처 (배경으로 사용)
|
||||
var bounds = GetAllScreenBounds();
|
||||
using var fullBmp = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format32bppArgb);
|
||||
using (var g = Graphics.FromImage(fullBmp))
|
||||
g.CopyFromScreen(bounds.X, bounds.Y, 0, 0, bounds.Size, CopyPixelOperation.SourceCopy);
|
||||
|
||||
// WPF 오버레이 창으로 영역 선택
|
||||
System.Drawing.Rectangle? selected = null;
|
||||
System.Windows.Application.Current?.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var overlay = new AxCopilot.Views.RegionSelectWindow(fullBmp, bounds);
|
||||
overlay.ShowDialog();
|
||||
selected = overlay.SelectedRect;
|
||||
});
|
||||
|
||||
if (selected == null || selected.Value.Width < 4 || selected.Value.Height < 4)
|
||||
{
|
||||
NotificationService.Notify("AX Copilot", "영역 선택이 취소되었습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
var r = selected.Value;
|
||||
using var crop = new Bitmap(r.Width, r.Height, PixelFormat.Format32bppArgb);
|
||||
using (var g = Graphics.FromImage(crop))
|
||||
g.DrawImage(fullBmp, new System.Drawing.Rectangle(0, 0, r.Width, r.Height), r, System.Drawing.GraphicsUnit.Pixel);
|
||||
|
||||
CopyToClipboard(crop);
|
||||
NotificationService.Notify("영역 캡처 완료", $"{r.Width}×{r.Height} · 클립보드에 복사되었습니다");
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ─── 헬퍼: 클립보드에 이미지 복사 ───────────────────────────────────────
|
||||
|
||||
private static void CopyToClipboard(Bitmap bmp)
|
||||
{
|
||||
try
|
||||
{
|
||||
// WPF Clipboard에 BitmapSource로 복사
|
||||
System.Windows.Application.Current?.Dispatcher.Invoke(() =>
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
bmp.Save(ms, ImageFormat.Bmp);
|
||||
ms.Position = 0;
|
||||
var bitmapImage = new BitmapImage();
|
||||
bitmapImage.BeginInit();
|
||||
bitmapImage.StreamSource = ms;
|
||||
bitmapImage.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bitmapImage.EndInit();
|
||||
bitmapImage.Freeze();
|
||||
System.Windows.Clipboard.SetImage(bitmapImage);
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"클립보드 이미지 복사 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 헬퍼: 런처 창 핸들 조회 (캡처 시 런처 숨김 대기용) ───────────────
|
||||
|
||||
private static IntPtr GetLauncherHwnd()
|
||||
{
|
||||
try
|
||||
{
|
||||
IntPtr hwnd = IntPtr.Zero;
|
||||
System.Windows.Application.Current?.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var launcher = System.Windows.Application.Current.Windows
|
||||
.OfType<System.Windows.Window>()
|
||||
.FirstOrDefault(w => w.GetType().Name == "LauncherWindow");
|
||||
if (launcher != null)
|
||||
hwnd = new System.Windows.Interop.WindowInteropHelper(launcher).Handle;
|
||||
});
|
||||
return hwnd;
|
||||
}
|
||||
catch (Exception) { return IntPtr.Zero; }
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ namespace AxCopilot.Handlers;
|
||||
/// 파일 저장 여부 / 경로는 설정 → 캡처 탭에서 변경 가능.
|
||||
/// 기본값: 저장 안 함, 클립보드에만 복사.
|
||||
/// </summary>
|
||||
public class ScreenCaptureHandler : IActionHandler
|
||||
public partial class ScreenCaptureHandler : IActionHandler
|
||||
{
|
||||
private readonly AxCopilot.Services.SettingsService _settings;
|
||||
|
||||
@@ -237,401 +237,4 @@ public class ScreenCaptureHandler : IActionHandler
|
||||
NotificationService.Notify("창 캡처 완료", "클립보드에 복사되었습니다");
|
||||
}
|
||||
|
||||
// ─── 스크롤 캡처 ───────────────────────────────────────────────────────────
|
||||
|
||||
private async Task CaptureScrollAsync(string timestamp, CancellationToken ct)
|
||||
{
|
||||
var hwnd = WindowTracker.PreviousWindow;
|
||||
if (hwnd == IntPtr.Zero || !IsWindow(hwnd))
|
||||
{
|
||||
NotificationService.Notify("AX Copilot", "캡처할 창이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 런처가 완전히 사라질 때까지 대기
|
||||
var launcherHwnd = GetLauncherHwnd();
|
||||
if (launcherHwnd != IntPtr.Zero)
|
||||
{
|
||||
for (int i = 0; i < 10 && IsWindowVisible(launcherHwnd); i++)
|
||||
await Task.Delay(50, ct);
|
||||
}
|
||||
|
||||
ShowWindow(hwnd, SW_RESTORE);
|
||||
SetForegroundWindow(hwnd);
|
||||
await Task.Delay(200, ct);
|
||||
|
||||
if (!GetWindowRect(hwnd, out var rect)) return;
|
||||
int w = rect.right - rect.left;
|
||||
int h = rect.bottom - rect.top;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
|
||||
// 스크롤 가능한 자식 창 찾기 (웹브라우저, 텍스트뷰어 등)
|
||||
var scrollTarget = FindScrollableChild(hwnd);
|
||||
|
||||
const int maxPages = 15;
|
||||
var frames = new List<Bitmap>();
|
||||
|
||||
// 첫 프레임
|
||||
frames.Add(CaptureWindow(hwnd, w, h, rect));
|
||||
|
||||
for (int i = 0; i < maxPages - 1; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Page Down 전송
|
||||
if (scrollTarget != IntPtr.Zero)
|
||||
SendMessage(scrollTarget, WM_VSCROLL, new IntPtr(SB_PAGEDOWN), IntPtr.Zero);
|
||||
else
|
||||
SendPageDown(hwnd);
|
||||
|
||||
await Task.Delay(ScrollDelayMs, ct);
|
||||
|
||||
// 현재 프레임 캡처
|
||||
if (!GetWindowRect(hwnd, out var newRect)) break;
|
||||
var frame = CaptureWindow(hwnd, w, h, newRect);
|
||||
|
||||
// 이전 프레임과 동일하면 끝 (스크롤 종료 감지)
|
||||
if (frames.Count > 0 && AreSimilar(frames[^1], frame))
|
||||
{
|
||||
frame.Dispose();
|
||||
break;
|
||||
}
|
||||
|
||||
frames.Add(frame);
|
||||
}
|
||||
|
||||
// 프레임들을 수직으로 이어 붙이기 (오버랩 제거)
|
||||
using var stitched = StitchFrames(frames, h);
|
||||
|
||||
// 각 프레임 해제
|
||||
foreach (var f in frames) f.Dispose();
|
||||
|
||||
CopyToClipboard(stitched);
|
||||
NotificationService.Notify("스크롤 캡처 완료", $"{stitched.Height}px · 클립보드에 복사되었습니다");
|
||||
}
|
||||
|
||||
// ─── 헬퍼: 창 캡처 (PrintWindow 우선, 실패 시 BitBlt 폴백) ──────────────
|
||||
|
||||
private static Bitmap CaptureWindow(IntPtr hwnd, int w, int h, RECT rect)
|
||||
{
|
||||
var bmp = new Bitmap(w, h, PixelFormat.Format32bppArgb);
|
||||
using var g = Graphics.FromImage(bmp);
|
||||
|
||||
// PrintWindow로 창 내용 캡처 (최소화된 창도 동작)
|
||||
var hdc = g.GetHdc();
|
||||
bool ok = PrintWindow(hwnd, hdc, 2); // PW_RENDERFULLCONTENT = 2
|
||||
g.ReleaseHdc(hdc);
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
// 폴백: 화면에서 BitBlt
|
||||
g.CopyFromScreen(rect.left, rect.top, 0, 0,
|
||||
new System.Drawing.Size(w, h), CopyPixelOperation.SourceCopy);
|
||||
}
|
||||
|
||||
return bmp;
|
||||
}
|
||||
|
||||
// ─── 헬퍼: 전체 화면 범위 ─────────────────────────────────────────────────
|
||||
|
||||
private static System.Drawing.Rectangle GetAllScreenBounds()
|
||||
{
|
||||
var screens = System.Windows.Forms.Screen.AllScreens;
|
||||
int minX = screens.Min(s => s.Bounds.X);
|
||||
int minY = screens.Min(s => s.Bounds.Y);
|
||||
int maxX = screens.Max(s => s.Bounds.Right);
|
||||
int maxY = screens.Max(s => s.Bounds.Bottom);
|
||||
return new System.Drawing.Rectangle(minX, minY, maxX - minX, maxY - minY);
|
||||
}
|
||||
|
||||
// ─── 헬퍼: 스크롤 가능 자식 창 찾기 ──────────────────────────────────────
|
||||
|
||||
private static IntPtr FindScrollableChild(IntPtr hwnd)
|
||||
{
|
||||
// 공통 스크롤 가능 클래스 탐색
|
||||
foreach (var cls in new[] { "Internet Explorer_Server", "Chrome_RenderWidgetHostHWND",
|
||||
"MozillaWindowClass", "RichEdit20W", "RICHEDIT50W",
|
||||
"TextBox", "EDIT" })
|
||||
{
|
||||
var child = FindWindowEx(hwnd, IntPtr.Zero, cls, null);
|
||||
if (child != IntPtr.Zero) return child;
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
// ─── 헬퍼: Page Down 키 전송 ─────────────────────────────────────────────
|
||||
|
||||
private static void SendPageDown(IntPtr hwnd)
|
||||
{
|
||||
SendMessage(hwnd, WM_KEYDOWN, new IntPtr(VK_NEXT), IntPtr.Zero);
|
||||
SendMessage(hwnd, WM_KEYUP, new IntPtr(VK_NEXT), IntPtr.Zero);
|
||||
}
|
||||
|
||||
// ─── 헬퍼: 두 비트맵이 유사한지 비교 (스크롤 종료 감지) ─────────────────
|
||||
// LockBits를 사용하여 GetPixel 대비 ~50× 빠르게 처리.
|
||||
|
||||
private static bool AreSimilar(Bitmap a, Bitmap b)
|
||||
{
|
||||
if (a.Width != b.Width || a.Height != b.Height) return false;
|
||||
|
||||
int startY = (int)(a.Height * 0.8);
|
||||
int w = a.Width;
|
||||
int h = a.Height;
|
||||
|
||||
var rectA = new System.Drawing.Rectangle(0, startY, w, h - startY);
|
||||
var rectB = new System.Drawing.Rectangle(0, startY, w, h - startY);
|
||||
|
||||
var dataA = a.LockBits(rectA, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
var dataB = b.LockBits(rectB, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
|
||||
try
|
||||
{
|
||||
int sameCount = 0;
|
||||
int totalSamples = 0;
|
||||
int stride = dataA.Stride;
|
||||
int sampleW = w / 16 + 1;
|
||||
int sampleH = (h - startY) / 8 + 1;
|
||||
|
||||
unsafe
|
||||
{
|
||||
byte* ptrA = (byte*)dataA.Scan0.ToPointer();
|
||||
byte* ptrB = (byte*)dataB.Scan0.ToPointer();
|
||||
|
||||
for (int sy = 0; sy < sampleH; sy++)
|
||||
{
|
||||
int row = sy * 8;
|
||||
if (row >= h - startY) break;
|
||||
for (int sx = 0; sx < sampleW; sx++)
|
||||
{
|
||||
int col = sx * 16;
|
||||
if (col >= w) break;
|
||||
int idx = row * stride + col * 4;
|
||||
if (Math.Abs(ptrA[idx] - ptrB[idx]) < 5 &&
|
||||
Math.Abs(ptrA[idx + 1] - ptrB[idx + 1]) < 5 &&
|
||||
Math.Abs(ptrA[idx + 2] - ptrB[idx + 2]) < 5)
|
||||
sameCount++;
|
||||
totalSamples++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return totalSamples > 0 && (double)sameCount / totalSamples > 0.97;
|
||||
}
|
||||
finally
|
||||
{
|
||||
a.UnlockBits(dataA);
|
||||
b.UnlockBits(dataB);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 헬퍼: 프레임 이어 붙이기 (증분만 추가) ──────────────────────────────
|
||||
|
||||
private static Bitmap StitchFrames(List<Bitmap> frames, int windowHeight)
|
||||
{
|
||||
if (frames.Count == 0)
|
||||
return new Bitmap(1, 1);
|
||||
if (frames.Count == 1)
|
||||
return new Bitmap(frames[0]);
|
||||
|
||||
int w = frames[0].Width;
|
||||
|
||||
// 각 프레임에서 새로운 부분의 시작 Y (오버랩 제외)
|
||||
var newPartStarts = new List<int>(); // 인덱스 1부터: frames[i]에서 오버랩 이후 시작 행
|
||||
var newPartHeights = new List<int>(); // 새로운 부분 높이
|
||||
|
||||
int totalHeight = windowHeight; // 첫 프레임은 전체 사용
|
||||
|
||||
for (int i = 1; i < frames.Count; i++)
|
||||
{
|
||||
int overlap = FindOverlap(frames[i - 1], frames[i]);
|
||||
int newStart = overlap > 0 ? overlap : windowHeight / 5; // 오버랩 감지 실패 시 상단 20% 제거
|
||||
int newH = windowHeight - newStart;
|
||||
if (newH <= 0) { newH = windowHeight / 4; newStart = windowHeight - newH; }
|
||||
newPartStarts.Add(newStart);
|
||||
newPartHeights.Add(newH);
|
||||
totalHeight += newH;
|
||||
}
|
||||
|
||||
var result = new Bitmap(w, totalHeight, PixelFormat.Format32bppArgb);
|
||||
using var g = Graphics.FromImage(result);
|
||||
|
||||
// 첫 프레임: 전체 그리기
|
||||
g.DrawImage(frames[0], 0, 0, w, windowHeight);
|
||||
|
||||
// 이후 프레임: 새로운 부분(증분)만 잘라서 붙이기
|
||||
int yPos = windowHeight;
|
||||
for (int i = 1; i < frames.Count; i++)
|
||||
{
|
||||
int srcY = newPartStarts[i - 1];
|
||||
int srcH = newPartHeights[i - 1];
|
||||
var srcRect = new System.Drawing.Rectangle(0, srcY, w, srcH);
|
||||
var dstRect = new System.Drawing.Rectangle(0, yPos, w, srcH);
|
||||
g.DrawImage(frames[i], dstRect, srcRect, System.Drawing.GraphicsUnit.Pixel);
|
||||
yPos += srcH;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── 헬퍼: 두 프레임 사이 오버랩 픽셀 수 계산 (다중 행 비교) ────────────
|
||||
|
||||
private static int FindOverlap(Bitmap prev, Bitmap next)
|
||||
{
|
||||
int w = Math.Min(prev.Width, next.Width);
|
||||
int h = prev.Height;
|
||||
if (h < 16 || w < 16) return 0;
|
||||
|
||||
int searchRange = (int)(h * 0.7); // 최대 70% 오버랩 탐색
|
||||
const int checkRows = 8; // 오버랩 후보당 검증할 행 수
|
||||
|
||||
var dataPrev = prev.LockBits(
|
||||
new System.Drawing.Rectangle(0, 0, prev.Width, prev.Height),
|
||||
ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
var dataNext = next.LockBits(
|
||||
new System.Drawing.Rectangle(0, 0, next.Width, next.Height),
|
||||
ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
|
||||
try
|
||||
{
|
||||
int stridePrev = dataPrev.Stride;
|
||||
int strideNext = dataNext.Stride;
|
||||
int bestOverlap = 0;
|
||||
|
||||
unsafe
|
||||
{
|
||||
byte* ptrPrev = (byte*)dataPrev.Scan0.ToPointer();
|
||||
byte* ptrNext = (byte*)dataNext.Scan0.ToPointer();
|
||||
|
||||
// 큰 오버랩부터 탐색 (스크롤은 보통 1페이지 미만)
|
||||
for (int overlap = searchRange; overlap > 8; overlap -= 2)
|
||||
{
|
||||
int prevStartY = h - overlap;
|
||||
if (prevStartY < 0) continue;
|
||||
|
||||
int totalMatch = 0;
|
||||
int totalCheck = 0;
|
||||
|
||||
// 오버랩 영역 내 여러 행을 검증
|
||||
for (int r = 0; r < checkRows; r++)
|
||||
{
|
||||
int rowInOverlap = r * (overlap / checkRows);
|
||||
int prevRow = prevStartY + rowInOverlap;
|
||||
int nextRow = rowInOverlap;
|
||||
if (prevRow >= h || nextRow >= next.Height) continue;
|
||||
|
||||
// 행 내 샘플 픽셀 비교
|
||||
for (int x = 4; x < w - 4; x += 12)
|
||||
{
|
||||
int idxP = prevRow * stridePrev + x * 4;
|
||||
int idxN = nextRow * strideNext + x * 4;
|
||||
if (idxP + 2 >= dataPrev.Height * stridePrev) continue;
|
||||
if (idxN + 2 >= dataNext.Height * strideNext) continue;
|
||||
|
||||
if (Math.Abs(ptrPrev[idxP] - ptrNext[idxN]) < 10 &&
|
||||
Math.Abs(ptrPrev[idxP + 1] - ptrNext[idxN + 1]) < 10 &&
|
||||
Math.Abs(ptrPrev[idxP + 2] - ptrNext[idxN + 2]) < 10)
|
||||
totalMatch++;
|
||||
totalCheck++;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCheck > 0 && (double)totalMatch / totalCheck > 0.80)
|
||||
{
|
||||
bestOverlap = overlap;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestOverlap;
|
||||
}
|
||||
finally
|
||||
{
|
||||
prev.UnlockBits(dataPrev);
|
||||
next.UnlockBits(dataNext);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 영역 선택 캡처 ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task CaptureRegionAsync(string timestamp, CancellationToken ct)
|
||||
{
|
||||
// 전체 화면을 먼저 캡처 (배경으로 사용)
|
||||
var bounds = GetAllScreenBounds();
|
||||
using var fullBmp = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format32bppArgb);
|
||||
using (var g = Graphics.FromImage(fullBmp))
|
||||
g.CopyFromScreen(bounds.X, bounds.Y, 0, 0, bounds.Size, CopyPixelOperation.SourceCopy);
|
||||
|
||||
// WPF 오버레이 창으로 영역 선택
|
||||
System.Drawing.Rectangle? selected = null;
|
||||
System.Windows.Application.Current?.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var overlay = new AxCopilot.Views.RegionSelectWindow(fullBmp, bounds);
|
||||
overlay.ShowDialog();
|
||||
selected = overlay.SelectedRect;
|
||||
});
|
||||
|
||||
if (selected == null || selected.Value.Width < 4 || selected.Value.Height < 4)
|
||||
{
|
||||
NotificationService.Notify("AX Copilot", "영역 선택이 취소되었습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
var r = selected.Value;
|
||||
using var crop = new Bitmap(r.Width, r.Height, PixelFormat.Format32bppArgb);
|
||||
using (var g = Graphics.FromImage(crop))
|
||||
g.DrawImage(fullBmp, new System.Drawing.Rectangle(0, 0, r.Width, r.Height), r, System.Drawing.GraphicsUnit.Pixel);
|
||||
|
||||
CopyToClipboard(crop);
|
||||
NotificationService.Notify("영역 캡처 완료", $"{r.Width}×{r.Height} · 클립보드에 복사되었습니다");
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ─── 헬퍼: 클립보드에 이미지 복사 ───────────────────────────────────────
|
||||
|
||||
private static void CopyToClipboard(Bitmap bmp)
|
||||
{
|
||||
try
|
||||
{
|
||||
// WPF Clipboard에 BitmapSource로 복사
|
||||
System.Windows.Application.Current?.Dispatcher.Invoke(() =>
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
bmp.Save(ms, ImageFormat.Bmp);
|
||||
ms.Position = 0;
|
||||
var bitmapImage = new BitmapImage();
|
||||
bitmapImage.BeginInit();
|
||||
bitmapImage.StreamSource = ms;
|
||||
bitmapImage.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bitmapImage.EndInit();
|
||||
bitmapImage.Freeze();
|
||||
System.Windows.Clipboard.SetImage(bitmapImage);
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"클립보드 이미지 복사 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 헬퍼: 런처 창 핸들 조회 (캡처 시 런처 숨김 대기용) ───────────────
|
||||
|
||||
private static IntPtr GetLauncherHwnd()
|
||||
{
|
||||
try
|
||||
{
|
||||
IntPtr hwnd = IntPtr.Zero;
|
||||
System.Windows.Application.Current?.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var launcher = System.Windows.Application.Current.Windows
|
||||
.OfType<System.Windows.Window>()
|
||||
.FirstOrDefault(w => w.GetType().Name == "LauncherWindow");
|
||||
if (launcher != null)
|
||||
hwnd = new System.Windows.Interop.WindowInteropHelper(launcher).Handle;
|
||||
});
|
||||
return hwnd;
|
||||
}
|
||||
catch (Exception) { return IntPtr.Zero; }
|
||||
}
|
||||
}
|
||||
|
||||
168
src/AxCopilot/Handlers/SystemInfoHandler.Helpers.cs
Normal file
168
src/AxCopilot/Handlers/SystemInfoHandler.Helpers.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
public partial class SystemInfoHandler
|
||||
{
|
||||
// ─── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static LauncherItem MakeItem(string title, string subtitle, string? copyValue, string symbol) =>
|
||||
new(title, subtitle, null,
|
||||
copyValue != null ? new InfoAction("copy", copyValue) : null,
|
||||
Symbol: symbol);
|
||||
|
||||
private static string? GetLocalIpAddress()
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var iface in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (iface.OperationalStatus != OperationalStatus.Up) continue;
|
||||
if (iface.NetworkInterfaceType is NetworkInterfaceType.Loopback
|
||||
or NetworkInterfaceType.Tunnel) continue;
|
||||
|
||||
foreach (var addr in iface.GetIPProperties().UnicastAddresses)
|
||||
{
|
||||
if (addr.Address.AddressFamily == AddressFamily.InterNetwork)
|
||||
return addr.Address.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception) { /* 무시 */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetDefaultGateway()
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var iface in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (iface.OperationalStatus != OperationalStatus.Up) continue;
|
||||
var gw = iface.GetIPProperties().GatewayAddresses
|
||||
.FirstOrDefault(g => g.Address.AddressFamily == AddressFamily.InterNetwork);
|
||||
if (gw != null) return gw.Address.ToString();
|
||||
}
|
||||
}
|
||||
catch (Exception) { /* 무시 */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static LauncherItem? GetBatteryItem()
|
||||
{
|
||||
try
|
||||
{
|
||||
var status = System.Windows.Forms.SystemInformation.PowerStatus;
|
||||
var chargeLevel = status.BatteryLifePercent;
|
||||
|
||||
if (chargeLevel < 0) // 배터리 없는 데스크톱
|
||||
return new LauncherItem("배터리: 해당 없음", "데스크톱 PC 또는 항상 연결됨 · Enter로 전원 설정 열기", null,
|
||||
new InfoAction("ms_settings", "ms-settings:powersleep"), Symbol: Symbols.Battery);
|
||||
|
||||
var pct = (int)(chargeLevel * 100);
|
||||
var charging = status.PowerLineStatus == System.Windows.Forms.PowerLineStatus.Online;
|
||||
var timeLeft = status.BatteryLifeRemaining >= 0
|
||||
? $" · 잔여: {FormatUptime(TimeSpan.FromSeconds(status.BatteryLifeRemaining))}"
|
||||
: "";
|
||||
|
||||
var symbol = charging ? Symbols.BatteryCharging
|
||||
: pct > 50 ? Symbols.Battery
|
||||
: Symbols.BatteryLow;
|
||||
|
||||
return new LauncherItem(
|
||||
$"배터리: {pct}%{(charging ? " ⚡ 충전 중" : "")}",
|
||||
$"전원: {(charging ? "AC 연결됨" : "배터리 사용 중")}{timeLeft} · Enter로 전원 설정 열기",
|
||||
null,
|
||||
new InfoAction("ms_settings", "ms-settings:powersleep"),
|
||||
Symbol: symbol);
|
||||
}
|
||||
catch (Exception) { return null; }
|
||||
}
|
||||
|
||||
private static LauncherItem? GetVolumeItem()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (waveOutGetVolume(IntPtr.Zero, out uint vol) == 0)
|
||||
{
|
||||
// 왼쪽 채널 0~0xFFFF → 0~100 변환
|
||||
var left = (int)((vol & 0xFFFF) / 655.35);
|
||||
var right = (int)(((vol >> 16) & 0xFFFF) / 655.35);
|
||||
var avg = (left + right) / 2;
|
||||
|
||||
return new LauncherItem(
|
||||
$"볼륨: {avg}%",
|
||||
$"L: {left}% · R: {right}% · Enter로 사운드 설정 열기",
|
||||
null,
|
||||
new InfoAction("ms_settings", "ms-settings:sound"),
|
||||
Symbol: avg == 0 ? Symbols.VolumeMute : Symbols.VolumeUp);
|
||||
}
|
||||
}
|
||||
catch (Exception) { /* 무시 */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetOsVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
// registry에서 실제 Windows 버전 읽기
|
||||
using var key = Microsoft.Win32.Registry.LocalMachine
|
||||
.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion");
|
||||
if (key != null)
|
||||
{
|
||||
var name = key.GetValue("ProductName") as string ?? "Windows";
|
||||
var build = key.GetValue("CurrentBuildNumber") as string ?? "";
|
||||
var ubr = key.GetValue("UBR");
|
||||
return ubr != null ? $"{name} (빌드 {build}.{ubr})" : $"{name} (빌드 {build})";
|
||||
}
|
||||
}
|
||||
catch (Exception) { /* 무시 */ }
|
||||
return Environment.OSVersion.ToString();
|
||||
}
|
||||
|
||||
private static string GetProcessorName()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Microsoft.Win32.Registry.LocalMachine
|
||||
.OpenSubKey(@"HARDWARE\DESCRIPTION\System\CentralProcessor\0");
|
||||
return key?.GetValue("ProcessorNameString") as string ?? "";
|
||||
}
|
||||
catch (Exception) { return ""; }
|
||||
}
|
||||
|
||||
private static string FormatUptime(TimeSpan t)
|
||||
{
|
||||
if (t.TotalDays >= 1)
|
||||
return $"{(int)t.TotalDays}일 {t.Hours}시간 {t.Minutes}분";
|
||||
if (t.TotalHours >= 1)
|
||||
return $"{t.Hours}시간 {t.Minutes}분";
|
||||
return $"{t.Minutes}분 {t.Seconds}초";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// * 단축키로 시스템 정보를 빠르게 조회합니다. SystemInfoHandler에 완전히 위임합니다.
|
||||
/// </summary>
|
||||
public class StarInfoHandler : IActionHandler
|
||||
{
|
||||
private readonly SystemInfoHandler _inner = new();
|
||||
|
||||
public string? Prefix => "*";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"StarInfo",
|
||||
"시스템 정보 빠른 조회 — * 단축키 (info와 동일)",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
=> _inner.GetItemsAsync(query, ct);
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
=> _inner.ExecuteAsync(item, ct);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ namespace AxCopilot.Handlers;
|
||||
/// info uptime → 시스템 가동 시간
|
||||
/// info volume → 볼륨 수준
|
||||
/// </summary>
|
||||
public class SystemInfoHandler : IActionHandler
|
||||
public partial class SystemInfoHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "info";
|
||||
|
||||
@@ -348,162 +348,4 @@ public class SystemInfoHandler : IActionHandler
|
||||
// "shell" → 셸 명령 실행 (Payload = 명령)
|
||||
// "ms_settings" → ms-settings: URI 열기 (Payload = URI)
|
||||
|
||||
// ─── 헬퍼 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private static LauncherItem MakeItem(string title, string subtitle, string? copyValue, string symbol) =>
|
||||
new(title, subtitle, null,
|
||||
copyValue != null ? new InfoAction("copy", copyValue) : null,
|
||||
Symbol: symbol);
|
||||
|
||||
private static string? GetLocalIpAddress()
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var iface in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (iface.OperationalStatus != OperationalStatus.Up) continue;
|
||||
if (iface.NetworkInterfaceType is NetworkInterfaceType.Loopback
|
||||
or NetworkInterfaceType.Tunnel) continue;
|
||||
|
||||
foreach (var addr in iface.GetIPProperties().UnicastAddresses)
|
||||
{
|
||||
if (addr.Address.AddressFamily == AddressFamily.InterNetwork)
|
||||
return addr.Address.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception) { /* 무시 */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetDefaultGateway()
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var iface in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (iface.OperationalStatus != OperationalStatus.Up) continue;
|
||||
var gw = iface.GetIPProperties().GatewayAddresses
|
||||
.FirstOrDefault(g => g.Address.AddressFamily == AddressFamily.InterNetwork);
|
||||
if (gw != null) return gw.Address.ToString();
|
||||
}
|
||||
}
|
||||
catch (Exception) { /* 무시 */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static LauncherItem? GetBatteryItem()
|
||||
{
|
||||
try
|
||||
{
|
||||
var status = System.Windows.Forms.SystemInformation.PowerStatus;
|
||||
var chargeLevel = status.BatteryLifePercent;
|
||||
|
||||
if (chargeLevel < 0) // 배터리 없는 데스크톱
|
||||
return new LauncherItem("배터리: 해당 없음", "데스크톱 PC 또는 항상 연결됨 · Enter로 전원 설정 열기", null,
|
||||
new InfoAction("ms_settings", "ms-settings:powersleep"), Symbol: Symbols.Battery);
|
||||
|
||||
var pct = (int)(chargeLevel * 100);
|
||||
var charging = status.PowerLineStatus == System.Windows.Forms.PowerLineStatus.Online;
|
||||
var timeLeft = status.BatteryLifeRemaining >= 0
|
||||
? $" · 잔여: {FormatUptime(TimeSpan.FromSeconds(status.BatteryLifeRemaining))}"
|
||||
: "";
|
||||
|
||||
var symbol = charging ? Symbols.BatteryCharging
|
||||
: pct > 50 ? Symbols.Battery
|
||||
: Symbols.BatteryLow;
|
||||
|
||||
return new LauncherItem(
|
||||
$"배터리: {pct}%{(charging ? " ⚡ 충전 중" : "")}",
|
||||
$"전원: {(charging ? "AC 연결됨" : "배터리 사용 중")}{timeLeft} · Enter로 전원 설정 열기",
|
||||
null,
|
||||
new InfoAction("ms_settings", "ms-settings:powersleep"),
|
||||
Symbol: symbol);
|
||||
}
|
||||
catch (Exception) { return null; }
|
||||
}
|
||||
|
||||
private static LauncherItem? GetVolumeItem()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (waveOutGetVolume(IntPtr.Zero, out uint vol) == 0)
|
||||
{
|
||||
// 왼쪽 채널 0~0xFFFF → 0~100 변환
|
||||
var left = (int)((vol & 0xFFFF) / 655.35);
|
||||
var right = (int)(((vol >> 16) & 0xFFFF) / 655.35);
|
||||
var avg = (left + right) / 2;
|
||||
|
||||
return new LauncherItem(
|
||||
$"볼륨: {avg}%",
|
||||
$"L: {left}% · R: {right}% · Enter로 사운드 설정 열기",
|
||||
null,
|
||||
new InfoAction("ms_settings", "ms-settings:sound"),
|
||||
Symbol: avg == 0 ? Symbols.VolumeMute : Symbols.VolumeUp);
|
||||
}
|
||||
}
|
||||
catch (Exception) { /* 무시 */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetOsVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
// registry에서 실제 Windows 버전 읽기
|
||||
using var key = Microsoft.Win32.Registry.LocalMachine
|
||||
.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion");
|
||||
if (key != null)
|
||||
{
|
||||
var name = key.GetValue("ProductName") as string ?? "Windows";
|
||||
var build = key.GetValue("CurrentBuildNumber") as string ?? "";
|
||||
var ubr = key.GetValue("UBR");
|
||||
return ubr != null ? $"{name} (빌드 {build}.{ubr})" : $"{name} (빌드 {build})";
|
||||
}
|
||||
}
|
||||
catch (Exception) { /* 무시 */ }
|
||||
return Environment.OSVersion.ToString();
|
||||
}
|
||||
|
||||
private static string GetProcessorName()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Microsoft.Win32.Registry.LocalMachine
|
||||
.OpenSubKey(@"HARDWARE\DESCRIPTION\System\CentralProcessor\0");
|
||||
return key?.GetValue("ProcessorNameString") as string ?? "";
|
||||
}
|
||||
catch (Exception) { return ""; }
|
||||
}
|
||||
|
||||
private static string FormatUptime(TimeSpan t)
|
||||
{
|
||||
if (t.TotalDays >= 1)
|
||||
return $"{(int)t.TotalDays}일 {t.Hours}시간 {t.Minutes}분";
|
||||
if (t.TotalHours >= 1)
|
||||
return $"{t.Hours}시간 {t.Minutes}분";
|
||||
return $"{t.Minutes}분 {t.Seconds}초";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// * 단축키로 시스템 정보를 빠르게 조회합니다. SystemInfoHandler에 완전히 위임합니다.
|
||||
/// </summary>
|
||||
public class StarInfoHandler : IActionHandler
|
||||
{
|
||||
private readonly SystemInfoHandler _inner = new();
|
||||
|
||||
public string? Prefix => "*";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"StarInfo",
|
||||
"시스템 정보 빠른 조회 — * 단축키 (info와 동일)",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
=> _inner.GetItemsAsync(query, ct);
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
=> _inner.ExecuteAsync(item, ct);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user