От сервера к десктопу: эксперимент с ASP.NET AOT и WebKitGTK

в 18:31, , рубрики: .net, angular, AOT, ASP.NET, C#, graphql, PInvoke, SkiaSharp, TypeScript, WebKitGTK

Введение

Пару лет назад для .NET в Linux не было не то чтобы production-ready фреймворков для создания native desktop приложений, но и экспериментальных. На тот момент существовали Uno Platform и Avalonia (тогда еще бета). Они запускались, работали, но написать реальное и относительно сложное приложение было практически нереально. Сегодня ситуация значительно лучше. Avalonia уже вполне production-ready продукт и является, на мой взгляд, лидером среди desktop фреймворков на .NET для Linux.

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

Цель этого эксперимента — проверка жизнеспособности стека ASP .NET + WebKitGTK + frontend на JS/TS. Этот стек предназначен для работы на ОС Linux. В проекте весь код написан на C# за исключением клиентской части, которая реализована при помощи TypeScript и Angular 19.1.

Этот концепт представляет из себя приложение, которое получает данные из Github API и для выбранного языка программирования проводит анализ количества новых репозиториев github по годам, строя линейную диаграмму, показывая тем самым тренд языка программирования. Этот анализ изначально не слишком объективен, но для тестового приложения вполне нормальный сценарий использования. В приложении на том же WebKitGTK реализована OAuth аутентификация Github. Полученные токены хранятся в системе при помощи библиотеки libsecret в зашифрованном (AES) виде. После первого входа пользователю необходимо установить пин-код, который является частью ключа. Немного заморочился с безопасностью токенов, да =)

Архитектура приложения

Главным в приложении является хост-процесс - консольное приложение C#. Оно управляет запуском окон WebKitGTK и бекендом.

Подробности на схеме:

Архитектура приложения

Архитектура приложения

Работа с WebKitGTK

Для работы с WebKitGTK необходимо написать байндинги PInvoke к соответствующим shared-библиотекам. Поскольку я использую net 9, буду использовать новое для .net API — атрибут LibraryImport.

internal static partial class WebKitGtk
{
    public static class Events
    {
        public const string Close = "close";
        public const string LoadChanged = "load-changed";
        public const string DecidePolicy = "decide-policy";
        public const string ContextMenu = "context-menu";
    }
    
    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    [return: MarshalAs(UnmanagedType.I1)] // gboolean => bool
    internal delegate bool CloseCallback(IntPtr webView, IntPtr userData);

    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    [return: MarshalAs(UnmanagedType.I1)] // gboolean => bool
    internal delegate bool DecidePolicyCallback(IntPtr webView, IntPtr decision, IntPtr type, IntPtr userData);

    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    [return: MarshalAs(UnmanagedType.I1)] // gboolean => bool
    internal delegate bool ContextMenuCallback(IntPtr webView, IntPtr menu, IntPtr @event, IntPtr hitTestResult,
        IntPtr userData);

    internal enum WebKitUserContentInjectedFrames : uint
    {
        AllFrames = 0,
        TopFrame = 1
    }

    internal enum WebKitUserScriptInjectionTime : uint
    {
        AtDocumentStart = 0,
        AtDocumentEnd = 1
    }

    public enum WebkitPolicyDecisionType : int
    {
        NavigationAction,
        NewWindowAction,
        Response,
    }

    public enum MenuAction : int
    {
        NoAction = 0,

        OpenLink,
        OpenLinkInNewWindow,
        DownloadLinkToDisk,
        CopyLinkToClipboard,
        OpenImageInNewWindow,
        DownloadImageToDisk,
        CopyImageToClipboard,
        CopyImageUrlToClipboard,
        OpenFrameInNewWindow,
        GoBack,
        GoForward,
        Stop,
        Reload,
        Copy,
        Cut,
        Paste,
        Delete,
        SelectAll,
        InputMethods,
        Unicode,
        SpellingGuess,
        NoGuessesFound,
        IgnoreSpelling,
        LearnSpelling,
        IgnoreGrammar,
        FontMenu,
        Bold,
        Italic,
        Underline,
        Outline,
        InspectElement,
        OpenVideoInNewWindow,
        OpenAudioInNewWindow,
        CopyVideoLinkToClipboard,
        CopyAudioLinkToClipboard,
        ToggleMediaControls,
        ToggleMediaLoop,
        EnterVideoFullscreen,
        MediaPlay,
        MediaPause,
        MediaMute,
        DownloadVideoToDisk,
        DownloadAudioToDisk,
        InsertEmoji,
        PasteAsPlainText,

        Custom = 10000
    }
    
    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    internal delegate void DestroyHandler(IntPtr widget, IntPtr data);

    internal static partial class WebView
    {
        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_web_view_new")]
        internal static partial IntPtr New();
        
        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_web_view_load_uri",
            StringMarshalling = StringMarshalling.Utf8)]
        internal static partial void LoadUri(IntPtr webView, string uri);
        
        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_web_view_get_uri")]
        internal static partial IntPtr GetUri(IntPtr webView);
        
        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_web_view_get_settings")]
        internal static partial IntPtr GetSettings(IntPtr webView);
        
        [LibraryImport(Libraries.WebKit,
            EntryPoint = "webkit_web_view_get_user_content_manager")]
        internal static partial IntPtr GetUserContentManager(IntPtr webView);
    }

    internal static partial class Navigation
    {
        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_navigation_policy_decision_get_navigation_action")]
        internal static partial IntPtr PolicyDecisionGetNavigationAction(IntPtr navDecision);

        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_navigation_action_get_request")]
        internal static partial IntPtr ActionGetRequest(IntPtr navAction);
        
        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_navigation_policy_decision_get_request")]
        internal static partial IntPtr PolicyDecisionGetRequest(IntPtr navDecision);
    }

    internal static partial class Settings
    {
        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_settings_set_enable_developer_extras")]
        internal static partial void SetEnableDeveloperExtras(IntPtr settings, Interop.GObject.GBoolean enabled);
    }

    internal static partial class ContextMenu
    {
        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_context_menu_get_n_items")]
        public static partial uint GetNItems(IntPtr menu);

        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_context_menu_get_item_at_position")]
        public static partial IntPtr GetItemAtPosition(IntPtr menu, uint position);

        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_context_menu_remove")]
        public static partial void Remove(IntPtr menu, IntPtr item);

        [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_context_menu_item_get_stock_action")]
        public static partial MenuAction ItemGetStockAction(IntPtr item);
    }

    internal static partial class UserContentManager
    {
        [LibraryImport(Libraries.WebKit,
            EntryPoint = "webkit_user_content_manager_add_script")]
        internal static partial void AddScript(
            IntPtr manager,
            IntPtr userScript);
    }

    [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_uri_request_get_uri")]
    internal static partial IntPtr UriRequestGetUri(IntPtr uriRequest);

    [LibraryImport(Libraries.WebKit,
        EntryPoint = "webkit_user_script_new",
        StringMarshalling = StringMarshalling.Utf8)]
    internal static partial IntPtr UserScriptNew(
        string source,
        WebKitUserContentInjectedFrames injectedFrames,
        WebKitUserScriptInjectionTime injectionTime,
        IntPtr allowList,
        IntPtr blockList);

    [LibraryImport(Libraries.WebKit, EntryPoint = "webkit_policy_decision_ignore")]
    internal static partial void PolicyDecisionIgnore(IntPtr policyDecision);
}

Аналогичным образом создаём байндинги для Gtk и GObject...

Далее пишем обёртку для WebKitGTK

public static partial class WebkitGtkWrapper
{
    private static WebViewConfig? _config;
    
    private static IntPtr _window = IntPtr.Zero;
    private static IntPtr _webView = IntPtr.Zero;
    private static IntPtr _overlay = IntPtr.Zero;
    private static IntPtr _animation = IntPtr.Zero;
    private static IntPtr _loadingAnimation = IntPtr.Zero;
    private static bool _splashVisible;

    /// <summary>
    /// Запустить WebKit с указанными настройками.
    /// </summary>
    /// <param name="config">Конфигурация Webkit</param>
    /// <param name="loadChangedCallback">Колбек состояния, адресации и закрытия окна.</param>
    /// <exception cref="InvalidOperationException">Когда не удаётся инициализировать WebKit.</exception>
    public static void RunWebkit(WebViewConfig config,
        Action<WebKitLoadEvent, string?, Action> loadChangedCallback)
    {
        if (!GtkWrapper.Initialized.Value)
        {
            throw new NullReferenceException("Gtk is not initialized.");
        }
        
        if (_window != IntPtr.Zero) // Если уже открыто
        {
            throw new InvalidOperationException("Webkit is already running.");
        }

        _config = config;

        _window = Gtk.Window.New(Gtk.WindowType.GtkWindowToplevel);

        if (_window == IntPtr.Zero)
        {
            throw new InvalidOperationException("Failed to create GtkWindow.");
        }

        Gtk.Window.SetTitle(_window, _config.WindowTitle);
        Gtk.Window.SetDefaultSize(_window, config.Width, config.Height);

        // Подписываемся на событие закрытия окна.
        GObject.GSignalConnectData(
            _window, Gtk.Events.Destroy, Marshal.GetFunctionPointerForDelegate(WindowDestroySignalHandler),
            IntPtr.Zero, IntPtr.Zero, GObject.GConnectFlags.GConnectDefault);

        _overlay = Gtk.Overlay.New();
        
        Gtk.Container.Add(_window, _overlay);
        
        _webView = WebKitGtk.WebView.New();
        if (_webView == IntPtr.Zero)
        {
            GObject.Unref(_window);
            _window = IntPtr.Zero;
            throw new Exception("Failed to create webview.");
        }

        if (!_config.AllowSelection)
        {
            DisableWebViewSelection();
        }

        Gtk.Container.Add(_overlay, _webView);

        // Подписываемся на событие закрытия WebView
        GObject.GSignalConnectData(
            _webView, WebKitGtk.Events.Close, Marshal.GetFunctionPointerForDelegate(WebviewCloseSignalHandler),
            IntPtr.Zero, IntPtr.Zero, GObject.GConnectFlags.GConnectDefault);

        // Подписываемся на событие навигации
        _onLoadChangedHandler = (webView, loadEventPtr, _) =>
        {
            int loadEventInt = loadEventPtr.ToInt32();
            var loadEvent = (WebKitLoadEvent)loadEventInt;

            // Выключение splash загрузки
            if (_splashVisible && loadEvent is WebKitLoadEvent.WebkitLoadStarted)
            {
                Gtk.Widget.Hide(_loadingAnimation);
                _splashVisible = false;
            }

            IntPtr uriPtr = WebKitGtk.WebView.GetUri(webView);
            string? currentUri = uriPtr == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(uriPtr);
            loadChangedCallback.Invoke(loadEvent, currentUri, () =>
            {
                DestroyWindow();
                Gtk.MainQuit();
            });
        };

        GObject.GSignalConnectData(
            _webView, WebKitGtk.Events.LoadChanged, Marshal.GetFunctionPointerForDelegate(_onLoadChangedHandler),
            IntPtr.Zero, IntPtr.Zero, GObject.GConnectFlags.GConnectDefault);

        if (config.StrictMode) // Строгий режим.
        {
            GObject.GSignalConnectData(_webView, WebKitGtk.Events.DecidePolicy,
                Marshal.GetFunctionPointerForDelegate(DecidePolicyHandler),
                IntPtr.Zero, IntPtr.Zero, GObject.GConnectFlags.GConnectDefault);
        }

        if (config.DebugMode)
        {
            IntPtr settings = WebKitGtk.WebView.GetSettings(_webView);
            WebKitGtk.Settings.SetEnableDeveloperExtras(settings, GBoolean.True);
        }
        else
        {
            // Отключение контекстного меню
            GObject.GSignalConnectData(_webView, WebKitGtk.Events.ContextMenu,
                Marshal.GetFunctionPointerForDelegate(ContextMenuHandler),
                IntPtr.Zero, IntPtr.Zero, GObject.GConnectFlags.GConnectDefault);
        }

        // Если задан splash, то устанавливаем.
        if (!string.IsNullOrWhiteSpace(config.SplashFilename) && File.Exists(config.SplashFilename))
        {
            _animation = GdkPixbuf.gdk_pixbuf_animation_new_from_file(config.SplashFilename, IntPtr.Zero);
            if (_animation != IntPtr.Zero)
            {
                _loadingAnimation = Gtk.Image.NewFromAnimation(_animation);
                Gtk.Overlay.AddOverlay(_overlay, _loadingAnimation);
                Gtk.Widget.SetHAlign(_loadingAnimation, Gtk.GtkAlign.GtkAlignCenter);
                Gtk.Widget.SetVAlign(_loadingAnimation, Gtk.GtkAlign.GtkAlignCenter);
                _splashVisible = true;
            }
        }

        WebKitGtk.WebView.LoadUri(_webView, config.StartUri.ToString());

        Gtk.Window.SetPosition(_window, _config.WindowPosition);
        Gtk.Widget.ShowAll(_window);
        Gtk.Main();
    }
}

Часть с делегатами намеренно опустил, чтобы не раздувать листинги.

Благодаря этой обёртке теперь имеем нехитрый нативный API для WebKitGTK:

Uri homepageUri = new(_baseAddress, "index.html");
        WebViewConfig viewConfig =
            new(homepageUri.ToString())
            {
                Width = 1250,
                Height = 1000,
                SplashFilename = SplashFilename,
#if DEBUG
                WindowTitle = "Github App (DEBUG MODE)",
                AllowSelection = true,
                StrictMode = false,
                DebugMode = true,
#else
                WindowTitle = "Github App", 
                AllowSelection = false,
                StrictMode = true,
                DebugMode = false,
                AllowedUrls = [_baseAddress],
#endif
            };

        WebkitGtkWrapper.RunWebkit(viewConfig, 
                                   (navigationEvent, uri, terminator) =>
        {
            Console.CancelKeyPress += (_, e) =>
            {
                e.Cancel = true;
                Console.WriteLine("Exiting...");
                backendHost.StopAsync().Wait();
                terminator();
            };
        });

Для профиля сборки Debug я разрешил отладчик webview и выделение курсором для удобства отладки. В профиле Release webview не сможет перейти вне заданных адресов AllowedUrls, отладчик и контекстное меню будут отключены. В контекстном меню оставляем доступными только пункты «Копировать, Вставить, Вырезать».

Интеграция libsecret

Аналогичным образом пишу interop-код для LibSecret и обёртку для него.

Опустив PInvoke и unsafe код, имеем такую удобную обёртку:

public static class TokenManager
{
    private const string ServiceName = "GithubApp";
    private const string Account = "Token";
    private const int SaltSize = 16;
    private const int KeySize = 32;
    private const int Iterations = 100_000;

    public static bool IsTokenExist()
    {
        string? data = LibSecret.GetSecret(ServiceName, Account);
        return !string.IsNullOrWhiteSpace(data);
    }
    
    public static bool SetRefreshToken(string token, string pinCode)
    {
        if (pinCode is not { Length: 4 })
        {
            throw new ArgumentException("Invalid pin code");
        }
        
        LibSecret.DeleteSecret(ServiceName, Account);
        
        byte[] bytes = Encoding.UTF8.GetBytes(token);
        byte[] salt = GenerateSalt(SaltSize);
        
        SecretInfo secretInfo = 
            EncryptToBase64(bytes, pinCode, salt);
        
        string json = JsonSerializer.Serialize(secretInfo, AssemblySerializationContext.Default.SecretInfo);
        return LibSecret.SetSecret(ServiceName, Account, json);
    }
    
    private static SecretInfo EncryptToBase64(byte[] payload, string pinCode, byte[] salt)
    {
        using Aes aes = Aes.Create();
        aes.GenerateIV();
        byte[] iv = aes.IV;
        aes.Key = DeriveKey(pinCode, salt);

        using MemoryStream memoryStream = new MemoryStream();
        using CryptoStream cryptoStream = new CryptoStream(memoryStream, aes.CreateEncryptor(), CryptoStreamMode.Write);
        cryptoStream.Write(payload, 0, payload.Length);
        cryptoStream.FlushFinalBlock();
        
        return new SecretInfo()
        {
            Secret = Convert.ToBase64String(memoryStream.ToArray()),
            Iv = Convert.ToBase64String(iv),
            Salt = Convert.ToBase64String(salt)
        };
    }

    public static string? TryGetRefreshToken(string pinCode)
    {
        string? encryptedToken = LibSecret.GetSecret(ServiceName, Account);
        
        if (pinCode is not { Length: 4 })
        {
            throw new ArgumentException("Invalid pin code");
        }

        if (string.IsNullOrWhiteSpace(encryptedToken))
        {
            return null;
        }
        
        var secretInfo = JsonSerializer.Deserialize<SecretInfo>(encryptedToken, AssemblySerializationContext.Default.SecretInfo);
        byte[] iv = Convert.FromBase64String(secretInfo.Iv);
        byte[] salt = Convert.FromBase64String(secretInfo.Salt);
        byte[] key = DeriveKey(pinCode, salt);
        byte[] encryptedPayload = Convert.FromBase64String(secretInfo.Secret);
        string token = Decrypt(encryptedPayload, key, iv);

        return token;
    }
    
    private static string Decrypt(byte[] payload, byte[] key, byte[] iv)
    {
        using Aes aes = Aes.Create();
        aes.Key = key;
        aes.IV = iv;
        using MemoryStream memoryStream = new MemoryStream(payload);
        using CryptoStream cryptoStream = new CryptoStream(memoryStream, aes.CreateDecryptor(), CryptoStreamMode.Read);
        using StreamReader reader = new StreamReader(cryptoStream);
        return reader.ReadToEnd();
    }

    private static byte[] GenerateSalt(int size)
    {
        byte[] salt = new byte[size];
        RandomNumberGenerator.Fill(salt);
        return salt;
    }

    private static byte[] DeriveKey(string pinCode, byte[] salt)
    {
        using var pbkdf2 = new Rfc2898DeriveBytes(pinCode, salt, Iterations, HashAlgorithmName.SHA256);
        return pbkdf2.GetBytes(KeySize);
    }
}

Бекэнд на ASP.NET AOT

Переходим к реализации бекенда. Я использовал minimal API с прицелом на сборку AOT. У этого подхода есть свои сложности. Например, коробочный функционал стандартного MVC Asp .net приложения не доступен полностью ввиду ограничений aot для рефлексии. Недоступны контроллеры.

public static class Program
{
    public static void Main(string[] args)
    {
        CreateHost(args).Run();
    }

    public static IHost CreateHost(string[] args)
    {
        string? refreshToken = Environment.GetEnvironmentVariable("REFRESH_TOKEN");
        string? accessToken = Environment.GetEnvironmentVariable("ACCESS_TOKEN");

        if (string.IsNullOrWhiteSpace(refreshToken))
        {
            throw new InvalidOperationException("Please set environment variable REFRESH_TOKEN");
        }
        
        if (string.IsNullOrWhiteSpace(accessToken))
        {
            throw new InvalidOperationException("Please set environment variable ACCESS_TOKEN");
        }
        
        var builder = WebApplication.CreateBuilder(args);

        builder.Services.AddTransient<HttpClient>(_ =>
        {
            HttpClient client = new();
            client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
            return client;
        });

        builder.Services.AddMemoryCache();

        List<IEndpointDefinition> definitions =
        [
            new PlTrendingsEndpoints()
        ];
        foreach (var def in definitions)
        {
            def.DefineServices(builder.Services);
        }

#if DEBUG
        builder.Services.AddCors(options =>
        {
            options.AddPolicy("AllowAngularApp",
                policy =>
                {
                    policy.WithOrigins("http://localhost:4200")
                        .AllowAnyMethod()
                        .AllowAnyHeader();
                });
        });
#endif
        
        var app = builder.Build();
        
#if DEBUG
        app.UseCors("AllowAngularApp");
#endif
        

        // Загружаем встроенные файлы из ресурсов сборки
        EmbeddedFileProvider embeddedProvider = new(Assembly.GetExecutingAssembly(), "");
        // Настраиваем раздачу встроенных файлов
        app.UseStaticFiles(new StaticFileOptions
        {
            FileProvider = embeddedProvider,
            RequestPath = ""
        });
        // SPA Fallback
        app.MapFallback(async context =>
        {
            var file = embeddedProvider.GetFileInfo("index.html");
            if (file.Exists)
            {
                context.Response.ContentType = "text/html";
                await using var stream = file.CreateReadStream();
                await stream.CopyToAsync(context.Response.Body);
            }
            else
            {
                context.Response.StatusCode = 404;
                await context.Response.WriteAsync("Not found");
            }
        });

        foreach (var def in definitions)
        {
            def.DefineEndpoints(app.MapGroup(def.GroupEndpoint));
        }

        return app;
    }
}

List<IEndpointDefinition> - это коллекция групп эндпоинтов. В этом интерфейсе мы объявляем методы для регистрации эндпоинтов.

public interface IEndpointDefinition
{
    string GroupEndpoint { get; }
    void DefineEndpoints(RouteGroupBuilder group);
    void DefineServices(IServiceCollection services);
}

Интерфейс позволяет нам реализовать модули такого вида:

public class PlTrendingsEndpoints : IEndpointDefinition
{
    public string GroupEndpoint => "/trend";

    public void DefineEndpoints(RouteGroupBuilder group)
    {
        group.MapGet("/diagram",
            async (string language, int beginYear, int endYear, string? format, ApiClient client, IMemoryCache cache) =>
            {
                if (string.IsNullOrWhiteSpace(language)
                    || beginYear < 2008
                    || endYear > 2025
                    || beginYear >= endYear)
                {
                    Results.BadRequest();
                }
                
                string cacheKey = $"{nameof(PlTrendings)}-{language}-{beginYear}-{endYear}";
                string? base64;
                
                if (!cache.TryGetValue(cacheKey, out base64) || string.IsNullOrWhiteSpace(base64))
                {
                    base64 =
                        await client.GetBase64PngDiagramForYears(
                            language, beginYear, endYear, 1024, 768);
                    cache.Set(cacheKey, base64);
                }

                if (format == "base64")
                {
                    return Results.Ok(base64);
                }

                // Декодируем base64 в byte[]
                byte[] imageBytes = Convert.FromBase64String(base64);
                // Возвращаем картинку в виде файла
                return Results.File(imageBytes, "image/png");
            });
    }

    public void DefineServices(IServiceCollection services)
    {
        services.AddTransient<ApiClient>();
        services.Configure<JsonOptions>(options =>
        {
            options.SerializerOptions.TypeInfoResolver = AssemblySerializationContext.Default;
        });
    }
}

Замечание: Обязательным условием при использовании сериализации json в AOT является объявление контекста сериализации, где перечислены DTO, которые мы сериализуем/десериализуем. Без этого произойдёт «обрезание» при сборке - сериализация корректно работать не будет!

Контекст сериализации

[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(int))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(List<string>))]
public partial class AssemblySerializationContext : JsonSerializerContext
{
}

Файлы Angular мы будем предоставлять как статическое содержимое, запакованное в сборку backend.

Класс ApiClient отвечает за генерацию диаграмм на базе данных запросов GraphQL Github API. Для создания диаграмм я написал собственную простую реализацию рендеринга при помощи SkiaSharp.

Так выглядит сгенерированная диаграмма для Python.

Диаграмма для Python

Диаграмма для Python

Так же я добавил in-memory кеширование с целью уменьшения повторных запросов к API Github.

Фронтент на Angular

В этом кейсе я выбрал Angular. Да, это далеко не самый рациональный выбор фреймворка с точки зрения производительности. Но аналогичная часть этой кодовой базы у меня уже была в одном из pet-проектов и я решил её переиспользовать.

А еще TypeScript мне ближе по духу, это тоже немаловажно =)

Главная страница довольно проста.

<main class="main">
  <app-select-language-dialog *ngIf="isDialogOpen" [languages]="availableLanguages" (close)="closeDialog()"
    (languageSelected)="addLanguage($event)">
  </app-select-language-dialog>

  <div class="content">
    <h1 id="title">Тренды языков программирования: анализ роста.</h1>
    <p>📊 Сколько репозиториев создавалось каждый год? Давайте разберёмся!</p>

    <div class="controls">
      <button class="app-button add-lang-btn" (click)="openDialog()">Добавить язык</button>
    </div>

    <div class="lang-trends-container">
      <div *ngFor="let lang of selectedLanguages" class="lang-card">
        <!-- Заголовок + кнопка удаления -->
        <div class="lang-header">
          <p>{{ lang }}</p>
          <button class="remove-btn" (click)="removeLanguage(lang)">✖</button>
        </div>

        <app-lang-trend [language]="lang"></app-lang-trend>
      </div>
    </div>
  </div>
</main>

Компонент app-lang-trend отвечает за получение из бекенда изображения в формате base64 и его отображение. Логика подразумевает возможность добавления и удаления диаграмм языков.

Я осознанно не стал добавлять в клиент material пакеты и написал вручную, на голом css + ts. Нужен был лишь ограниченный функционал из этих пакетов, поэтому я отказался от их использования.

<!-- Оверлей для затемнения и выхода из зума -->
<div class="overlay" *ngIf="isZoomed$ | async" (click)="toggleZoom()"></div>

<!-- Картинка с увеличением -->
<div class="image-container" [class.zoomed]="isZoomed$ | async" (click)="toggleZoom()">
  <div *ngIf="isLoading" class="spinner"></div>
  <div *ngIf="hasError" class="error-message">Ошибка загрузки</div>
  <img *ngIf="base64Image$ | async as base64Image" [src]="base64Image" alt="Trending Image">
</div>

Было бы совсем скучно отображать обычные картинки. Я добавил возможность увеличения по клику.

@Component({
  selector: 'app-lang-trend',
  imports: [CommonModule],
  templateUrl: './lang-trend.component.html',
  styleUrl: './lang-trend.component.css'
})
export class LangTrendComponent implements OnInit {
  @Input() language: string = "C#";
  base64Image$: Observable<string|null> | undefined;
  isLoading: boolean = true;
  imageId: string;
  isZoomed$: Observable<boolean>;
  hasError: boolean = false;

  constructor(
    private langTrendingService: PlTrendingsService,
    private zoomService: ZoomService) {
    this.imageId = `image-${Math.random().toString(36).substr(2, 9)}`;
    this.isZoomed$ = this.zoomService.zoomedImageId$.pipe(map(id => id === this.imageId));
  }

  ngOnInit(): void {
    this.base64Image$ = this.langTrendingService.getLangTrendingImage(this.language, 2008, 2024).pipe(
      map(base64Data => `data:image/png;base64,${base64Data}`),
      catchError(error => {
        console.error(`Ошибка загрузки изображения для ${this.language}:`, error);
        this.hasError = true; // Фиксируем ошибку
        return of(null); // Возвращаем `null`, чтобы не сломался поток
      }),
      finalize(() => this.isLoading = false) // Завершаем состояние загрузки
    );
  }

  toggleZoom() {
    if (this.hasError) return;

    const currentZoomedId = this.zoomService.getZoomedImageId(); // Получаем текущее значение
    this.zoomService.setZoomedImage(currentZoomedId === this.imageId ? null : this.imageId);
  }
}

Для загрузки данных инкапсулируем логику в сервис PlTrendingsService — он будет работать с нашим API ASP .NET.

@Injectable({
  providedIn: 'root'
})
export class PlTrendingsService {
  constructor(private httpClient: HttpClient) { }

  getLangTrendingImage(language: string, beginYear: number, endYear: number): Observable<string> {
    let url = `/trend/diagram?language=${encodeURIComponent(language)}&beginYear=${beginYear}&endYear=${endYear}&format=base64`;
    return this.httpClient.get<string>(url);
  }
}

И сервис для управления состоянием зума диаграмм. Он позволит просматривать в увеличенном режиме только одну диаграмму одновременно:

@Injectable({
  providedIn: 'root'
})
export class ZoomService {
  private zoomedImageId = new BehaviorSubject<string | null>(null);
  zoomedImageId$ = this.zoomedImageId.asObservable();

  setZoomedImage(id: string | null) {
    this.zoomedImageId.next(id);
  }

  getZoomedImageId(): string | null {
    return this.zoomedImageId.getValue();
  }
}

Об алгоритме работы

При первом входе, приложение попросит войти в аккаунт Github. При успешном входе webview вернёт код oauth для обмена на access+refresh токены. Полученные токены шифруются и сохраняются в libsecret в виде вектора инициализации, соли и зашифрованной части. Ключ для расшифровки состоит из соли и пин-кода. Упрощённо, он будет четырёхзначным. Это мера дополнительной безопасности при хранении в libsecret. Многие приложения в Linux хранят пароли в libsecret в незашифрованном виде.

Текущий подход подразумевает установку пин-кода. При повторном входе в приложение, если в libsecret существует зашифрованный refresh token, мы просто просим у пользователя пин-код, расшифровываем refresh token и получаем новый access token. Если refresh token истёк, то открывает окно входа OAuth Github, которое позволяет получить актуальную связку токенов.

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

Результат

После трёх дней экспериментов и работы над кодовой базой, приложение готово.

Главное окно приложения

Главное окно приложения
Краткая демонстрация работы приложения

Краткая демонстрация работы приложения

Результат можно лицезреть на вложенной гифке или более детально на видео.

Сбор метрик и сравнение

Для более наглядного сравнения я создам «голое» приложение Avalonia без логики и без фреймворков CommunityToolkit и ReactiveUI. В приложение входят только следующие пакеты:

  • Avalonia-11.2.1

  • Avalonia.Desktop-11.2.1

  • Avalonia.Themes.Fluent-11.2.1

  • Avalonia.Fonts.Inter-11.2.1.

Профиль сборки идентичный моему приложению.

Да, Avalonia тоже поддерживает AOT и это круто.

Размер бинарного файла

Бинарник моего приложения, собранного в профиле Release, с оптимизациями и AOT, весит 33 Мб. В сжатом виде (flatpak пакет) около 11 мб. Вместе с бинарником библиотеки SkiaSharp.

Бинарник Avalonia весит аналогично - 33 Мб.

Сравнение размера выходных файлов приложений

Сравнение размера выходных файлов приложений

Потребление ОЗУ

Для определения этого типа метрик я буду использовать приложение smem.

USS Unique Set Size — количество физической памяти, которое используется исключительно данным процессом.

PSSProportional Set Size — количество памяти, пропорционально распределённое между всеми процессами, которые её используют.

RSSResident Set Size — Общее количество физической памяти, которое занимает процесс, включая уникальную и разделяемую память.

Моё приложение в загруженном состоянии, с шестью загруженными диаграммами, потребляет

USS: 77 Мб

PSS: 95.4 Мб

RSS: 182.9 Мб

Пустое приложение Avalonia:

USS: 50.5 Мб

PSS: 59.7 Мб

RSS: 104.2 Мб

Диаграмма сравнения использования ОЗУ

Диаграмма сравнения использования ОЗУ

Скорость запуска и пользовательский опыт

Приложение, загружается быстро. При загрузке основная часть времени уходит на запрос к Github API. Визуально, по скорости запуска не уступает приложению Avalonia.

Выводы

Проведя этот эксперимент, я убедился в жизнеспособности стека ASP .NET AOT + WebKitGTK + Js frontend. Конечно это нельзя назвать какой-то прорывной технологией или чем-то экстраординарным. Но раньше я не встречал использование такого стека. Этот стек похож больше на tauri и меньше на electron. По использованию ресурсов и быстродействию в описанном сценарии стек текущего приложения точно не уступает Electron и Avalonia. Большим преимуществом перед Electron является использование строго типизированного языка программирования и сборка в нативный код с триммингом. Значит ли это, что этот стек является убийцей Electron? Нет, не значит. Реализация этого приложения лишь показывает, что можно разрабатывать клиент-серверные приложения на чистом .net, без задействования JS и Electron.

Ничего не мешает доработать это приложение и дополнить его библиотекой edge webview2, базирующейся на Winforms/WPF окне. Это позволит переиспользовать логику для Windows. Логика бекенда и фронтенда в этом случае не требует изменения.

От сервера к десктопу: эксперимент с ASP.NET AOT и WebKitGTK - 7

Вывод, за несколько дней я написал Electron-like приложение на .net, попробовав в деле AOT + minimal API.

Эксперимент считаю удавшимся.

Спасибо за внимание!

Автор: Madfisht3

Источник

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


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