about summary refs log tree commit diff
path: root/MatrixUtils.Desktop
diff options
context:
space:
mode:
Diffstat (limited to 'MatrixUtils.Desktop')
-rw-r--r--MatrixUtils.Desktop/App.axaml10
-rw-r--r--MatrixUtils.Desktop/App.axaml.cs49
-rw-r--r--MatrixUtils.Desktop/Components/NavigationStack.axaml12
-rw-r--r--MatrixUtils.Desktop/Components/NavigationStack.axaml.cs72
-rw-r--r--MatrixUtils.Desktop/Components/Pages/RoomList.axaml21
-rw-r--r--MatrixUtils.Desktop/Components/Pages/RoomList.axaml.cs15
-rw-r--r--MatrixUtils.Desktop/Components/RoomListEntry.axaml16
-rw-r--r--MatrixUtils.Desktop/Components/RoomListEntry.axaml.cs74
-rw-r--r--MatrixUtils.Desktop/LoginWindow.axaml25
-rw-r--r--MatrixUtils.Desktop/LoginWindow.axaml.cs37
-rw-r--r--MatrixUtils.Desktop/MainWindow.axaml16
-rw-r--r--MatrixUtils.Desktop/MainWindow.axaml.cs57
-rw-r--r--MatrixUtils.Desktop/MatrixUtils.Desktop.csproj50
-rw-r--r--MatrixUtils.Desktop/Program.cs33
-rw-r--r--MatrixUtils.Desktop/Properties/launchSettings.json27
-rw-r--r--MatrixUtils.Desktop/RMUDesktopConfiguration.cs47
-rw-r--r--MatrixUtils.Desktop/RMUStorageWrapper.cs123
-rw-r--r--MatrixUtils.Desktop/SentryService.cs29
-rw-r--r--MatrixUtils.Desktop/app.manifest18
-rw-r--r--MatrixUtils.Desktop/appsettings.Development.json14
-rw-r--r--MatrixUtils.Desktop/appsettings.json13
21 files changed, 758 insertions, 0 deletions
diff --git a/MatrixUtils.Desktop/App.axaml b/MatrixUtils.Desktop/App.axaml
new file mode 100644
index 0000000..bc69400
--- /dev/null
+++ b/MatrixUtils.Desktop/App.axaml
@@ -0,0 +1,10 @@
+<Application xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             x:Class="MatrixUtils.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/MatrixUtils.Desktop/App.axaml.cs b/MatrixUtils.Desktop/App.axaml.cs
new file mode 100644
index 0000000..3a106ab
--- /dev/null
+++ b/MatrixUtils.Desktop/App.axaml.cs
@@ -0,0 +1,49 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using Avalonia.Styling;
+using LibMatrix.Services;
+using MatrixUtils.Abstractions;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace MatrixUtils.Desktop;
+
+public partial class App : Application {
+    public IHost host { get; set; }
+
+    public override void OnFrameworkInitializationCompleted() {
+        host = Host.CreateDefaultBuilder().ConfigureServices((ctx, services) => {
+            services.AddSingleton<RMUDesktopConfiguration>();
+            services.AddSingleton<SentryService>();
+            services.AddSingleton<TieredStorageService>(x =>
+                new TieredStorageService(
+                    cacheStorageProvider: new FileStorageProvider(x.GetService<RMUDesktopConfiguration>()!.CacheStoragePath),
+                    dataStorageProvider: new FileStorageProvider(x.GetService<RMUDesktopConfiguration>()!.DataStoragePath)
+                )
+            );
+            services.AddSingleton(new RoryLibMatrixConfiguration {
+                AppName = "MatrixUtils.Desktop"
+            });
+            services.AddRoryLibMatrixServices();
+            // foreach (var commandClass in new ClassCollector<ICommand>().ResolveFromAllAccessibleAssemblies()) {
+            // Console.WriteLine($"Adding command {commandClass.Name}");
+            // services.AddScoped(typeof(ICommand), commandClass);
+            // }
+            services.AddSingleton<RMUStorageWrapper>();
+            services.AddSingleton<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>();
+        }
+        
+        if(Environment.GetEnvironmentVariable("AVALONIA_THEME")?.Equals("dark", StringComparison.OrdinalIgnoreCase) ?? false)
+            RequestedThemeVariant = ThemeVariant.Dark;
+        
+        base.OnFrameworkInitializationCompleted();
+    }
+}
\ No newline at end of file
diff --git a/MatrixUtils.Desktop/Components/NavigationStack.axaml b/MatrixUtils.Desktop/Components/NavigationStack.axaml
new file mode 100644
index 0000000..b24895d
--- /dev/null
+++ b/MatrixUtils.Desktop/Components/NavigationStack.axaml
@@ -0,0 +1,12 @@
+<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="800" d:DesignHeight="450"
+             x:Class="MatrixUtils.Desktop.Components.NavigationStack">
+    <StackPanel x:Name="dock">
+        <Label>NagivationStack</Label>
+        <StackPanel x:Name="navPanel" Orientation="Horizontal"></StackPanel>
+        <ContentControl x:Name="content"></ContentControl>
+    </StackPanel>
+</UserControl>
\ No newline at end of file
diff --git a/MatrixUtils.Desktop/Components/NavigationStack.axaml.cs b/MatrixUtils.Desktop/Components/NavigationStack.axaml.cs
new file mode 100644
index 0000000..632ae3c
--- /dev/null
+++ b/MatrixUtils.Desktop/Components/NavigationStack.axaml.cs
@@ -0,0 +1,72 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+
+namespace MatrixUtils.Desktop.Components;
+
+public partial class NavigationStack : UserControl {
+    public NavigationStack() {
+        InitializeComponent();
+    }
+
+    // private void InitializeComponent() {
+        // AvaloniaXamlLoader.Load(this);
+        // buildView();
+    // }
+
+    protected override void OnLoaded(RoutedEventArgs e) {
+        base.OnLoaded(e);
+        buildView();
+    }
+    
+    private void buildView() {
+        if (navPanel is null) {
+            Console.WriteLine("NavigationStack buildView called while navpanel is null!");
+            // await Task.Delay(100);
+            // if (navPanel is null)
+                // await buildView();
+            // else Console.WriteLine("navpanel is not null!");
+        }
+        navPanel.Children.Clear();
+        foreach (var item in _stack) {
+            Button btn = new() {
+                Content = item.Name
+            };
+            btn.Click += (_, _) => {
+                PopTo(_stack.IndexOf(item));
+                buildView();
+            };
+            navPanel.Children.Add(btn);
+        }
+        content.Content = Current?.View ?? new UserControl();
+    }
+
+
+    public class NavigationStackItem {
+        public string Name { get; set; }
+        public string Description { get; set; } = "";
+        public UserControl View { get; set; }
+    }
+
+    private List<NavigationStackItem> _stack = new();
+
+    public NavigationStackItem? Current => _stack.LastOrDefault();
+
+    public void Push(string name, UserControl view) {
+        _stack.Add(new NavigationStackItem {
+            Name = name,
+            View = view
+        });
+        buildView();
+    }
+
+    public void Pop() {
+        _stack.RemoveAt(_stack.Count - 1);
+        buildView();
+    }
+
+    public void PopTo(int index) {
+        _stack.RemoveRange(index, _stack.Count - index);
+        buildView();
+    }
+}
diff --git a/MatrixUtils.Desktop/Components/Pages/RoomList.axaml b/MatrixUtils.Desktop/Components/Pages/RoomList.axaml
new file mode 100644
index 0000000..45778f3
--- /dev/null
+++ b/MatrixUtils.Desktop/Components/Pages/RoomList.axaml
@@ -0,0 +1,21 @@
+<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:components="clr-namespace:MatrixUtils.Desktop.Components.Pages"
+             xmlns:components1="clr-namespace:MatrixUtils.Desktop.Components"
+             xmlns:abstractions="clr-namespace:MatrixUtils.Abstractions;assembly=MatrixUtils.Abstractions"
+
+             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             x:Class="MatrixUtils.Desktop.Components.Pages.RoomList"
+             x:DataType="components:RoomList"
+             DataContext="{Binding $self}"
+             >
+    <ListBox ItemsSource="{Binding Rooms}">
+        <ListBox.ItemTemplate>
+            <DataTemplate DataType="abstractions:RoomInfo">
+                <components1:RoomListEntry Room="{Binding Path=.}"/>
+            </DataTemplate>
+        </ListBox.ItemTemplate>
+    </ListBox>
+</UserControl>
diff --git a/MatrixUtils.Desktop/Components/Pages/RoomList.axaml.cs b/MatrixUtils.Desktop/Components/Pages/RoomList.axaml.cs
new file mode 100644
index 0000000..a0c9fcc
--- /dev/null
+++ b/MatrixUtils.Desktop/Components/Pages/RoomList.axaml.cs
@@ -0,0 +1,15 @@
+using System.Collections.ObjectModel;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+using MatrixUtils.Abstractions;
+
+namespace MatrixUtils.Desktop.Components.Pages;
+
+public partial class RoomList : UserControl {
+    private ObservableCollection<RoomInfo> Rooms { get; set; } = new();
+
+    public RoomList() {
+        InitializeComponent();
+    }
+}
\ No newline at end of file
diff --git a/MatrixUtils.Desktop/Components/RoomListEntry.axaml b/MatrixUtils.Desktop/Components/RoomListEntry.axaml
new file mode 100644
index 0000000..97e6fdc
--- /dev/null
+++ b/MatrixUtils.Desktop/Components/RoomListEntry.axaml
@@ -0,0 +1,16 @@
+<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:components="clr-namespace:MatrixUtils.Desktop.Components"
+             mc:Ignorable="d" d:DesignWidth="250" d:DesignHeight="32"
+             x:Class="MatrixUtils.Desktop.Components.RoomListEntry"
+             
+             x:DataType="components:RoomListEntry"
+             DataContext="{Binding $self}"
+             >
+    <StackPanel Orientation="Horizontal">
+        <Image MaxWidth="64" x:Name="RoomIcon"></Image>
+        <Label x:Name="RoomName" Content="{Binding Room.RoomName}"></Label>
+    </StackPanel>
+</UserControl>
diff --git a/MatrixUtils.Desktop/Components/RoomListEntry.axaml.cs b/MatrixUtils.Desktop/Components/RoomListEntry.axaml.cs
new file mode 100644
index 0000000..1e4a127
--- /dev/null
+++ b/MatrixUtils.Desktop/Components/RoomListEntry.axaml.cs
@@ -0,0 +1,74 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Media.Imaging;
+using LibMatrix;
+using LibMatrix.EventTypes.Spec.State;
+using LibMatrix.EventTypes.Spec.State.RoomInfo;
+using LibMatrix.Helpers;
+using LibMatrix.Interfaces.Services;
+using LibMatrix.Services;
+using MatrixUtils.Abstractions;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace MatrixUtils.Desktop.Components;
+
+public partial class RoomListEntry : UserControl {
+    public RoomInfo Room { get; set; }
+
+    public RoomListEntry() {
+        InitializeComponent();
+    }
+
+    protected override void OnLoaded(RoutedEventArgs e) {
+        base.OnLoaded(e);
+        RoomName.Content = Room.Room.RoomId;
+        Task.WhenAll(GetRoomName(), GetRoomIcon());
+    }
+
+    private async Task GetRoomName() {
+        try {
+            var nameEvent = await Room.GetStateEvent("m.room.name");
+            if (nameEvent?.TypedContent is RoomNameEventContent nameData)
+                RoomName.Content = nameData.Name;
+        }
+        catch (MatrixException e) {
+            if (e.ErrorCode != "M_NOT_FOUND")
+                throw;
+        }
+    }
+
+    private async Task GetRoomIcon() {
+        try {
+            using var hc = new HttpClient();
+            var avatarEvent = await Room.GetStateEvent("m.room.avatar");
+            if (avatarEvent?.TypedContent is RoomAvatarEventContent avatarData) {
+                var mxcUrl = avatarData.Url;
+                var resolvedUrl = await Room.Room.GetResolvedRoomAvatarUrlAsync();
+                
+                // await using var svc = _serviceScopeFactory.CreateAsyncScope();
+                // var hs = await svc.ServiceProvider.GetService<RMUStorageWrapper>()?.GetCurrentSessionOrPrompt()!;
+                // var hsResolver = svc.ServiceProvider.GetService<HomeserverResolverService>();
+                // var storage = svc.ServiceProvider.GetService<TieredStorageService>()?.CacheStorageProvider;
+                // var resolvedUrl = await hsResolver.ResolveMediaUri(hs.ServerName, mxcUrl);
+                var storage = new FileStorageProvider("cache");
+                var storageKey = $"media/{mxcUrl.Replace("mxc://", "").Replace("/", ".")}";
+                try {
+                    if (!await storage.ObjectExistsAsync(storageKey))
+                        await storage.SaveStreamAsync(storageKey, await hc.GetStreamAsync(resolvedUrl));
+
+                    RoomIcon.Source = new Bitmap(await storage.LoadStreamAsync(storageKey) ?? throw new NullReferenceException());
+                }
+                catch (IOException) { }
+                catch (MatrixException e) {
+                    if (e.ErrorCode != "M_UNKNOWN")
+                        throw;
+                }
+            }
+        }
+        catch (MatrixException e) {
+            if (e.ErrorCode != "M_NOT_FOUND")
+                throw;
+        }
+    }
+}
diff --git a/MatrixUtils.Desktop/LoginWindow.axaml b/MatrixUtils.Desktop/LoginWindow.axaml
new file mode 100644
index 0000000..ecfa3f5
--- /dev/null
+++ b/MatrixUtils.Desktop/LoginWindow.axaml
@@ -0,0 +1,25 @@
+<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:MatrixUtils.Desktop"
+        mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+        Title="LoginWindow"
+        x:Class="MatrixUtils.Desktop.LoginWindow"
+        x:DataType="desktop:LoginWindow"
+        DataContext="{Binding $self}"
+        SizeToContent="WidthAndHeight" CanResize="False"
+        MinWidth="250">
+    <StackPanel>
+        <Label>Log in</Label>
+        <StackPanel Orientation="Horizontal">
+            <Label Width="100">User ID</Label>
+            <TextBox MinWidth="250" Text="{Binding Username, Mode=TwoWay}" />
+        </StackPanel>
+        <StackPanel Orientation="Horizontal">
+            <Label Width="100">Password</Label>
+            <MaskedTextBox MinWidth="250" PasswordChar="*" Text="{Binding Password, Mode=TwoWay}" />
+        </StackPanel>
+        <Button Click="Login">Login</Button>
+    </StackPanel>
+</Window>
\ No newline at end of file
diff --git a/MatrixUtils.Desktop/LoginWindow.axaml.cs b/MatrixUtils.Desktop/LoginWindow.axaml.cs
new file mode 100644
index 0000000..ac59317
--- /dev/null
+++ b/MatrixUtils.Desktop/LoginWindow.axaml.cs
@@ -0,0 +1,37 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using Avalonia.VisualTree;
+
+namespace MatrixUtils.Desktop;
+
+public partial class LoginWindow : Window {
+    private readonly RMUStorageWrapper _storage;
+
+    public LoginWindow(RMUStorageWrapper 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/MatrixUtils.Desktop/MainWindow.axaml b/MatrixUtils.Desktop/MainWindow.axaml
new file mode 100644
index 0000000..6457678
--- /dev/null
+++ b/MatrixUtils.Desktop/MainWindow.axaml
@@ -0,0 +1,16 @@
+<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:MatrixUtils.Desktop"
+        xmlns:components="clr-namespace:MatrixUtils.Desktop.Components"
+        mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+        x:Class="MatrixUtils.Desktop.MainWindow"
+        Title="Rory&amp;::MatrixUtils">
+    <!-- <Interaction.Behaviors> -->
+        <!-- <EventTriggerBehavior EventName="Loaded"> -->
+            <!-- <InvokeCommandAction Command="{Binding LoadedCommand}"></InvokeCommandAction> -->
+        <!-- </EventTriggerBehavior> -->
+    <!-- </Interaction.Behaviors> -->
+    <components:NavigationStack x:Name="windowContent"/>
+</Window>
diff --git a/MatrixUtils.Desktop/MainWindow.axaml.cs b/MatrixUtils.Desktop/MainWindow.axaml.cs
new file mode 100644
index 0000000..562ab1a
--- /dev/null
+++ b/MatrixUtils.Desktop/MainWindow.axaml.cs
@@ -0,0 +1,57 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using MatrixUtils.Abstractions;
+using MatrixUtils.Desktop.Components;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace MatrixUtils.Desktop;
+
+public partial class MainWindow : Window {
+    private readonly ILogger<MainWindow> _logger;
+    private readonly IServiceScopeFactory _scopeFactory;
+    private readonly RMUStorageWrapper _storageWrapper;
+    private readonly RMUDesktopConfiguration _configuration;
+    public static MainWindow Instance { get; private set; } = null!;
+
+    public MainWindow(ILogger<MainWindow> logger, IServiceScopeFactory scopeFactory, SentryService _) {
+        Instance = this;
+        _logger = logger;
+        _scopeFactory = scopeFactory;
+        _configuration = scopeFactory.CreateScope().ServiceProvider.GetRequiredService<RMUDesktopConfiguration>();
+        _storageWrapper = scopeFactory.CreateScope().ServiceProvider.GetRequiredService<RMUStorageWrapper>();
+
+        _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();
+        var rooms = await hs.GetJoinedRooms();
+        foreach (var room in rooms) {
+            // roomList.Children.Add(new RoomListEntry(_scopeFactory, new RoomInfo(room)));
+
+            windowContent.Push("home", new RoomListEntry() {
+                Room = new RoomInfo() {
+                    Room = room
+                }
+            });
+            base.OnLoaded(e);
+        }
+    }
+
+    // public Command
+    // protected void LoadedCommand() {
+    // _logger.LogInformation("async command");
+    // }
+}
\ No newline at end of file
diff --git a/MatrixUtils.Desktop/MatrixUtils.Desktop.csproj b/MatrixUtils.Desktop/MatrixUtils.Desktop.csproj
new file mode 100644
index 0000000..f1bd2b6
--- /dev/null
+++ b/MatrixUtils.Desktop/MatrixUtils.Desktop.csproj
@@ -0,0 +1,50 @@
+<Project Sdk="Microsoft.NET.Sdk">
+    <PropertyGroup>
+        <OutputType>WinExe</OutputType>
+        <TargetFramework>net8.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.6" />
+        <PackageReference Include="Avalonia.Desktop" Version="11.0.6" />
+        <PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.6" />
+        <PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.6" />
+        <!--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.6" />
+        <PackageReference Include="Sentry" Version="3.36.0" />
+    </ItemGroup>
+
+
+
+
+    <ItemGroup>
+        <PackageReference Include="Avalonia.Xaml.Behaviors" Version="11.0.5" />
+        <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
+    </ItemGroup>
+    <ItemGroup>
+        <Content Include="appsettings*.json">
+            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+        </Content>
+        <Content Update="appsettings.Local.json">
+            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+        </Content>
+    </ItemGroup>
+    <ItemGroup>
+      <ProjectReference Include="..\MatrixUtils.Abstractions\MatrixUtils.Abstractions.csproj" />
+    </ItemGroup>
+</Project>
diff --git a/MatrixUtils.Desktop/Program.cs b/MatrixUtils.Desktop/Program.cs
new file mode 100644
index 0000000..0f4c09c
--- /dev/null
+++ b/MatrixUtils.Desktop/Program.cs
@@ -0,0 +1,33 @@
+using Avalonia;
+using Microsoft.Extensions.Hosting;
+using Tmds.DBus.Protocol;
+
+namespace MatrixUtils.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 Task Main(string[] args) {
+        try {
+            BuildAvaloniaApp()
+                .StartWithClassicDesktopLifetime(args);
+        }
+        catch (DBusException e) { }
+        catch (Exception e) {
+            Console.WriteLine(e);
+            throw;
+        }
+
+        return Task.CompletedTask;
+    }
+
+    // Avalonia configuration, don't remove; also used by visual designer.
+    public static AppBuilder BuildAvaloniaApp()
+        => AppBuilder.Configure<App>()
+            .UsePlatformDetect()
+            .WithInterFont()
+            .LogToTrace();
+}
diff --git a/MatrixUtils.Desktop/Properties/launchSettings.json b/MatrixUtils.Desktop/Properties/launchSettings.json
new file mode 100644
index 0000000..36405e8
--- /dev/null
+++ b/MatrixUtils.Desktop/Properties/launchSettings.json
@@ -0,0 +1,27 @@
+{
+  "$schema": "http://json.schemastore.org/launchsettings.json",
+  "profiles": {
+    "Default": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "environmentVariables": {
+
+      }
+    },
+    "Development": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "environmentVariables": {
+        "DOTNET_ENVIRONMENT": "Development",
+        "AVALONIA_THEME": "Dark"
+      }
+    },
+    "Local config": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "environmentVariables": {
+        "DOTNET_ENVIRONMENT": "Local"
+      }
+    }
+  }
+}
diff --git a/MatrixUtils.Desktop/RMUDesktopConfiguration.cs b/MatrixUtils.Desktop/RMUDesktopConfiguration.cs
new file mode 100644
index 0000000..62646ca
--- /dev/null
+++ b/MatrixUtils.Desktop/RMUDesktopConfiguration.cs
@@ -0,0 +1,47 @@
+using System.Collections;
+using System.Diagnostics.CodeAnalysis;
+using ArcaneLibs.Extensions;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace MatrixUtils.Desktop;
+
+public class RMUDesktopConfiguration {
+    private static ILogger<RMUDesktopConfiguration> _logger;
+
+    [RequiresUnreferencedCode("Uses reflection binding")]
+    public RMUDesktopConfiguration(ILogger<RMUDesktopConfiguration> logger, IConfiguration config, HostBuilderContext host) {
+        _logger = logger;
+        logger.LogInformation("Loading configuration for environment: {}...", host.HostingEnvironment.EnvironmentName);
+        config.GetSection("RMUDesktop").Bind(this);
+        DataStoragePath = ExpandPath(DataStoragePath);
+        CacheStoragePath = ExpandPath(CacheStoragePath);
+    }
+
+    public string DataStoragePath { get; set; } = "";
+    public string CacheStoragePath { get; set; } = "";
+    public string? SentryDsn { 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);
+        var 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/MatrixUtils.Desktop/RMUStorageWrapper.cs b/MatrixUtils.Desktop/RMUStorageWrapper.cs
new file mode 100644
index 0000000..c8172d0
--- /dev/null
+++ b/MatrixUtils.Desktop/RMUStorageWrapper.cs
@@ -0,0 +1,123 @@
+using Avalonia;
+using LibMatrix;
+using LibMatrix.Homeservers;
+using LibMatrix.Responses;
+using LibMatrix.Services;
+
+namespace MatrixUtils.Desktop;
+
+public class RMUStorageWrapper(TieredStorageService storageService, HomeserverProviderService homeserverProviderService) {
+    public async Task<List<LoginResponse>?> GetAllTokens() {
+        if (!await storageService.DataStorageProvider.ObjectExistsAsync("rmu.tokens")) {
+            return null;
+        }
+        return await storageService.DataStorageProvider.LoadObjectAsync<List<LoginResponse>>("rmu.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() ?? new List<LoginResponse>();
+
+        tokens.Add(loginResponse);
+        await storageService.DataStorageProvider.SaveObjectAsync("rmu.tokens", tokens);
+        if (await GetCurrentToken() is null)
+            await SetCurrentToken(loginResponse);
+    }
+
+    private async Task<AuthenticatedHomeserverGeneric?> GetCurrentSession() {
+        var token = await GetCurrentToken();
+        if (token == null) {
+            return null;
+        }
+
+        return await homeserverProviderService.GetAuthenticatedWithToken(token.Homeserver, token.AccessToken);
+    }
+
+    public async Task<AuthenticatedHomeserverGeneric?> GetCurrentSessionOrPrompt() {
+        AuthenticatedHomeserverGeneric? 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.Position = MainWindow.Instance.Position + new PixelPoint(50, 50);
+            await wnd.ShowDialog(MainWindow.Instance);
+            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("rmu.tokens", tokens);
+    }
+
+    public async Task SetCurrentToken(LoginResponse? auth) => await 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/MatrixUtils.Desktop/SentryService.cs b/MatrixUtils.Desktop/SentryService.cs
new file mode 100644
index 0000000..c965632
--- /dev/null
+++ b/MatrixUtils.Desktop/SentryService.cs
@@ -0,0 +1,29 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Sentry;
+
+namespace MatrixUtils.Desktop;
+
+public class SentryService : IDisposable {
+    private IDisposable? _sentrySdkDisposable;
+    public SentryService(IServiceScopeFactory scopeFactory, ILogger<SentryService> logger) {
+        var config = scopeFactory.CreateScope().ServiceProvider.GetRequiredService<RMUDesktopConfiguration>();
+        if (config.SentryDsn is null) {
+            logger.LogWarning("Sentry DSN is not set, skipping Sentry initialisation");
+            return;
+        }
+        _sentrySdkDisposable = SentrySdk.Init(o => {
+            o.Dsn = config.SentryDsn;
+            // When configuring for the first time, to see what the SDK is doing:
+            o.Debug = true;
+            // Set traces_sample_rate to 1.0 to capture 100% of transactions for performance monitoring.
+            // We recommend adjusting this value in production.
+            o.TracesSampleRate = 1.0;
+            // Enable Global Mode if running in a client app
+            o.IsGlobalModeEnabled = true;
+        });
+    }
+
+    /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
+    public void Dispose() => _sentrySdkDisposable?.Dispose();
+}
diff --git a/MatrixUtils.Desktop/app.manifest b/MatrixUtils.Desktop/app.manifest
new file mode 100644
index 0000000..1c4a2e1
--- /dev/null
+++ b/MatrixUtils.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="MatrixUtils.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/MatrixUtils.Desktop/appsettings.Development.json b/MatrixUtils.Desktop/appsettings.Development.json
new file mode 100644
index 0000000..a1add03
--- /dev/null
+++ b/MatrixUtils.Desktop/appsettings.Development.json
@@ -0,0 +1,14 @@
+{
+    "Logging": {
+        "LogLevel": {
+            "Default": "Debug",
+            "System": "Information",
+            "Microsoft": "Information"
+        }
+    },
+    "RMUDesktop": {
+        "DataStoragePath": "rmu-desktop/data",
+        "CacheStoragePath": "rmu-desktop/cache",
+        "SentryDsn": "https://a41e99dd2fdd45f699c432b21ebce632@sentry.thearcanebrony.net/15"
+    }
+}
diff --git a/MatrixUtils.Desktop/appsettings.json b/MatrixUtils.Desktop/appsettings.json
new file mode 100644
index 0000000..058723c
--- /dev/null
+++ b/MatrixUtils.Desktop/appsettings.json
@@ -0,0 +1,13 @@
+{
+    "Logging": {
+        "LogLevel": {
+            "Default": "Debug",
+            "System": "Information",
+            "Microsoft": "Information"
+        }
+    },
+    "RMUDesktop": {
+        "DataStoragePath": "~/.local/share/rmu-desktop",
+        "CacheStoragePath": "~/.cache/rmu-desktop"
+    }
+}