about summary refs log tree commit diff
path: root/ModerationClient
diff options
context:
space:
mode:
authorRory& <root@rory.gay>2024-07-29 22:45:36 +0200
committerRory& <root@rory.gay>2024-08-08 03:02:10 +0200
commit03f1669d98e1fad81bc4832900ae149ac6510ebc (patch)
tree625361563a92452297f3e2700946e51e513e78b9 /ModerationClient
downloadModerationClient-03f1669d98e1fad81bc4832900ae149ac6510ebc.tar.xz
Initial commit
Diffstat (limited to 'ModerationClient')
-rw-r--r--ModerationClient/App.axaml15
-rw-r--r--ModerationClient/App.axaml.cs91
-rw-r--r--ModerationClient/Assets/avalonia-logo.icobin0 -> 176111 bytes
-rw-r--r--ModerationClient/ModerationClient.csproj53
-rw-r--r--ModerationClient/Program.cs21
-rw-r--r--ModerationClient/Services/CommandLineConfiguration.cs52
-rw-r--r--ModerationClient/Services/FileStorageProvider.cs54
-rw-r--r--ModerationClient/Services/MatrixAuthenticationService.cs56
-rw-r--r--ModerationClient/Services/ModerationClientConfiguration.cs31
-rw-r--r--ModerationClient/ViewLocator.cs38
-rw-r--r--ModerationClient/ViewModels/ClientViewModel.cs63
-rw-r--r--ModerationClient/ViewModels/LoginViewModel.cs26
-rw-r--r--ModerationClient/ViewModels/MainWindowViewModel.cs33
-rw-r--r--ModerationClient/ViewModels/ViewModelBase.cs7
-rw-r--r--ModerationClient/Views/ClientView.axaml30
-rw-r--r--ModerationClient/Views/ClientView.axaml.cs33
-rw-r--r--ModerationClient/Views/LoginView.axaml25
-rw-r--r--ModerationClient/Views/LoginView.axaml.cs33
-rw-r--r--ModerationClient/Views/MainWindow.axaml42
-rw-r--r--ModerationClient/Views/MainWindow.axaml.cs93
-rw-r--r--ModerationClient/app.manifest18
-rw-r--r--ModerationClient/appsettings.Development.json14
-rw-r--r--ModerationClient/appsettings.json13
23 files changed, 841 insertions, 0 deletions
diff --git a/ModerationClient/App.axaml b/ModerationClient/App.axaml
new file mode 100644
index 0000000..82366b6
--- /dev/null
+++ b/ModerationClient/App.axaml
@@ -0,0 +1,15 @@
+<Application xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             x:Class="ModerationClient.App"
+             xmlns:local="using:ModerationClient"
+             RequestedThemeVariant="Default">
+             <!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
+
+    <Application.DataTemplates>
+        <local:ViewLocator/>
+    </Application.DataTemplates>
+  
+    <Application.Styles>
+        <FluentTheme />
+    </Application.Styles>
+</Application>
\ No newline at end of file
diff --git a/ModerationClient/App.axaml.cs b/ModerationClient/App.axaml.cs
new file mode 100644
index 0000000..db584de
--- /dev/null
+++ b/ModerationClient/App.axaml.cs
@@ -0,0 +1,91 @@
+using System;
+using System.IO;
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Data.Core;
+using Avalonia.Data.Core.Plugins;
+using Avalonia.Markup.Xaml;
+using LibMatrix.Services;
+using MatrixUtils.Abstractions;
+using MatrixUtils.Desktop;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using ModerationClient.Services;
+using ModerationClient.ViewModels;
+using ModerationClient.Views;
+
+namespace ModerationClient;
+
+public partial class App : Application {
+    /// <summary>
+    /// Gets the current <see cref="App"/> instance in use
+    /// </summary>
+    public new static App Current => (App)Application.Current;
+
+    /// <summary>
+    /// Gets the <see cref="IServiceProvider"/> instance to resolve application services.
+    /// </summary>
+    public IServiceProvider Services => Host.Services;
+
+    public IHost Host { get; private set; }
+
+    public override void Initialize() {
+        AvaloniaXamlLoader.Load(this);
+    }
+
+    // ReSharper disable once AsyncVoidMethod
+    public override async void OnFrameworkInitializationCompleted() {
+        var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(Environment.GetCommandLineArgs());
+        builder.Services.AddTransient<MainWindowViewModel>();
+        ConfigureServices(builder.Services);
+        // builder.Services.AddHostedService<HostedBackgroundService>();
+
+        Host = builder.Build();
+        if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
+            // Line below is needed to remove Avalonia data validation.
+            // Without this line you will get duplicate validations from both Avalonia and CT
+            BindingPlugins.DataValidators.RemoveAt(0);
+            // desktop.MainWindow = new MainWindow {
+                // DataContext = Host.Services.GetRequiredService<MainWindowViewModel>()
+            // };
+            desktop.MainWindow = Host.Services.GetRequiredService<MainWindow>();
+            desktop.Exit += (sender, args) => {
+                Host.StopAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult();
+                Host.Dispose();
+            };
+        }
+
+        base.OnFrameworkInitializationCompleted();
+        await Host.StartAsync();
+    }
+
+    /// <summary>
+    /// Configures the services for the application.
+    /// </summary>
+    private static IServiceProvider ConfigureServices(IServiceCollection services) {
+        services.AddRoryLibMatrixServices(new() {
+            AppName = "ModerationClient",
+        });
+        services.AddSingleton<CommandLineConfiguration>();
+        services.AddSingleton<MatrixAuthenticationService>();
+        services.AddSingleton<ModerationClientConfiguration>();
+
+        services.AddSingleton<TieredStorageService>(x => {
+                var cmdLine = x.GetRequiredService<CommandLineConfiguration>();
+                return new TieredStorageService(
+                    cacheStorageProvider: new FileStorageProvider(Directory.CreateTempSubdirectory($"modcli-{cmdLine.Profile}").FullName),
+                    dataStorageProvider: new FileStorageProvider(Directory.CreateTempSubdirectory($"modcli-{cmdLine.Profile}").FullName)
+                );
+            }
+        );
+
+        // Register views
+        services.AddSingleton<MainWindow>();
+        services.AddTransient<LoginView>();
+        services.AddTransient<ClientView>();
+        // Register ViewModels
+        services.AddTransient<ClientViewModel>();
+
+        return services.BuildServiceProvider();
+    }
+}
\ No newline at end of file
diff --git a/ModerationClient/Assets/avalonia-logo.ico b/ModerationClient/Assets/avalonia-logo.ico
new file mode 100644
index 0000000..da8d49f
--- /dev/null
+++ b/ModerationClient/Assets/avalonia-logo.ico
Binary files differdiff --git a/ModerationClient/ModerationClient.csproj b/ModerationClient/ModerationClient.csproj
new file mode 100644
index 0000000..84adbc3
--- /dev/null
+++ b/ModerationClient/ModerationClient.csproj
@@ -0,0 +1,53 @@
+<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>
+    </PropertyGroup>
+
+    <ItemGroup>
+        <Folder Include="Models\"/>
+        <AvaloniaResource Include="Assets\**"/>
+    </ItemGroup>
+
+    <ItemGroup>
+        <PackageReference Include="Avalonia" Version="11.1.0"/>
+        <PackageReference Include="Avalonia.Desktop" Version="11.1.0"/>
+        <PackageReference Include="Avalonia.Themes.Fluent" Version="11.1.0"/>
+        <PackageReference Include="Avalonia.Fonts.Inter" Version="11.1.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.1.0"/>
+        <PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1"/>
+        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
+        <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
+        <PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
+    </ItemGroup>
+
+    <ItemGroup>
+      <ProjectReference Include="..\LibMatrix\LibMatrix\LibMatrix.csproj" />
+    </ItemGroup>
+
+    <ItemGroup>
+        <Content Include="appsettings*.json">
+            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+        </Content>
+    </ItemGroup>
+
+    <ItemGroup>
+      <Compile Update="Views\LoginView.axaml.cs">
+        <DependentUpon>LoginWindow.axaml</DependentUpon>
+        <SubType>Code</SubType>
+      </Compile>
+      <Compile Update="Views\ClientView.axaml.cs">
+        <DependentUpon>ClientView.axaml</DependentUpon>
+        <SubType>Code</SubType>
+      </Compile>
+    </ItemGroup>
+
+    <ItemGroup>
+      <UpToDateCheckInput Remove="Windows\LoginWindow.axaml" />
+    </ItemGroup>
+</Project>
diff --git a/ModerationClient/Program.cs b/ModerationClient/Program.cs
new file mode 100644
index 0000000..9229194
--- /dev/null
+++ b/ModerationClient/Program.cs
@@ -0,0 +1,21 @@
+using Avalonia;
+using System;
+
+namespace ModerationClient;
+
+internal sealed class Program
+{
+    // 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 void Main(string[] args) => BuildAvaloniaApp()
+        .StartWithClassicDesktopLifetime(args);
+
+    // Avalonia configuration, don't remove; also used by visual designer.
+    public static AppBuilder BuildAvaloniaApp()
+        => AppBuilder.Configure<App>()
+            .UsePlatformDetect()
+            .WithInterFont()
+            .LogToTrace();
+}
\ No newline at end of file
diff --git a/ModerationClient/Services/CommandLineConfiguration.cs b/ModerationClient/Services/CommandLineConfiguration.cs
new file mode 100644
index 0000000..4debd5c
--- /dev/null
+++ b/ModerationClient/Services/CommandLineConfiguration.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using ArcaneLibs;
+using Microsoft.Extensions.Logging;
+
+namespace ModerationClient.Services;
+
+public class CommandLineConfiguration {
+    public CommandLineConfiguration(ILogger<CommandLineConfiguration> logger) {
+        var args = Environment.GetCommandLineArgs();
+        logger.LogInformation("Command line arguments: " + string.Join(", ", args));
+        for (var i = 0; i < args.Length; i++) {
+            logger.LogInformation("Processing argument: " + args[i]);
+            switch (args[i]) {
+                case "--profile":
+                case "-p":
+                    if (args.Length <= i + 1 || args[i + 1].StartsWith("-")) {
+                        throw new ArgumentException("No profile specified");
+                    }
+
+                    Profile = args[++i];
+                    logger.LogInformation("Set profile to: " + Profile);
+                    break;
+                case "--temporary":
+                    IsTemporary = true;
+                    logger.LogInformation("Using temporary profile");
+                    break;
+                case "--profile-dir":
+                    ProfileDirectory = args[++i];
+                    break;
+                case "--scale":
+                    Scale = float.Parse(args[++i]);
+                    break;
+            }
+        }
+
+        if (string.IsNullOrWhiteSpace(ProfileDirectory))
+            ProfileDirectory = IsTemporary
+                ? Directory.CreateTempSubdirectory("ModerationClient-tmp").FullName
+                : Util.ExpandPath($"$HOME/.local/share/ModerationClient/{Profile}");
+
+        logger.LogInformation("Profile directory: " + ProfileDirectory);
+        Directory.CreateDirectory(ProfileDirectory);
+    }
+
+    public string Profile { get; private set; } = "default";
+    public bool IsTemporary { get; private set; }
+
+    public string ProfileDirectory { get; private set; }
+    public float Scale { get; private set; } = 1f;
+}
\ No newline at end of file
diff --git a/ModerationClient/Services/FileStorageProvider.cs b/ModerationClient/Services/FileStorageProvider.cs
new file mode 100644
index 0000000..3658369
--- /dev/null
+++ b/ModerationClient/Services/FileStorageProvider.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using ArcaneLibs.Extensions;
+using LibMatrix.Extensions;
+using LibMatrix.Interfaces.Services;
+using Microsoft.Extensions.Logging;
+
+namespace MatrixUtils.Abstractions;
+
+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), value?.ToJson());
+
+    [RequiresUnreferencedCode("This API uses reflection to deserialize JSON")]
+    public async Task<T?> LoadObjectAsync<T>(string key) => JsonSerializer.Deserialize<T>(await File.ReadAllTextAsync(Path.Join(TargetPath, key)));
+
+    public Task<bool> ObjectExistsAsync(string key) => Task.FromResult(File.Exists(Path.Join(TargetPath, key)));
+
+    public Task<List<string>> GetAllKeysAsync() => Task.FromResult(Directory.GetFiles(TargetPath).Select(Path.GetFileName).ToList());
+
+    public Task DeleteObjectAsync(string key) {
+        File.Delete(Path.Join(TargetPath, key));
+        return Task.CompletedTask;
+    }
+
+    public async Task SaveStreamAsync(string key, Stream stream) {
+        Directory.CreateDirectory(Path.GetDirectoryName(Path.Join(TargetPath, key)) ?? throw new InvalidOperationException());
+        await using var fileStream = File.Create(Path.Join(TargetPath, key));
+        await stream.CopyToAsync(fileStream);
+    }
+
+    public Task<Stream?> LoadStreamAsync(string key) => Task.FromResult<Stream?>(File.Exists(Path.Join(TargetPath, key)) ? File.OpenRead(Path.Join(TargetPath, key)) : null);
+}
diff --git a/ModerationClient/Services/MatrixAuthenticationService.cs b/ModerationClient/Services/MatrixAuthenticationService.cs
new file mode 100644
index 0000000..69f8810
--- /dev/null
+++ b/ModerationClient/Services/MatrixAuthenticationService.cs
@@ -0,0 +1,56 @@
+using System;
+using System.IO;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using ArcaneLibs;
+using ArcaneLibs.Extensions;
+using Avalonia.Controls.Diagnostics;
+using LibMatrix;
+using LibMatrix.Homeservers;
+using LibMatrix.Responses;
+using LibMatrix.Services;
+using MatrixUtils.Desktop;
+using Microsoft.Extensions.Logging;
+
+namespace ModerationClient.Services;
+
+public class MatrixAuthenticationService(ILogger<MatrixAuthenticationService> logger, HomeserverProviderService hsProvider, CommandLineConfiguration cfg) : NotifyPropertyChanged {
+    private bool _isLoggedIn = false;
+    public string Profile => cfg.Profile;
+    public AuthenticatedHomeserverGeneric? Homeserver { get; private set; }
+
+    public bool IsLoggedIn {
+        get => _isLoggedIn;
+        private set => SetField(ref _isLoggedIn, value);
+    }
+
+    public async Task LoadProfileAsync() {
+        if (!File.Exists(Util.ExpandPath($"{cfg.ProfileDirectory}/login.json")!)) return;
+        var loginJson = await File.ReadAllTextAsync(Util.ExpandPath($"{cfg.ProfileDirectory}/login.json")!);
+        var login = JsonSerializer.Deserialize<LoginResponse>(loginJson);
+        if (login is null) return;
+        try {
+            Homeserver = await hsProvider.GetAuthenticatedWithToken(login.Homeserver, login.AccessToken);
+            IsLoggedIn = true;
+        }
+        catch (MatrixException e) {
+            if (e is not { Error: MatrixException.ErrorCodes.M_UNKNOWN_TOKEN }) throw;
+        }
+    }
+
+    public async Task LoginAsync(string username, string password) {
+        Directory.CreateDirectory(Util.ExpandPath($"{cfg.ProfileDirectory}")!);
+        var mxidParts = username.Split(':', 2);
+        var res = await hsProvider.Login(mxidParts[1], username, password);
+        await File.WriteAllTextAsync(Path.Combine(cfg.ProfileDirectory, "login.json"), res.ToJson());
+        IsLoggedIn = true;
+
+        // Console.WriteLine("Login result: " + res.ToJson());
+    }
+
+    public async Task LogoutAsync() {
+        Directory.Delete(Util.ExpandPath($"{cfg.ProfileDirectory}")!, true);
+        IsLoggedIn = false;
+    }
+}
\ No newline at end of file
diff --git a/ModerationClient/Services/ModerationClientConfiguration.cs b/ModerationClient/Services/ModerationClientConfiguration.cs
new file mode 100644
index 0000000..f770fef
--- /dev/null
+++ b/ModerationClient/Services/ModerationClientConfiguration.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Collections;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using ArcaneLibs;
+using ArcaneLibs.Extensions;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace MatrixUtils.Desktop;
+
+public class ModerationClientConfiguration
+{
+    private ILogger<ModerationClientConfiguration> _logger;
+
+    [RequiresUnreferencedCode("Uses reflection binding")]
+    public ModerationClientConfiguration(ILogger<ModerationClientConfiguration> logger, IConfiguration config, HostBuilderContext host)
+    {
+        _logger = logger;
+        logger.LogInformation("Loading configuration for environment: {}...", host.HostingEnvironment.EnvironmentName);
+        config.GetSection("ModerationClient").Bind(this);
+        DataStoragePath = Util.ExpandPath(DataStoragePath);
+        CacheStoragePath = Util.ExpandPath(CacheStoragePath);
+    }
+
+    public string? DataStoragePath { get; set; } = "";
+    public string? CacheStoragePath { get; set; } = "";
+    public string? SentryDsn { get; set; }
+}
\ No newline at end of file
diff --git a/ModerationClient/ViewLocator.cs b/ModerationClient/ViewLocator.cs
new file mode 100644
index 0000000..3de8107
--- /dev/null
+++ b/ModerationClient/ViewLocator.cs
@@ -0,0 +1,38 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Microsoft.Extensions.DependencyInjection;
+using ModerationClient.ViewModels;
+
+namespace ModerationClient;
+
+public class ViewLocator : IDataTemplate {
+    public Control? Build(object? data) {
+        try {
+            if (data is null)
+                return null;
+
+            var name = data.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
+            Console.WriteLine($"ViewLocator: Locating {name} for {data.GetType().FullName}");
+            var type = Type.GetType(name);
+            Console.WriteLine($"ViewLocator: Got {type?.FullName ?? "null"}");
+
+            if (type != null) {
+                var control = (Control)App.Current.Services.GetRequiredService(type);
+                Console.WriteLine($"ViewLocator: Created {control.GetType().FullName}");
+                control.DataContext = data;
+                return control;
+            }
+
+            return new TextBlock { Text = "Not Found: " + name };
+        }
+        catch (Exception e) {
+            Console.WriteLine($"ViewLocator: Error: {e}");
+            return new TextBlock { Text = e.ToString(), Foreground = Avalonia.Media.Brushes.Red };
+        }
+    }
+
+    public bool Match(object? data) {
+        return data is ViewModelBase;
+    }
+}
\ No newline at end of file
diff --git a/ModerationClient/ViewModels/ClientViewModel.cs b/ModerationClient/ViewModels/ClientViewModel.cs
new file mode 100644
index 0000000..340eb56
--- /dev/null
+++ b/ModerationClient/ViewModels/ClientViewModel.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Threading.Tasks;
+using ArcaneLibs.Collections;
+using LibMatrix.Helpers;
+using LibMatrix.Responses;
+using Microsoft.Extensions.Logging;
+using ModerationClient.Services;
+
+namespace ModerationClient.ViewModels;
+
+public partial class ClientViewModel : ViewModelBase
+{
+    public ClientViewModel(ILogger<ClientViewModel> logger, MatrixAuthenticationService authService) {
+        this.logger = logger;
+        this.authService = authService;
+        _ = Task.Run(Run);
+    }
+    
+    private readonly ILogger<ClientViewModel> logger;
+    private readonly MatrixAuthenticationService authService;
+    
+    private Exception? _exception;
+
+    public Exception? Exception {
+        get => _exception;
+        private set => SetProperty(ref _exception, value);
+    }
+
+    public ObservableCollection<SpaceNode> DisplayedSpaces { get; } = [
+        new SpaceNode { Name = "Root", Children = [
+            new SpaceNode { Name = "Child 1" },
+            new SpaceNode { Name = "Child 2" },
+            new SpaceNode { Name = "Child 3" }
+        ] },
+        new SpaceNode { Name = "Root 2", Children = [
+            new SpaceNode { Name = "Child 4" },
+            new SpaceNode { Name = "Child 5" },
+            new SpaceNode { Name = "Child 6" }
+        ] }
+    ];
+
+    public async Task Run() {
+        var sh = new SyncStateResolver(authService.Homeserver, logger);
+        // var res = await sh.SyncAsync();
+        while (true) {
+            var res = await sh.ContinueAsync();
+            Console.WriteLine("mow");
+        }
+    }
+
+    private void ApplySpaceChanges(SyncResponse newSync) {
+        List<string> handledRoomIds = [];
+       
+    }
+}
+
+public class SpaceNode {
+    public string Name { get; set; }
+    public ObservableCollection<SpaceNode> Children { get; set;  } = [];
+}
\ No newline at end of file
diff --git a/ModerationClient/ViewModels/LoginViewModel.cs b/ModerationClient/ViewModels/LoginViewModel.cs
new file mode 100644
index 0000000..32f0d6e
--- /dev/null
+++ b/ModerationClient/ViewModels/LoginViewModel.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Threading.Tasks;
+using ModerationClient.Services;
+
+namespace ModerationClient.ViewModels;
+
+public partial class LoginViewModel(MatrixAuthenticationService authService) : ViewModelBase
+{
+    private Exception? _exception;
+    public string Username { get; set; }
+    public string Password { get; set; }
+
+    public Exception? Exception {
+        get => _exception;
+        private set => SetProperty(ref _exception, value);
+    }
+
+    public async Task LoginAsync() {
+        try {
+            Exception = null;
+            await authService.LoginAsync(Username, Password);
+        } catch (Exception e) {
+            Exception = e;
+        }
+    }
+}
\ No newline at end of file
diff --git a/ModerationClient/ViewModels/MainWindowViewModel.cs b/ModerationClient/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..01ec6d6
--- /dev/null
+++ b/ModerationClient/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,33 @@
+using System;
+using ModerationClient.Services;
+using ModerationClient.Views;
+
+namespace ModerationClient.ViewModels;
+
+public partial class MainWindowViewModel(MatrixAuthenticationService authService, CommandLineConfiguration cfg) : ViewModelBase {
+    public MainWindow? MainWindow { get; set; }
+    
+    private float _scale = 1.0f;
+    private ViewModelBase _currentViewModel = new LoginViewModel(authService);
+
+    public ViewModelBase CurrentViewModel {
+        get => _currentViewModel;
+        set => SetProperty(ref _currentViewModel, value);
+    }
+
+    public CommandLineConfiguration CommandLineConfiguration { get; } = cfg;
+    public MatrixAuthenticationService AuthService { get; } = authService;
+
+    public float Scale {
+        get => _scale;
+        set {
+            SetProperty(ref _scale, (float)Math.Round(value, 2));
+            OnPropertyChanged(nameof(ChildTargetWidth));
+            OnPropertyChanged(nameof(ChildTargetHeight));
+        }
+    }
+    public int ChildTargetWidth => (int)(MainWindow?.Width / Scale ?? 1);
+    public int ChildTargetHeight => (int)(MainWindow?.Height / Scale ?? 1);
+
+    
+}
\ No newline at end of file
diff --git a/ModerationClient/ViewModels/ViewModelBase.cs b/ModerationClient/ViewModels/ViewModelBase.cs
new file mode 100644
index 0000000..0137aaf
--- /dev/null
+++ b/ModerationClient/ViewModels/ViewModelBase.cs
@@ -0,0 +1,7 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+
+namespace ModerationClient.ViewModels;
+
+public class ViewModelBase : ObservableObject
+{
+}
\ No newline at end of file
diff --git a/ModerationClient/Views/ClientView.axaml b/ModerationClient/Views/ClientView.axaml
new file mode 100644
index 0000000..21ce5d9
--- /dev/null
+++ b/ModerationClient/Views/ClientView.axaml
@@ -0,0 +1,30 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             xmlns:views="clr-namespace:ModerationClient.Views"
+             xmlns:viewModels="clr-namespace:ModerationClient.ViewModels"
+             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             x:Class="ModerationClient.Views.ClientView"
+             x:DataType="viewModels:ClientViewModel">
+    <Grid Width="{Binding $parent.Width}">
+        <Grid.ColumnDefinitions>
+            <ColumnDefinition Width="128" MinWidth="16" />
+            <ColumnDefinition Width="1" />
+            <ColumnDefinition Width="128" MinWidth="16" />
+            <ColumnDefinition Width="1" />
+            <ColumnDefinition Width="*" MinWidth="16" />
+        </Grid.ColumnDefinitions>
+        <TreeView Grid.Column="0" Background="Red" ItemsSource="{CompiledBinding DisplayedSpaces}">
+            <TreeView.ItemTemplate>
+                <TreeDataTemplate ItemsSource="{Binding Children}">
+                    <TextBlock Text="{Binding Name}" />
+                </TreeDataTemplate>
+            </TreeView.ItemTemplate>
+        </TreeView>
+        <GridSplitter Grid.Column="1" Background="Black" ResizeDirection="Columns" />
+        <Rectangle Grid.Column="2" Fill="Green" />
+        <GridSplitter Grid.Column="3" Background="Black" ResizeDirection="Columns" />
+        <Rectangle Grid.Column="4" Fill="Blue" />
+    </Grid>
+</UserControl>
\ No newline at end of file
diff --git a/ModerationClient/Views/ClientView.axaml.cs b/ModerationClient/Views/ClientView.axaml.cs
new file mode 100644
index 0000000..1ca5a89
--- /dev/null
+++ b/ModerationClient/Views/ClientView.axaml.cs
@@ -0,0 +1,33 @@
+using System;
+using System.Linq;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace ModerationClient.Views;
+
+public partial class ClientView : UserControl {
+    
+    public ClientView() {
+        InitializeComponent();
+        
+        // PropertyChanged += (_, e) => {
+        //     switch (e.Property.Name) {
+        //         case nameof(Width): {
+        //             //make sure all columns fit
+        //             var grid = this.LogicalChildren.OfType<Grid>().FirstOrDefault();
+        //             if(grid is null) {
+        //                 Console.WriteLine("Failed to find Grid in ClientView");
+        //                 return;
+        //             }
+        //             Console.WriteLine($"ClientView width changed to {Width}");
+        //             var columns = grid.ColumnDefinitions;
+        //             break;
+        //         }
+        //     }
+        // };
+    }
+    
+    private void InitializeComponent() {
+        AvaloniaXamlLoader.Load(this);
+    }
+}
diff --git a/ModerationClient/Views/LoginView.axaml b/ModerationClient/Views/LoginView.axaml
new file mode 100644
index 0000000..10e97c6
--- /dev/null
+++ b/ModerationClient/Views/LoginView.axaml
@@ -0,0 +1,25 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+             xmlns:views="clr-namespace:ModerationClient.Views"
+             xmlns:viewModels="clr-namespace:ModerationClient.ViewModels"
+             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+             x:Class="ModerationClient.Views.LoginView"
+             x:DataType="viewModels:LoginViewModel">
+             <!-- DataContext="{Binding $self}"> -->
+             <!-- DataContext="{Binding $self}"> -->
+    <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>
+        <TextBlock Text="{CompiledBinding Exception}" Foreground="#ff3333"/>
+    </StackPanel>
+</UserControl>
\ No newline at end of file
diff --git a/ModerationClient/Views/LoginView.axaml.cs b/ModerationClient/Views/LoginView.axaml.cs
new file mode 100644
index 0000000..2e95e80
--- /dev/null
+++ b/ModerationClient/Views/LoginView.axaml.cs
@@ -0,0 +1,33 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using Avalonia.VisualTree;
+using Microsoft.Extensions.DependencyInjection;
+using ModerationClient.Services;
+using ModerationClient.ViewModels;
+
+namespace ModerationClient.Views;
+
+public partial class LoginView : UserControl {
+    private MatrixAuthenticationService AuthService { get; set; }
+    
+    public LoginView() {
+        InitializeComponent();
+    }
+    
+    private void InitializeComponent() {
+        Console.WriteLine("LoginWindow loaded");
+
+        AvaloniaXamlLoader.Load(this);
+        Console.WriteLine("LoginWindow loaded 2");
+    }
+
+    // ReSharper disable once AsyncVoidMethod
+    private async void Login(object? sender, RoutedEventArgs e) {
+        Console.WriteLine("Login????");
+        // await AuthService.LoginAsync(Username, Password);
+        await ((LoginViewModel)DataContext).LoginAsync();
+    }
+}
diff --git a/ModerationClient/Views/MainWindow.axaml b/ModerationClient/Views/MainWindow.axaml
new file mode 100644
index 0000000..1c2b396
--- /dev/null
+++ b/ModerationClient/Views/MainWindow.axaml
@@ -0,0 +1,42 @@
+<Window xmlns="https://github.com/avaloniaui"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        xmlns:vm="using:ModerationClient.ViewModels"
+        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+        xmlns:views="clr-namespace:ModerationClient.Views"
+        mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
+        x:Class="ModerationClient.Views.MainWindow"
+        x:DataType="vm:MainWindowViewModel"
+        Icon="/Assets/avalonia-logo.ico"
+        Title="ModerationClient"
+        Width="640" Height="480">
+
+    <Design.DataContext>
+        <!-- This only sets the DataContext for the previewer in an IDE,
+             to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
+        <vm:MainWindowViewModel />
+    </Design.DataContext>
+
+    <StackPanel>
+        <Grid ColumnDefinitions="Auto, *, Auto">
+            <StackPanel Orientation="Horizontal" Grid.Column="0">
+                <Label Content="{CompiledBinding Scale}" />
+                <Label>x</Label>
+                <Rectangle Width="32" />
+                <Label Content="{CompiledBinding ChildTargetWidth}" />
+                <Label>x</Label>
+                <Label Content="{CompiledBinding ChildTargetHeight}" />
+            </StackPanel>
+            <Label Grid.Column="2">Press '?' for keybinds</Label>
+        </Grid>
+        <Viewbox>
+            <ContentControl
+                Width="{CompiledBinding ChildTargetWidth}"
+                Height="{CompiledBinding ChildTargetHeight}"
+                Background="#222222"
+                Content="{CompiledBinding CurrentViewModel}" />
+        </Viewbox>
+
+    </StackPanel>
+
+</Window>
\ No newline at end of file
diff --git a/ModerationClient/Views/MainWindow.axaml.cs b/ModerationClient/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..ccabd71
--- /dev/null
+++ b/ModerationClient/Views/MainWindow.axaml.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Threading;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Diagnostics;
+using Avalonia.Input;
+using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using ModerationClient.Services;
+using ModerationClient.ViewModels;
+
+namespace ModerationClient.Views;
+
+public partial class MainWindow : Window {
+    //viewmodel
+    private MainWindowViewModel? _viewModel { get; set; }
+
+    public MainWindow(CommandLineConfiguration cfg, MainWindowViewModel dataContext, IHostApplicationLifetime appLifetime) {
+        InitializeComponent();
+        Console.WriteLine("mainwnd");
+#if DEBUG
+        this.AttachDevTools(new DevToolsOptions() {
+            ShowAsChildWindow = true,
+            LaunchView = DevToolsViewKind.LogicalTree,
+        });
+#endif
+        PropertyChanged += (sender, args) => {
+            // Console.WriteLine($"MainWindow PropertyChanged: {args.Property.Name} ({args.OldValue} -> {args.NewValue})");
+            switch (args.Property.Name) {
+                case nameof(Height):
+                case nameof(Width): {
+                    if (_viewModel is null) {
+                        Console.WriteLine("WARN: MainWindowViewModel is null, ignoring height/width change!");
+                        return;
+                    }
+
+                    // Console.WriteLine("height/width changed");
+                    _viewModel.Scale = _viewModel.Scale;
+                    break;
+                }
+            }
+        };
+        DataContext = _viewModel = dataContext;
+        _ = dataContext.AuthService.LoadProfileAsync();
+        dataContext.AuthService.PropertyChanged += (sender, args) => {
+            if (args.PropertyName == nameof(MatrixAuthenticationService.IsLoggedIn)) {
+                if (dataContext.AuthService.IsLoggedIn) {
+                    // dataContext.CurrentViewModel = new ClientViewModel(dataContext.AuthService);
+                    dataContext.CurrentViewModel = App.Current.Host.Services.GetRequiredService<ClientViewModel>();
+                }
+                else {
+                    dataContext.CurrentViewModel = new LoginViewModel(dataContext.AuthService);
+                }
+            }
+        };
+        dataContext.MainWindow = this;
+        dataContext.Scale = cfg.Scale;
+        Width *= cfg.Scale;
+        Height *= cfg.Scale;
+
+        appLifetime.ApplicationStopping.Register(() => {
+            Console.WriteLine("ApplicationStopping triggered");
+            Close();
+        });
+    }
+
+    protected override void OnKeyDown(KeyEventArgs e) => OnKeyDown(this, e);
+
+    private void OnKeyDown(object? _, KeyEventArgs e) {
+        if (_viewModel is null) {
+            Console.WriteLine("WARN: MainWindowViewModel is null, ignoring key press!");
+            return;
+        }
+
+        // Console.WriteLine("MainWindow KeyDown: " + e.Key);
+        if (e.Key == Key.Escape) {
+            _viewModel.Scale = 1.0f;
+        }
+        else if (e.Key == Key.F1) {
+            _viewModel.Scale -= 0.1f;
+            if (_viewModel.Scale < 0.1f) {
+                _viewModel.Scale = 0.1f;
+            }
+        }
+        else if (e.Key == Key.F2) {
+            _viewModel.Scale += 0.1f;
+            if (_viewModel.Scale > 5.0f) {
+                _viewModel.Scale = 5.0f;
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/ModerationClient/app.manifest b/ModerationClient/app.manifest
new file mode 100644
index 0000000..8c7b424
--- /dev/null
+++ b/ModerationClient/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 embedded controls.
+       For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
+  <assemblyIdentity version="1.0.0.0" name="ModerationClient.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/ModerationClient/appsettings.Development.json b/ModerationClient/appsettings.Development.json
new file mode 100644
index 0000000..a1add03
--- /dev/null
+++ b/ModerationClient/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/ModerationClient/appsettings.json b/ModerationClient/appsettings.json
new file mode 100644
index 0000000..058723c
--- /dev/null
+++ b/ModerationClient/appsettings.json
@@ -0,0 +1,13 @@
+{
+    "Logging": {
+        "LogLevel": {
+            "Default": "Debug",
+            "System": "Information",
+            "Microsoft": "Information"
+        }
+    },
+    "RMUDesktop": {
+        "DataStoragePath": "~/.local/share/rmu-desktop",
+        "CacheStoragePath": "~/.cache/rmu-desktop"
+    }
+}