Initial Commit
This commit is contained in:
commit
86fc06b877
37 changed files with 3424 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
11
Decky.slnx
Normal 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
10
Directory.Build.props
Normal 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
1402
Implementation Plan.md
Normal file
File diff suppressed because it is too large
Load diff
10
NuGet.Config
Normal file
10
NuGet.Config
Normal 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
41
README.md
Normal 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`.
|
||||
16
installer/Decky.appinstaller.template
Normal file
16
installer/Decky.appinstaller.template
Normal 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
15
installer/README.md
Normal 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.
|
||||
10
src/Decky.ApiSpike/Decky.ApiSpike.csproj
Normal file
10
src/Decky.ApiSpike/Decky.ApiSpike.csproj
Normal 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>
|
||||
|
||||
244
src/Decky.ApiSpike/Program.cs
Normal file
244
src/Decky.ApiSpike/Program.cs
Normal 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
13
src/Decky.App/App.xaml
Normal 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
20
src/Decky.App/App.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
||||
50
src/Decky.App/Converters/DeckColorConverters.cs
Normal file
50
src/Decky.App/Converters/DeckColorConverters.cs
Normal 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();
|
||||
}
|
||||
|
||||
22
src/Decky.App/Decky.App.csproj
Normal file
22
src/Decky.App/Decky.App.csproj
Normal 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>
|
||||
119
src/Decky.App/MainWindow.xaml
Normal file
119
src/Decky.App/MainWindow.xaml
Normal 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="" 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>
|
||||
50
src/Decky.App/MainWindow.xaml.cs
Normal file
50
src/Decky.App/MainWindow.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
10
src/Decky.App/Services/AppDataPaths.cs
Normal file
10
src/Decky.App/Services/AppDataPaths.cs
Normal 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");
|
||||
}
|
||||
|
||||
73
src/Decky.App/Services/WindowsCredentialService.cs
Normal file
73
src/Decky.App/Services/WindowsCredentialService.cs
Normal 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('/')}";
|
||||
}
|
||||
|
||||
324
src/Decky.App/ViewModels/MainViewModel.cs
Normal file
324
src/Decky.App/ViewModels/MainViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
17
src/Decky.App/app.manifest
Normal file
17
src/Decky.App/app.manifest
Normal 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>
|
||||
|
||||
191
src/Decky.Core/Api/DeckApiClient.cs
Normal file
191
src/Decky.Core/Api/DeckApiClient.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
39
src/Decky.Core/Api/DeckApiException.cs
Normal file
39
src/Decky.Core/Api/DeckApiException.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
|
||||
104
src/Decky.Core/Api/Models/DeckDtos.cs
Normal file
104
src/Decky.Core/Api/Models/DeckDtos.cs
Normal 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; }
|
||||
}
|
||||
|
||||
81
src/Decky.Core/Api/Requests/CardRequests.cs
Normal file
81
src/Decky.Core/Api/Requests/CardRequests.cs
Normal 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; }
|
||||
}
|
||||
|
||||
48
src/Decky.Core/Configuration/DeckConnectionOptions.cs
Normal file
48
src/Decky.Core/Configuration/DeckConnectionOptions.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
6
src/Decky.Core/Decky.Core.csproj
Normal file
6
src/Decky.Core/Decky.Core.csproj
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
52
src/Decky.Core/Models/AppSettings.cs
Normal file
52
src/Decky.Core/Models/AppSettings.cs
Normal 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; }
|
||||
}
|
||||
21
src/Decky.Core/Services/ICredentialService.cs
Normal file
21
src/Decky.Core/Services/ICredentialService.cs
Normal 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);
|
||||
}
|
||||
|
||||
11
src/Decky.Core/Services/ISettingsService.cs
Normal file
11
src/Decky.Core/Services/ISettingsService.cs
Normal 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);
|
||||
}
|
||||
|
||||
112
src/Decky.Core/Services/JsonSettingsService.cs
Normal file
112
src/Decky.Core/Services/JsonSettingsService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
41
src/Decky.Core/Utilities/DeckColor.cs
Normal file
41
src/Decky.Core/Utilities/DeckColor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
40
tests/Decky.Core.Tests/CardUpdateRequestTests.cs
Normal file
40
tests/Decky.Core.Tests/CardUpdateRequestTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
90
tests/Decky.Core.Tests/DeckApiClientTests.cs
Normal file
90
tests/Decky.Core.Tests/DeckApiClientTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
24
tests/Decky.Core.Tests/DeckColorTests.cs
Normal file
24
tests/Decky.Core.Tests/DeckColorTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
||||
19
tests/Decky.Core.Tests/Decky.Core.Tests.csproj
Normal file
19
tests/Decky.Core.Tests/Decky.Core.Tests.csproj
Normal 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>
|
||||
|
||||
75
tests/Decky.Core.Tests/JsonSettingsServiceTests.cs
Normal file
75
tests/Decky.Core.Tests/JsonSettingsServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
tests/Decky.Core.Tests/Usings.cs
Normal file
1
tests/Decky.Core.Tests/Usings.cs
Normal file
|
|
@ -0,0 +1 @@
|
|||
global using Xunit;
|
||||
Loading…
Add table
Add a link
Reference in a new issue