about summary refs log tree commit diff
path: root/MatrixRoomUtils.Desktop
diff options
context:
space:
mode:
authorEmma@Rory& <root@rory.gay>2023-07-24 20:57:23 +0200
committerEmma@Rory& <root@rory.gay>2023-07-24 20:57:23 +0200
commitcf6f87bea88df3f90e33dfdc5bb16f47b8716054 (patch)
tree92605891ae683d91f1d71a725d1c94f6675be43b /MatrixRoomUtils.Desktop
parentDebug validation api (diff)
downloadMatrixUtils-cf6f87bea88df3f90e33dfdc5bb16f47b8716054.tar.xz
Start of MRU Desktop
Diffstat (limited to 'MatrixRoomUtils.Desktop')
-rw-r--r--MatrixRoomUtils.Desktop/App.axaml10
-rw-r--r--MatrixRoomUtils.Desktop/App.axaml.cs43
-rw-r--r--MatrixRoomUtils.Desktop/FileStorageProvider.cs35
-rw-r--r--MatrixRoomUtils.Desktop/LoginWindow.axaml15
-rw-r--r--MatrixRoomUtils.Desktop/LoginWindow.axaml.cs36
-rw-r--r--MatrixRoomUtils.Desktop/MRUDesktopConfiguration.cs44
-rw-r--r--MatrixRoomUtils.Desktop/MRUStorageWrapper.cs132
-rw-r--r--MatrixRoomUtils.Desktop/MainWindow.axaml17
-rw-r--r--MatrixRoomUtils.Desktop/MainWindow.axaml.cs41
-rw-r--r--MatrixRoomUtils.Desktop/MatrixRoomUtils.Desktop.csproj49
-rw-r--r--MatrixRoomUtils.Desktop/Program.cs31
-rw-r--r--MatrixRoomUtils.Desktop/Properties/launchSettings.json26
-rw-r--r--MatrixRoomUtils.Desktop/RoomListEntry.axaml11
-rw-r--r--MatrixRoomUtils.Desktop/RoomListEntry.axaml.cs17
-rw-r--r--MatrixRoomUtils.Desktop/app.manifest18
-rw-r--r--MatrixRoomUtils.Desktop/appsettings.Development.json13
-rw-r--r--MatrixRoomUtils.Desktop/appsettings.json13
17 files changed, 551 insertions, 0 deletions
diff --git a/MatrixRoomUtils.Desktop/App.axaml b/MatrixRoomUtils.Desktop/App.axaml
new file mode 100644
index 0000000..9c99838
--- /dev/null
+++ b/MatrixRoomUtils.Desktop/App.axaml
@@ -0,0 +1,10 @@
+<Application xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             x:Class="MatrixRoomUtils.Desktop.App"
+             RequestedThemeVariant="Default">
+             <!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
+
+    <Application.Styles>
+        <FluentTheme />
+    </Application.Styles>
+</Application>
\ No newline at end of file
diff --git a/MatrixRoomUtils.Desktop/App.axaml.cs b/MatrixRoomUtils.Desktop/App.axaml.cs
new file mode 100644
index 0000000..3dfcdee
--- /dev/null
+++ b/MatrixRoomUtils.Desktop/App.axaml.cs
@@ -0,0 +1,43 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using MatrixRoomUtils.Core.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace MatrixRoomUtils.Desktop;
+
+public partial class App : Application {
+    public IHost host { get; set; }
+
+    public override void Initialize() {
+        AvaloniaXamlLoader.Load(this);
+    }
+
+    public override void OnFrameworkInitializationCompleted() {
+        host = Host.CreateDefaultBuilder().ConfigureServices((ctx, services) => {
+            services.AddScoped<MRUDesktopConfiguration>();
+            services.AddScoped<TieredStorageService>(x =>
+                new(
+                    cacheStorageProvider: new FileStorageProvider(x.GetService<MRUDesktopConfiguration>().CacheStoragePath),
+                    dataStorageProvider: new FileStorageProvider(x.GetService<MRUDesktopConfiguration>().CacheStoragePath)
+                )
+            );
+            services.AddRoryLibMatrixServices();
+            // foreach (var commandClass in new ClassCollector<ICommand>().ResolveFromAllAccessibleAssemblies()) {
+            // Console.WriteLine($"Adding command {commandClass.Name}");
+            // services.AddScoped(typeof(ICommand), commandClass);
+            // }
+            services.AddScoped<MRUStorageWrapper>();
+            services.AddScoped<MainWindow>();
+            services.AddSingleton(this);
+        }).UseConsoleLifetime().Build();
+
+        if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
+            var scopeFac = host.Services.GetService<IServiceScopeFactory>();
+            var scope = scopeFac.CreateScope();
+            desktop.MainWindow = scope.ServiceProvider.GetRequiredService<MainWindow>();
+        }
+        base.OnFrameworkInitializationCompleted();
+    }
+}
diff --git a/MatrixRoomUtils.Desktop/FileStorageProvider.cs b/MatrixRoomUtils.Desktop/FileStorageProvider.cs
new file mode 100644
index 0000000..36025eb
--- /dev/null
+++ b/MatrixRoomUtils.Desktop/FileStorageProvider.cs
@@ -0,0 +1,35 @@
+using System.Text.Json;
+using MatrixRoomUtils.Core.Extensions;
+using MatrixRoomUtils.Core.Interfaces.Services;
+using Microsoft.Extensions.Logging;
+
+namespace MatrixRoomUtils.Desktop; 
+
+public class FileStorageProvider : IStorageProvider {
+    private readonly ILogger<FileStorageProvider> _logger;
+
+    public string TargetPath { get; }
+
+    /// <summary>
+    /// Creates a new instance of <see cref="FileStorageProvider" />.
+    /// </summary>
+    /// <param name="targetPath"></param>
+    public FileStorageProvider(string targetPath) {
+        new Logger<FileStorageProvider>(new LoggerFactory()).LogInformation("test");
+        Console.WriteLine($"Initialised FileStorageProvider with path {targetPath}");
+        TargetPath = targetPath;
+        if(!Directory.Exists(targetPath)) {
+            Directory.CreateDirectory(targetPath);
+        }
+    }
+
+    public async Task SaveObjectAsync<T>(string key, T value) => await File.WriteAllTextAsync(Path.Join(TargetPath, key), ObjectExtensions.ToJson(value));
+
+    public async Task<T?> LoadObjectAsync<T>(string key) => JsonSerializer.Deserialize<T>(await File.ReadAllTextAsync(Path.Join(TargetPath, key)));
+
+    public async Task<bool> ObjectExistsAsync(string key) => File.Exists(Path.Join(TargetPath, key));
+
+    public async Task<List<string>> GetAllKeysAsync() => Directory.GetFiles(TargetPath).Select(Path.GetFileName).ToList();
+
+    public async Task DeleteObjectAsync(string key) => File.Delete(Path.Join(TargetPath, key));
+}
\ No newline at end of file
diff --git a/MatrixRoomUtils.Desktop/LoginWindow.axaml b/MatrixRoomUtils.Desktop/LoginWindow.axaml
new file mode 100644
index 0000000..d61bfd3
--- /dev/null
+++ b/MatrixRoomUtils.Desktop/LoginWindow.axaml
@@ -0,0 +1,15 @@
+<Window 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:desktop="clr-namespace:MatrixRoomUtils.Desktop"
+        mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+        x:Class="MatrixRoomUtils.Desktop.LoginWindow"
+        Title="LoginWindow"
+        x:DataType="desktop:LoginWindow">
+    <StackPanel>
+        <TextBox Text="{Binding Username, Mode=TwoWay}" />
+        <MaskedTextBox Text="{Binding Password, Mode=TwoWay}" />
+        <Button Click="Login">Login</Button>
+    </StackPanel>
+</Window>
diff --git a/MatrixRoomUtils.Desktop/LoginWindow.axaml.cs b/MatrixRoomUtils.Desktop/LoginWindow.axaml.cs
new file mode 100644
index 0000000..1f31b05
--- /dev/null
+++ b/MatrixRoomUtils.Desktop/LoginWindow.axaml.cs
@@ -0,0 +1,36 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+
+namespace MatrixRoomUtils.Desktop;
+
+public partial class LoginWindow : Window {
+    private readonly MRUStorageWrapper _storage;
+
+    public LoginWindow(MRUStorageWrapper storage) {
+        _storage = storage;
+        InitializeComponent();
+#if DEBUG
+        this.AttachDevTools();
+#endif
+    }
+
+    private void InitializeComponent() {
+        AvaloniaXamlLoader.Load(this);
+    }
+
+    public string Username { get; set; }
+    public string Password { get; set; }
+    // ReSharper disable once AsyncVoidMethod
+    private async void Login(object? sender, RoutedEventArgs e) {
+        var res = await _storage.Login(Username.Split(':')[1], Username.Split(':')[0][1..], Password);
+        if (res is not null) {
+            await _storage.AddToken(res);
+            Close();
+        }
+        else {
+            Password = "";
+        }
+    }
+}
diff --git a/MatrixRoomUtils.Desktop/MRUDesktopConfiguration.cs b/MatrixRoomUtils.Desktop/MRUDesktopConfiguration.cs
new file mode 100644
index 0000000..d321591
--- /dev/null
+++ b/MatrixRoomUtils.Desktop/MRUDesktopConfiguration.cs
@@ -0,0 +1,44 @@
+using System.Collections;
+using ArcaneLibs.Extensions;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace MatrixRoomUtils.Desktop;
+
+public class MRUDesktopConfiguration {
+    private static ILogger<MRUDesktopConfiguration> _logger;
+
+    public MRUDesktopConfiguration(ILogger<MRUDesktopConfiguration> logger, IConfiguration config, HostBuilderContext host) {
+        _logger = logger;
+        logger.LogInformation($"Loading configuration for environment: {host.HostingEnvironment.EnvironmentName}...");
+        config.GetSection("MRUDesktop").Bind(this);
+        DataStoragePath = ExpandPath(DataStoragePath);
+        CacheStoragePath = ExpandPath(CacheStoragePath);
+    }
+
+    public string DataStoragePath { get; set; } = "";
+    public string CacheStoragePath { get; set; } = "";
+
+    private static string ExpandPath(string path, bool retry = true) {
+        _logger.LogInformation($"Expanding path `{path}`");
+
+        if (path.StartsWith("~")) {
+            path = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), path[1..]);
+        }
+
+        Environment.GetEnvironmentVariables().Cast<DictionaryEntry>().OrderByDescending(x => x.Key.ToString().Length).ToList().ForEach(x => {
+            path = path.Replace($"${x.Key}", x.Value.ToString());
+        });
+
+        _logger.LogInformation($"Expanded path to `{path}`");
+        int tries = 0;
+        while(retry && path.ContainsAnyOf("~$".Split())) {
+            if(tries++ > 100)
+                throw new Exception($"Path `{path}` contains unrecognised environment variables");
+            path = ExpandPath(path, false);
+        }
+
+        return path;
+    }
+}
diff --git a/MatrixRoomUtils.Desktop/MRUStorageWrapper.cs b/MatrixRoomUtils.Desktop/MRUStorageWrapper.cs
new file mode 100644
index 0000000..27403dc
--- /dev/null
+++ b/MatrixRoomUtils.Desktop/MRUStorageWrapper.cs
@@ -0,0 +1,132 @@
+using MatrixRoomUtils.Core;
+using MatrixRoomUtils.Core.Responses;
+using MatrixRoomUtils.Core.Services;
+
+namespace MatrixRoomUtils.Desktop;
+
+public class MRUStorageWrapper {
+    private readonly TieredStorageService _storageService;
+    private readonly HomeserverProviderService _homeserverProviderService;
+
+    public MRUStorageWrapper(
+        TieredStorageService storageService,
+        HomeserverProviderService homeserverProviderService
+    ) {
+        _storageService = storageService;
+        _homeserverProviderService = homeserverProviderService;
+    }
+
+    public async Task<List<LoginResponse>?> GetAllTokens() {
+        if(!await _storageService.DataStorageProvider.ObjectExistsAsync("mru.tokens")) {
+            return null;
+        }
+        return await _storageService.DataStorageProvider.LoadObjectAsync<List<LoginResponse>>("mru.tokens") ??
+               new List<LoginResponse>();
+    }
+
+    public async Task<LoginResponse?> GetCurrentToken() {
+        if(!await _storageService.DataStorageProvider.ObjectExistsAsync("token")) {
+            return null;
+        }
+        var currentToken = await _storageService.DataStorageProvider.LoadObjectAsync<LoginResponse>("token");
+        var allTokens = await GetAllTokens();
+        if (allTokens is null or { Count: 0 }) {
+            await SetCurrentToken(null);
+            return null;
+        }
+
+        if (currentToken is null) {
+            await SetCurrentToken(currentToken = allTokens[0]);
+        }
+
+        if (!allTokens.Any(x => x.AccessToken == currentToken.AccessToken)) {
+            await SetCurrentToken(currentToken = allTokens[0]);
+        }
+
+        return currentToken;
+    }
+
+    public async Task AddToken(LoginResponse loginResponse) {
+        var tokens = await GetAllTokens();
+        if (tokens == null) {
+            tokens = new List<LoginResponse>();
+        }
+
+        tokens.Add(loginResponse);
+        await _storageService.DataStorageProvider.SaveObjectAsync("mru.tokens", tokens);
+    }
+
+    private async Task<AuthenticatedHomeServer?> GetCurrentSession() {
+        var token = await GetCurrentToken();
+        if (token == null) {
+            return null;
+        }
+
+        return await _homeserverProviderService.GetAuthenticatedWithToken(token.Homeserver, token.AccessToken);
+    }
+
+    public async Task<AuthenticatedHomeServer?> GetCurrentSessionOrPrompt() {
+        AuthenticatedHomeServer? session = null;
+
+        try {
+            //catch if the token is invalid
+            session = await GetCurrentSession();
+        }
+        catch (MatrixException e) {
+            if (e.ErrorCode == "M_UNKNOWN_TOKEN") {
+                var token = await GetCurrentToken();
+                // _navigationManager.NavigateTo("/InvalidSession?ctx=" + token.AccessToken);
+                return null;
+            }
+
+            throw;
+        }
+
+        if (session is null) {
+            // _navigationManager.NavigateTo("/Login");
+            var wnd = new LoginWindow(this);
+            wnd.Show();
+            while (wnd.IsVisible) await Task.Delay(100);
+            session = await GetCurrentSession();
+        }
+
+        return session;
+    }
+
+    public class Settings {
+        public DeveloperSettings DeveloperSettings { get; set; } = new();
+    }
+
+    public class DeveloperSettings {
+        public bool EnableLogViewers { get; set; } = false;
+        public bool EnableConsoleLogging { get; set; } = true;
+        public bool EnablePortableDevtools { get; set; } = false;
+    }
+
+    public async Task RemoveToken(LoginResponse auth) {
+        var tokens = await GetAllTokens();
+        if (tokens == null) {
+            return;
+        }
+
+        tokens.RemoveAll(x => x.AccessToken == auth.AccessToken);
+        await _storageService.DataStorageProvider.SaveObjectAsync("mru.tokens", tokens);
+    }
+
+    public async Task SetCurrentToken(LoginResponse? auth) {
+        _storageService.DataStorageProvider.SaveObjectAsync("token", auth);
+    }
+
+    public async Task<LoginResponse?> Login(string homeserver, string username, string password) {
+        try {
+            return await _homeserverProviderService.Login(homeserver, username, password);
+        }
+        catch (MatrixException e) {
+            if (e.ErrorCode == "M_FORBIDDEN") {
+                return null;
+            }
+
+            throw;
+        }
+    }
+}
diff --git a/MatrixRoomUtils.Desktop/MainWindow.axaml b/MatrixRoomUtils.Desktop/MainWindow.axaml
new file mode 100644
index 0000000..bc01bee
--- /dev/null
+++ b/MatrixRoomUtils.Desktop/MainWindow.axaml
@@ -0,0 +1,17 @@
+<Window 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"
+        mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+        x:Class="MatrixRoomUtils.Desktop.MainWindow"
+        Title="Rory&amp;::MatrixRoomUtils">
+    <!-- <Interaction.Behaviors> -->
+        <!-- <EventTriggerBehavior EventName="Loaded"> -->
+            <!-- <InvokeCommandAction Command="{Binding LoadedCommand}"></InvokeCommandAction> -->
+        <!-- </EventTriggerBehavior> -->
+    <!-- </Interaction.Behaviors> -->
+    <StackPanel>
+        <Label FontSize="24">Rooms</Label>
+        <StackPanel x:Name="roomList" Orientation="Vertical"/>
+    </StackPanel>
+</Window>
diff --git a/MatrixRoomUtils.Desktop/MainWindow.axaml.cs b/MatrixRoomUtils.Desktop/MainWindow.axaml.cs
new file mode 100644
index 0000000..41e0888
--- /dev/null
+++ b/MatrixRoomUtils.Desktop/MainWindow.axaml.cs
@@ -0,0 +1,41 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace MatrixRoomUtils.Desktop;
+
+public partial class MainWindow : Window {
+    private readonly ILogger<MainWindow> _logger;
+    private readonly MRUStorageWrapper _storageWrapper;
+    private readonly MRUDesktopConfiguration _configuration;
+
+    public MainWindow(ILogger<MainWindow> logger, IServiceScopeFactory scopeFactory) {
+        _logger = logger;
+        _configuration = scopeFactory.CreateScope().ServiceProvider.GetRequiredService<MRUDesktopConfiguration>();
+        _storageWrapper = scopeFactory.CreateScope().ServiceProvider.GetRequiredService<MRUStorageWrapper>();
+        _logger.LogInformation("Initialising MainWindow");
+
+        InitializeComponent();
+
+        _logger.LogInformation("Cache location: " + _configuration.CacheStoragePath);
+        _logger.LogInformation("Data location: " + _configuration.DataStoragePath);
+
+
+        for (int i = 0; i < 100; i++) {
+            roomList.Children.Add(new RoomListEntry());
+        }
+    }
+
+    // ReSharper disable once AsyncVoidMethod
+    protected override async void OnLoaded(RoutedEventArgs e) {
+        _logger.LogInformation("async onloaded override");
+        var hs = await _storageWrapper.GetCurrentSessionOrPrompt();
+        base.OnLoaded(e);
+    }
+
+    // public Command
+    // protected void LoadedCommand() {
+        // _logger.LogInformation("async command");
+    // }
+}
diff --git a/MatrixRoomUtils.Desktop/MatrixRoomUtils.Desktop.csproj b/MatrixRoomUtils.Desktop/MatrixRoomUtils.Desktop.csproj
new file mode 100644
index 0000000..5b6d3f6
--- /dev/null
+++ b/MatrixRoomUtils.Desktop/MatrixRoomUtils.Desktop.csproj
@@ -0,0 +1,49 @@
+<Project Sdk="Microsoft.NET.Sdk">
+    <PropertyGroup>
+        <OutputType>WinExe</OutputType>
+        <TargetFramework>net7.0</TargetFramework>
+        <Nullable>enable</Nullable>
+        <BuiltInComInteropSupport>true</BuiltInComInteropSupport>
+        <ApplicationManifest>app.manifest</ApplicationManifest>
+        <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
+
+        <LangVersion>preview</LangVersion>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <InvariantGlobalization>true</InvariantGlobalization>
+        <PublishTrimmed>true</PublishTrimmed>
+        <PublishReadyToRun>true</PublishReadyToRun>
+        <PublishSingleFile>true</PublishSingleFile>
+        <PublishReadyToRunShowWarnings>true</PublishReadyToRunShowWarnings>
+        <PublishTrimmedShowLinkerSizeComparison>true</PublishTrimmedShowLinkerSizeComparison>
+        <PublishTrimmedShowLinkerSizeComparisonWarnings>true</PublishTrimmedShowLinkerSizeComparisonWarnings>
+    </PropertyGroup>
+
+
+    <ItemGroup>
+        <PackageReference Include="Avalonia" Version="11.0.0" />
+        <PackageReference Include="Avalonia.Desktop" Version="11.0.0" />
+        <PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0" />
+        <PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.0" />
+        <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
+        <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.0" />
+    </ItemGroup>
+
+
+    <ItemGroup>
+        <ProjectReference Include="..\MatrixRoomUtils.Core\MatrixRoomUtils.Core.csproj" />
+    </ItemGroup>
+
+    <ItemGroup>
+        <PackageReference Include="ArcaneLibs" Version="1.0.0-preview3020494760.012ed3f" />
+        <PackageReference Include="Avalonia.Xaml.Behaviors" Version="11.0.0.1" />
+        <PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1" />
+    </ItemGroup>
+    <ItemGroup>
+        <Content Include="appsettings*.json">
+            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+        </Content>
+        <Content Update="appsettings.Local.json">
+            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+        </Content>
+    </ItemGroup>
+</Project>
diff --git a/MatrixRoomUtils.Desktop/Program.cs b/MatrixRoomUtils.Desktop/Program.cs
new file mode 100644
index 0000000..74ab579
--- /dev/null
+++ b/MatrixRoomUtils.Desktop/Program.cs
@@ -0,0 +1,31 @@
+using Avalonia;
+using Microsoft.Extensions.Hosting;
+using Tmds.DBus.Protocol;
+
+namespace MatrixRoomUtils.Desktop;
+
+internal class Program {
+    private static IHost appHost;
+    // Initialization code. Don't use any Avalonia, third-party APIs or any
+    // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
+    // yet and stuff might break.
+    [STAThread]
+    public static async Task Main(string[] args) {
+        try {
+            BuildAvaloniaApp()
+                .StartWithClassicDesktopLifetime(args);
+        }
+        catch (DBusException e) { }
+        catch (Exception e) {
+            Console.WriteLine(e);
+            throw;
+        }
+    }
+
+    // Avalonia configuration, don't remove; also used by visual designer.
+    public static AppBuilder BuildAvaloniaApp()
+        => AppBuilder.Configure<App>()
+            .UsePlatformDetect()
+            .WithInterFont()
+            .LogToTrace();
+}
diff --git a/MatrixRoomUtils.Desktop/Properties/launchSettings.json b/MatrixRoomUtils.Desktop/Properties/launchSettings.json
new file mode 100644
index 0000000..997e294
--- /dev/null
+++ b/MatrixRoomUtils.Desktop/Properties/launchSettings.json
@@ -0,0 +1,26 @@
+{
+  "$schema": "http://json.schemastore.org/launchsettings.json",
+  "profiles": {
+    "Default": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "environmentVariables": {
+
+      }
+    },
+    "Development": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "environmentVariables": {
+        "DOTNET_ENVIRONMENT": "Development"
+      }
+    },
+    "Local config": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "environmentVariables": {
+        "DOTNET_ENVIRONMENT": "Local"
+      }
+    }
+  }
+}
diff --git a/MatrixRoomUtils.Desktop/RoomListEntry.axaml b/MatrixRoomUtils.Desktop/RoomListEntry.axaml
new file mode 100644
index 0000000..c80ef2f
--- /dev/null
+++ b/MatrixRoomUtils.Desktop/RoomListEntry.axaml
@@ -0,0 +1,11 @@
+<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"
+             mc:Ignorable="d" d:DesignWidth="250" d:DesignHeight="32"
+             x:Class="MatrixRoomUtils.Desktop.RoomListEntry">
+    <StackPanel Orientation="Horizontal">
+        <Image MaxWidth="64" x:Name="RoomIcon"></Image>
+        <Label x:Name="RoomName"></Label>
+    </StackPanel>
+</UserControl>
diff --git a/MatrixRoomUtils.Desktop/RoomListEntry.axaml.cs b/MatrixRoomUtils.Desktop/RoomListEntry.axaml.cs
new file mode 100644
index 0000000..490316d
--- /dev/null
+++ b/MatrixRoomUtils.Desktop/RoomListEntry.axaml.cs
@@ -0,0 +1,17 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Media.Imaging;
+
+namespace MatrixRoomUtils.Desktop;
+
+public partial class RoomListEntry : UserControl {
+    public RoomListEntry() {
+        InitializeComponent();
+    }
+
+    protected override void OnLoaded(RoutedEventArgs e) {
+        base.OnLoaded(e);
+        RoomName.Content = "asdf";
+        RoomIcon.Source = new Bitmap("/home/root@Rory/giphy.gif");
+    }
+}
diff --git a/MatrixRoomUtils.Desktop/app.manifest b/MatrixRoomUtils.Desktop/app.manifest
new file mode 100644
index 0000000..35ffb0d
--- /dev/null
+++ b/MatrixRoomUtils.Desktop/app.manifest
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
+  <!-- This manifest is used on Windows only.
+       Don't remove it as it might cause problems with window transparency and embeded controls.
+       For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
+  <assemblyIdentity version="1.0.0.0" name="MatrixRoomUtils.Desktop.Desktop"/>
+
+  <compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
+    <application>
+      <!-- A list of the Windows versions that this application has been tested on
+           and is designed to work with. Uncomment the appropriate elements
+           and Windows will automatically select the most compatible environment. -->
+
+      <!-- Windows 10 -->
+      <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
+    </application>
+  </compatibility>
+</assembly>
diff --git a/MatrixRoomUtils.Desktop/appsettings.Development.json b/MatrixRoomUtils.Desktop/appsettings.Development.json
new file mode 100644
index 0000000..20b09a7
--- /dev/null
+++ b/MatrixRoomUtils.Desktop/appsettings.Development.json
@@ -0,0 +1,13 @@
+{
+    "Logging": {
+        "LogLevel": {
+            "Default": "Debug",
+            "System": "Information",
+            "Microsoft": "Information"
+        }
+    },
+    "MRUDesktop": {
+        "DataStoragePath": "mru-desktop/data",
+        "CacheStoragePath": "mru-desktop/cache"
+    }
+}
diff --git a/MatrixRoomUtils.Desktop/appsettings.json b/MatrixRoomUtils.Desktop/appsettings.json
new file mode 100644
index 0000000..4164e87
--- /dev/null
+++ b/MatrixRoomUtils.Desktop/appsettings.json
@@ -0,0 +1,13 @@
+{
+    "Logging": {
+        "LogLevel": {
+            "Default": "Debug",
+            "System": "Information",
+            "Microsoft": "Information"
+        }
+    },
+    "MRUDesktop": {
+        "DataStoragePath": "~/.local/share/mru-desktop",
+        "CacheStoragePath": "~/.cache/mru-desktop"
+    }
+}