Разработка кроссплатформенного приложения на Avalonia для Raspberry Pi с использованием Github Action

в 13:48, , рубрики: .net, avaloniaui, C#, CICD, cross-platform, github, github actions, raspberrypi, ssh, Разработка на Raspberry Pi, Яндекс API

Вступление

В связи с желанием апгрейдить свое рабочее место, появилась потребность в мониторе, на котором будут отображаться информативные виджеты, например: погода, календарь, показатели датчиков в доме -, и, так как готовые решения меня не устраивают, я решил, что сделаю свой аналог домашнего «дашбоарда».

Примерный план был такой: приобрести Raspberry PI 3 и экран, подключить его к интернету, написать приложение, повесить на стенку и пользоваться с удовольствием.

В процессе проектирования, я сразу же увидел проблему в процессе разработки – как разрабатывать на домашнем компьютере и автоматически доставлять и запускать написанное приложение на Raspberry Pi, чтобы это не было долгим и мучительным ручным процессом.

Для решения проблемы, я пообщался в чатах, почитал в интернете несколько советов и выбрал для себя оптимальный способ развёртывания десктопного кроссплатформенного приложения.

Статья будет посвящена полному циклу разработки кроссплатформенного десктопного приложения, преимущественно для использования на одноплатном компьютере Raspberry PI 3, а также, речь пойдет о его автоматическом развертывании, с описанием проблем и их решений, которые возникли в процессе разработки. В статье упор сделан на решение проблемы с доставкой, сборкой и запуском приложения на Raspberry Pi.

Выбор технологий для разработки и настройка Raspberry Pi

Для решения поставленных задач, нам потребуется ряд технологий, а именно:

-        Кроссплатформенный фреймворк для работы логики и GUI приложения;

-        ПО для автоматического развертывания приложения;

-        Внешнее API для работы виджетов.

Для работы логики приложения будет использоваться .Net платформа и так как у нас нет зависимостей от версии, мы возьмем самую новую из стабильных - .Net 5.0.

Для разработки GUI, возьмем Avalonia – как один из немногочисленных кроссплатформенных фреймворков для разработки интерфейсов.

Главный плюс авалонии в том, что это – кроссплатформенный фреймворк, который позволит разрабатывать и тестировать на Windows компьютере, а эксплуатировать на компьютерах с Linux.

Для реализации CI/CD, воспользуемся Github Action. Легковесный, просто настраиваемый и интегрированный с Github – местом, где и будет хранится наш проект.

Для начала, будет реализован виджет с погодой, поэтому, нам понадобится сервис, который предоставит качественное API с погодой. Недолго думая, был выбран сервис Yandex.Weather, как наиболее простой, популярный и бесплатный.

Что касается инструментов для разработки, нам потребуется: IDE Rider для написания кода, SSH клиент PuTTY для удаленного подключения к Raspberry PI и Advanced IP Scanner для поиска IP Raspberry Pi в интернет-сети.

В результате, мы имеем следующие начальные условия:

Мы имеем Raspberry PI с подключенным экраном, в нашем случае, это LCD экран на 3.5 дюйма, установленной операционной системой Raspberry PI OS (32-bit), драйвера на подключенный монитор, SSH, и, настроенный интернет.

Если с установкой чего-либо возникли трудности, вот ссылки на гайды с необходимыми настройками Raspberry Pi:

1. Гайд для установки операционной системы Raspberry Pi OS;

2. Гайд для подключения Raspberry Pi к интернету;

3. Гайд для настройки SSH подключения;

4. Для корректной работы LCD экрана потребуется драйвер. Гайд для его установки тут.

PS: В зависимости от версии Raspberry Pi, ОС и экрана настройки варьируются.

После установки и настройки всего необходимого, можно приступить к разработке приложения.

Подготовка Raspberry Pi для разработки приложения

После того, как мы настроим Raspberry Pi, нам потребуется установить на него .Net платформу. На официальном сайте Microsoft есть подробная документация по установке .Net на Raspberry Pi OS. Для установки необходимого ПО, требуется в консоли выполнить следующие команды:

curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel Current

echo 'export DOTNET_ROOT=$HOME/.dotnet' >> ~/.bashrc
echo 'export PATH=$PATH:$HOME/.dotnet' >> ~/.bashrc
source ~/.bashrc

После установки необходимых пакетов, мы можем воспользоваться командой dotnet –-version для получения установленных версий .Net на компьютере.

Узнаем версию установленной версии .Net
Узнаем версию установленной версии .Net

Если все установлено, мы приступаем к написанию приложения.

Процесс разработки десктопного приложения

Очень подробно описывать процесс написания кода не буду, а лишь вкратце расскажу об основных виджетах и сервисах, используемых в приложении.

Приложение будет написано используя паттерн MVVM – эталонный паттерн для разработки десктопных приложений.
Чтобы создать приложение Avalonia с использованием паттерна MVVM, воспользуемся встроенными шаблонами:

# Качаем шаблоны
dotnet new -i Avalonia.Templates

# Создаем приложение
dotnet new avalonia.mvvm -o AvaRaspberry -n AvaRaspberry

Разработку приложения начнем с логики для отображения текущей температуры и местоположения - WeatherViewModel.

namespace AvaRaspberry.ViewModels
{
    public class WeatherWidgetViewModel : ViewModelBase
    {
			  // Регистрируем сервис для работы Яндекс.Погода 
        private readonly YandexWeatherService _weatherService = new();
        
        private WeatherModel _weatherModel = new("Unknown", 0);

        // Свойство, подписанное на изменения переменной. 
        // Хранит в себе данные о погоде.
        public WeatherModel WeatherModel
        {
            get => _weatherModel;
            private set => this.RaiseAndSetIfChanged(ref _weatherModel, value);
        }

        // Конструктор, обновляющий погоду во время создания виджета
        public WeatherWidgetViewModel()
        {
            Task.Run(async () => await UpdateForecast());
        }

        // Метод для обновления погоды
        private async Task UpdateForecast()
        {
            var yaWeatherResponse = await _weatherService.GetByCoordinate();

            WeatherModel = new WeatherModel(yaWeatherResponse.Info.Tzinfo.Name, 
            	yaWeatherResponse.Fact.Temp);
        }
    }

YandexWeatherService – сервис, предоставляющий данные о погоде, используя Яндекс.Погода API.

namespace AvaRaspberry.Services
{
    public class YandexWeatherService
    {
        // Константы с языковой настройкой и координатами Москвы
        private const string ApiGetPathBase = "https://api.weather.yandex.ru/v2/forecast?";
        private const string Local = "ru_RU";
        private const string Latitude = "55.751244";
        private const string Longitude = "37.618423";

        private readonly HttpClient _http = new();

        // Получаем токен из JSON файла в проекте для авторизации в API Yandex.Weather
        private string YandexWeatherApiToken =>
            ConfigurationSingleton.GetInstance().Widgets.Weather.YandexWeather.ApiToken;
        
        private Uri YandexWeatherUrl(string lat, string lon) =>
            new (ApiGetPathBase + $"lat={lat}&lon={lon}&[lang={Local}]");

        public YandexWeatherService()
        {
            // Добавляем полученный раннее токен в заголовок
            ApplyTokenToHeaders(YandexWeatherApiToken);
        }

        // Делаем Http GET запрос в API Yandex.Weather
        public async Task<YandexWeatherResponse> GetByCoordinate(string lat = Latitude, string lon = Longitude)
        {
            var path = YandexWeatherUrl(lat, lon);
            using var response = await _http.GetAsync(path).ConfigureAwait(false);
            response.EnsureSuccessStatusCode();

            var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
            var content = JsonConvert.DeserializeObject<YandexWeatherResponse>(json)
                          ?? new YandexWeatherResponse();

            return content;
        }

        private void ApplyTokenToHeaders(string token)
        {
            _http.DefaultRequestHeaders.Clear();
            _http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
            _http.DefaultRequestHeaders.Add("X-Yandex-API-Key",token);
        }
    }
}

Далее, напишем View для отображения температуры и местоположения - WeatherView.

<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:viewModels="clr-namespace:AvaRaspberry.ViewModels"
             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
             Background="#B0E0E6"
             x:Class="AvaRaspberry.Views.WeatherWidgetView"
             >
    <UserControl.DataContext>
        <viewModels:WeatherWidgetViewModel/>
    </UserControl.DataContext>
    <StackPanel Orientation="Vertical" HorizontalAlignment="Center" VerticalAlignment="Center">
        <Image Source="/Assets/cloud_widget.png" Width="48" Height="48"/>
        <TextBlock TextAlignment="Center" Text="{Binding WeatherModel.City}"/>
        <TextBlock TextAlignment="Center"  Text="{Binding WeatherModel.DisplayTemperature}" />
    </StackPanel>
</UserControl>

После, реализуем View главной страницы с виджетами. Это будет обыкновенный Grid, каждая ячейка которого – View с конкретным виджетом.

<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:views="clr-namespace:AvaRaspberry.Views"
             xmlns:viewModels="clr-namespace:AvaRaspberry.ViewModels"
             mc:Ignorable="d" d:DesignWidth="250" d:DesignHeight="350"
             x:Class="AvaRaspberry.Views.GridWidgetView">
    <UserControl.DataContext>
        <viewModels:GridWidgetViewModel/>
    </UserControl.DataContext>
    <Grid ShowGridLines="True" ColumnDefinitions="*,*,*" RowDefinitions="*,*,*">
        <views:WeatherWidgetView Grid.Column="0" Grid.Row="0" ></views:WeatherWidgetView>
        <views:EmptyWidgetView Grid.Column="0" Grid.Row="1" ></views:EmptyWidgetView>
        <views:EmptyWidgetView Grid.Column="0" Grid.Row="2" ></views:EmptyWidgetView>
        <views:EmptyWidgetView Grid.Column="1" Grid.Row="0" ></views:EmptyWidgetView>
        <views:EmptyWidgetView Grid.Column="1" Grid.Row="1" ></views:EmptyWidgetView>
        <views:EmptyWidgetView Grid.Column="1" Grid.Row="2" ></views:EmptyWidgetView>
        <views:EmptyWidgetView Grid.Column="2" Grid.Row="0" ></views:EmptyWidgetView>
        <views:EmptyWidgetView Grid.Column="2" Grid.Row="1" ></views:EmptyWidgetView>
        <views:EmptyWidgetView Grid.Column="2" Grid.Row="2" ></views:EmptyWidgetView>
    </Grid>
</UserControl>

EmptyWidgetView – заглушка для пустых ячеек в Grid компоненте.

В результате, у нас получилось вот такое приложение:

Вид приложения
Вид приложения

Итак, приложение с виджетами написано и корректно работает на компьютере с ОС Windows. Коммитим изменения и пушим их в репозиторий на Github.

Автоматическое развертывание приложения на Raspberry Pi

Итак, у нас имеется репозиторий с приложением и теперь возникает вопрос: как это приложение доставить на Raspberry Pi, собрать его и запустить?

Первое, что приходит на ум – флешка или FileZilla и раннее подключенный PuTTY: переносим .zip файл с приложением на Raspberry, распаковываем, собираем и запускаем.
Но, конечно, это не то решение, которое хотелось бы видеть и использовать, ведь после каждого обновления кода, нужно таким же ручным способом доставлять приложение на Raspberry Pi. Это долго и неудобно.

Оптимальное, на мой взгляд, решение – это CI/CD с помощью Github Action.

Для того, чтобы автоматически доставлять, собирать и запускать приложение, нам потребуется workflow – файл, устанавливающий правила и порядок доставки, сборки и запуска приложения. Например, в нем указывается, на какие действия (коммит или пулл реквест) триггерится Github Action Jobs и в каком порядке запускаются необходимые команды.

Для запуска нашего приложения, потребуется вот такой workflow файл:

name: .NET Core Desktop

on: [push]

jobs:
  build:
    runs-on: [self-hosted, linux]

    steps:
    - name: Checkout
      uses: actions/checkout@v2
      with:
        fetch-depth: 0
        
    - name: Install .NET Core
      uses: actions/setup-dotnet@v1
      with:
        dotnet-version: 5.0.x
     
    # Ресторим зависимости     
    - name: Restore project
      run: dotnet restore

    # Собираем релизную сборку проекта  
    - name: Build project
      run: dotnet build --configuration Release
      
    # Закрываем приложение, запущенное ранее, убивая процесс dotnet  
    - name: Kill dotnet process
      run: pkill dotnet   

    # Запускаем выполнение самописного скрипта для сборки и запуска приложения       
    - name: Execute run script
      shell: bash
      env:
        SECRET_PASSPHRASE: ${{ secrets.SECRET_PASSPHRASE }}
      run: |
         chmod u+r+x build.sh
         RUNNER_TRACKING_ID="" && sh build.sh "$SECRET_PASSPHRASE"

Отдельным пунктом хочу выделить следующий участок кода:

RUNNER_TRACKING_ID=""

Дело в том, что после выполнения Jobs, Github Action запускает процесс «чистки» - удаление тех процессов, которые были созданы в результате работы этой же самой Job. В результате, создавалась ситуация, когда приложение запустилось, но после завершения работы джобы, она убивала все дочерние процессы, включая процесс приложения. Чтобы этого избежать, в issue на эту тему, порекомендовали перед вызовом команды, процессы которого следует оставить, вызвать эту команду.

Теперь перейдем к build.sh – bash скрипту, вызывающегося из workflow файла.

#!/usr/bin/env bash

echo "Started build and run project script."

# Configure linux machine

# Является способом доступа к локальному дисплею машины извне локального сеанса
export DISPLAY=:0

# Ресторим и собираем приложение
dotnet restore
dotnet build --configuration Release

# Переходим в деректорию, где собрана рабочая версия приложения
cd AvaRaspberry/bin/Release/net5.0/ || exit

# Расшифровываем файл с настройками (api токеном) 
gpg --quiet --batch --yes --decrypt --passphrase="$SECRET_PASSPHRASE" 
--output appsettings.json appsettings.json.gpg

# Запускаем приложение
( dotnet AvaRaspberry.dll & )
echo "Build and run project script is executed."

 А теперь по порядку.

Собственно, первое, о чем бы хотелось бы рассказать – это о использовании gpg библиотеки для шифрования и дешифрования.

Как можно было заметить, при обращении к Yandex.Weather API мы используем токен для аутентификации, который не стоит хранить в публичном доступе, и, так как проект расположен на Github и стремится быть Open Source проектом, возникает вопрос - как не выкладывать API токен в публичный доступ и одновременно доставлять и запускать приложение без ручного вмешательства?

Первое, что приходит на ум – Github Secrets. Но, к сожалению, данные из секретов можно вставлять только в рантайме джобы Github Action. Это значит, что, если я, например, попытаюсь сохранить данные из секрета в файл, он этого не сделает, а мне нужно именно противоположенное поведение.

Долго думая, как бы решить проблему, наткнулся в документации Github Action на кейс, при котором Secrets настолько огромный, что не помещается в разрешенный диапазон. Для решения такой проблемы предлагается следующее:

Сохранить секрет в файл, зашифровать его с помощью gpg библиотеки, используя секретное слово. Само секретное слово, требуется поместить в Github Secrets. Зашифрованный файл требуется добавить в репозиторий и закоммитить – без ключевого слова, его практически невозможно расшифровать, поэтому наличие его в публичном доступе не страшно.

В нашем случае, мы помещаем в файл JSON структуру с токеном - appsettings.json.

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

Следующее решение, которое цепляет глаз – это запуск приложения в дочернем потоке.

Дело в том, что запуская приложение в потоке исполнения джобы, мы, очевидно, блокируем его выполнение, запуская наше приложение. Чтобы не блокировать основной поток выполнения, мы запускаем наше приложение в дочернем потоке.

После того, как мы написали workflow файл для приложения, закоммитили и запушили его в репозиторий, запустится джоба, которая зависнет на этапе подхвата action-runner.

Для того, чтобы workflow скрипт продолжил выполнение, требуется на Raspberry Pi установить action-runner. Для этого заходим в наш репозиторий -> Settings -> Action -> Runners. Выбираем Linux и Arm архитектуру и добавляем раннер в репозиторий.

После создания action-runner в репозитории, требуется выполнить указанные скрипты последовательно на Raspberry Pi.

Скрипты для создания action-runner на Raspberry Pi
Скрипты для создания action-runner на Raspberry Pi

В результате выполнения скриптов, на Raspberry Pi запустится action-runner.

Демонстрация работающего action-runner
Демонстрация работающего action-runner

Итак, приложение написано, Github Action настроен. Пришло время проверить весь процесс доставки, сборки и запуска приложения, сделав тестовый коммит в приложение.

Заключение

Подведем итог полученных результатов.
В результате выполненной работы, было написано кроссплатформенное десктопное приложение с возможностью разработки на Windows и запуска на Linux системах. В приложении используются внешние интеграции для взаимодействия с виджетами. Применены действия для защиты не публичных данных (токенов). Настроен процесс доставки, сборки и запуска приложения на удаленном ПК.

Исходный код приложения находится тут.

Автор: Dmitry K.

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js