From 86fc06b87706233064abf25fd49b337cb8255167 Mon Sep 17 00:00:00 2001 From: Elijah Date: Sun, 19 Jul 2026 13:10:55 -0700 Subject: [PATCH] Initial Commit --- .gitignore | 12 + Decky.slnx | 11 + Directory.Build.props | 10 + Implementation Plan.md | 1402 +++++++++++++++++ NuGet.Config | 10 + README.md | 41 + installer/Decky.appinstaller.template | 16 + installer/README.md | 15 + src/Decky.ApiSpike/Decky.ApiSpike.csproj | 10 + src/Decky.ApiSpike/Program.cs | 244 +++ src/Decky.App/App.xaml | 13 + src/Decky.App/App.xaml.cs | 20 + .../Converters/DeckColorConverters.cs | 50 + src/Decky.App/Decky.App.csproj | 22 + src/Decky.App/MainWindow.xaml | 119 ++ src/Decky.App/MainWindow.xaml.cs | 50 + src/Decky.App/Services/AppDataPaths.cs | 10 + .../Services/WindowsCredentialService.cs | 73 + src/Decky.App/ViewModels/MainViewModel.cs | 324 ++++ src/Decky.App/app.manifest | 17 + src/Decky.Core/Api/DeckApiClient.cs | 191 +++ src/Decky.Core/Api/DeckApiException.cs | 39 + src/Decky.Core/Api/Models/DeckDtos.cs | 104 ++ src/Decky.Core/Api/Requests/CardRequests.cs | 81 + .../Configuration/DeckConnectionOptions.cs | 48 + src/Decky.Core/Decky.Core.csproj | 6 + src/Decky.Core/Models/AppSettings.cs | 52 + src/Decky.Core/Services/ICredentialService.cs | 21 + src/Decky.Core/Services/ISettingsService.cs | 11 + .../Services/JsonSettingsService.cs | 112 ++ src/Decky.Core/Utilities/DeckColor.cs | 41 + .../CardUpdateRequestTests.cs | 40 + tests/Decky.Core.Tests/DeckApiClientTests.cs | 90 ++ tests/Decky.Core.Tests/DeckColorTests.cs | 24 + .../Decky.Core.Tests/Decky.Core.Tests.csproj | 19 + .../JsonSettingsServiceTests.cs | 75 + tests/Decky.Core.Tests/Usings.cs | 1 + 37 files changed, 3424 insertions(+) create mode 100644 .gitignore create mode 100644 Decky.slnx create mode 100644 Directory.Build.props create mode 100644 Implementation Plan.md create mode 100644 NuGet.Config create mode 100644 README.md create mode 100644 installer/Decky.appinstaller.template create mode 100644 installer/README.md create mode 100644 src/Decky.ApiSpike/Decky.ApiSpike.csproj create mode 100644 src/Decky.ApiSpike/Program.cs create mode 100644 src/Decky.App/App.xaml create mode 100644 src/Decky.App/App.xaml.cs create mode 100644 src/Decky.App/Converters/DeckColorConverters.cs create mode 100644 src/Decky.App/Decky.App.csproj create mode 100644 src/Decky.App/MainWindow.xaml create mode 100644 src/Decky.App/MainWindow.xaml.cs create mode 100644 src/Decky.App/Services/AppDataPaths.cs create mode 100644 src/Decky.App/Services/WindowsCredentialService.cs create mode 100644 src/Decky.App/ViewModels/MainViewModel.cs create mode 100644 src/Decky.App/app.manifest create mode 100644 src/Decky.Core/Api/DeckApiClient.cs create mode 100644 src/Decky.Core/Api/DeckApiException.cs create mode 100644 src/Decky.Core/Api/Models/DeckDtos.cs create mode 100644 src/Decky.Core/Api/Requests/CardRequests.cs create mode 100644 src/Decky.Core/Configuration/DeckConnectionOptions.cs create mode 100644 src/Decky.Core/Decky.Core.csproj create mode 100644 src/Decky.Core/Models/AppSettings.cs create mode 100644 src/Decky.Core/Services/ICredentialService.cs create mode 100644 src/Decky.Core/Services/ISettingsService.cs create mode 100644 src/Decky.Core/Services/JsonSettingsService.cs create mode 100644 src/Decky.Core/Utilities/DeckColor.cs create mode 100644 tests/Decky.Core.Tests/CardUpdateRequestTests.cs create mode 100644 tests/Decky.Core.Tests/DeckApiClientTests.cs create mode 100644 tests/Decky.Core.Tests/DeckColorTests.cs create mode 100644 tests/Decky.Core.Tests/Decky.Core.Tests.csproj create mode 100644 tests/Decky.Core.Tests/JsonSettingsServiceTests.cs create mode 100644 tests/Decky.Core.Tests/Usings.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb152fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +bin/ +obj/ +.vs/ +*.user +*.suo +TestResults/ +artifacts/ +*.pfx +*.cer +appsettings.local.json +.dotnet-home/ +.packages/ diff --git a/Decky.slnx b/Decky.slnx new file mode 100644 index 0000000..b715271 --- /dev/null +++ b/Decky.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..9f4977a --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + latest + enable + enable + true + true + + + diff --git a/Implementation Plan.md b/Implementation Plan.md new file mode 100644 index 0000000..f0cb206 --- /dev/null +++ b/Implementation Plan.md @@ -0,0 +1,1402 @@ +# Implementation plan: Nextcloud Deck desktop client + +## Architecture review (July 19, 2026) + +The overall direction remains appropriate: a C# WinUI 3 client, an API-first core, +and MSIX packaging are a good fit for a small Windows-only Deck client. The +following decisions refine the original plan before implementation: + +- Target **.NET 10 LTS** and a stable Windows App SDK release. Pin exact package + versions in the repository and update them deliberately. +- Keep the Nextcloud API and domain code in a UI-independent `Decky.Core` + project. This makes the compatibility spike and most tests runnable without + installing or launching the desktop UI. +- Treat board, stack, and card identifiers as `int`, matching the Deck REST API. + Open board tabs are therefore stored as `List`, not `List`. +- Keep `done` as an ISO-8601 timestamp or `null`; it is not a Boolean in the Deck + API. Marking a card complete sends the current UTC time, and reopening it sends + an explicit JSON `null`. +- Normalize card and board colors to six-digit RGB values without a leading `#` + at the API boundary; add `#` only for UI display. +- Discover the Deck API base path once during connection and persist the working + path. Prefer the documented `/index.php/apps/deck/api/v1.0/` path, then try the + rewrite-friendly `/apps/deck/api/v1.0/` path. +- Preserve the last successfully loaded board snapshot for disconnected viewing, + but never store the app password in that snapshot or in settings. +- Use ETags/`If-None-Match` once basic synchronization works; the Deck API + propagates child changes into parent ETags and this avoids re-downloading + unchanged boards every 30 seconds. +- Store credentials through Windows Credential Locker behind an interface so the + API core remains testable. Never pass credentials on command lines, including + the API spike. + +### Program update strategy + +Do **not** update an installed application by pulling its Git repository. A Git +pull requires developer tools, exposes the source tree to local changes, and +bypasses Windows package identity, signing, rollback, and file-integrity rules. + +Use this release flow instead: + +1. Forgejo Actions builds, tests, and publishes a versioned, signed + `.msixbundle` for each tagged release. +2. The same release publishes a stable `Decky.appinstaller` file whose + `MainBundle` points to that release asset. +3. Users install `Decky.appinstaller` once. Windows App Installer checks for a + newer package on launch and can also check in the background. +4. Package identity and publisher certificate remain constant; the four-part + MSIX version increases for every release. + +For a private Forgejo instance, the release files must be reachable by Windows +App Installer over HTTPS without an interactive Forgejo login. If private release +assets cannot be exposed safely, publish signed artifacts to a small static HTTPS +location (which may still be deployed by Forgejo Actions). Keep updates optional +and non-blocking for this personal utility. A portable unpackaged build can be a +later secondary distribution, but it would need a separate updater. + + + +I would build this as a \*\*C# WinUI 3 application using MVVM\*\*, distributed through an \*\*MSIX installer\*\*. WinUI 3 provides a modern native Windows interface, its list controls support drag-based reordering, and MSIX gives you a normal installed application rather than a folder of source files. An unpackaged self-contained executable could be added later, but MSIX is the cleaner primary distribution format. (\[Microsoft Learn]\[1]) + + + +\## 1. Defined scope for version 1 + + + +The first version should support: + + + +\* Connecting to one Nextcloud server + +\* Viewing every accessible Deck board + +\* Opening multiple boards in tabs + +\* Switching each board between horizontal and vertical layouts + +\* Creating, editing, deleting, and completing cards + +\* Dragging cards to reorder them within a list + +\* Dragging cards between lists + +\* Displaying and changing card colors + +\* Creating reusable local color presets + +\* Automatically applying configurable active and completed colors + +\* Periodic and manual synchronization + +\* Remembering window position, monitor, size, and layout choices + + + +It should not initially include attachments, comments, labels, sharing controls, board creation, or offline editing. Those would add a disproportionate amount of complexity for a desktop task list. + + + +\## 2. Technology stack + + + +```text + +Language: C# + +UI framework: WinUI 3 / XAML + +Runtime: .NET 10 LTS + +Architecture: MVVM + +API client: HttpClient + +JSON: System.Text.Json + +Local settings: JSON files in local app data + +Credentials: Windows PasswordVault or Credential Manager + +Distribution: MSIX installer + +Testing: xUnit + +``` + + + +Use `CommunityToolkit.Mvvm` for observable properties, commands, and dependency injection. It removes considerable MVVM boilerplate without changing the basic application architecture. + + + +No local database is necessary. The source of truth remains Nextcloud, while a few small JSON files hold interface settings and color presets. + + + +\## 3. Main interface + + + +\### Application shell + + + +The main window should contain: + + + +```text + +┌──────────────────────────────────────────────────────────────┐ + +│ Board tabs Refresh Layout Always on top Settings│ + +├───────────────┬──────────────────────────────────────────────┤ + +│ Board list │ │ + +│ │ Selected board │ + +│ Homework │ │ + +│ Misc. To Do │ │ + +│ │ │ + +└───────────────┴──────────────────────────────────────────────┘ + +``` + + + +The board sidebar should be collapsible, particularly for a narrow vertical monitor. + + + +Selecting a board opens it in the current tab. An option in its context menu can open it in a new tab. Each tab maintains its own scroll position and layout orientation. + + + +\### Horizontal board layout + + + +This resembles the standard Deck interface: + + + +```text + +Saturday Sunday Monday + +──────── ────── ────── + +Card Card Card + +Card Card Card + +\ Card + +``` + + + +The stacks are arranged side by side inside a horizontal `ScrollViewer`. Every stack contains a vertical card `ListView`. + + + +\### Vertical board layout + + + +This is intended for the vertical monitor: + + + +```text + +SATURDAY + +Card + +Card + + + +SUNDAY + +Card + +Card + +Card + + + +MONDAY + +Card + +Card + +``` + + + +Each stack occupies the available width and the stacks are arranged from top to bottom. The orientation should be saved separately for each board. + + + +\## 4. Card design + + + +Each card should display: + + + +\* Card title + +\* Card background color + +\* Optional due date + +\* Checkmark button + +\* Context-menu button, visible on hover + +\* A subtle drag handle, or allow dragging from the whole card + + + +A compact card could look like: + + + +```text + +┌────────────────────────────────┐ + +│ ✓ Pharm Med Assignment 2 ⋮ │ + +└────────────────────────────────┘ + +``` + + + +Text color should be calculated from the card background so light cards use dark text and dark cards use light text. + + + +\### Checkmark behavior + + + +The checkmark should perform two related actions: + + + +1\. Set the card’s actual Deck `done` value. + +2\. Apply the configured completed color preset. + + + +When unchecked: + + + +1\. Clear the `done` value. + +2\. Apply the configured active color preset. + + + +This means your typical configuration could be: + + + +```text + +Active card preset: Red + +Completed card preset: Green + +``` + + + +This is preferable to treating completion as only a visual color change because current Deck card updates support a `done` property as well as the card `color` property. (\[GitHub]\[2]) + + + +An optional setting can control this behavior: + + + +```text + +Checkmark action: + +○ Mark done and change color + +○ Change color only + +``` + + + +The first option should be the default. + + + +\### Right-click menu + + + +```text + +Mark as done / Mark as not done + +Apply color preset > + +────────────────────────────── + +Edit card + +Move to list > + +────────────────────────────── + +Delete card + +Open in Nextcloud + +``` + + + +“Apply color preset” opens a submenu containing every saved preset plus a custom color option. + + + +\## 5. Card editor + + + +Use a modal dialog or right-side panel containing: + + + +```text + +Title + +Description + +Due date + +Color + +\ \\\[Red] \\\[Green] \\\[Orange] \\\[Blue] \\\[Custom...] + +\ \\\[Save current color as preset] + +Completed checkbox + + + +Cancel Save + +``` + + + +Creating and editing should use the same component. + + + +When a custom color is selected, show: + + + +\* Visual color picker + +\* Hex value + +\* “Save as preset” checkbox + +\* Preset name field + + + +Saving a preset stores it locally. It does not create a Deck label or modify anything else on Nextcloud. + + + +\## 6. Color preset settings + + + +The settings page should have a dedicated section: + + + +```text + +Color presets + + + +Name Color Actions + +Needs attention #D99080 Edit Delete + +Completed #7FBC9F Edit Delete + +Optional #D6B36A Edit Delete + + + +\\\[Add preset] + + + +Active-card preset: Needs attention + +Completed-card preset: Completed + +``` + + + +Recommended local model: + + + +```csharp + +public sealed class ColorPreset + +{ + +\ public Guid Id { get; init; } + +\ public string Name { get; set; } = ""; + +\ public string HexColor { get; set; } = ""; + +} + +``` + + + +Color presets should be global by default. Individual boards can optionally override the active and completed preset selections. + + + +\## 7. Nextcloud API integration + + + +Deck’s documented API supports loading boards and stacks, creating cards, updating cards, deleting cards, and changing card order. The current Deck controller also accepts `color` during create and update, and accepts `done` during update. (\[Nextcloud Deck]\[3]) + + + +\### API operations + + + +| Operation | Request | + +| --------------------- | --------------------------------------------------------------- | + +| List boards | `GET /boards` | + +| Load stacks and cards | `GET /boards/{boardId}/stacks` | + +| Get full card | `GET /boards/{boardId}/stacks/{stackId}/cards/{cardId}` | + +| Create card | `POST /boards/{boardId}/stacks/{stackId}/cards` | + +| Update card | `PUT /boards/{boardId}/stacks/{stackId}/cards/{cardId}` | + +| Delete card | `DELETE /boards/{boardId}/stacks/{stackId}/cards/{cardId}` | + +| Reorder or move | `PUT /boards/{boardId}/stacks/{stackId}/cards/{cardId}/reorder` | + + + +The client should accept only the server root from the user: + + + +```text + +https://drive.elijahkuntz.com + +``` + + + +It can then test these API path forms: + + + +```text + +/apps/deck/api/v1.0 + +/index.php/apps/deck/api/v1.0 + +``` + + + +The second is the formally documented form, while installations using rewritten URLs commonly omit `index.php`. (\[Nextcloud Deck]\[3]) + + + +\### Authentication + + + +The first-run connection screen should request: + + + +\* Nextcloud server address + +\* Username + +\* App password + + + +Use Basic authentication with the app password and include: + + + +```http + +OCS-APIRequest: true + +Accept: application/json + +Content-Type: application/json + +``` + + + +App passwords are preferable because they can be revoked independently and do not expose the primary account password. Nextcloud supports Basic authentication using a username and password or app token. (\[Nextcloud]\[4]) + + + +The app password must be stored in Windows credential storage, never in `settings.json`. + + + +\## 8. API client structure + + + +```text + +Services/ + +├── DeckApiClient.cs + +├── AuthenticationService.cs + +├── CredentialService.cs + +├── SettingsService.cs + +├── ColorPresetService.cs + +├── BoardSyncService.cs + +└── WindowPlacementService.cs + +``` + + + +`DeckApiClient` should expose methods such as: + + + +```csharp + +Task> GetBoardsAsync(); + +Task> GetStacksAsync(int boardId); + +Task GetCardAsync(int boardId, int stackId, int cardId); + +Task CreateCardAsync(CreateCardRequest request); + +Task UpdateCardAsync(UpdateCardRequest request); + +Task DeleteCardAsync(int boardId, int stackId, int cardId); + +Task ReorderCardAsync(ReorderCardRequest request); + +``` + + + +Use one long-lived `HttpClient`, not a new instance for each request. + + + +\## 9. Important card-update behavior + + + +Deck’s card update is effectively a complete update rather than a simple patch. The current controller expects required values such as `title`, `type`, and `owner`, with other fields passed alongside them. The desktop application should retain the complete server card model and construct an update request from the latest copy rather than creating a minimal payload. (\[GitHub]\[2]) + + + +This is especially important for colors. Deck 1.18.2 has a currently reported bug where updating a card without including `color` can clear the existing color. Every card update from this client should therefore resend the existing color, even when only changing the title or completion status. (\[GitHub]\[5]) + + + +The update request should include at least: + + + +```json + +{ + +\ "title": "Pharm Med Assignment 2", + +\ "type": "plain", + +\ "owner": "Elijah", + +\ "description": "", + +\ "order": 2, + +\ "duedate": null, + +\ "startdate": null, + +\ "archived": false, + +\ "done": null, + +\ "color": "D99080" + +} + +``` + + + +The DTO should distinguish between: + + + +\* A field not being sent + +\* A field explicitly being sent as `null` + + + +That distinction matters for clearing `done`, due dates, and other nullable fields. + + + +\## 10. Drag-and-drop implementation + + + +WinUI `ListView` supports drag reordering within a list. Moving between separate stack lists requires handling the drag source and destination manually. (\[Microsoft Learn]\[6]) + + + +\### Drag data + + + +When dragging starts, attach: + + + +```csharp + +public sealed record CardDragData( + +\ int BoardId, + +\ int CardId, + +\ int SourceStackId, + +\ int SourceIndex); + +``` + + + +\### Drop process + + + +1\. Determine the destination stack. + +2\. Determine the destination card index. + +3\. Move the card locally for immediate feedback. + +4\. Lock further card mutations for that board. + +5\. Call the reorder endpoint with the destination stack ID and index. + +6\. Verify the returned `stackId` and `order`. + +7\. Keep the change on success. + +8\. Roll back the local change on failure. + +9\. Reload the affected stacks from Nextcloud. + +10\. Release the board mutation lock. + + + +Use a per-board `SemaphoreSlim` so rapid drag operations cannot overlap and corrupt the local ordering. + + + +\### Cross-stack compatibility check + + + +A reported Deck 1.16.4 API problem caused the source stack ID in the URL to override the destination stack ID in the request body, preventing cards from moving between stacks through the reorder endpoint. (\[GitHub]\[7]) + + + +Because Deck versions vary, the application should verify the response from every cross-stack move: + + + +```text + +Requested destination stack: 12 + +Returned card stack: 12 -> success + + + +Requested destination stack: 12 + +Returned card stack: 9 -> rollback and compatibility warning + +``` + + + +Do not assume an HTTP 200 response means the card actually moved. + + + +The application can show: + + + +```text + +Your installed Deck version did not accept a cross-list move. + +The card has been restored to its original position. + +``` + + + +This needs to be tested against your actual server before the rest of drag-and-drop development proceeds. + + + +\## 11. Synchronization approach + + + +Keep synchronization intentionally simple. + + + +\### Initial loading + + + +1\. Load the list of boards. + +2\. Restore previously open board tabs. + +3\. Load only those boards. + +4\. Lazy-load other boards when selected. + + + +\### Refresh behavior + + + +\* Automatic refresh every 30 seconds by default + +\* Manual refresh button + +\* Pause background refresh while a card is being edited + +\* Refresh the affected board after every mutation + +\* Show “Last updated” in a tooltip or status area + + + +\### Optimistic changes + + + +Use optimistic updates for: + + + +\* Reordering + +\* Completion toggles + +\* Color changes + + + +Use confirmed updates for: + + + +\* Creating cards + +\* Editing card details + +\* Deleting cards + + + +For deletion, show a confirmation dialog. Do not automatically retry a failed delete. + + + +\### Offline behavior + + + +Version 1 should be read-only when disconnected: + + + +\* Keep the most recently loaded cards on screen + +\* Display an offline banner + +\* Disable create, edit, delete, and dragging + +\* Retry when the user refreshes or the polling timer runs + + + +An offline mutation queue is unnecessary for this application. + + + +\## 12. Local settings + + + +Suggested settings model: + + + +```csharp + +public sealed class AppSettings + +{ + +\ public string ServerUrl { get; set; } = ""; + +\ public string Username { get; set; } = ""; + +\ public int RefreshIntervalSeconds { get; set; } = 30; + +\ public bool AlwaysOnTop { get; set; } + +\ public bool StartWithWindows { get; set; } + +\ public bool StartMinimized { get; set; } + + + +\ public List OpenBoardTabs { get; set; } = \\\[]; + +\ public Dictionary Boards { get; set; } = \\\[]; + +\ public List ColorPresets { get; set; } = \\\[]; + + + +\ public Guid? ActiveColorPresetId { get; set; } + +\ public Guid? CompletedColorPresetId { get; set; } + +} + +``` + + + +Per-board settings: + + + +```csharp + +public sealed class BoardViewSettings + +{ + +\ public BoardOrientation Orientation { get; set; } + +\ public Guid? ActiveColorPresetOverride { get; set; } + +\ public Guid? CompletedColorPresetOverride { get; set; } + +} + +``` + + + +Also persist: + + + +\* Window size + +\* Window coordinates + +\* Monitor identifier + +\* Sidebar width and collapsed state + +\* Last selected board + +\* Open tabs + + + +Validate the saved monitor position at launch so the window is not placed offscreen after a monitor is disconnected. + + + +\## 13. Suggested project structure + + + +```text + +DeckDesktop/ + +├── App.xaml + +├── App.xaml.cs + +├── Models/ + +│ ├── Board.cs + +│ ├── Stack.cs + +│ ├── Card.cs + +│ ├── ColorPreset.cs + +│ └── AppSettings.cs + +├── Api/ + +│ ├── DeckApiClient.cs + +│ ├── Dtos/ + +│ └── Requests/ + +├── Services/ + +│ ├── BoardSyncService.cs + +│ ├── CredentialService.cs + +│ ├── SettingsService.cs + +│ ├── ColorPresetService.cs + +│ └── WindowPlacementService.cs + +├── ViewModels/ + +│ ├── MainViewModel.cs + +│ ├── BoardViewModel.cs + +│ ├── StackViewModel.cs + +│ ├── CardViewModel.cs + +│ ├── CardEditorViewModel.cs + +│ └── SettingsViewModel.cs + +├── Views/ + +│ ├── MainWindow.xaml + +│ ├── ConnectionPage.xaml + +│ ├── BoardView.xaml + +│ ├── CardEditorDialog.xaml + +│ └── SettingsPage.xaml + +├── Controls/ + +│ ├── DeckCardControl.xaml + +│ ├── HorizontalBoardControl.xaml + +│ └── VerticalBoardControl.xaml + +└── Tests/ + +\ ├── DeckApiClientTests.cs + +\ ├── CardUpdateRequestTests.cs + +\ ├── DragOrderTests.cs + +\ └── ColorPresetTests.cs + +``` + + + +\## 14. Development phases + + + +\### Phase 0: API compatibility spike + + + +Before building the interface, create a console test client and a disposable Deck test board. + + + +Verify: + + + +\* Authentication + +\* Board retrieval + +\* Card color in responses + +\* Creating a card with a color + +\* Editing without losing color + +\* Setting and clearing `done` + +\* Reordering within one stack + +\* Moving between stacks + +\* Deleting a card + + + +This phase is important because the published API documentation does not yet fully describe newer fields such as card color, while current source code does. Card colors were added to Deck in version 1.18.0. (\[GitHub]\[8]) + + + +\### Phase 1: Read-only application + + + +Build: + + + +\* Connection screen + +\* Credential storage + +\* Board sidebar + +\* Board tabs + +\* Horizontal layout + +\* Vertical layout + +\* Card color rendering + +\* Manual refresh + +\* Window placement persistence + + + +Acceptance criteria: + + + +\* The program starts as an installed application. + +\* It reconnects without requesting credentials again. + +\* It displays the same lists, cards, order, and colors as Nextcloud. + +\* Layout orientation persists per board. + + + +\### Phase 2: Card CRUD + + + +Build: + + + +\* New-card dialog + +\* Edit-card dialog + +\* Delete confirmation + +\* Color picker + +\* Automatic board refresh + +\* Error notifications + + + +Acceptance criteria: + + + +\* New cards appear in both the desktop client and web Deck. + +\* Edited titles, descriptions, dates, colors, and completion status persist. + +\* Unrelated card properties are never lost. + +\* Delete removes the correct card. + + + +\### Phase 3: Drag and drop + + + +Build: + + + +\* Same-stack reordering + +\* Cross-stack movement + +\* Drop indicators + +\* Optimistic movement + +\* Response verification + +\* Rollback on failure + +\* Per-board mutation locking + + + +Acceptance criteria: + + + +\* Cards retain the correct order after reloading. + +\* Cross-stack moves are verified against the server response. + +\* Failed operations restore the previous UI state. + + + +\### Phase 4: Color presets and completion workflow + + + +Build: + + + +\* Preset management page + +\* Custom preset creation from the editor + +\* Preset context menu + +\* Active and completed preset selection + +\* Checkmark completion button + +\* Board-specific overrides + + + +Acceptance criteria: + + + +\* Clicking the checkmark changes the card to the completed preset. + +\* Unchecking applies the active preset. + +\* The actual Deck `done` state is synchronized when enabled. + +\* Presets remain available after restarting. + + + +\### Phase 5: Desktop polish and release + + + +Build: + + + +\* Light and dark themes + +\* Always-on-top option + +\* Launch at Windows startup + +\* Keyboard shortcuts + +\* Loading placeholders + +\* Offline status banner + +\* Logging + +\* Application icon + +\* MSIX packaging + + + +Suggested keyboard controls: + + + +```text + +Ctrl+N New card + +Ctrl+R Refresh board + +Ctrl+Tab Next board tab + +Ctrl+Shift+Tab Previous board tab + +Ctrl+, Settings + +Enter Edit selected card + +Delete Delete selected card + +Space Toggle completion + +``` + + + +\## 15. Final acceptance checklist + + + +The application is complete when: + + + +\* It installs through one MSIX package. + +\* Credentials are not stored as plaintext. + +\* Every accessible Deck board can be opened. + +\* Multiple boards can remain open in tabs. + +\* Horizontal and vertical layouts work and persist independently. + +\* Cards can be created, edited, deleted, reordered, and moved. + +\* Colors remain intact during every type of update. + +\* Color presets can be added, edited, removed, and applied. + +\* The checkmark reliably changes completion state and color. + +\* Network or API failures do not silently discard or misplace cards. + +\* The application restores itself to the correct monitor and position. + +\* A test suite covers request serialization, color preservation, completion toggling, and drag-order calculations. + + + +This keeps the project small enough to remain a simple personal utility while leaving clear extension points for labels, due-date notifications, attachments, and comments later. + + + +\[1]: https://learn.microsoft.com/en-us/windows/apps/get-started/winui-get-started-overview?utm\_source=chatgpt.com "Get started with WinUI - Windows apps | Microsoft Learn" + +\[2]: https://github.com/nextcloud/deck/blob/main/lib/Controller/CardApiController.php?utm\_source=chatgpt.com "deck/lib/Controller/CardApiController.php at main" + +\[3]: https://deck.readthedocs.io/en/latest/API/?utm\_source=chatgpt.com "REST API - Nextcloud Deck" + +\[4]: https://docs.nextcloud.com/server/latest/developer\_manual/client\_apis/OCS/ocs-api-overview.html?utm\_source=chatgpt.com "OCS APIs overview — Nextcloud latest Developer Manual latest documentation" + +\[5]: https://github.com/nextcloud/deck/issues/8131?utm\_source=chatgpt.com "Card update API clears the existing color when ..." + +\[6]: https://learn.microsoft.com/sr-cyrl-rs/windows/apps/develop/data/drag-and-drop?view=gestures-1.0\&utm\_source=chatgpt.com "Drag and drop - Windows apps | Microsoft Learn" + +\[7]: https://github.com/nextcloud/deck/issues/7933 "Deck API: CardApiController reorder() ignores request body stackId due to route parameter conflict · Issue #7933 · nextcloud/deck · GitHub" + +\[8]: https://github.com/nextcloud/deck/releases "Releases · nextcloud/deck · GitHub" + diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..e21afbe --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..eed9b55 --- /dev/null +++ b/README.md @@ -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`. diff --git a/installer/Decky.appinstaller.template b/installer/Decky.appinstaller.template new file mode 100644 index 0000000..f378564 --- /dev/null +++ b/installer/Decky.appinstaller.template @@ -0,0 +1,16 @@ + + + + + + + + + diff --git a/installer/README.md b/installer/README.md new file mode 100644 index 0000000..47ebfd9 --- /dev/null +++ b/installer/README.md @@ -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. diff --git a/src/Decky.ApiSpike/Decky.ApiSpike.csproj b/src/Decky.ApiSpike/Decky.ApiSpike.csproj new file mode 100644 index 0000000..edff8bb --- /dev/null +++ b/src/Decky.ApiSpike/Decky.ApiSpike.csproj @@ -0,0 +1,10 @@ + + + Exe + net10.0 + + + + + + diff --git a/src/Decky.ApiSpike/Program.cs b/src/Decky.ApiSpike/Program.cs new file mode 100644 index 0000000..70172a8 --- /dev/null +++ b/src/Decky.ApiSpike/Program.cs @@ -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 RunWriteProbeAsync(DeckApiClient client, IReadOnlyList 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 TryDeleteTemporaryCardAsync( + DeckApiClient client, + int boardId, + int cardId, + int likelyStackId, + IEnumerable 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(); + + 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('*'); + } + } +} diff --git a/src/Decky.App/App.xaml b/src/Decky.App/App.xaml new file mode 100644 index 0000000..76a5f13 --- /dev/null +++ b/src/Decky.App/App.xaml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/src/Decky.App/App.xaml.cs b/src/Decky.App/App.xaml.cs new file mode 100644 index 0000000..ba0424e --- /dev/null +++ b/src/Decky.App/App.xaml.cs @@ -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(); + } +} + diff --git a/src/Decky.App/Converters/DeckColorConverters.cs b/src/Decky.App/Converters/DeckColorConverters.cs new file mode 100644 index 0000000..d99931d --- /dev/null +++ b/src/Decky.App/Converters/DeckColorConverters.cs @@ -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(); +} + diff --git a/src/Decky.App/Decky.App.csproj b/src/Decky.App/Decky.App.csproj new file mode 100644 index 0000000..657b180 --- /dev/null +++ b/src/Decky.App/Decky.App.csproj @@ -0,0 +1,22 @@ + + + WinExe + net10.0-windows10.0.19041.0 + 10.0.19041.0 + Decky.App + app.manifest + x64;ARM64 + win-x64 + win-x64;win-arm64 + true + None + true + + + + + + + + + diff --git a/src/Decky.App/MainWindow.xaml b/src/Decky.App/MainWindow.xaml new file mode 100644 index 0000000..be74d58 --- /dev/null +++ b/src/Decky.App/MainWindow.xaml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +