Initial Commit

This commit is contained in:
Elijah 2026-07-19 13:10:55 -07:00
commit 86fc06b877
37 changed files with 3424 additions and 0 deletions

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
bin/
obj/
.vs/
*.user
*.suo
TestResults/
artifacts/
*.pfx
*.cer
appsettings.local.json
.dotnet-home/
.packages/

11
Decky.slnx Normal file
View file

@ -0,0 +1,11 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Decky.App/Decky.App.csproj" />
<Project Path="src/Decky.ApiSpike/Decky.ApiSpike.csproj" />
<Project Path="src/Decky.Core/Decky.Core.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Decky.Core.Tests/Decky.Core.Tests.csproj" />
</Folder>
</Solution>

10
Directory.Build.props Normal file
View file

@ -0,0 +1,10 @@
<Project>
<PropertyGroup>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Deterministic>true</Deterministic>
</PropertyGroup>
</Project>

1402
Implementation Plan.md Normal file

File diff suppressed because it is too large Load diff

10
NuGet.Config Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
<config>
<add key="globalPackagesFolder" value=".packages" />
</config>
</configuration>

41
README.md Normal file
View file

@ -0,0 +1,41 @@
# Decky
Decky is a focused Windows desktop client for viewing and editing boards from a
Nextcloud Deck instance.
## Current status
The repository currently contains:
- `Decky.Core`, a UI-independent Deck REST API client and domain foundation;
- `Decky.ApiSpike`, a read-only connection and compatibility probe;
- `Decky.App`, the initial WinUI 3 desktop shell;
- focused tests for API path discovery, required headers, update serialization,
completion clearing, color preservation, and text contrast.
## Development
Install the .NET 10 SDK and the Visual Studio WinUI application-development
workload. Then run:
```powershell
dotnet restore Decky.slnx
dotnet test tests/Decky.Core.Tests/Decky.Core.Tests.csproj
dotnet run --project src/Decky.ApiSpike/Decky.ApiSpike.csproj
```
The compatibility probe prompts for a Nextcloud app password without echoing or
storing it. It starts with read-only board, stack, and card-list requests. On a
disposable test board, its explicitly confirmed `WRITE` mode also verifies card
color creation/preservation, setting and clearing completion, same-stack reorder,
cross-stack movement, response verification, and cleanup.
The desktop app stores its JSON settings in `%LOCALAPPDATA%\Decky\settings.json`.
The app password is stored separately in Windows Credential Locker and is never
written to the JSON file.
## Updates
Release builds will be distributed as a signed MSIX bundle through a stable
`Decky.appinstaller` feed. Forgejo is the source and CI/release host; installed
copies do not update with `git pull`.

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<AppInstaller
xmlns="http://schemas.microsoft.com/appx/appinstaller/2021"
Version="${VERSION}"
Uri="${PUBLIC_BASE_URL}/Decky.appinstaller">
<MainBundle
Name="com.elijahkuntz.Decky"
Publisher="${PUBLISHER_SUBJECT}"
Version="${VERSION}"
Uri="${PUBLIC_BASE_URL}/Decky_${VERSION}.msixbundle" />
<UpdateSettings>
<OnLaunch HoursBetweenUpdateChecks="12" ShowPrompt="true" UpdateBlocksActivation="false" />
<AutomaticBackgroundTask />
</UpdateSettings>
</AppInstaller>

15
installer/README.md Normal file
View file

@ -0,0 +1,15 @@
# Packaging and updates
`Decky.appinstaller.template` is the stable update-feed template. The release
pipeline will replace its placeholders after producing and signing a versioned
MSIX bundle.
The final `PUBLIC_BASE_URL` must be an HTTPS location that Windows App Installer
can read without an interactive sign-in. Package name and publisher must remain
stable across releases, and `VERSION` must be a monotonically increasing
four-part MSIX version such as `1.2.0.0`.
The application project is temporarily configured as unpackaged so the first
read-only shell can be developed before signing identity and public artifact URL
are chosen. MSIX packaging becomes the release configuration, not a different
application or update mechanism.

View file

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Decky.Core\Decky.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,244 @@
using System.Net;
using Decky.Core.Api;
using Decky.Core.Api.Models;
using Decky.Core.Api.Requests;
using Decky.Core.Configuration;
using Decky.Core.Utilities;
Console.WriteLine("Decky Nextcloud Deck compatibility probe");
Console.WriteLine("Credentials are prompted securely and are never written to disk.\n");
Console.Write("Nextcloud server URL: ");
var serverUrl = Console.ReadLine() ?? string.Empty;
Console.Write("Username: ");
var username = Console.ReadLine() ?? string.Empty;
Console.Write("App password: ");
var appPassword = ReadSecret();
Console.WriteLine();
try
{
var options = DeckConnectionOptions.Create(serverUrl, username, appPassword);
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
var client = await DeckApiClient.ConnectAsync(httpClient, options);
var boards = await client.GetBoardsAsync();
Console.WriteLine($"Connected through {client.ApiBaseUri}");
Console.WriteLine($"Found {boards.Count} accessible board(s).\n");
foreach (var board in boards.OrderBy(board => board.Title, StringComparer.CurrentCultureIgnoreCase))
{
var stacks = await client.GetStacksAsync(board.Id);
var cardCount = stacks.Sum(stack => stack.Cards.Count);
Console.WriteLine($"[{board.Id}] {board.Title}: {stacks.Count} stack(s), {cardCount} card(s)");
}
Console.WriteLine("\nPASS Authentication, board retrieval, stack retrieval, and card-list retrieval");
Console.WriteLine("\nOptional write tests create and delete one temporary card, but can reorder that card");
Console.WriteLine("inside the selected board. Use a disposable board with at least two stacks.");
Console.Write("Type WRITE to run mutation compatibility checks, or press Enter to finish: ");
if (string.Equals(Console.ReadLine(), "WRITE", StringComparison.Ordinal))
{
var writeTestsPassed = await RunWriteProbeAsync(client, boards);
Environment.ExitCode = writeTestsPassed ? 0 : 2;
}
else
{
Console.WriteLine("\nRead-only API compatibility checks passed.");
}
}
catch (Exception exception) when (exception is DeckApiException or HttpRequestException or ArgumentException)
{
Console.Error.WriteLine($"\nCompatibility check failed: {exception.Message}");
Environment.ExitCode = 1;
}
static async Task<bool> RunWriteProbeAsync(DeckApiClient client, IReadOnlyList<BoardDto> boards)
{
Console.Write("Disposable board ID: ");
if (!int.TryParse(Console.ReadLine(), out var boardId) || boards.All(board => board.Id != boardId))
{
Console.Error.WriteLine("That board ID is not in the accessible board list.");
return false;
}
var stacks = (await client.GetStacksAsync(boardId)).OrderBy(stack => stack.Order).ToArray();
if (stacks.Length < 2)
{
Console.Error.WriteLine("Write tests require a disposable board with at least two stacks.");
return false;
}
const string activeColor = "D99080";
int? temporaryCardId = null;
var currentStackId = stacks[0].Id;
try
{
var created = await client.CreateCardAsync(
boardId,
currentStackId,
new CreateCardRequest
{
Title = $"Decky compatibility test {DateTimeOffset.UtcNow:yyyy-MM-dd HH:mm:ss}",
Description = "Temporary card created by Decky's compatibility probe.",
Type = "plain",
Order = 999,
Color = activeColor,
});
temporaryCardId = created.Id;
var card = await client.GetCardAsync(boardId, currentStackId, created.Id);
RequireColor(card, activeColor, "create");
PrintPass("Create a card with a color");
card = await client.UpdateCardAsync(
boardId,
currentStackId,
card.Id,
CardUpdateRequest.FromCard(card, title: card.Title + " · updated"));
RequireColor(card, activeColor, "title update");
PrintPass("Edit a card without losing its color");
card = await client.UpdateCardAsync(
boardId,
currentStackId,
card.Id,
CardUpdateRequest.FromCard(card, done: DateTimeOffset.UtcNow, replaceDone: true));
if (card.Done is null)
{
throw new InvalidOperationException("The server did not return a completion timestamp.");
}
RequireColor(card, activeColor, "completion update");
PrintPass("Set done while preserving color");
card = await client.UpdateCardAsync(
boardId,
currentStackId,
card.Id,
CardUpdateRequest.FromCard(card, done: null, replaceDone: true));
if (card.Done is not null)
{
throw new InvalidOperationException("The server did not clear the completion timestamp.");
}
RequireColor(card, activeColor, "completion clear");
PrintPass("Clear done while preserving color");
card = await client.ReorderCardAsync(
boardId,
currentStackId,
card.Id,
new ReorderCardRequest { Order = 0, StackId = currentStackId });
if (card.StackId != currentStackId)
{
throw new InvalidOperationException("Same-stack reorder returned an unexpected stack ID.");
}
PrintPass("Reorder within one stack");
var destinationStackId = stacks[1].Id;
card = await client.ReorderCardAsync(
boardId,
currentStackId,
card.Id,
new ReorderCardRequest { Order = 0, StackId = destinationStackId });
if (card.StackId != destinationStackId)
{
Console.Error.WriteLine(
$"FAIL Cross-stack move returned stack {card.StackId}; expected {destinationStackId}.");
Console.Error.WriteLine(" This Deck version is affected by the reorder compatibility problem.");
currentStackId = card.StackId;
return false;
}
currentStackId = destinationStackId;
PrintPass("Move between stacks and verify the returned destination");
return true;
}
catch (Exception exception) when (
exception is DeckApiException or HttpRequestException or InvalidOperationException or ArgumentException)
{
Console.Error.WriteLine($"FAIL Mutation compatibility check: {exception.Message}");
return false;
}
finally
{
if (temporaryCardId is not null)
{
var deleted = await TryDeleteTemporaryCardAsync(
client,
boardId,
temporaryCardId.Value,
currentStackId,
stacks.Select(stack => stack.Id));
Console.WriteLine(deleted
? "PASS Delete the temporary card"
: "WARN The temporary card could not be deleted automatically; remove it in Deck.");
}
}
}
static async Task<bool> TryDeleteTemporaryCardAsync(
DeckApiClient client,
int boardId,
int cardId,
int likelyStackId,
IEnumerable<int> knownStackIds)
{
foreach (var stackId in new[] { likelyStackId }.Concat(knownStackIds).Distinct())
{
try
{
await client.DeleteCardAsync(boardId, stackId, cardId);
return true;
}
catch (DeckApiException exception) when (exception.StatusCode == HttpStatusCode.NotFound)
{
}
}
return false;
}
static void RequireColor(CardDto card, string expected, string operation)
{
if (!string.Equals(DeckColor.Normalize(card.Color), expected, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"The {operation} returned color '{card.Color ?? "null"}' instead of '{expected}'.");
}
}
static void PrintPass(string check) => Console.WriteLine($"PASS {check}");
static string ReadSecret()
{
var buffer = new List<char>();
while (true)
{
var key = Console.ReadKey(intercept: true);
if (key.Key == ConsoleKey.Enter)
{
return new string([.. buffer]);
}
if (key.Key == ConsoleKey.Backspace && buffer.Count > 0)
{
buffer.RemoveAt(buffer.Count - 1);
Console.Write("\b \b");
continue;
}
if (!char.IsControl(key.KeyChar))
{
buffer.Add(key.KeyChar);
Console.Write('*');
}
}
}

13
src/Decky.App/App.xaml Normal file
View file

@ -0,0 +1,13 @@
<Application
x:Class="Decky.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

20
src/Decky.App/App.xaml.cs Normal file
View file

@ -0,0 +1,20 @@
using Microsoft.UI.Xaml;
namespace Decky.App;
public partial class App : Application
{
private Window? _window;
public App()
{
InitializeComponent();
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
_window = new MainWindow();
_window.Activate();
}
}

View file

@ -0,0 +1,50 @@
using Decky.Core.Utilities;
using Microsoft.UI;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace Decky.App.Converters;
public sealed class DeckColorToBrushConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
try
{
var color = DeckColor.Normalize(value as string) ?? "E5E7EB";
return new SolidColorBrush(Color.FromArgb(
255,
System.Convert.ToByte(color[..2], 16),
System.Convert.ToByte(color[2..4], 16),
System.Convert.ToByte(color[4..], 16)));
}
catch (ArgumentException)
{
return new SolidColorBrush(Colors.LightGray);
}
}
public object ConvertBack(object value, Type targetType, object parameter, string language) =>
throw new NotSupportedException();
}
public sealed class DeckColorToTextBrushConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
try
{
var color = DeckColor.Normalize(value as string) ?? "E5E7EB";
return new SolidColorBrush(DeckColor.UseDarkText(color) ? Colors.Black : Colors.White);
}
catch (ArgumentException)
{
return new SolidColorBrush(Colors.Black);
}
}
public object ConvertBack(object value, Type targetType, object parameter, string language) =>
throw new NotSupportedException();
}

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion>
<RootNamespace>Decky.App</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Platforms>x64;ARM64</Platforms>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<UseWinUI>true</UseWinUI>
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Decky.Core\Decky.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,119 @@
<Window
x:Class="Decky.App.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="using:Decky.App.Converters"
Title="Decky">
<Grid x:Name="RootGrid" Loaded="RootGrid_OnLoaded"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
<Grid.Resources>
<converters:DeckColorToBrushConverter x:Key="DeckColorToBrush" />
<converters:DeckColorToTextBrushConverter x:Key="DeckColorToTextBrush" />
</Grid.Resources>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<CommandBar Grid.Row="0" DefaultLabelPosition="Right">
<CommandBar.Content>
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<FontIcon Glyph="&#xE8FD;" FontSize="22" />
<TextBlock Text="Decky" FontSize="20" FontWeight="SemiBold" />
</StackPanel>
</CommandBar.Content>
<AppBarButton Icon="Refresh" Label="Refresh" Command="{Binding RefreshCommand}" />
<AppBarButton Icon="Setting" Label="Settings" IsEnabled="False" />
</CommandBar>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="240" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid Grid.Column="0" Padding="16" Background="{ThemeResource CardBackgroundFillColorDefaultBrush}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<StackPanel x:Name="ConnectionPanel" Spacing="10">
<TextBlock Text="Nextcloud connection" FontSize="16" FontWeight="SemiBold" />
<TextBox Header="Server" PlaceholderText="https://cloud.example.com"
Text="{Binding ServerUrl, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Header="Username"
Text="{Binding Username, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<PasswordBox x:Name="AppPasswordBox" Header="App password"
PasswordChanged="AppPasswordBox_OnPasswordChanged" />
<Button Content="Connect" HorizontalAlignment="Stretch"
Command="{Binding ConnectCommand}" />
<TextBlock Text="Use a revocable Nextcloud app password. Decky stores it in Windows Credential Locker."
TextWrapping="Wrap" Opacity="0.7" FontSize="12" />
</StackPanel>
<StackPanel Grid.Row="1" Margin="0,24,0,0" Spacing="8">
<TextBlock Text="Boards" FontSize="16" FontWeight="SemiBold" />
<ListView ItemsSource="{Binding Boards}"
SelectedItem="{Binding SelectedBoard, Mode=TwoWay}"
SelectionMode="Single">
<ListView.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Title}" TextTrimming="CharacterEllipsis" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</StackPanel>
</Grid>
<ScrollViewer Grid.Column="1" HorizontalScrollMode="Enabled" HorizontalScrollBarVisibility="Auto"
VerticalScrollMode="Enabled" VerticalScrollBarVisibility="Auto" Padding="20">
<ItemsControl ItemsSource="{Binding Stacks}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="16" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Width="300" Padding="12" CornerRadius="10"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
VerticalAlignment="Top">
<StackPanel Spacing="10">
<TextBlock Text="{Binding Title}" FontSize="18" FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding Cards}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Margin="0,0,0,8" Padding="12" CornerRadius="7"
Background="{Binding Color, Converter={StaticResource DeckColorToBrush}}">
<StackPanel Spacing="5">
<TextBlock Text="{Binding Title}" TextWrapping="Wrap"
Foreground="{Binding Color, Converter={StaticResource DeckColorToTextBrush}}" />
<TextBlock Text="{Binding DueDate}" FontSize="11" Opacity="0.75"
Foreground="{Binding Color, Converter={StaticResource DeckColorToTextBrush}}" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
<Grid Grid.Row="2" Padding="12,7" Background="{ThemeResource CardBackgroundFillColorDefaultBrush}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ProgressRing Width="16" Height="16" IsActive="{Binding IsBusy}" />
<TextBlock Grid.Column="1" Margin="10,0,0,0" Text="{Binding StatusMessage}"
TextTrimming="CharacterEllipsis" />
</Grid>
</Grid>
</Window>

View file

@ -0,0 +1,50 @@
using System.ComponentModel;
using Decky.App.Services;
using Decky.App.ViewModels;
using Decky.Core.Services;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Decky.App;
public sealed partial class MainWindow : Window
{
private readonly MainViewModel _viewModel;
public MainWindow()
{
InitializeComponent();
_viewModel = new MainViewModel(
new JsonSettingsService(AppDataPaths.SettingsFile),
new WindowsCredentialService());
RootGrid.DataContext = _viewModel;
_viewModel.PropertyChanged += ViewModel_OnPropertyChanged;
Closed += OnClosed;
}
private async void RootGrid_OnLoaded(object sender, RoutedEventArgs e)
{
await _viewModel.InitializeAsync();
}
private void AppPasswordBox_OnPasswordChanged(object sender, RoutedEventArgs e)
{
_viewModel.AppPassword = ((PasswordBox)sender).Password;
}
private void ViewModel_OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MainViewModel.AppPassword) &&
string.IsNullOrEmpty(_viewModel.AppPassword) &&
!string.IsNullOrEmpty(AppPasswordBox.Password))
{
AppPasswordBox.Password = string.Empty;
}
}
private void OnClosed(object sender, WindowEventArgs args)
{
_viewModel.PropertyChanged -= ViewModel_OnPropertyChanged;
_viewModel.Dispose();
}
}

View file

@ -0,0 +1,10 @@
namespace Decky.App.Services;
public static class AppDataPaths
{
public static string SettingsFile => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Decky",
"settings.json");
}

View file

@ -0,0 +1,73 @@
using Decky.Core.Services;
using Windows.Security.Credentials;
namespace Decky.App.Services;
public sealed class WindowsCredentialService : ICredentialService
{
private const int ElementNotFoundHResult = unchecked((int)0x80070490);
private readonly PasswordVault _vault = new();
public Task<string?> GetAppPasswordAsync(
Uri serverUri,
string username,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var credential = _vault.Retrieve(GetResourceName(serverUri), username);
credential.RetrievePassword();
return Task.FromResult<string?>(credential.Password);
}
catch (Exception exception) when (exception.HResult == ElementNotFoundHResult)
{
return Task.FromResult<string?>(null);
}
}
public Task SaveAppPasswordAsync(
Uri serverUri,
string username,
string appPassword,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrEmpty(appPassword))
{
throw new ArgumentException("An app password is required.", nameof(appPassword));
}
RemoveIfPresent(serverUri, username);
_vault.Add(new PasswordCredential(GetResourceName(serverUri), username, appPassword));
return Task.CompletedTask;
}
public Task DeleteAppPasswordAsync(
Uri serverUri,
string username,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
RemoveIfPresent(serverUri, username);
return Task.CompletedTask;
}
private void RemoveIfPresent(Uri serverUri, string username)
{
try
{
var credential = _vault.Retrieve(GetResourceName(serverUri), username);
_vault.Remove(credential);
}
catch (Exception exception) when (exception.HResult == ElementNotFoundHResult)
{
}
}
private static string GetResourceName(Uri serverUri) =>
$"Decky:{serverUri.AbsoluteUri.TrimEnd('/')}";
}

View file

@ -0,0 +1,324 @@
using System.Collections.ObjectModel;
using System.Runtime.InteropServices;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Decky.Core.Api;
using Decky.Core.Api.Models;
using Decky.Core.Configuration;
using Decky.Core.Models;
using Decky.Core.Services;
namespace Decky.App.ViewModels;
public partial class MainViewModel : ObservableObject, IDisposable
{
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) };
private readonly ISettingsService _settingsService;
private readonly ICredentialService _credentialService;
private DeckApiClient? _apiClient;
private CancellationTokenSource? _loadCancellation;
private AppSettings _settings = new();
private bool _isInitialized;
private bool _suppressBoardSelectionActions;
private string _serverUrl = string.Empty;
private string _username = string.Empty;
private string _appPassword = string.Empty;
private bool _isBusy;
private bool _isConnected;
private string _statusMessage = "Connect to Nextcloud to load your Deck boards.";
private BoardDto? _selectedBoard;
public MainViewModel(ISettingsService settingsService, ICredentialService credentialService)
{
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
_credentialService = credentialService ?? throw new ArgumentNullException(nameof(credentialService));
}
public string ServerUrl
{
get => _serverUrl;
set => SetProperty(ref _serverUrl, value);
}
public string Username
{
get => _username;
set => SetProperty(ref _username, value);
}
public string AppPassword
{
get => _appPassword;
set => SetProperty(ref _appPassword, value);
}
public bool IsBusy
{
get => _isBusy;
set
{
if (SetProperty(ref _isBusy, value))
{
ConnectCommand.NotifyCanExecuteChanged();
RefreshCommand.NotifyCanExecuteChanged();
}
}
}
public bool IsConnected
{
get => _isConnected;
set => SetProperty(ref _isConnected, value);
}
public string StatusMessage
{
get => _statusMessage;
set => SetProperty(ref _statusMessage, value);
}
public BoardDto? SelectedBoard
{
get => _selectedBoard;
set
{
if (SetProperty(ref _selectedBoard, value))
{
RefreshCommand.NotifyCanExecuteChanged();
if (value is not null && !_suppressBoardSelectionActions)
{
_ = LoadBoardAsync(value);
_ = SaveSelectedBoardAsync(value.Id);
}
}
}
}
public ObservableCollection<BoardDto> Boards { get; } = [];
public ObservableCollection<StackDto> Stacks { get; } = [];
public async Task InitializeAsync()
{
if (_isInitialized)
{
return;
}
_isInitialized = true;
try
{
_settings = await _settingsService.LoadAsync();
ServerUrl = _settings.ServerUrl;
Username = _settings.Username;
if (string.IsNullOrWhiteSpace(ServerUrl) || string.IsNullOrWhiteSpace(Username))
{
return;
}
if (!Uri.TryCreate(ServerUrl, UriKind.Absolute, out var serverUri))
{
StatusMessage = "The saved Nextcloud server address is invalid. Enter it again.";
return;
}
var savedPassword = await _credentialService.GetAppPasswordAsync(serverUri, Username);
if (string.IsNullOrEmpty(savedPassword))
{
StatusMessage = "Saved connection found. Enter your Nextcloud app password.";
return;
}
AppPassword = savedPassword;
await ConnectAndLoadAsync(saveCredential: false);
}
catch (Exception exception) when (IsPersistenceException(exception))
{
StatusMessage = $"Local settings could not be loaded: {exception.Message}";
}
}
[RelayCommand(CanExecute = nameof(CanConnect))]
private Task ConnectAsync() => ConnectAndLoadAsync(saveCredential: true);
private async Task ConnectAndLoadAsync(bool saveCredential)
{
IsBusy = true;
StatusMessage = "Connecting…";
try
{
var appPassword = AppPassword;
var options = DeckConnectionOptions.Create(ServerUrl, Username, appPassword) with
{
ApiPath = _settings.ApiPath,
};
_apiClient = await DeckApiClient.ConnectAsync(_httpClient, options);
var boards = await _apiClient.GetBoardsAsync();
Boards.Clear();
foreach (var board in boards.Where(board => !board.Archived).OrderBy(board => board.Title))
{
Boards.Add(board);
}
IsConnected = true;
var persistenceWarning = await PersistConnectionAsync(options, appPassword, saveCredential);
StatusMessage = persistenceWarning is null
? $"Connected · {Boards.Count} board(s)"
: $"Connected, but {persistenceWarning}";
var restoredBoardId = _settings.OpenBoardTabs.FirstOrDefault();
var boardToOpen = Boards.FirstOrDefault(board => board.Id == restoredBoardId)
?? Boards.FirstOrDefault();
_suppressBoardSelectionActions = true;
SelectedBoard = boardToOpen;
_suppressBoardSelectionActions = false;
if (boardToOpen is not null)
{
await LoadBoardAsync(boardToOpen);
await SaveSelectedBoardAsync(boardToOpen.Id);
}
}
catch (Exception exception) when (exception is DeckApiException or HttpRequestException or ArgumentException)
{
IsConnected = false;
StatusMessage = exception.Message;
}
finally
{
AppPassword = string.Empty;
IsBusy = false;
}
}
private async Task<string?> PersistConnectionAsync(
DeckConnectionOptions options,
string appPassword,
bool saveCredential)
{
try
{
if (saveCredential)
{
await _credentialService.SaveAppPasswordAsync(
options.ServerUri,
options.Username,
appPassword);
}
var apiPath = options.ServerUri.MakeRelativeUri(_apiClient!.ApiBaseUri).ToString();
_settings = _settings with
{
ServerUrl = options.ServerUri.ToString(),
Username = options.Username,
ApiPath = apiPath,
};
await _settingsService.SaveAsync(_settings);
return null;
}
catch (Exception exception) when (IsPersistenceException(exception))
{
return $"the saved connection could not be updated: {exception.Message}";
}
}
private async Task SaveSelectedBoardAsync(int boardId)
{
if (!IsConnected)
{
return;
}
try
{
_settings = _settings with { OpenBoardTabs = [boardId] };
await _settingsService.SaveAsync(_settings);
}
catch (Exception exception) when (IsPersistenceException(exception))
{
StatusMessage = $"Board loaded, but its tab could not be remembered: {exception.Message}";
}
}
private static bool IsPersistenceException(Exception exception) =>
exception is IOException or UnauthorizedAccessException or COMException;
private bool CanConnect() => !IsBusy;
[RelayCommand(CanExecute = nameof(CanRefresh))]
private async Task RefreshAsync()
{
if (SelectedBoard is not null)
{
await LoadBoardAsync(SelectedBoard);
}
}
private bool CanRefresh() => !IsBusy && _apiClient is not null && SelectedBoard is not null;
private async Task LoadBoardAsync(BoardDto board)
{
if (_apiClient is null)
{
return;
}
_loadCancellation?.Cancel();
_loadCancellation?.Dispose();
_loadCancellation = new CancellationTokenSource();
var cancellationToken = _loadCancellation.Token;
IsBusy = true;
StatusMessage = $"Loading {board.Title}…";
try
{
var stacks = await _apiClient.GetStacksAsync(board.Id, cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
return;
}
Stacks.Clear();
foreach (var stack in stacks.OrderBy(stack => stack.Order))
{
Stacks.Add(stack);
}
StatusMessage = $"{board.Title} · {Stacks.Sum(stack => stack.Cards.Count)} card(s) · " +
$"Updated {DateTimeOffset.Now:t}";
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (Exception exception) when (exception is DeckApiException or HttpRequestException)
{
StatusMessage = $"Offline · {exception.Message}";
}
finally
{
if (!cancellationToken.IsCancellationRequested)
{
IsBusy = false;
}
}
}
public void Dispose()
{
_loadCancellation?.Cancel();
_loadCancellation?.Dispose();
_httpClient.Dispose();
if (_settingsService is IDisposable disposableSettingsService)
{
disposableSettingsService.Dispose();
}
GC.SuppressFinalize(this);
}
}

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Decky.App" />
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<maxversiontested Id="10.0.26100.0" />
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>

View file

@ -0,0 +1,191 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Decky.Core.Api.Models;
using Decky.Core.Api.Requests;
using Decky.Core.Configuration;
namespace Decky.Core.Api;
public sealed class DeckApiClient
{
public const string DocumentedApiPath = "index.php/apps/deck/api/v1.0/";
public const string RewrittenApiPath = "apps/deck/api/v1.0/";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
};
private readonly HttpClient _httpClient;
private readonly AuthenticationHeaderValue _authorization;
public DeckApiClient(HttpClient httpClient, DeckConnectionOptions options, Uri apiBaseUri)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
ArgumentNullException.ThrowIfNull(options);
ApiBaseUri = apiBaseUri ?? throw new ArgumentNullException(nameof(apiBaseUri));
var token = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{options.Username}:{options.AppPassword}"));
_authorization = new AuthenticationHeaderValue("Basic", token);
}
public Uri ApiBaseUri { get; }
public static async Task<DeckApiClient> ConnectAsync(
HttpClient httpClient,
DeckConnectionOptions options,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(httpClient);
ArgumentNullException.ThrowIfNull(options);
var paths = string.IsNullOrWhiteSpace(options.ApiPath)
? new[] { DocumentedApiPath, RewrittenApiPath }
: new[] { options.ApiPath, DocumentedApiPath, RewrittenApiPath };
DeckApiException? lastApiError = null;
HttpRequestException? lastNetworkError = null;
foreach (var path in paths.Distinct(StringComparer.OrdinalIgnoreCase))
{
var apiBaseUri = new Uri(options.ServerUri, path.TrimStart('/'));
var client = new DeckApiClient(httpClient, options, apiBaseUri);
try
{
_ = await client.GetBoardsAsync(cancellationToken).ConfigureAwait(false);
return client;
}
catch (DeckApiException ex) when ((int)ex.StatusCode is 404 or 405)
{
lastApiError = ex;
}
catch (HttpRequestException ex)
{
lastNetworkError = ex;
}
}
if (lastApiError is not null)
{
throw lastApiError;
}
throw new HttpRequestException(
"Decky's documented and rewrite-friendly Deck API paths could not be reached.",
lastNetworkError);
}
public Task<IReadOnlyList<BoardDto>> GetBoardsAsync(CancellationToken cancellationToken = default) =>
SendJsonAsync<IReadOnlyList<BoardDto>>(HttpMethod.Get, "boards", null, cancellationToken);
public Task<IReadOnlyList<StackDto>> GetStacksAsync(int boardId, CancellationToken cancellationToken = default) =>
SendJsonAsync<IReadOnlyList<StackDto>>(HttpMethod.Get, $"boards/{boardId}/stacks", null, cancellationToken);
public Task<CardDto> GetCardAsync(
int boardId,
int stackId,
int cardId,
CancellationToken cancellationToken = default) =>
SendJsonAsync<CardDto>(
HttpMethod.Get,
$"boards/{boardId}/stacks/{stackId}/cards/{cardId}",
null,
cancellationToken);
public Task<CardDto> CreateCardAsync(
int boardId,
int stackId,
CreateCardRequest request,
CancellationToken cancellationToken = default) =>
SendJsonAsync<CardDto>(
HttpMethod.Post,
$"boards/{boardId}/stacks/{stackId}/cards",
request,
cancellationToken);
public Task<CardDto> UpdateCardAsync(
int boardId,
int stackId,
int cardId,
CardUpdateRequest request,
CancellationToken cancellationToken = default) =>
SendJsonAsync<CardDto>(
HttpMethod.Put,
$"boards/{boardId}/stacks/{stackId}/cards/{cardId}",
request,
cancellationToken);
public Task<CardDto> ReorderCardAsync(
int boardId,
int sourceStackId,
int cardId,
ReorderCardRequest request,
CancellationToken cancellationToken = default) =>
SendJsonAsync<CardDto>(
HttpMethod.Put,
$"boards/{boardId}/stacks/{sourceStackId}/cards/{cardId}/reorder",
request,
cancellationToken);
public async Task DeleteCardAsync(
int boardId,
int stackId,
int cardId,
CancellationToken cancellationToken = default)
{
using var response = await SendAsync(
HttpMethod.Delete,
$"boards/{boardId}/stacks/{stackId}/cards/{cardId}",
null,
cancellationToken).ConfigureAwait(false);
}
private async Task<T> SendJsonAsync<T>(
HttpMethod method,
string relativePath,
object? body,
CancellationToken cancellationToken)
{
using var response = await SendAsync(method, relativePath, body, cancellationToken).ConfigureAwait(false);
var result = await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken).ConfigureAwait(false);
return result ?? throw new JsonException($"Deck returned an empty JSON body for '{relativePath}'.");
}
private async Task<HttpResponseMessage> SendAsync(
HttpMethod method,
string relativePath,
object? body,
CancellationToken cancellationToken)
{
var requestUri = new Uri(ApiBaseUri, relativePath);
using var request = new HttpRequestMessage(method, requestUri);
request.Headers.Authorization = _authorization;
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.TryAddWithoutValidation("OCS-APIRequest", "true");
if (body is not null)
{
request.Content = JsonContent.Create(body, body.GetType(), options: JsonOptions);
}
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
return response;
}
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var exception = new DeckApiException(
response.StatusCode,
requestUri.ToString(),
responseBody,
response.ReasonPhrase);
response.Dispose();
throw exception;
}
}

View file

@ -0,0 +1,39 @@
using System.Net;
namespace Decky.Core.Api;
public sealed class DeckApiException : Exception
{
public DeckApiException(
HttpStatusCode statusCode,
string requestUri,
string? responseBody,
string? reasonPhrase)
: base(BuildMessage(statusCode, requestUri, reasonPhrase, responseBody))
{
StatusCode = statusCode;
RequestUri = requestUri;
ResponseBody = responseBody;
}
public HttpStatusCode StatusCode { get; }
public string RequestUri { get; }
public string? ResponseBody { get; }
private static string BuildMessage(
HttpStatusCode statusCode,
string requestUri,
string? reasonPhrase,
string? responseBody)
{
var detail = string.IsNullOrWhiteSpace(responseBody)
? string.Empty
: $" Response: {responseBody}";
return $"Deck API request to '{requestUri}' failed with " +
$"{(int)statusCode} {reasonPhrase}.{detail}";
}
}

View file

@ -0,0 +1,104 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Decky.Core.Api.Models;
public sealed record DeckUserDto
{
[JsonPropertyName("primaryKey")]
public string PrimaryKey { get; init; } = string.Empty;
[JsonPropertyName("uid")]
public string UserId { get; init; } = string.Empty;
[JsonPropertyName("displayname")]
public string DisplayName { get; init; } = string.Empty;
}
public sealed record BoardPermissionsDto
{
[JsonPropertyName("PERMISSION_READ")]
public bool CanRead { get; init; }
[JsonPropertyName("PERMISSION_EDIT")]
public bool CanEdit { get; init; }
[JsonPropertyName("PERMISSION_MANAGE")]
public bool CanManage { get; init; }
[JsonPropertyName("PERMISSION_SHARE")]
public bool CanShare { get; init; }
}
public sealed record BoardDto
{
public int Id { get; init; }
public string Title { get; init; } = string.Empty;
public DeckUserDto? Owner { get; init; }
public string? Color { get; init; }
public bool Archived { get; init; }
public long LastModified { get; init; }
public BoardPermissionsDto? Permissions { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? AdditionalData { get; init; }
}
public sealed record StackDto
{
public int Id { get; init; }
public int BoardId { get; init; }
public string Title { get; init; } = string.Empty;
public int Order { get; init; }
public long LastModified { get; init; }
public IReadOnlyList<CardDto> Cards { get; init; } = [];
[JsonExtensionData]
public Dictionary<string, JsonElement>? AdditionalData { get; init; }
}
public sealed record CardDto
{
public int Id { get; init; }
public int StackId { get; init; }
public string Title { get; init; } = string.Empty;
public string? Description { get; init; }
public string Type { get; init; } = "plain";
public string Owner { get; init; } = string.Empty;
public int Order { get; init; }
public bool Archived { get; init; }
public DateTimeOffset? Done { get; init; }
[JsonPropertyName("duedate")]
public DateTimeOffset? DueDate { get; init; }
[JsonPropertyName("startdate")]
public DateTimeOffset? StartDate { get; init; }
public string? Color { get; init; }
public long LastModified { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? AdditionalData { get; init; }
}

View file

@ -0,0 +1,81 @@
using System.Text.Json.Serialization;
using Decky.Core.Api.Models;
using Decky.Core.Utilities;
namespace Decky.Core.Api.Requests;
public sealed record CreateCardRequest
{
public required string Title { get; init; }
public string Type { get; init; } = "plain";
public int Order { get; init; }
public string? Description { get; init; }
[JsonPropertyName("duedate")]
public DateTimeOffset? DueDate { get; init; }
public string? Color { get; init; }
}
public sealed record CardUpdateRequest
{
public required string Title { get; init; }
public required string Type { get; init; }
public required string Owner { get; init; }
public string? Description { get; init; }
public int Order { get; init; }
[JsonPropertyName("duedate")]
public DateTimeOffset? DueDate { get; init; }
[JsonPropertyName("startdate")]
public DateTimeOffset? StartDate { get; init; }
public bool Archived { get; init; }
public DateTimeOffset? Done { get; init; }
public string? Color { get; init; }
public static CardUpdateRequest FromCard(
CardDto card,
string? title = null,
string? description = null,
DateTimeOffset? dueDate = null,
bool replaceDueDate = false,
DateTimeOffset? done = null,
bool replaceDone = false,
string? color = null)
{
ArgumentNullException.ThrowIfNull(card);
return new CardUpdateRequest
{
Title = title ?? card.Title,
Type = card.Type,
Owner = card.Owner,
Description = description ?? card.Description,
Order = card.Order,
DueDate = replaceDueDate ? dueDate : card.DueDate,
StartDate = card.StartDate,
Archived = card.Archived,
Done = replaceDone ? done : card.Done,
Color = DeckColor.Normalize(color ?? card.Color),
};
}
}
public sealed record ReorderCardRequest
{
public required int Order { get; init; }
public required int StackId { get; init; }
}

View file

@ -0,0 +1,48 @@
namespace Decky.Core.Configuration;
public sealed record DeckConnectionOptions
{
public required Uri ServerUri { get; init; }
public required string Username { get; init; }
public required string AppPassword { get; init; }
public string? ApiPath { get; init; }
public static DeckConnectionOptions Create(string serverUrl, string username, string appPassword)
{
if (!Uri.TryCreate(serverUrl.Trim(), UriKind.Absolute, out var serverUri) ||
(serverUri.Scheme != Uri.UriSchemeHttps && serverUri.Scheme != Uri.UriSchemeHttp))
{
throw new ArgumentException("Enter an absolute HTTP or HTTPS Nextcloud server URL.", nameof(serverUrl));
}
if (serverUri.Scheme == Uri.UriSchemeHttp && !serverUri.IsLoopback)
{
throw new ArgumentException(
"Decky requires HTTPS except when connecting to a loopback development server.",
nameof(serverUrl));
}
if (string.IsNullOrWhiteSpace(username))
{
throw new ArgumentException("A Nextcloud username is required.", nameof(username));
}
if (string.IsNullOrEmpty(appPassword))
{
throw new ArgumentException("A Nextcloud app password is required.", nameof(appPassword));
}
var builder = new UriBuilder(serverUri) { Query = string.Empty, Fragment = string.Empty };
builder.Path = builder.Path.TrimEnd('/') + "/";
return new DeckConnectionOptions
{
ServerUri = builder.Uri,
Username = username.Trim(),
AppPassword = appPassword,
};
}
}

View file

@ -0,0 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,52 @@
namespace Decky.Core.Models;
public enum BoardOrientation
{
Horizontal,
Vertical,
}
public sealed record ColorPreset
{
public Guid Id { get; init; } = Guid.NewGuid();
public string Name { get; init; } = string.Empty;
public string HexColor { get; init; } = string.Empty;
}
public sealed record BoardViewSettings
{
public BoardOrientation Orientation { get; init; }
public Guid? ActiveColorPresetOverride { get; init; }
public Guid? CompletedColorPresetOverride { get; init; }
}
public sealed record AppSettings
{
public string ServerUrl { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string? ApiPath { get; init; }
public int RefreshIntervalSeconds { get; init; } = 30;
public bool AlwaysOnTop { get; init; }
public bool StartWithWindows { get; init; }
public bool StartMinimized { get; init; }
public List<int> OpenBoardTabs { get; init; } = [];
public Dictionary<int, BoardViewSettings> Boards { get; init; } = [];
public List<ColorPreset> ColorPresets { get; init; } = [];
public Guid? ActiveColorPresetId { get; init; }
public Guid? CompletedColorPresetId { get; init; }
}

View file

@ -0,0 +1,21 @@
namespace Decky.Core.Services;
public interface ICredentialService
{
Task<string?> GetAppPasswordAsync(
Uri serverUri,
string username,
CancellationToken cancellationToken = default);
Task SaveAppPasswordAsync(
Uri serverUri,
string username,
string appPassword,
CancellationToken cancellationToken = default);
Task DeleteAppPasswordAsync(
Uri serverUri,
string username,
CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,11 @@
using Decky.Core.Models;
namespace Decky.Core.Services;
public interface ISettingsService
{
Task<AppSettings> LoadAsync(CancellationToken cancellationToken = default);
Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,112 @@
using System.Text.Json;
using Decky.Core.Models;
namespace Decky.Core.Services;
public sealed class JsonSettingsService : ISettingsService, IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
};
private readonly string _filePath;
private readonly SemaphoreSlim _gate = new(1, 1);
public JsonSettingsService(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
{
throw new ArgumentException("A settings file path is required.", nameof(filePath));
}
_filePath = Path.GetFullPath(filePath);
}
public async Task<AppSettings> LoadAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (!File.Exists(_filePath))
{
return new AppSettings();
}
try
{
await using var stream = File.OpenRead(_filePath);
return await JsonSerializer.DeserializeAsync<AppSettings>(stream, JsonOptions, cancellationToken)
.ConfigureAwait(false)
?? new AppSettings();
}
catch (JsonException)
{
PreserveCorruptSettingsFile();
return new AppSettings();
}
}
finally
{
_gate.Release();
}
}
public async Task SaveAsync(AppSettings settings, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(settings);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
string? temporaryPath = null;
try
{
var directory = Path.GetDirectoryName(_filePath)
?? throw new InvalidOperationException("The settings path has no parent directory.");
Directory.CreateDirectory(directory);
temporaryPath = Path.Combine(directory, $".{Path.GetFileName(_filePath)}.{Guid.NewGuid():N}.tmp");
await using (var stream = new FileStream(
temporaryPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
bufferSize: 4096,
useAsync: true))
{
await JsonSerializer.SerializeAsync(stream, settings, JsonOptions, cancellationToken)
.ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
File.Move(temporaryPath, _filePath, overwrite: true);
temporaryPath = null;
}
finally
{
if (temporaryPath is not null && File.Exists(temporaryPath))
{
File.Delete(temporaryPath);
}
_gate.Release();
}
}
private void PreserveCorruptSettingsFile()
{
var directory = Path.GetDirectoryName(_filePath)!;
var fileName = Path.GetFileNameWithoutExtension(_filePath);
var extension = Path.GetExtension(_filePath);
var backupPath = Path.Combine(
directory,
$"{fileName}.corrupt-{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}{extension}");
File.Move(_filePath, backupPath);
}
public void Dispose()
{
_gate.Dispose();
GC.SuppressFinalize(this);
}
}

View file

@ -0,0 +1,41 @@
using System.Globalization;
namespace Decky.Core.Utilities;
public static class DeckColor
{
public static string? Normalize(string? color)
{
if (string.IsNullOrWhiteSpace(color))
{
return null;
}
var value = color.Trim().TrimStart('#');
if (value.Length != 6 || !int.TryParse(value, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _))
{
throw new ArgumentException("A Deck color must be a six-digit RGB hexadecimal value.", nameof(color));
}
return value.ToUpperInvariant();
}
public static bool UseDarkText(string color)
{
var normalized = Normalize(color)!;
var red = Convert.ToInt32(normalized[..2], 16) / 255d;
var green = Convert.ToInt32(normalized[2..4], 16) / 255d;
var blue = Convert.ToInt32(normalized[4..], 16) / 255d;
static double Linearize(double component) => component <= 0.04045
? component / 12.92
: Math.Pow((component + 0.055) / 1.055, 2.4);
var luminance = (0.2126 * Linearize(red)) +
(0.7152 * Linearize(green)) +
(0.0722 * Linearize(blue));
return luminance > 0.179;
}
}

View file

@ -0,0 +1,40 @@
using System.Text.Json;
using Decky.Core.Api.Models;
using Decky.Core.Api.Requests;
namespace Decky.Core.Tests;
public sealed class CardUpdateRequestTests
{
[Fact]
public void FromCard_preserves_fields_and_serializes_explicit_null_done()
{
var card = new CardDto
{
Id = 7,
StackId = 3,
Title = "Original",
Description = "Details",
Type = "plain",
Owner = "elijah",
Order = 42,
Archived = false,
Done = DateTimeOffset.Parse("2026-07-19T12:00:00Z"),
DueDate = DateTimeOffset.Parse("2026-07-20T12:00:00Z"),
StartDate = DateTimeOffset.Parse("2026-07-18T12:00:00Z"),
Color = "d99080",
};
var request = CardUpdateRequest.FromCard(card, title: "Changed", done: null, replaceDone: true);
var json = JsonSerializer.Serialize(request, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
Assert.Equal("Changed", root.GetProperty("title").GetString());
Assert.Equal("elijah", root.GetProperty("owner").GetString());
Assert.Equal("D99080", root.GetProperty("color").GetString());
Assert.Equal("2026-07-20T12:00:00+00:00", root.GetProperty("duedate").GetString());
Assert.Equal(JsonValueKind.Null, root.GetProperty("done").ValueKind);
}
}

View file

@ -0,0 +1,90 @@
using System.Net;
using System.Text;
using Decky.Core.Api;
using Decky.Core.Configuration;
namespace Decky.Core.Tests;
public sealed class DeckApiClientTests
{
[Fact]
public void Connection_options_reject_insecure_remote_servers()
{
var exception = Assert.Throws<ArgumentException>(() =>
DeckConnectionOptions.Create("http://cloud.example.test", "elijah", "app-password"));
Assert.Contains("HTTPS", exception.Message, StringComparison.Ordinal);
}
[Fact]
public async Task GetBoardsAsync_sends_required_headers_and_deserializes_response()
{
HttpRequestMessage? capturedRequest = null;
var handler = new StubHttpMessageHandler(request =>
{
capturedRequest = CloneRequest(request);
return JsonResponse("[{\"id\":10,\"title\":\"Homework\",\"color\":\"ff0000\"}]");
});
using var httpClient = new HttpClient(handler);
var options = DeckConnectionOptions.Create("https://cloud.example.test", "elijah", "app-password");
var client = new DeckApiClient(
httpClient,
options,
new Uri("https://cloud.example.test/index.php/apps/deck/api/v1.0/"));
var boards = await client.GetBoardsAsync();
Assert.Single(boards);
Assert.Equal("Homework", boards[0].Title);
Assert.NotNull(capturedRequest);
Assert.Equal(
"https://cloud.example.test/index.php/apps/deck/api/v1.0/boards",
capturedRequest.RequestUri!.ToString());
Assert.Equal("true", capturedRequest.Headers.GetValues("OCS-APIRequest").Single());
Assert.Equal("Basic", capturedRequest.Headers.Authorization!.Scheme);
}
[Fact]
public async Task ConnectAsync_falls_back_to_rewritten_path_after_not_found()
{
var requestedPaths = new List<string>();
var handler = new StubHttpMessageHandler(request =>
{
requestedPaths.Add(request.RequestUri!.AbsolutePath);
return request.RequestUri.AbsolutePath.Contains("/index.php/", StringComparison.Ordinal)
? new HttpResponseMessage(HttpStatusCode.NotFound)
: JsonResponse("[]");
});
using var httpClient = new HttpClient(handler);
var options = DeckConnectionOptions.Create("https://cloud.example.test", "elijah", "app-password");
var client = await DeckApiClient.ConnectAsync(httpClient, options);
Assert.Equal("https://cloud.example.test/apps/deck/api/v1.0/", client.ApiBaseUri.ToString());
Assert.Equal(2, requestedPaths.Count);
}
private static HttpResponseMessage JsonResponse(string json) => new(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
};
private static HttpRequestMessage CloneRequest(HttpRequestMessage request)
{
var clone = new HttpRequestMessage(request.Method, request.RequestUri);
foreach (var header in request.Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
return clone;
}
private sealed class StubHttpMessageHandler(
Func<HttpRequestMessage, HttpResponseMessage> responseFactory) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) => Task.FromResult(responseFactory(request));
}
}

View file

@ -0,0 +1,24 @@
using Decky.Core.Utilities;
namespace Decky.Core.Tests;
public sealed class DeckColorTests
{
[Theory]
[InlineData("#d99080", "D99080")]
[InlineData(" 7FBC9F ", "7FBC9F")]
public void Normalize_returns_api_hex(string input, string expected)
{
Assert.Equal(expected, DeckColor.Normalize(input));
}
[Theory]
[InlineData("FFFFFF", true)]
[InlineData("000000", false)]
[InlineData("D99080", true)]
public void UseDarkText_uses_relative_luminance(string color, bool expected)
{
Assert.Equal(expected, DeckColor.UseDarkText(color));
}
}

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Decky.Core\Decky.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,75 @@
using Decky.Core.Models;
using Decky.Core.Services;
namespace Decky.Core.Tests;
public sealed class JsonSettingsServiceTests : IDisposable
{
private readonly string _testDirectory = Path.Combine(
Path.GetTempPath(),
$"Decky.Tests.{Guid.NewGuid():N}");
[Fact]
public async Task LoadAsync_returns_defaults_when_file_does_not_exist()
{
using var service = CreateService();
var settings = await service.LoadAsync();
Assert.Equal(30, settings.RefreshIntervalSeconds);
Assert.Empty(settings.OpenBoardTabs);
}
[Fact]
public async Task SaveAsync_round_trips_connection_and_board_settings()
{
using var service = CreateService();
var expected = new AppSettings
{
ServerUrl = "https://cloud.example.test/",
Username = "elijah",
ApiPath = "index.php/apps/deck/api/v1.0/",
OpenBoardTabs = [17],
Boards = new Dictionary<int, BoardViewSettings>
{
[17] = new() { Orientation = BoardOrientation.Vertical },
},
};
await service.SaveAsync(expected);
var actual = await service.LoadAsync();
Assert.Equal(expected.ServerUrl, actual.ServerUrl);
Assert.Equal(expected.Username, actual.Username);
Assert.Equal(expected.ApiPath, actual.ApiPath);
Assert.Equal([17], actual.OpenBoardTabs);
Assert.Equal(BoardOrientation.Vertical, actual.Boards[17].Orientation);
Assert.DoesNotContain("password", await File.ReadAllTextAsync(GetSettingsPath()), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task LoadAsync_preserves_corrupt_file_and_returns_defaults()
{
Directory.CreateDirectory(_testDirectory);
await File.WriteAllTextAsync(GetSettingsPath(), "{ definitely not json }");
using var service = CreateService();
var settings = await service.LoadAsync();
Assert.Equal(string.Empty, settings.ServerUrl);
Assert.False(File.Exists(GetSettingsPath()));
Assert.Single(Directory.GetFiles(_testDirectory, "settings.corrupt-*.json"));
}
private JsonSettingsService CreateService() => new(GetSettingsPath());
private string GetSettingsPath() => Path.Combine(_testDirectory, "settings.json");
public void Dispose()
{
if (Directory.Exists(_testDirectory))
{
Directory.Delete(_testDirectory, recursive: true);
}
}
}

View file

@ -0,0 +1 @@
global using Xunit;