484 lines
19 KiB
C#
484 lines
19 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text.Json.Nodes;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using System.Windows;
|
||
using System.Windows.Controls;
|
||
using System.Windows.Input;
|
||
using System.Windows.Media;
|
||
using System.Windows.Media.Imaging;
|
||
using System.Windows.Threading;
|
||
using JJServiceManager.Remote;
|
||
|
||
namespace JJServiceManager
|
||
{
|
||
/// <summary>대상 PC: 원격 화면 연결 승인 창 (30초 안에 답하지 않으면 거부)</summary>
|
||
public sealed class ApprovalWindow : Window
|
||
{
|
||
private readonly TaskCompletionSource<bool> _result = new TaskCompletionSource<bool>();
|
||
private readonly DispatcherTimer _timer;
|
||
private readonly TextBlock _countdown;
|
||
private int _left = 30;
|
||
|
||
private ApprovalWindow(string who, bool control)
|
||
{
|
||
Title = "원격관리 연결 요청" + AppInstance.TitleTag;
|
||
Width = 460;
|
||
SizeToContent = SizeToContent.Height;
|
||
ResizeMode = ResizeMode.NoResize;
|
||
WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||
Topmost = true;
|
||
FontFamily = new FontFamily("Malgun Gothic");
|
||
FontSize = 13;
|
||
Background = Brushes2.Make("#FAFAFA");
|
||
AppIcon.Apply(this);
|
||
|
||
var root = new StackPanel { Margin = new Thickness(20, 18, 20, 16) };
|
||
root.Children.Add(new TextBlock
|
||
{
|
||
Text = control ? "원격 화면 보기 + 키보드·마우스 제어 요청" : "원격 화면 보기 요청",
|
||
FontSize = 15,
|
||
FontWeight = FontWeights.Bold,
|
||
Foreground = Brushes2.Make(control ? "#B71C1C" : "#155E75"),
|
||
Margin = new Thickness(0, 0, 0, 10)
|
||
});
|
||
root.Children.Add(new TextBlock
|
||
{
|
||
Text = $"관리 PC [{who}] 가 이 PC 화면을 {(control ? "보고 조작" : "보")}려고 합니다.\n허용할까요?",
|
||
TextWrapping = TextWrapping.Wrap,
|
||
Margin = new Thickness(0, 0, 0, 10)
|
||
});
|
||
_countdown = new TextBlock { Foreground = Brushes.Gray };
|
||
root.Children.Add(_countdown);
|
||
|
||
var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 12, 0, 0) };
|
||
var allow = new Button { Content = "허용", MinWidth = 90, FontWeight = FontWeights.Bold };
|
||
var deny = new Button { Content = "거부", MinWidth = 90, IsCancel = true };
|
||
allow.Click += (s, e) => Finish(true);
|
||
deny.Click += (s, e) => Finish(false);
|
||
buttons.Children.Add(allow);
|
||
buttons.Children.Add(deny);
|
||
root.Children.Add(buttons);
|
||
Content = root;
|
||
|
||
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||
_timer.Tick += (s, e) =>
|
||
{
|
||
_left--;
|
||
UpdateCountdown();
|
||
if (_left <= 0) Finish(false);
|
||
};
|
||
UpdateCountdown();
|
||
Closed += (s, e) => { _timer.Stop(); _result.TrySetResult(false); };
|
||
Loaded += (s, e) => { _timer.Start(); Activate(); };
|
||
}
|
||
|
||
private void UpdateCountdown() => _countdown.Text = $"{_left}초 뒤 자동으로 거부됩니다.";
|
||
|
||
private void Finish(bool ok)
|
||
{
|
||
_result.TrySetResult(ok);
|
||
Close();
|
||
}
|
||
|
||
/// <summary>UI 스레드에서 호출</summary>
|
||
public static Task<bool> AskAsync(string who, bool control)
|
||
{
|
||
var w = new ApprovalWindow(who, control);
|
||
w.Show();
|
||
return w._result.Task;
|
||
}
|
||
}
|
||
|
||
/// <summary>대상 PC: 원격 화면 연결 중 화면 위쪽 표시줄 (항상 표시)</summary>
|
||
public sealed class RemoteIndicatorWindow : Window
|
||
{
|
||
private readonly TextBlock _text;
|
||
private readonly Border _bar;
|
||
|
||
public event Action DisconnectRequested;
|
||
|
||
public RemoteIndicatorWindow()
|
||
{
|
||
WindowStyle = WindowStyle.None;
|
||
ResizeMode = ResizeMode.NoResize;
|
||
AllowsTransparency = true;
|
||
Background = Brushes.Transparent;
|
||
Topmost = true;
|
||
ShowInTaskbar = false;
|
||
ShowActivated = false;
|
||
SizeToContent = SizeToContent.WidthAndHeight;
|
||
FontFamily = new FontFamily("Malgun Gothic");
|
||
FontSize = 13;
|
||
|
||
_bar = new Border
|
||
{
|
||
CornerRadius = new CornerRadius(0, 0, 8, 8),
|
||
Padding = new Thickness(14, 6, 8, 6),
|
||
Background = Brushes2.Make("#E65100")
|
||
};
|
||
var panel = new StackPanel { Orientation = Orientation.Horizontal };
|
||
_text = new TextBlock { Foreground = Brushes.White, FontWeight = FontWeights.Bold, VerticalAlignment = VerticalAlignment.Center };
|
||
var stop = new Button
|
||
{
|
||
Content = "연결 끊기",
|
||
Margin = new Thickness(12, 0, 0, 0),
|
||
MinWidth = 0,
|
||
MinHeight = 0,
|
||
Padding = new Thickness(8, 2, 8, 2),
|
||
Background = Brushes.White
|
||
};
|
||
stop.Click += (s, e) => DisconnectRequested?.Invoke();
|
||
panel.Children.Add(_text);
|
||
panel.Children.Add(stop);
|
||
_bar.Child = panel;
|
||
Content = _bar;
|
||
|
||
SizeChanged += (s, e) => Reposition();
|
||
}
|
||
|
||
public void Update(IReadOnlyList<ScreenViewer> viewers)
|
||
{
|
||
if (viewers.Count == 0)
|
||
{
|
||
Hide();
|
||
return;
|
||
}
|
||
bool control = viewers.Any(v => v.Control);
|
||
_bar.Background = Brushes2.Make(control ? "#C62828" : "#E65100");
|
||
_text.Text = $"● 원격 {(control ? "제어" : "보기")} 중: {string.Join(", ", viewers.Select(v => v.Who).Distinct())}";
|
||
if (!IsVisible) Show();
|
||
Reposition();
|
||
}
|
||
|
||
private void Reposition()
|
||
{
|
||
var wa = SystemParameters.WorkArea;
|
||
Left = wa.Left + (wa.Width - ActualWidth) / 2;
|
||
Top = wa.Top;
|
||
}
|
||
}
|
||
|
||
/// <summary>관리 PC: 원격 화면 창 (보기 / 제어)</summary>
|
||
public sealed class RemoteViewerWindow : Window
|
||
{
|
||
private readonly RemoteClient _client;
|
||
private readonly string _label;
|
||
private readonly Image _image;
|
||
private readonly TextBlock _status, _overlay;
|
||
private readonly StackPanel _monitorButtons;
|
||
private readonly HashSet<int> _pressedKeys = new HashSet<int>();
|
||
private int _sid;
|
||
private bool _control;
|
||
private bool _ended;
|
||
private int _monitor = 1;
|
||
private int _frames;
|
||
private DateTime _fpsStart = DateTime.UtcNow;
|
||
private int _decoding;
|
||
private DateTime _lastMove = DateTime.MinValue;
|
||
|
||
public string ViewerKey { get; }
|
||
|
||
public RemoteViewerWindow(string key, RemoteClient client, string label)
|
||
{
|
||
ViewerKey = key;
|
||
_client = client;
|
||
_label = label;
|
||
|
||
Title = $"원격관리 - {label}" + AppInstance.TitleTag;
|
||
Width = 1280;
|
||
Height = 800;
|
||
WindowStartupLocation = WindowStartupLocation.CenterScreen;
|
||
FontFamily = new FontFamily("Malgun Gothic");
|
||
FontSize = 13;
|
||
Background = Brushes2.Make("#263238");
|
||
AppIcon.Apply(this);
|
||
// 한글 입력기는 대상 PC에서 처리하도록 이 창에서는 끔
|
||
InputMethod.SetIsInputMethodEnabled(this, false);
|
||
|
||
var root = new DockPanel();
|
||
|
||
// ── 도구 모음
|
||
var bar = new DockPanel { Background = Brushes2.Make("#ECEFF1") };
|
||
DockPanel.SetDock(bar, Dock.Top);
|
||
var close = new Button { Content = "연결 끊기" };
|
||
close.Click += (s, e) => Close();
|
||
DockPanel.SetDock(close, Dock.Right);
|
||
bar.Children.Add(close);
|
||
_status = new TextBlock { VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(12, 0, 12, 0), Foreground = Brushes2.Make("#37474F") };
|
||
DockPanel.SetDock(_status, Dock.Right);
|
||
bar.Children.Add(_status);
|
||
_monitorButtons = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(4) };
|
||
bar.Children.Add(_monitorButtons);
|
||
root.Children.Add(bar);
|
||
|
||
// ── 화면
|
||
var stage = new Grid { Background = Brushes.Black, ClipToBounds = true };
|
||
_image = new Image
|
||
{
|
||
Stretch = Stretch.Uniform,
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
Focusable = true,
|
||
Cursor = Cursors.Arrow
|
||
};
|
||
RenderOptions.SetBitmapScalingMode(_image, BitmapScalingMode.HighQuality);
|
||
_overlay = new TextBlock
|
||
{
|
||
Text = "연결 중…",
|
||
Foreground = Brushes.White,
|
||
FontSize = 16,
|
||
HorizontalAlignment = HorizontalAlignment.Center,
|
||
VerticalAlignment = VerticalAlignment.Center,
|
||
TextAlignment = TextAlignment.Center,
|
||
TextWrapping = TextWrapping.Wrap
|
||
};
|
||
stage.Children.Add(_image);
|
||
stage.Children.Add(_overlay);
|
||
root.Children.Add(stage);
|
||
Content = root;
|
||
|
||
_image.MouseMove += OnMouseMove;
|
||
_image.MouseDown += OnMouseDown;
|
||
_image.MouseUp += OnMouseUp;
|
||
_image.MouseWheel += OnMouseWheel;
|
||
PreviewKeyDown += OnKey;
|
||
PreviewKeyUp += OnKey;
|
||
Deactivated += (s, e) => ReleaseKeys();
|
||
|
||
_client.ScreenEvent += OnScreenEvent;
|
||
Loaded += async (s, e) => await StartAsync();
|
||
Closed += (s, e) => Stop();
|
||
}
|
||
|
||
// ───────────────────────── 시작 / 끝 ─────────────────────────
|
||
|
||
private async Task StartAsync()
|
||
{
|
||
try
|
||
{
|
||
_overlay.Text = "대상 PC 정보를 확인하는 중…";
|
||
var info = await _client.RequestAsync("screenInfo", null, TimeSpan.FromSeconds(15));
|
||
if (!Json.Bool(info, "allowView"))
|
||
{
|
||
End("대상 PC에서 화면 보기를 허용하지 않았습니다.\n(대상 PC 설정 → 원격 접속 허용 → 화면 보기 허용)");
|
||
return;
|
||
}
|
||
|
||
var monitors = (info["monitors"] as JsonArray)?.OfType<JsonObject>().ToList() ?? new List<JsonObject>();
|
||
var primary = monitors.FirstOrDefault(m => Json.Bool(m, "primary"));
|
||
_monitor = primary != null ? Json.Int(primary, "index", 1) : 1;
|
||
foreach (var m in monitors)
|
||
{
|
||
int idx = Json.Int(m, "index", 1);
|
||
AddMonitorButton($"모니터 {idx}" + (Json.Bool(m, "primary") ? " (주)" : ""), idx,
|
||
$"{Json.Int(m, "width")} × {Json.Int(m, "height")}");
|
||
}
|
||
if (monitors.Count > 1) AddMonitorButton("전체", 0, "모든 모니터를 한 화면에");
|
||
HighlightMonitor();
|
||
|
||
_overlay.Text = "대상 PC의 승인을 기다리는 중…";
|
||
var r = await _client.RequestAsync("screenStart", new JsonObject { ["monitor"] = _monitor }, TimeSpan.FromSeconds(45));
|
||
_sid = Json.Int(r, "sid");
|
||
_control = Json.Bool(r, "control");
|
||
_overlay.Text = "";
|
||
UpdateStatus();
|
||
_image.Cursor = _control ? Cursors.Cross : Cursors.Arrow;
|
||
_image.Focus();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
End(RemoteClient.Friendly(ex));
|
||
}
|
||
}
|
||
|
||
private void AddMonitorButton(string text, int index, string tip)
|
||
{
|
||
var b = new Button { Content = text, Tag = index, ToolTip = tip, MinWidth = 70 };
|
||
b.Click += async (s, e) =>
|
||
{
|
||
_monitor = index;
|
||
HighlightMonitor();
|
||
try { await _client.RequestAsync("screenMonitor", new JsonObject { ["monitor"] = index }, TimeSpan.FromSeconds(10)); }
|
||
catch { }
|
||
_image.Focus();
|
||
};
|
||
_monitorButtons.Children.Add(b);
|
||
}
|
||
|
||
private void HighlightMonitor()
|
||
{
|
||
foreach (Button b in _monitorButtons.Children)
|
||
{
|
||
bool on = (int)b.Tag == _monitor;
|
||
b.FontWeight = on ? FontWeights.Bold : FontWeights.Normal;
|
||
b.Background = on ? Brushes2.Make("#BBDEFB") : Brushes.White;
|
||
}
|
||
}
|
||
|
||
private void End(string reason)
|
||
{
|
||
_ended = true;
|
||
_overlay.Text = reason;
|
||
_status.Text = "연결 끝";
|
||
_image.Cursor = Cursors.Arrow;
|
||
}
|
||
|
||
private void Stop()
|
||
{
|
||
_client.ScreenEvent -= OnScreenEvent;
|
||
ReleaseKeys();
|
||
if (_sid > 0 && !_ended)
|
||
_ = _client.RequestAsync("screenStop", null, TimeSpan.FromSeconds(5)).ContinueWith(t => { _ = t.Exception; });
|
||
}
|
||
|
||
private void UpdateStatus()
|
||
{
|
||
var secs = (DateTime.UtcNow - _fpsStart).TotalSeconds;
|
||
double fps = secs > 0 ? _frames / secs : 0;
|
||
if (secs > 3) { _frames = 0; _fpsStart = DateTime.UtcNow; }
|
||
_status.Text = $"{(_control ? "● 제어 중" : "보기 전용")} · {fps:0.0}장/초";
|
||
_status.Foreground = _control ? Brushes2.Stopped : Brushes2.Make("#37474F");
|
||
}
|
||
|
||
// ───────────────────────── 화면 받기 ─────────────────────────
|
||
|
||
private void OnScreenEvent(JsonObject msg)
|
||
{
|
||
var ev = Json.Str(msg, "event");
|
||
int sid = Json.Int(msg, "sid");
|
||
if (ev == "screenEnd")
|
||
{
|
||
if (sid == 0 || sid == _sid)
|
||
{
|
||
var reason = Json.Str(msg, "reason");
|
||
Dispatcher.BeginInvoke(new Action(() => End("원격 화면 연결이 끝났습니다.\n" + reason)));
|
||
}
|
||
return;
|
||
}
|
||
if (ev != "frame" || sid != _sid || _ended) return;
|
||
|
||
// 화면 그리기가 밀리면 이전 장면은 건너뜀
|
||
if (Interlocked.Exchange(ref _decoding, 1) == 1) return;
|
||
try
|
||
{
|
||
var bytes = Convert.FromBase64String(Json.Str(msg, "data"));
|
||
var bmp = new BitmapImage();
|
||
bmp.BeginInit();
|
||
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
||
bmp.StreamSource = new MemoryStream(bytes);
|
||
bmp.EndInit();
|
||
bmp.Freeze();
|
||
Dispatcher.BeginInvoke(new Action(() =>
|
||
{
|
||
_image.Source = bmp;
|
||
_frames++;
|
||
UpdateStatus();
|
||
Interlocked.Exchange(ref _decoding, 0);
|
||
}), DispatcherPriority.Render);
|
||
}
|
||
catch
|
||
{
|
||
Interlocked.Exchange(ref _decoding, 0);
|
||
}
|
||
}
|
||
|
||
// ───────────────────────── 입력 보내기 ─────────────────────────
|
||
|
||
private bool CanControl => _control && !_ended && _sid > 0;
|
||
|
||
private (double x, double y) Norm(MouseEventArgs e)
|
||
{
|
||
var p = e.GetPosition(_image);
|
||
double w = Math.Max(1, _image.ActualWidth), h = Math.Max(1, _image.ActualHeight);
|
||
return (Math.Clamp(p.X / w, 0, 1), Math.Clamp(p.Y / h, 0, 1));
|
||
}
|
||
|
||
private void SendMouse(MouseEventArgs e, string action, string button = "", int delta = 0)
|
||
{
|
||
var (x, y) = Norm(e);
|
||
_client.SendNoReply(new JsonObject
|
||
{
|
||
["op"] = "input",
|
||
["kind"] = "mouse",
|
||
["action"] = action,
|
||
["button"] = button,
|
||
["x"] = Math.Round(x, 5),
|
||
["y"] = Math.Round(y, 5),
|
||
["delta"] = delta
|
||
});
|
||
}
|
||
|
||
private static string ButtonName(MouseButton b) => b switch
|
||
{
|
||
MouseButton.Right => "right",
|
||
MouseButton.Middle => "middle",
|
||
_ => "left"
|
||
};
|
||
|
||
private void OnMouseMove(object sender, MouseEventArgs e)
|
||
{
|
||
if (!CanControl) return;
|
||
// 너무 자주 보내지 않도록 (약 30회/초)
|
||
if ((DateTime.UtcNow - _lastMove).TotalMilliseconds < 33) return;
|
||
_lastMove = DateTime.UtcNow;
|
||
SendMouse(e, "move");
|
||
}
|
||
|
||
private void OnMouseDown(object sender, MouseButtonEventArgs e)
|
||
{
|
||
_image.Focus();
|
||
if (!CanControl) return;
|
||
_image.CaptureMouse();
|
||
SendMouse(e, "down", ButtonName(e.ChangedButton));
|
||
e.Handled = true;
|
||
}
|
||
|
||
private void OnMouseUp(object sender, MouseButtonEventArgs e)
|
||
{
|
||
if (!CanControl) return;
|
||
SendMouse(e, "up", ButtonName(e.ChangedButton));
|
||
if (Mouse.LeftButton == MouseButtonState.Released && Mouse.RightButton == MouseButtonState.Released && Mouse.MiddleButton == MouseButtonState.Released)
|
||
_image.ReleaseMouseCapture();
|
||
e.Handled = true;
|
||
}
|
||
|
||
private void OnMouseWheel(object sender, MouseWheelEventArgs e)
|
||
{
|
||
if (!CanControl) return;
|
||
SendMouse(e, "wheel", "", e.Delta);
|
||
e.Handled = true;
|
||
}
|
||
|
||
private void OnKey(object sender, KeyEventArgs e)
|
||
{
|
||
if (!CanControl) return;
|
||
var key = e.Key switch
|
||
{
|
||
Key.System => e.SystemKey,
|
||
Key.ImeProcessed => e.ImeProcessedKey,
|
||
Key.DeadCharProcessed => e.DeadCharProcessedKey,
|
||
_ => e.Key
|
||
};
|
||
int vk = KeyInterop.VirtualKeyFromKey(key);
|
||
if (vk <= 0) return;
|
||
bool down = e.RoutedEvent == Keyboard.PreviewKeyDownEvent;
|
||
if (down) _pressedKeys.Add(vk); else _pressedKeys.Remove(vk);
|
||
_client.SendNoReply(new JsonObject { ["op"] = "input", ["kind"] = "key", ["vk"] = vk, ["down"] = down });
|
||
e.Handled = true;
|
||
}
|
||
|
||
/// <summary>창을 벗어나면 눌린 키가 대상 PC에 남지 않도록 모두 뗌</summary>
|
||
private void ReleaseKeys()
|
||
{
|
||
if (_pressedKeys.Count == 0) return;
|
||
foreach (var vk in _pressedKeys.ToList())
|
||
_client.SendNoReply(new JsonObject { ["op"] = "input", ["kind"] = "key", ["vk"] = vk, ["down"] = false });
|
||
_pressedKeys.Clear();
|
||
}
|
||
}
|
||
}
|