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