Как часто вы писали плагины для своих приложений?
В статье я хочу рассказать как можно написать плагины используя AppDomain, и кросс доменные операции. Плагины будем писать для уже существующего моего приложения TCPChat.
Кто хочет повелосипедить — вперед под кат.
Чат находится тут.
А об архитектуре приложения можно почитать вот тут. В данном случае нас интересует только модель. Кардинально она не менялась, и достаточно будет знать про основные сущности (Корень модели/API/Команды).
О том, что необходимо реализовать в приложении:
Необходимо иметь возможно расширить набор команд с помощью плагинов, при этом код в плагинах должен выполняться в другом домене.
Очевидно, что команды не будут вызываться сами собой, поэтому нужно также добавить возможность изменять UI. Для этого предоставим возможность добавить пункты меню, а также создавать свои окна.
По окончании, я напишу плагин с помощью которого можно будет удаленно делать снимок экрана любого пользователя.
Для чего нужен AppDomain?
Домен приложения нужен для выполнения кода с ограниченными правами, а также для выгрузки библиотек во время работы приложения. Как известно сборки из домена приложений выгрузить невозможно, а вот домен — пожалуйста.
Для того, чтобы выгружать домен было возможно, взаимодействие между ними сведено к минимуму.
По сути мы можем:
Немного о продвижении:
Продвижение, может происходить по ссылке или по значению.
Со значением все относительно просто. Класс сериализуется в одном домене, передается массивом байт в другой, десериализуется, и мы получаем копию объекта. При этом необходимо чтобы сборка была загружена в оба домена. Если необходимо чтобы сборка не грузилась в основной домен, то лучше чтобы папка с плагинами не была добавлена в список папок, где ваше приложение будет искать сборки по умолчанию (AppDomain.BaseDirectory / AppDomainSetup.PrivateBinPath). В таком случае будет исключение о том, что тип не удалось найти, и вы не получите молча загруженную сборку.
Для выполнения продвижения по ссылке класс должен реализовывать MarshalByRefObject. Для каждого такого объекта, после вызова метода CreateInstanceAndUnwrap, в вызывающем домене создается представитель. Это объект который содержит все методы настоящего объекта (полей при этом там нет). В этих методах, с помощью специальных механизмов он вызывает методы настоящего объекта, находящегося в другом домене и соответственно методы тоже выполняются в домене в котором объект был создан. Также стоит сказать, что время жизни представителей ограничено. После создания они живут 5 минут, и после каждого вызова какого-либо метода, их время жизни становится 2 минуты. Время аренды можно изменить, для этого у MarshalByRefObject можно переопределить метод InitializeLifetimeService.
Продвижение по ссылке не требует загрузки в основной домен сборки с плагином.
Отступление про поля:
Это одна из причин пользоваться не открытыми полями, а свойствами. Доступ к полю через представитель получить можно, но работает это все намного медленнее. Причем, для того чтобы это работало медленнее, не обязательно использовать кросс-доменные операции, достаточно унаследоваться от MarshalByRefObject.
Детальнее о выполнении кода:
Выполнение кода происходит с помощью метода AppDomain.DoCallBack().
При этом выполняется продвижения делегата в другой домен, поэтому нужно быть уверенным, что это возможно.
Это небольшие проблемы на которые я наткнулся:
Приступим
В первую очередь нужно узнать какие плагины приложение может загрузить. В одной сборке может быть несколько плагинов, а нам необходимо обеспечить для каждого плагина отдельный домен. Поэтому нужно написать загрузчик информации, который тоже будет работать в отдельном домене, и по окончании работы загрузчика этот домен будет выгружен.
Структура для хранения загрузочной информации о плагине, помечена атрибутом Serializable, т.к. она будет продвигаться между доменами.
Сам загрузчик информации. Можете обратить внимание, что класс Proxy наследуется от MarshalByRefObject, т.к. его поля будут использоваться для входных и выходных параметров. А сам он будет создан в домене загрузчика.
Для ограничения возможностей загрузчика, в домен к нему я передаю набор разрешений. Как видно в листинге уст��навливается 3 разрешения:
Некоторым из разрешений (почти всем) можно указать как частичный доступ, так и полный. Частичный дается с помощью конкретных для каждого разрешения перечислений. Для полного доступа или, наоборот, запрета можно отдельно передать состояние:
PermissionState.None — для запрета.
PermissionState.Unrestricted — для полного разрешения.
Детальнее о том какие еще есть разрешения можно почитать тут. Также можно посмотреть какие параметры у доменов по умолчанию вот здесь.
В метод для создания домена я передаю экземпляр класса AppDomainSetup. Для него установлено только 2 поля, по которым он понимает где ему нужно по умолчанию искать сборки.
Далее, после ничем не примечательного создания домена, мы вызываем у него метод CreateInstanceAndUnwrap, передавая в параметры полное название сборки и типа. Метод создаст объект в домене загрузчика и выполнит продвижение, в данном случае по ссылке.
Плагины:
Плагины в моей реализации разделены на клиентские и серверные. Серверные предоставляют только команды. Для каждого клиентского плагина будет создан отдельный пункт меню и он, как и серверный, может отдать набор команд для чата.
У обоих плагинов есть метод инициализации, в который я проталкиваю обертку над моделью и сохраняю ее в статическом поле. Почему это делается не в конструкторе?
Имя загружаемого плагина неизвестно и обнаружится оно только после создания объекта. Вдруг плагин с таким именем уже добавлен? Тогда он должен быть выгружен. Если же плагина-тезки еще нету, то выполняется инициализация. Таким образом обеспечивается инициализация только в случае удачной загрузки.
Вот собственно базовый класс плагина:
CrossDomainObject — это объект который содержит только 1 метод — Process, обеспечивающий продление времени жизни представителя. Со стороны чата менеджер плагинов раз в минуту вызывает его у всех плагинов. Со стороны плагина, он сам обеспечивает вызов метода Process у обертки модели.
Базовые классы для серверного и клиентского плагина:
Менеджер плагинов ответственен за выгрузку, загрузку плагинов и владение ими.
Рассмотри загрузку:
Аналогично загрузчику, в начале мы инциализируем и создаем домен. Далее уже с помощью метода AppDomain.CreateInstanceFromAndUnwrap создаем объект. После его создания имя плагина анализируется, если такой уже был добавлен, то домен вместе с плагином выгружается. Если же такого плагина нет — он инициализируется.
Детальнее код менеджера можно посмотреть тут.
Одной из проблем, которая решилась достаточно просто, было предоставление доступа плагинов к модели. Корень модели у меня статический, и в другом домене он будет не инициализирован, т.к. типы и статические поля у каждого домена свои.
Решилась проблема написанием обертки, в которую сохраняются объекты модели, и продвигается уже экземпляр этой обертки. Модельным объектам только необходимо было добавить в базовые классы MarshalByRefObject. Исключение это клиентский и серверный (серверный просто из симметрии) API которые пришлось также обернуть. Клиентский API создается после менеджера плагинов, и в момент загрузки дополнений его еще просто нет. Пример клиентской обертки.
Для клиентских и серверных плагинов я написал 2 различных менеджера, которые реализуют базовый PluginManager. У обоих есть метод TryGetCommand, который вызывается в соответствующем API, если не найдена родная команда с таким айдишником. Ниже реализация метода API GetCommand.
Написание плагина:
Теперь на основе написанного кода, можно попробовать реализовать плагин.
Напишу я плагин который, по нажатию на пункт меню, открывает окно с кнопкой и текстовым полем. В обработчике кнопки будет посылаться команда юзеру, ник которого мы ввели в поле. Команда будет делать снимок и сохранять его в папку. После этого выкладывать его в главную комнату и оправлять нам ответ.
Это будет P2P взаимодействие, поэтому написание серверного плагина не понадобится.
Для начала создадим проект, выберем библиотеку классов. И добавим к нему в ссылки 3 основные сборки: Engine.dll, Lidgren.Network.dll, OpenAL.dll. Не забудьте поставить правильную версию .NET Framework, я собираю чат под 3.5, и соответственно плагины тоже должны быть такой же версии, или ниже.
Далее реализуем основной класс плагина, который предоставляет 2 команды. А по обработчику пункта меню открывает диалоговое окно.
Стоит отметить что менеджеры плагинов на своей стороне кешируют команды, поэтому необходимо чтобы плагин удерживал на них ссылки. И свойство Commands возвращало одни и те же экземпляры команд.
Диалоговое окно выглядит вот так:

При пересылке файлов я воспользовался уже написанной функциональностью чата, с помощью API.
Самое интересное происходит в последних 3-ех строчках. Здесь мы используем API добавляя в комнату файл. После этого отправляем команду ответ. У пира вызывается перегрузка метода, принимающая набор байт, т.к. наш объект не сможет быть сериализован в основной сборке чата.
Ниже приведена реализация команды, которая будет принимать ответ. Она огласит на всю главную комнату, о том что мы сделали снимок экрана у бедного пользователя.
Полный проект с плагином могу выложить, но не знаю куда. (Для отдельного репозитория на гитхабе, он сильно маленький, как мне кажется).
UPD: выложил плагин на github.
UPD 2: В статье для поддержки времени жизни плагинов используется пулинг, что странно. В реализации на github я позже реализовал нормальную модель.
В статье я хочу рассказать как можно написать плагины используя AppDomain, и кросс доменные операции. Плагины будем писать для уже существующего моего приложения TCPChat.
Кто хочет повелосипедить — вперед под кат.
Чат находится тут.
А об архитектуре приложения можно почитать вот тут. В данном случае нас интересует только модель. Кардинально она не менялась, и достаточно будет знать про основные сущности (Корень модели/API/Команды).
О том, что необходимо реализовать в приложении:
Необходимо иметь возможно расширить набор команд с помощью плагинов, при этом код в плагинах должен выполняться в другом домене.
Очевидно, что команды не будут вызываться сами собой, поэтому нужно также добавить возможность изменять UI. Для этого предоставим возможность добавить пункты меню, а также создавать свои окна.
По окончании, я напишу плагин с помощью которого можно будет удаленно делать снимок экрана любого пользователя.
Для чего нужен AppDomain?
Домен приложения нужен для выполнения кода с ограниченными правами, а также для выгрузки библиотек во время работы приложения. Как известно сборки из домена приложений выгрузить невозможно, а вот домен — пожалуйста.
Для того, чтобы выгружать домен было возможно, взаимодействие между ними сведено к минимуму.
По сути мы можем:
- Выполнить код в другом домене.
- Создать объект и продвинуть его по значению.
- Создать объект и продвинуть его по ссылке.
Немного о продвижении:
Продвижение, может происходить по ссылке или по значению.
Со значением все относительно просто. Класс сериализуется в одном домене, передается массивом байт в другой, десериализуется, и мы получаем копию объекта. При этом необходимо чтобы сборка была загружена в оба домена. Если необходимо чтобы сборка не грузилась в основной домен, то лучше чтобы папка с плагинами не была добавлена в список папок, где ваше приложение будет искать сборки по умолчанию (AppDomain.BaseDirectory / AppDomainSetup.PrivateBinPath). В таком случае будет исключение о том, что тип не удалось найти, и вы не получите молча загруженную сборку.
Для выполнения продвижения по ссылке класс должен реализовывать MarshalByRefObject. Для каждого такого объекта, после вызова метода CreateInstanceAndUnwrap, в вызывающем домене создается представитель. Это объект который содержит все методы настоящего объекта (полей при этом там нет). В этих методах, с помощью специальных механизмов он вызывает методы настоящего объекта, находящегося в другом домене и соответственно методы тоже выполняются в домене в котором объект был создан. Также стоит сказать, что время жизни представителей ограничено. После создания они живут 5 минут, и после каждого вызова какого-либо метода, их время жизни становится 2 минуты. Время аренды можно изменить, для этого у MarshalByRefObject можно переопределить метод InitializeLifetimeService.
Продвижение по ссылке не требует загрузки в основной домен сборки с плагином.
Отступление про поля:
Это одна из причин пользоваться не открытыми полями, а свойствами. Доступ к полю через представитель получить можно, но работает это все намного медленнее. Причем, для того чтобы это работало медленнее, не обязательно использовать кросс-доменные операции, достаточно унаследоваться от MarshalByRefObject.
Детальнее о выполнении кода:
Выполнение кода происходит с помощью метода AppDomain.DoCallBack().
При этом выполняется продвижения делегата в другой домен, поэтому нужно быть уверенным, что это возможно.
Это небольшие проблемы на которые я наткнулся:
- Это экземплярный метод, а класс-хозяин не может быть продвинут. Как известно делегат для каждого подписанного метода хранит 2 основных поля, ссылка на экземпляр класса, а также указатель на метод.
- Вы использовали замыкания. По умолчанию класс который создает компилятор не помечается как сериализуемый и не реализовывает MarshalByRefObject. (Далее см. пункт 1)
- Если унаследовать класс от MarshalByRefObject, создать его в домене 1 и пытаться выполнить его экземплярный метод в другом домене 2, то граница доменов будет пересечена 2 раза и код будет выполнен в домене 1.
Приступим
В первую очередь нужно узнать какие плагины приложение может загрузить. В одной сборке может быть несколько плагинов, а нам необходимо обеспечить для каждого плагина отдельный домен. Поэтому нужно написать загрузчик информации, который тоже будет работать в отдельном домене, и по окончании работы загрузчика этот домен будет выгружен.
Структура для хранения загрузочной информации о плагине, помечена атрибутом Serializable, т.к. она будет продвигаться между доменами.
[Serializable] struct PluginInfo { private string assemblyPath; private string typeName; public PluginInfo(string assemblyPath, string typeName) { this.assemblyPath = assemblyPath; this.typeName = typeName; } public string AssemblyPath { get { return assemblyPath; } } public string TypeName { get { return typeName; } } }
Сам загрузчик информации. Можете обратить внимание, что класс Proxy наследуется от MarshalByRefObject, т.к. его поля будут использоваться для входных и выходных параметров. А сам он будет создан в домене загрузчика.
class PluginInfoLoader { private class Proxy : MarshalByRefObject { public string[] PluginLibs { get; set; } public string FullTypeName { get; set; } public List<PluginInfo> PluginInfos { get; set; } public void LoadInfos() { foreach (var assemblyPath in PluginLibs) { var assembly = AppDomain.CurrentDomain.Load(AssemblyName.GetAssemblyName(assemblyPath).FullName); foreach (var type in assembly.GetExportedTypes()) { if (type.IsAbstract) continue; var currentBaseType = type.BaseType; while (currentBaseType != typeof(object)) { if (string.Compare(currentBaseType.FullName, FullTypeName, StringComparison.OrdinalIgnoreCase) == 0) { PluginInfos.Add(new PluginInfo(assemblyPath, type.FullName)); break; } currentBaseType = currentBaseType.BaseType; } } } } } public List<PluginInfo> LoadFrom(string typeName, string[] inputPluginLibs) { var domainSetup = new AppDomainSetup(); domainSetup.ApplicationBase = AppDomain.CurrentDomain.BaseDirectory; domainSetup.PrivateBinPath = "plugins;bin"; var permmisions = new PermissionSet(PermissionState.None); permmisions.AddPermission(new ReflectionPermission(ReflectionPermissionFlag.MemberAccess)); permmisions.AddPermission(new SecurityPermission(SecurityPermissionFlag.Execution)); permmisions.AddPermission(new UIPermission(UIPermissionWindow.AllWindows)); permmisions.AddPermission(new FileIOPermission(FileIOPermissionAccess.PathDiscovery | FileIOPermissionAccess.Read, inputPluginLibs)); List<PluginInfo> result; var pluginLoader = AppDomain.CreateDomain("Plugin loader", null, domainSetup, permmisions); try { var engineAssemblyPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"bin\Engine.dll"); var proxy = (Proxy)pluginLoader.CreateInstanceAndUnwrap(AssemblyName.GetAssemblyName(engineAssemblyPath).FullName, typeof(Proxy).FullName); proxy.PluginInfos = new List<PluginInfo>(); proxy.PluginLibs = inputPluginLibs; proxy.FullTypeName = typeName; proxy.LoadInfos(); result = proxy.PluginInfos; } finally { AppDomain.Unload(pluginLoader); } return result; } }
Для ограничения возможностей загрузчика, в домен к нему я передаю набор разрешений. Как видно в листинге уст��навливается 3 разрешения:
- ReflectionPermission разрешение на использование отражений.
- SecurityPermission разрешение на выполнение управляемого кода.
- FileIOPermission разрешение на чтение файлов переданных во втором параметре.
Некоторым из разрешений (почти всем) можно указать как частичный доступ, так и полный. Частичный дается с помощью конкретных для каждого разрешения перечислений. Для полного доступа или, наоборот, запрета можно отдельно передать состояние:
PermissionState.None — для запрета.
PermissionState.Unrestricted — для полного разрешения.
Детальнее о том какие еще есть разрешения можно почитать тут. Также можно посмотреть какие параметры у доменов по умолчанию вот здесь.
В метод для создания домена я передаю экземпляр класса AppDomainSetup. Для него установлено только 2 поля, по которым он понимает где ему нужно по умолчанию искать сборки.
Далее, после ничем не примечательного создания домена, мы вызываем у него метод CreateInstanceAndUnwrap, передавая в параметры полное название сборки и типа. Метод создаст объект в домене загрузчика и выполнит продвижение, в данном случае по ссылке.
Плагины:
Плагины в моей реализации разделены на клиентские и серверные. Серверные предоставляют только команды. Для каждого клиентского плагина будет создан отдельный пункт меню и он, как и серверный, может отдать набор команд для чата.
У обоих плагинов есть метод инициализации, в который я проталкиваю обертку над моделью и сохраняю ее в статическом поле. Почему это делается не в конструкторе?
Имя загружаемого плагина неизвестно и обнаружится оно только после создания объекта. Вдруг плагин с таким именем уже добавлен? Тогда он должен быть выгружен. Если же плагина-тезки еще нету, то выполняется инициализация. Таким образом обеспечивается инициализация только в случае удачной загрузки.
Вот собственно базовый класс плагина:
public abstract class Plugin<TModel> : CrossDomainObject where TModel : CrossDomainObject { public static TModel Model { get; private set; } private Thread processThread; public void Initialize(TModel model) { Model = model; processThread = new Thread(ProcessThreadHandler); processThread.IsBackground = true; processThread.Start(); Initialize(); } private void ProcessThreadHandler() { while (true) { Thread.Sleep(TimeSpan.FromMinutes(1)); Model.Process(); OnProcess(); } } public abstract string Name { get; } protected abstract void Initialize(); protected virtual void OnProcess() { } }
CrossDomainObject — это объект который содержит только 1 метод — Process, обеспечивающий продление времени жизни представителя. Со стороны чата менеджер плагинов раз в минуту вызывает его у всех плагинов. Со стороны плагина, он сам обеспечивает вызов метода Process у обертки модели.
public abstract class CrossDomainObject : MarshalByRefObject { public void Process() { } }
Базовые классы для серверного и клиентского плагина:
public abstract class ServerPlugin : Plugin<ServerModelWrapper> { public abstract List<ServerPluginCommand> Commands { get; } } public abstract class ClientPlugin : Plugin<ClientModelWrapper> { public abstract List<ClientPluginCommand> Commands { get; } public abstract string MenuCaption { get; } public abstract void InvokeMenuHandler(); }
Менеджер плагинов ответственен за выгрузку, загрузку плагинов и владение ими.
Рассмотри загрузку:
private void LoadPlugin(PluginInfo info) { var domainSetup = new AppDomainSetup(); domainSetup.ApplicationBase = AppDomain.CurrentDomain.BaseDirectory; domainSetup.PrivateBinPath = "plugins;bin"; var permmisions = new PermissionSet(PermissionState.None); permmisions.AddPermission(new UIPermission(PermissionState.Unrestricted)); permmisions.AddPermission(new SecurityPermission( SecurityPermissionFlag.Execution | SecurityPermissionFlag.UnmanagedCode | SecurityPermissionFlag.SerializationFormatter | SecurityPermissionFlag.Assertion)); permmisions.AddPermission(new FileIOPermission( FileIOPermissionAccess.PathDiscovery | FileIOPermissionAccess.Write | FileIOPermissionAccess.Read, AppDomain.CurrentDomain.BaseDirectory)); var domain = AppDomain.CreateDomain( string.Format("Plugin Domain [{0}]", Path.GetFileNameWithoutExtension(info.AssemblyPath)), null, domainSetup, permmisions); var pluginName = string.Empty; try { var plugin = (TPlugin)domain.CreateInstanceFromAndUnwrap(info.AssemblyPath, info.TypeName); pluginName = plugin.Name; if (plugins.ContainsKey(pluginName)) { AppDomain.Unload(domain); return; } plugin.Initialize(model); var container = new PluginContainer(domain, plugin); plugins.Add(pluginName, container); OnPluginLoaded(container); } catch (Exception e) { OnError(string.Format("plugin failed: {0}", pluginName), e); AppDomain.Unload(domain); return; } }
Аналогично загрузчику, в начале мы инциализируем и создаем домен. Далее уже с помощью метода AppDomain.CreateInstanceFromAndUnwrap создаем объект. После его создания имя плагина анализируется, если такой уже был добавлен, то домен вместе с плагином выгружается. Если же такого плагина нет — он инициализируется.
Детальнее код менеджера можно посмотреть тут.
Одной из проблем, которая решилась достаточно просто, было предоставление доступа плагинов к модели. Корень модели у меня статический, и в другом домене он будет не инициализирован, т.к. типы и статические поля у каждого домена свои.
Решилась проблема написанием обертки, в которую сохраняются объекты модели, и продвигается уже экземпляр этой обертки. Модельным объектам только необходимо было добавить в базовые классы MarshalByRefObject. Исключение это клиентский и серверный (серверный просто из симметрии) API которые пришлось также обернуть. Клиентский API создается после менеджера плагинов, и в момент загрузки дополнений его еще просто нет. Пример клиентской обертки.
Для клиентских и серверных плагинов я написал 2 различных менеджера, которые реализуют базовый PluginManager. У обоих есть метод TryGetCommand, который вызывается в соответствующем API, если не найдена родная команда с таким айдишником. Ниже реализация метода API GetCommand.
Код
public IClientCommand GetCommand(byte[] message) { if (message == null) throw new ArgumentNullException("message"); if (message.Length < 2) throw new ArgumentException("message.Length < 2"); ushort id = BitConverter.ToUInt16(message, 0); IClientCommand command; if (commandDictionary.TryGetValue(id, out command)) return command; if (ClientModel.Plugins.TryGetCommand(id, out command)) return command; return ClientEmptyCommand.Empty; }
Написание плагина:
Теперь на основе написанного кода, можно попробовать реализовать плагин.
Напишу я плагин который, по нажатию на пункт меню, открывает окно с кнопкой и текстовым полем. В обработчике кнопки будет посылаться команда юзеру, ник которого мы ввели в поле. Команда будет делать снимок и сохранять его в папку. После этого выкладывать его в главную комнату и оправлять нам ответ.
Это будет P2P взаимодействие, поэтому написание серверного плагина не понадобится.
Для начала создадим проект, выберем библиотеку классов. И добавим к нему в ссылки 3 основные сборки: Engine.dll, Lidgren.Network.dll, OpenAL.dll. Не забудьте поставить правильную версию .NET Framework, я собираю чат под 3.5, и соответственно плагины тоже должны быть такой же версии, или ниже.
Далее реализуем основной класс плагина, который предоставляет 2 команды. А по обработчику пункта меню открывает диалоговое окно.
Стоит отметить что менеджеры плагинов на своей стороне кешируют команды, поэтому необходимо чтобы плагин удерживал на них ссылки. И свойство Commands возвращало одни и те же экземпляры команд.
public class ScreenClientPlugin : ClientPlugin { private List<ClientPluginCommand> commands; public override List<ClientPluginCommand> Commands { get { return commands; } } protected override void Initialize() { commands = new List<ClientPluginCommand> { new ClientMakeScreenCommand(), new ClientScreenDoneCommand() }; } public override void InvokeMenuHandler() { var dialog = new PluginDialog(); dialog.ShowDialog(); } public override string Name { get { return "ScreenClientPlugin"; } } public override string MenuCaption { get { return "Сделать скриншот"; } } }
Диалоговое окно выглядит вот так:

Код
<Window x:Class="ScreenshotPlugin.PluginDialog" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Screen" SizeToContent="WidthAndHeight" ResizeMode="NoResize"> <Grid> <Grid.RowDefinitions> <RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/> </Grid.RowDefinitions> <TextBox Grid.Row="0" Margin="10, 10, 10, 5" MinWidth="200" Name="UserNameTextBox"/> <Button Grid.Row="1" Margin="10, 5, 10, 10" Padding="5, 2, 5, 2" Content="Сделать скриншот" Click="Button_Click"/> </Grid> </Window>
public partial class PluginDialog : Window { public PluginDialog() { InitializeComponent(); } private void Button_Click(object sender, RoutedEventArgs e) { ScreenClientPlugin.Model.Peer.SendMessage(UserNameTextBox.Text, ClientMakeScreenCommand.CommandId, null); } }
При пересылке файлов я воспользовался уже написанной функциональностью чата, с помощью API.
public class ClientMakeScreenCommand : ClientPluginCommand { public static ushort CommandId { get { return 50000; } } public override ushort Id { get { return ClientMakeScreenCommand.CommandId; } } public override void Run(ClientCommandArgs args) { if (args.PeerConnectionId == null) return; string screenDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "screens"); if (!Directory.Exists(screenDirectory)) Directory.CreateDirectory(screenDirectory); string fileName = Path.GetFileNameWithoutExtension(Path.GetRandomFileName()) + ".bmp"; string fullPath = Path.Combine(screenDirectory, fileName); using (Bitmap bmpScreenCapture = new Bitmap(Screen.PrimaryScreen.Bounds.Width, Screen.PrimaryScreen.Bounds.Height)) using (Graphics graphic = Graphics.FromImage(bmpScreenCapture)) { graphic.CopyFromScreen( Screen.PrimaryScreen.Bounds.X, Screen.PrimaryScreen.Bounds.Y, 0, 0, bmpScreenCapture.Size, CopyPixelOperation.SourceCopy); bmpScreenCapture.Save(fullPath); } ScreenClientPlugin.Model.API.AddFileToRoom(ServerModel.MainRoomName, fullPath); var messageContent = Serializer.Serialize(new ClientScreenDoneCommand.MessageContent { FileName = fullPath }); ScreenClientPlugin.Model.Peer.SendMessage(args.PeerConnectionId, ClientScreenDoneCommand.CommandId, messageContent); } }
Самое интересное происходит в последних 3-ех строчках. Здесь мы используем API добавляя в комнату файл. После этого отправляем команду ответ. У пира вызывается перегрузка метода, принимающая набор байт, т.к. наш объект не сможет быть сериализован в основной сборке чата.
Ниже приведена реализация команды, которая будет принимать ответ. Она огласит на всю главную комнату, о том что мы сделали снимок экрана у бедного пользователя.
public class ClientScreenDoneCommand : ClientPluginCommand { public static ushort CommandId { get { return 50001; } } public override ushort Id { get { return ClientScreenDoneCommand.CommandId; } } public override void Run(ClientCommandArgs args) { if (args.PeerConnectionId == null) return; var receivedContent = Serializer.Deserialize<MessageContent>(args.Message); ScreenClientPlugin.Model.API.SendMessage( string.Format("Выполнен снимок у пользователя {0}.", args.PeerConnectionId), ServerModel.MainRoomName); } [Serializable] public class MessageContent { private string fileName; public string FileName { get { return fileName; } set { fileName = value; } } } }
Полный проект с плагином могу выложить, но не знаю куда. (Для отдельного репозитория на гитхабе, он сильно маленький, как мне кажется).
UPD: выложил плагин на github.
UPD 2: В статье для поддержки времени жизни плагинов используется пулинг, что странно. В реализации на github я позже реализовал нормальную модель.
