diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..990f7bd
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,54 @@
+# Decky agent notes
+
+## Project
+
+- The active app is `app/`: Tauri 2 + Svelte 5 + TypeScript. The older .NET
+ projects under `src/` and `tests/` are reference code, not the product.
+- Decky is a compact, Nextcloud-inspired desktop client for Nextcloud Deck.
+ Preserve its dark native title bar, horizontal/vertical views, drag-and-drop,
+ compact cards, collapsible sidebar, and footer-free layout.
+- Background sync begins after 10 seconds idle and repeats every 30 seconds. It
+ must pause during edits, drags, loading, modals, and while the window is hidden.
+- Never store or commit Nextcloud credentials. Use an app password and the OS
+ credential store. Live-instance tests require the user to enter credentials.
+
+## Nextcloud API compatibility
+
+- Client code and tests are in `app/src/lib/deck-api.ts` and
+ `app/src/lib/deck-api.test.ts`.
+- Support both documented `index.php/apps/deck/api/v1.0/` and rewrite-friendly
+ `apps/deck/api/v1.0/` paths.
+- Preserve complete card fields on updates, including color and object/string
+ owner handling. Keep the tested cross-stack card-move fallback.
+- Stack reorder uses Deck's OCS reorder endpoint and verifies persisted order.
+- Respect board permissions before exposing edit/manage/delete controls.
+
+## Validation
+
+From `app/`, run `npm ci`, `npm run check`, `npm test`, and `npm run build`.
+Build Windows installers on this Windows machine; the Unraid Forgejo Docker
+runner only validates the web client via `.forgejo/workflows/validate.yml`.
+
+## Versions and releases
+
+- Installed apps update from signed Forgejo Release assets, never from Git.
+- A released version/tag is immutable. Always increment the semantic version;
+ never republish or replace assets for an existing version.
+- Use `scripts/Set-Version.ps1` to synchronize npm, Cargo, Tauri, and the browser
+ fallback. Commit and push source to `main` before publishing the release.
+- Build/sign with `scripts/Prepare-Release.ps1`. Generated files live under
+ `artifacts/release/vX.Y.Z/` and stay out of Git.
+- Publish by double-clicking `scripts/Publish-DeckyRelease.cmd` or by invoking
+ `scripts/Publish-ForgejoRelease.ps1`. The publisher creates the tag/release,
+ uploads the installer/signature, commits `releases/latest.json` to `main`, and
+ automatically fast-forwards the local checkout to that metadata commit.
+- The publisher requires a clean `main` that exactly matches `origin/main`, so
+ commit and push source first. LF-to-CRLF messages on Windows are warnings,
+ not failures.
+- Forgejo: `https://git.elijahkuntz.com/Elijah/Decky`. A publishing PAT needs
+ `write:repository`; never commit or print it.
+- The permanent Tauri updater key is outside the repository at
+ `%USERPROFILE%\.tauri\decky.key` with its password beside it. Never expose,
+ replace, or regenerate this key after releases have shipped.
+
+See `app/UPDATES.md` for the complete release procedure.
diff --git a/README.md b/README.md
index afab0fb..ac97669 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@ Implemented functionality includes:
- active-board filtering and saved board selection;
- board creation, renaming, recoloring, and deletion;
- stack creation, renaming, reordering, and deletion;
+- idle-aware background synchronization of boards, stacks, and cards;
- horizontal and vertical layouts saved independently per board;
- card creation, editing, deletion, due date, description, and color controls;
- a directly visible completion checkmark that synchronizes Deck's `done` value
diff --git a/app/UPDATES.md b/app/UPDATES.md
index 64b5c11..39a8ad3 100644
--- a/app/UPDATES.md
+++ b/app/UPDATES.md
@@ -73,14 +73,24 @@ current PowerShell process and run:
$env:FORGEJO_PAT = 'paste-token-for-this-session'
powershell -ExecutionPolicy Bypass -File .\scripts\Publish-ForgejoRelease.ps1 -Version 0.3.0 -Notes "Board and stack management"
Remove-Item Env:FORGEJO_PAT
-git pull --ff-only
```
-The publisher creates the `v0.3.0` Forgejo release/tag at `main`, uploads the
-installer, signature, and metadata, then commits the tracked
-`releases/latest.json` through the Forgejo API. The final pull synchronizes that
-small metadata commit locally. Existing Decky installations then discover the
-release through Settings.
+For an interactive, double-clickable option, run:
+
+```text
+scripts\Publish-DeckyRelease.cmd
+```
+
+It bypasses PowerShell's script policy only for the publisher process, prompts
+for the version and a hidden personal access token, and leaves the window open
+so any error can be read. The token is not saved.
+
+The publisher first verifies that the working tree is clean and local `main`
+exactly matches `origin/main`. It then creates the `v0.3.0` Forgejo release/tag,
+uploads the installer, signature, and metadata, and commits the tracked
+`releases/latest.json` through the Forgejo API. Finally, it automatically
+fast-forwards local `main` to that metadata commit. Existing Decky installations
+then discover the release through Settings.
## Forgejo Actions
diff --git a/app/package-lock.json b/app/package-lock.json
index 9edc841..305ef39 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "decky",
- "version": "0.3.0",
+ "version": "0.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "decky",
- "version": "0.3.0",
+ "version": "0.3.2",
"dependencies": {
"@lucide/svelte": "^0.545.0",
"@tauri-apps/api": "^2.0.0",
diff --git a/app/package.json b/app/package.json
index 91c90a6..014efc3 100644
--- a/app/package.json
+++ b/app/package.json
@@ -1,7 +1,7 @@
{
"name": "decky",
"private": true,
- "version": "0.3.0",
+ "version": "0.3.2",
"type": "module",
"scripts": {
"dev": "vite --configLoader runner",
diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock
index d556937..ccac9cd 100644
--- a/app/src-tauri/Cargo.lock
+++ b/app/src-tauri/Cargo.lock
@@ -590,7 +590,7 @@ dependencies = [
[[package]]
name = "decky"
-version = "0.3.0"
+version = "0.3.2"
dependencies = [
"keyring",
"serde",
diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml
index 3d172b1..2f2f041 100644
--- a/app/src-tauri/Cargo.toml
+++ b/app/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "decky"
-version = "0.3.0"
+version = "0.3.2"
description = "A focused cross-platform client for Nextcloud Deck"
authors = ["Elijah Kuntz"]
edition = "2021"
diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json
index 3a07518..9e23b13 100644
--- a/app/src-tauri/tauri.conf.json
+++ b/app/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Decky",
- "version": "0.3.0",
+ "version": "0.3.2",
"identifier": "com.elijahkuntz.decky",
"build": {
"beforeDevCommand": "npm run dev",
@@ -34,7 +34,7 @@
"category": "Productivity",
"shortDescription": "Nextcloud Deck on your desktop",
"longDescription": "A focused cross-platform desktop client for viewing and editing Nextcloud Deck boards.",
- "copyright": "Copyright © 2026 Elijah Kuntz",
+ "copyright": "Copyright (c) 2026 Elijah Kuntz",
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper"
diff --git a/app/src/App.svelte b/app/src/App.svelte
index 906a5d6..e2bd819 100644
--- a/app/src/App.svelte
+++ b/app/src/App.svelte
@@ -14,7 +14,6 @@
ChevronRight,
Columns3,
LayoutList,
- LoaderCircle,
Minus,
Pencil,
Plus,
@@ -26,6 +25,7 @@
X
} from '@lucide/svelte';
import { cssColor, normalizeColor, textColor } from './lib/color';
+ import { shouldAutoRefresh } from './lib/auto-refresh';
import { loadPassword, removePassword, savePassword } from './lib/credentials';
import { cardUpdateFrom, DeckApiClient } from './lib/deck-api';
import { loadSettings, saveSettings } from './lib/settings';
@@ -52,7 +52,7 @@
let dragOriginStacks: Stack[] | null = null;
let dragGhost: { kind: 'card' | 'stack'; title: string; color?: string | null; x: number; y: number; width: number; offsetX: number; offsetY: number } | null = null;
let reduceMotion = false;
- let currentVersion = '0.3.0';
+ let currentVersion = '0.3.2';
let pendingUpdate: Update | null = null;
let updateState: 'idle' | 'checking' | 'available' | 'current' | 'downloading' | 'installing' | 'error' = 'idle';
let updateMessage = 'Updates are checked securely against releases from git.elijahkuntz.com.';
@@ -63,6 +63,9 @@
| { kind: 'stack'; mode: 'create' | 'edit'; id?: number; title: string; order: number }
| null = null;
let pendingDeletion: { kind: 'board' | 'stack'; id: number; title: string; cardCount: number } | null = null;
+ let lastInteractionAt = Date.now();
+ let lastAutoRefreshAt = 0;
+ let autoRefreshInFlight = false;
const appWindow = '__TAURI_INTERNALS__' in window ? getCurrentWindow() : null;
const [sendCard, receiveCard] = crossfade({
duration: (distance) => reduceMotion ? 0 : Math.min(190, Math.max(120, Math.sqrt(distance * 220))),
@@ -109,7 +112,7 @@
{ id: 106, stackId: 13, title: 'Patho Concept Map', type: 'plain', owner: 'admin', order: 2, color: 'E69A88', done: null }
]}
];
- status = 'Homework · 6 cards · Preview data';
+ status = 'Preview data';
return;
}
if (!serverUrl || !username) return;
@@ -122,6 +125,17 @@
}
});
+ onMount(() => {
+ const noteInteraction = () => { lastInteractionAt = Date.now(); };
+ const events: (keyof WindowEventMap)[] = ['pointerdown', 'keydown', 'wheel'];
+ for (const event of events) window.addEventListener(event, noteInteraction, { passive: true });
+ const timer = window.setInterval(() => void runIdleRefresh(), 1_000);
+ return () => {
+ window.clearInterval(timer);
+ for (const event of events) window.removeEventListener(event, noteInteraction);
+ };
+ });
+
function friendlyError(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
@@ -134,7 +148,7 @@
return;
}
updateState = 'checking';
- updateMessage = 'Checking Forgejo for a newer signed release…';
+ updateMessage = 'Checking Forgejo for a newer signed release...';
updateProgress = 0;
pendingUpdate = null;
try {
@@ -155,7 +169,7 @@
async function installUpdate() {
if (!pendingUpdate) return;
updateState = 'downloading';
- updateMessage = `Downloading Decky ${pendingUpdate.version}…`;
+ updateMessage = `Downloading Decky ${pendingUpdate.version}...`;
let downloaded = 0;
let total = 0;
try {
@@ -169,7 +183,7 @@
} else if (event.event === 'Finished') {
updateState = 'installing';
updateProgress = 100;
- updateMessage = 'Download verified. Installing and restarting Decky…';
+ updateMessage = 'Download verified. Installing and restarting Decky...';
}
});
await relaunch();
@@ -181,7 +195,7 @@
async function connect(silent = false) {
busy = true;
- status = 'Connecting to Nextcloud…';
+ status = 'Connecting...';
try {
client = await DeckApiClient.connect({ serverUrl, username, appPassword });
boards = (await client.getBoards()).filter((board) => !board.archived && !board.deletedAt);
@@ -213,13 +227,12 @@
async function refreshBoard() {
if (!client || !selectedBoard) return;
busy = true;
- status = `Loading ${selectedBoard.title}…`;
+ status = 'Refreshing...';
try {
stacks = (await client.getStacks(selectedBoard.id))
.filter((stack) => !stack.deletedAt)
.sort((a, b) => a.order - b.order);
- const count = stacks.reduce((total, stack) => total + (stack.cards?.filter((card) => !card.archived).length ?? 0), 0);
- status = `${selectedBoard.title} · ${count} card${count === 1 ? '' : 's'} · Updated ${new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
+ status = `Updated ${new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
} catch (error) {
status = friendlyError(error);
} finally {
@@ -227,6 +240,67 @@
}
}
+ async function refreshFromNextcloud(background = false) {
+ if (!client) return;
+ const activeClient = client;
+ const startingBoardId = selectedBoard?.id;
+ const interactionAtStart = lastInteractionAt;
+ if (!background) {
+ busy = true;
+ status = 'Refreshing...';
+ }
+ try {
+ const refreshedBoards = (await activeClient.getBoards()).filter((board) => !board.archived && !board.deletedAt);
+ if (client !== activeClient || (background && (selectedBoard?.id !== startingBoardId || lastInteractionAt !== interactionAtStart || document.visibilityState !== 'visible'))) return;
+ boards = refreshedBoards;
+ const board = refreshedBoards.find((item) => item.id === startingBoardId) ?? refreshedBoards[0];
+ if (!board) {
+ selectedBoard = null;
+ stacks = [];
+ status = 'Connected, but no active Deck boards were found.';
+ return;
+ }
+
+ selectedBoard = board;
+ if (settings.selectedBoardId !== board.id) {
+ settings = { ...settings, selectedBoardId: board.id };
+ saveSettings(settings);
+ }
+ const refreshedStacks = (await activeClient.getStacks(board.id))
+ .filter((stack) => !stack.deletedAt)
+ .sort((a, b) => a.order - b.order);
+ if (client !== activeClient || selectedBoard?.id !== board.id || (background && (lastInteractionAt !== interactionAtStart || document.visibilityState !== 'visible'))) return;
+ stacks = refreshedStacks;
+ status = `Updated ${new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
+ } catch (error) {
+ status = `${background ? 'Background refresh failed' : 'Refresh failed'}: ${friendlyError(error)}`;
+ } finally {
+ if (!background) busy = false;
+ }
+ }
+
+ async function runIdleRefresh() {
+ const now = Date.now();
+ if (autoRefreshInFlight || !shouldAutoRefresh({
+ now,
+ lastInteractionAt,
+ lastRefreshAt: lastAutoRefreshAt,
+ connected: Boolean(client),
+ busy,
+ editing: Boolean(editor || structureEditor || pendingDeletion || settingsOpen),
+ dragging: Boolean(dragKind),
+ visible: document.visibilityState === 'visible'
+ })) return;
+
+ autoRefreshInFlight = true;
+ lastAutoRefreshAt = now;
+ try {
+ await refreshFromNextcloud(true);
+ } finally {
+ autoRefreshInFlight = false;
+ }
+ }
+
function canEdit(board: Board | null): boolean {
return Boolean(board) && (board?.permissions?.PERMISSION_EDIT ?? true);
}
@@ -426,7 +500,7 @@
}
async function deleteCard(card: Card) {
- if (!client || !selectedBoard || !confirm(`Delete “${card.title}”?`)) return;
+ if (!client || !selectedBoard || !confirm(`Delete "${card.title}"?`)) return;
busy = true;
try {
await client.deleteCard(selectedBoard.id, card.stackId, card.id);
@@ -598,8 +672,7 @@
} else if (stackId !== null) {
const targetIndex = stacks.findIndex((stack) => stack.id === stackId);
stacks = await client.reorderStack(board.id, stackId, targetIndex);
- const count = stacks.reduce((total, stack) => total + (stack.cards?.filter((item) => !item.archived).length ?? 0), 0);
- status = `${board.title} · ${count} card${count === 1 ? '' : 's'} · Stack order saved`;
+ status = `Updated ${new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`;
}
} catch (error) {
stacks = origin;
@@ -634,7 +707,7 @@
}
-