Я всегда хотел себе портативный удалённый рабочий стол на телефоне, чтобы, например, когда кто-то стучится в аську, а я на балкон покурить вышел, можно было не уходя с балкона посмотреть на телефоне, кто там. Ну или, например, переключить трек, принимая ванну. Да, я знаю, что всевозможные 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 является небольшой размер атомарно передаваемых данных (пакетов), а так же потери и переупорядочивание пакетов.