Pull to refresh

Делаем сами remote-desktop клиент для смартфона. Часть 1: серверная

Reading time7 min
Views13K
Я всегда хотел себе портативный удалённый рабочий стол на телефоне, чтобы, например, когда кто-то стучится в аську, а я на балкон покурить вышел, можно было не уходя с балкона посмотреть на телефоне, кто там. Ну или, например, переключить трек, принимая ванну. Да, я знаю, что всевозможные VNC-клиенты уже написаны, но я решил сделать такую программу сам.

В первой части статьи я ограничусь только созданием несложного remote desktop приложения, в котором и сервер, и клиент будут работать на обычных настольных компьютерах. Во второй и третьей части я рассмотрю сжатие изображения и программирование собственно телефона.

Конечную функциональность я представляю себе так: на дектопе (сервер) резидентно висит программка, которая, по пришедшему снаружи UDP-пакету начинает передавать фрагменты изображения на обратный адрес. На телефоне (клиент) отображаются присланные фрагменты. Пользователь может сдвинуть оконо отображения или кликнуть внутри него. Информация о сдвигах и кликах передаётся на сервер так же – по UDP.

Заранее прошу извинить меня за то, что я пишу на С#, так, как-будто бы это Javascript – во первых, в статье я хочу обойтись короткими листингами, во вторых, программа на самом деле несложная и разводить сложные структуры данных тут совсем ни к чему, ну и, в третьих, C# уже не является чисто-ООП языком.

Ввиду небольшой сложности программы и я выбрал самый простой известный мне процесс разработки «на коленке» – последовательные простые усложнения.

Начнём с самой простой программы, которая просто покажет нам фрагмент нашего же рабочего стола, в движении:
using System;
using System.Collections.Generic;
using System.Windows.Forms;
using System.Drawing;

namespace rd2
{
    class Program
    {
        static void Main(string[] args)
        {
            Form f = new Form();

            var timer = new System.Windows.Forms.Timer() { Interval = 40 };
            timer.Tick += (s, e) =>
            {
                Graphics g = f.CreateGraphics();
                g.CopyFromScreen(0, 0, 0, 0, f.Size);
                g.Dispose();
            };
            timer.Start();

            Application.Run(f);
        }
    }
}

Тут всё просто: создаём окно, создаём таймер, по событиям от которого будет захватываться изображение и копироваться в окно, запускаем. Убеждаемся, что всё работает.

всё работает

Теперь добавим перетаскивание десктопа внутри окна. Сразу после создания формы вставим:
Point window_topleft = new Point();
Size mouse_prev_loc = new Size();
bool mouse_lbdown = false;
f.MouseDown += (s,e) => { mouse_lbdown = true; };
f.MouseUp += (s, e) => { mouse_lbdown = false; };
f.MouseMove += (s, e) => 
{
    if (mouse_lbdown) window_topleft += mouse_prev_loc - (Size)(e.Location);
    mouse_prev_loc = (Size)e.Location;
};

Переменная window_topleft – координата верхнего левого угла той области, которая отображается в окно. Исправим CopyFromScreen:
g.CopyFromScreen(window_topleft.X, window_topleft.Y, 0, 0, f.Size);

Отлично! Перетаскивается.

Теперь добавим обработку щелчков левой кнопки мыши, так, чтобы щелчок внутри окна транслировался в щелчок на то, что в этом окне отображается. Для того чтобы отличать перетаскавание от щелчка, я буду запоминать координаты, в которой кнопка мыши была нажаты, и, если по отжатию мышь не ушла слишком далеко, буду генерировать щелчок мыши вместо перетаскивания. Вот так:
Point mouse_down_loc = new Point();
f.MouseDown += (s, e) => { mouse_lbdown = true; mouse_down_loc = e.Location; };
f.MouseUp += (s, e) => { 
    mouse_lbdown = false; 
    if( Math.Abs(e.Location.X - mouse_down_loc.X) <1 
    	&& Math.Abs(e.Location.Y - mouse_down_loc.Y) <1)
    {
        int click_to_x = (window_topleft.X + mouse_down_loc.X) 
        			* 65536 / Screen.PrimaryScreen.Bounds.Width;
        int click_to_y = (window_topleft.Y + mouse_down_loc.Y) 
        			* 65536 / Screen.PrimaryScreen.Bounds.Height;

        mouse_event((uint)(MOUSEEVENTF_LEFTDOWN | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE), 
            (uint)click_to_x, (uint)click_to_y, 0, 0);
        mouse_event((uint)(MOUSEEVENTF_LEFTUP | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE), 
            (uint)click_to_x, (uint)click_to_y, 0, 0);
    }
};

Щелчок мыши получается из двух последовательных вызовов функции WinAPI mouse_event. Первый вызов – нажатие кнопки (MOUSEEVENTF_LEFTDOWN), второй – отжатие (MOUSEEVENTF_LEFTUP). Вместе c нажатием кнопки мы передаём перемещение (MOUSEEVENTF_MOVE) мышки в нужные координаты, которые указываются абсолютным значением (MOUSEEVENTF_ABSOLUTE). Ноль абсолютных мышиных координат расположен в верхнем левом углу первичного экрана (PrimaryScreen). Точка (65535, 65535) расположена в нижнем правом углу того же экрана. Все остальные экраны, если они есть в системе, прилегают к этому квадрату.

Ну и, конешно, нужно экспортировать себе mouse_event. Это объявление располагается в объявлении класса:
[DllImport("user32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.StdCall)]
public static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint cButtons, uint dwExtraInfo);
private const int MOUSEEVENTF_MOVE = 0x01;
private const int MOUSEEVENTF_LEFTDOWN = 0x02;
private const int MOUSEEVENTF_LEFTUP = 0x04;
private const int MOUSEEVENTF_ABSOLUTE = 0x8000;

О чём следует подумать, прежде чем двигаться дальше


UDP теряет, задерживает и переупорядочивает пакеты. В решаемой задаче переотправка пакета бессмыслена: к тому моменту, как мы определим что пакет потерялся, уже подойдёт время снова обновлять экран. Именно поэтому я выбрал UDP, а не TCP. Бороться с потерями невозможно, но к ним нужно приспособиться: протокол не должен иметь долгоживущего состяния, а потери пакетов не должны быть фатальными или портить картинку надолго.

Передача фрагмента экрана размером с экран смартфона даже с частотой 10 герц это 3*800*480*10 = 11520000 байт в секуду. Это почти 100 мегабит. Без сжатия не обойтись.
Не нужно перепосылать части экрана, которые не изменились –таких может быть довольно много. Но нельзя полностью отказаться от перепосылки неизменившихся частей – у нас ненадёжный канал, и, на самом деле, мы не знаем, что отображается на клиенте.

Размер окна может изменяться. Например из-за поворота телефона из портретного в ландшафтный режим.
Однако, учесть сразу все эти замечания невозможно – от размышлений встанет работа. Поэтому, для начала просто проигнорируем всё, что можно, ради простоты.

Разделяем надвое


А теперь начнём разделять имеющуюся программу на две – сервер и клиент. Пусть они пока остаются в пределах одного процесса, но пусть они работают в разных тредах и не зависят друг от друга по данным.

На этом этапе уже можно было бы заставить программу посылать датаграммы самой себе, но это было бы слишком большим шагом. Для начала, качестве канала взаимодействия между этими двуми процессами я выбрал ConcurrentQueue – это thread-safe очередь, предназначенная для реализации взаимодействия по схеме Производитель-Потребитель (Producer-Consumer). По прямому каналу сервер будет поставлять фрагменты изображения клиенту, а клиент, по обратному каналу, будет поставлять информацию о сдвигах окна обозрения и о щелчках мыши.

ConcurrentQueue по многим свойствам родственна UDP, и, когда мы отладим взаимодействие через ConcurrentQueue, я надеюсь просто заменить работу с очередью на отправку и приём датаграмм. Для того, чтобы такая замена была проста, нужно, привести программу к тому, чтобы в очередях передавались последовательности байт небольшой длинны.
Но сначала я буду использовать типизированные очереди.
Разделяем

Сначала определим структуры данных, которые будут использоваться для отправки сообщений от сервера к клиенту и обратно:
struct ImageChunk
{
    public Rectangle place;
    public Bitmap img;
};

struct ControlData
{
    public enum Action : byte { Shift, Click };
    public Action action;
    public Point point;
}

Итак, сервер передаёт клиенту изображение, с указанием, где оно располагалось на его, сервера, мониторе. Обратно – сдвиги (Shift) и щелчки мыши (Click); условимся везде использовать серверные координаты.

Теперь скопируем имеющуюся функцию Main ещё раз, переименуем обе копии в Server и Client, и напишем новый Main:
static void Main(string[] args)
{
    var img_channel = new BlockingCollection<ImageChunk>( 
                new ConcurrentQueue<ImageChunk>() );
    var control_channel = new BlockingCollection<ControlData>(
                new ConcurrentQueue<ControlData>());

    Server(control_channel, img_channel);
    Client(img_channel, control_channel);
}

Похоже на текстовое изложение простой блок-схемы, правда?

Удалим из Server всё то, что не относится к захвату изображения с экрана и добавим работу с очередьми. Также, я решил пока фиксировать размер окна на размере 400x300 на клиенте и на сервере, чтобы листинг не вырос ещё на пару абзацев.
static void Server(BlockingCollection<ControlData> input, 
                    BlockingCollection<ImageChunk> output)
{

    Point window_topleft = new Point();
    Size window_size = new Size(400, 300);

    var timer = new System.Windows.Forms.Timer() { Interval = 40 };
    timer.Tick += (s, e) =>
    {
        // перед отправкой изображения разгребём входящую очередь 
        ControlData incoming;
        while (input.TryTake(out incoming))
        {
            switch (incoming.action)
            {
                case ControlData.Action.Click:
                    mouse_event((uint)(MOUSEEVENTF_LEFTDOWN | MOUSEEVENTF_ABSOLUTE 
                        | MOUSEEVENTF_MOVE),
                        (uint)incoming.point.X, (uint)incoming.point.Y, 0, 0);
                    mouse_event((uint)(MOUSEEVENTF_LEFTUP | MOUSEEVENTF_ABSOLUTE 
                        | MOUSEEVENTF_MOVE),
                        (uint)incoming.point.X, (uint)incoming.point.Y, 0, 0);
                    break;
                case ControlData.Action.Shift:
                    window_topleft = incoming.point;
                    break;
            }
        }
   
        // захватим с экрана изображение и отправим его
        var b = new Bitmap(window_size.Width,window_size.Height);
        var g = Graphics.FromImage(b);
        g.CopyFromScreen(window_topleft.X, window_topleft.Y, 0, 0, window_size);
        g.Dispose();
        output.Add(new ImageChunk() { img = b, 
                place = new Rectangle(window_topleft, window_size) } );
    };

    timer.Start();
}

Из Client уберём всё то, что за него теперь делает Server:
static void Client(BlockingCollection<ImageChunk> input,
                    BlockingCollection<ControlData> output)
{
    Form f = new Form(){ ClientSize = new Size(400, 300) };

    Point window_topleft = new Point();
    Size mouse_prev_loc = new Size();
    bool mouse_lbdown = false;
    Point mouse_down_loc = new Point();
                
    // обработка движений мыши
    f.MouseDown += (s, e) => { mouse_lbdown = true; mouse_down_loc = e.Location; };
    f.MouseUp += (s, e) =>
    {
        mouse_lbdown = false;
        if (Math.Abs(e.Location.X - mouse_down_loc.X) < 1 
                && Math.Abs(e.Location.Y - mouse_down_loc.Y) < 1)
        {
            int click_to_x = (window_topleft.X + mouse_down_loc.X) 
                            * 65536 / Screen.PrimaryScreen.Bounds.Width;
            int click_to_y = (window_topleft.Y + mouse_down_loc.Y) 
                            * 65536 / Screen.PrimaryScreen.Bounds.Height;

            output.Add(new ControlData() { action=ControlData.Action.Click, 
                            point=new Point(click_to_x,click_to_y) });
        }
    };
    f.MouseMove += (s, e) =>
    {
        if (mouse_lbdown)
        {
            window_topleft += mouse_prev_loc - (Size)(e.Location);
            output.Add(new ControlData() 
                { action = ControlData.Action.Shift, point = window_topleft }
            );
        }
        mouse_prev_loc = (Size)e.Location;
    };

    // приём фрагментов изображения
    var timer = new System.Windows.Forms.Timer() { Interval = 40 };
    timer.Tick += (s, e) =>
    {
        ImageChunk incoming;
        // если очередь пуста - выходим
        if( ! input.TryTake(out incoming,5) ) return;

        Graphics g = f.CreateGraphics();
        g.DrawImageUnscaled(incoming.img, incoming.place.X - window_topleft.X, 
                                            incoming.place.Y - window_topleft.Y);
        g.Dispose();
        incoming.img.Dispose();
    };
    timer.Start();

    Application.Run(f);
}

Внешний вид приложения не изменился, поэтому скриншота не будет.

NB: кстати, тут можно форкнуть..


… и сделать себе remote desktop через пайпы, через http, через RS232 и пр. Достаточно просто написать сериализацию, сжатие и транспорт для объектов, которые ходят в очередях.

В следующей части статьи я опишу сжатие, заточенное под последующую передачу по UDP. Особенностью UDP является небольшой размер атомарно передаваемых данных (пакетов), а так же потери и переупорядочивание пакетов.
Tags:
Hubs:
+37
Comments35

Articles

Change theme settings