Compare commits

...

9 commits
v0.3.0 ... main

Author SHA1 Message Date
e7b2326521 Synchronize local branch after publishing
Some checks are pending
Validate Decky / web-client (push) Waiting to run
2026-07-20 09:35:51 -07:00
a0b46497c1 Add Decky agent guidance
Some checks are pending
Validate Decky / web-client (push) Waiting to run
2026-07-20 09:33:34 -07:00
134a0ea05b chore(release): publish Decky 0.3.2 updater metadata
Some checks are pending
Validate Decky / web-client (push) Waiting to run
2026-07-20 09:16:08 -07:00
0055fb196b Release Decky 0.3.2
Some checks are pending
Validate Decky / web-client (push) Waiting to run
2026-07-20 09:15:52 -07:00
c5287352fd Simplify refresh status layout
Some checks are pending
Validate Decky / web-client (push) Waiting to run
2026-07-20 09:11:03 -07:00
42ed790956 chore(release): publish Decky 0.3.1 updater metadata
Some checks are pending
Validate Decky / web-client (push) Waiting to run
2026-07-20 09:01:07 -07:00
ee2ef03982 Add automatic background refresh
Some checks are pending
Validate Decky / web-client (push) Waiting to run
2026-07-20 09:00:29 -07:00
e9d76cc4a2 Prompt for Forgejo token during release publishing 2026-07-20 09:00:29 -07:00
1bea5547a6 chore(release): publish Decky 0.3.0 updater metadata
Some checks are pending
Validate Decky / web-client (push) Waiting to run
2026-07-19 19:12:28 -07:00
15 changed files with 316 additions and 54 deletions

54
AGENTS.md Normal file
View file

@ -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.

View file

@ -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

View file

@ -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

4
app/package-lock.json generated
View file

@ -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",

View file

@ -1,7 +1,7 @@
{
"name": "decky",
"private": true,
"version": "0.3.0",
"version": "0.3.2",
"type": "module",
"scripts": {
"dev": "vite --configLoader runner",

View file

@ -590,7 +590,7 @@ dependencies = [
[[package]]
name = "decky"
version = "0.3.0"
version = "0.3.2"
dependencies = [
"keyring",
"serde",

View file

@ -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"

View file

@ -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"

View file

@ -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 @@
}
</script>
<svelte:head><title>{selectedBoard ? `${selectedBoard.title} · Decky` : 'Decky'}</title></svelte:head>
<svelte:head><title>{selectedBoard ? `${selectedBoard.title} - Decky` : 'Decky'}</title></svelte:head>
<div class:sidebar-collapsed={settings.sidebarCollapsed} class="app-shell">
<header class="window-titlebar" role="presentation" on:mousedown={dragWindow} on:dblclick={() => runWindowAction('maximize')}>
@ -654,7 +727,7 @@
<label>Username<input bind:value={username} autocomplete="username" /></label>
<label>App password<input type="password" bind:value={appPassword} autocomplete="current-password" /></label>
<label class="checkbox-row"><input type="checkbox" bind:checked={rememberPassword} /> Save securely on this device</label>
<button class="primary-button" disabled={busy || !serverUrl || !username || !appPassword}>{busy ? 'Connecting' : 'Connect'}</button>
<button class="primary-button" disabled={busy || !serverUrl || !username || !appPassword}>{busy ? 'Connecting...' : 'Connect'}</button>
</form>
{/if}
@ -682,7 +755,10 @@
<button class:active={orientation === 'horizontal'} title="Horizontal layout" on:click={() => setOrientation('horizontal')}><Columns3 size={18} /><span>Columns</span></button>
<button class:active={orientation === 'vertical'} title="Vertical layout" on:click={() => setOrientation('vertical')}><LayoutList size={18} /><span>List</span></button>
</div>
<button class="sidebar-tool" title="Refresh" disabled={!selectedBoard || busy} on:click={refreshBoard}><RefreshCw size={18} class={busy ? 'spinning' : ''} /><span>Refresh</span></button>
<div class="refresh-row">
<button class="sidebar-tool" title="Refresh" disabled={!client || busy} on:click={() => refreshFromNextcloud()}><RefreshCw size={18} class={busy ? 'spinning' : ''} /><span>Refresh</span></button>
<span class="sync-status" title={status}>{status}</span>
</div>
<button class="sidebar-tool" title="Settings" on:click={() => (settingsOpen = true)}><Settings size={18} /><span>Settings</span></button>
</div>
</aside>
@ -764,10 +840,6 @@
{/if}
</main>
<footer class="statusbar">
{#if busy}<LoaderCircle size={14} class="spinning" />{:else}<span class:connected={Boolean(client)} class="status-dot"></span>{/if}
<span>{status}</span>
</footer>
</div>
{#if dragGhost}
@ -790,7 +862,7 @@
<form class="modal" on:submit|preventDefault={saveCard}>
<header><div><small>{editor.mode === 'create' ? 'NEW CARD' : 'EDIT CARD'}</small><h2>{editor.mode === 'create' ? 'Create a card' : editor.card?.title}</h2></div><button type="button" class="icon-button" on:click={() => (editor = null, colorPickerOpen = false)}><X size={20} /></button></header>
<label>Title<input bind:value={editor.draft.title} maxlength="255" /></label>
<label>Description<textarea bind:value={editor.draft.description} rows="5" placeholder="Add details"></textarea></label>
<label>Description<textarea bind:value={editor.draft.description} rows="5" placeholder="Add details..."></textarea></label>
<div class="form-row">
<label>Due date<input type="datetime-local" bind:value={editor.draft.dueDate} /></label>
<label>Card color
@ -866,7 +938,7 @@
<div class="modal-backdrop" role="presentation" on:click={(event) => event.currentTarget === event.target && (pendingDeletion = null)}>
<div class="modal confirm-modal" role="alertdialog" aria-modal="true" aria-labelledby="delete-title">
<header>
<div><small>CONFIRM DELETE</small><h2 id="delete-title">Delete {pendingDeletion.title}?</h2></div>
<div><small>CONFIRM DELETE</small><h2 id="delete-title">Delete "{pendingDeletion.title}"?</h2></div>
<button type="button" class="icon-button" aria-label="Close" on:click={() => (pendingDeletion = null)}><X size={20} /></button>
</header>
<p class="modal-copy">
@ -876,7 +948,7 @@
This permanently deletes the stack and its {pendingDeletion.cardCount} card{pendingDeletion.cardCount === 1 ? '' : 's'} from Nextcloud.
{/if}
</p>
<footer><button type="button" class="secondary-button" on:click={() => (pendingDeletion = null)}>Cancel</button><button type="button" class="danger-button" disabled={busy} on:click={confirmStructureDeletion}>{busy ? 'Deleting' : `Delete ${pendingDeletion.kind}`}</button></footer>
<footer><button type="button" class="secondary-button" on:click={() => (pendingDeletion = null)}>Cancel</button><button type="button" class="danger-button" disabled={busy} on:click={confirmStructureDeletion}>{busy ? 'Deleting...' : `Delete ${pendingDeletion.kind}`}</button></footer>
</div>
</div>
{/if}
@ -895,7 +967,7 @@
<label class="checkbox-row"><input type="checkbox" bind:checked={rememberPassword} /> Save securely on this device</label>
<div class="settings-actions">
{#if client}<button type="button" class="secondary-button" on:click={disconnect}>Disconnect</button>{/if}
<button class="primary-button" disabled={busy || !serverUrl || !username || !appPassword}>{busy ? 'Connecting' : client ? 'Reconnect' : 'Connect'}</button>
<button class="primary-button" disabled={busy || !serverUrl || !username || !appPassword}>{busy ? 'Connecting...' : client ? 'Reconnect' : 'Connect'}</button>
</div>
</form>
</section>
@ -919,7 +991,7 @@
<button type="button" class="primary-button" on:click={installUpdate}>Download and install {pendingUpdate?.version}</button>
{:else}
<button type="button" class="secondary-button" disabled={updateState === 'checking' || updateState === 'downloading' || updateState === 'installing'} on:click={checkForUpdates}>
{updateState === 'checking' ? 'Checking' : 'Check for updates'}
{updateState === 'checking' ? 'Checking...' : 'Check for updates'}
</button>
{/if}
</div>

View file

@ -29,7 +29,7 @@ button { color: inherit; }
.app-shell {
height: 100%;
display: grid;
grid-template: 34px minmax(0, 1fr) 28px / 250px minmax(0, 1fr);
grid-template: 34px minmax(0, 1fr) / 250px minmax(0, 1fr);
background: var(--bg);
}
@ -115,6 +115,8 @@ textarea { resize: vertical; min-height: 90px; }
.sidebar-layout button { justify-content: center; gap: 6px; padding: 0 6px; background: #292929; }
.sidebar-layout button.active { color: #fff; background: #3a3a3a; box-shadow: inset 0 -2px var(--accent); }
.sidebar-tool span, .sidebar-layout span { font-size: 12px; }
.refresh-row { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 4px; }
.sync-status { overflow: hidden; padding-right: 7px; color: var(--muted); font-size: 10px; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
.workspace { grid-column: 2; grid-row: 2; min-width: 0; min-height: 0; display: flex; flex-direction: column; background: #232323; }
.workspace-tools { min-height: 46px; flex: 0 0 auto; display: grid; grid-template-columns: minmax(120px, 1fr) minmax(180px, 280px) minmax(80px, 1fr); align-items: center; gap: 16px; padding: 6px 18px; color: var(--muted); font-size: 12px; border-bottom: 1px solid var(--line); }
@ -173,8 +175,6 @@ textarea { resize: vertical; min-height: 90px; }
.empty-illustration span { position: absolute; width: 66px; height: 44px; z-index: -1; border: 1px solid var(--line); border-radius: 8px; background: var(--panel-2); transform: translate(-28px, 13px) rotate(-12deg); }
.empty-illustration span:last-child { transform: translate(28px, 13px) rotate(12deg); }
.statusbar { grid-column: 1 / -1; grid-row: 3; display: flex; align-items: center; gap: 8px; min-width: 0; padding: 0 12px; border-top: 1px solid var(--line); color: var(--muted); background: #161616; font-size: 11px; }
.statusbar span:last-child { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.spinning { animation: spin .9s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }

View file

@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { AUTO_REFRESH_IDLE_MS, AUTO_REFRESH_REPEAT_MS, shouldAutoRefresh } from './auto-refresh';
const idleState = {
now: AUTO_REFRESH_REPEAT_MS,
lastInteractionAt: 0,
lastRefreshAt: 0,
connected: true,
busy: false,
editing: false,
dragging: false,
visible: true
};
describe('automatic refresh scheduling', () => {
it('refreshes after the user has been idle long enough', () => {
expect(shouldAutoRefresh(idleState)).toBe(true);
});
it('waits for ten seconds of inactivity', () => {
expect(shouldAutoRefresh({ ...idleState, lastInteractionAt: idleState.now - AUTO_REFRESH_IDLE_MS + 1 })).toBe(false);
});
it('does not repeatedly refresh more often than the background interval', () => {
expect(shouldAutoRefresh({ ...idleState, lastRefreshAt: idleState.now - AUTO_REFRESH_REPEAT_MS + 1 })).toBe(false);
});
it.each([
['disconnected', { connected: false }],
['busy', { busy: true }],
['editing', { editing: true }],
['dragging', { dragging: true }],
['hidden', { visible: false }]
])('pauses while %s', (_name, change) => {
expect(shouldAutoRefresh({ ...idleState, ...change })).toBe(false);
});
});

View file

@ -0,0 +1,23 @@
export const AUTO_REFRESH_IDLE_MS = 10_000;
export const AUTO_REFRESH_REPEAT_MS = 30_000;
export interface AutoRefreshState {
now: number;
lastInteractionAt: number;
lastRefreshAt: number;
connected: boolean;
busy: boolean;
editing: boolean;
dragging: boolean;
visible: boolean;
}
export function shouldAutoRefresh(state: AutoRefreshState): boolean {
return state.connected
&& state.visible
&& !state.busy
&& !state.editing
&& !state.dragging
&& state.now - state.lastInteractionAt >= AUTO_REFRESH_IDLE_MS
&& state.now - state.lastRefreshAt >= AUTO_REFRESH_REPEAT_MS;
}

View file

@ -1,11 +1,11 @@
{
"version": "0.2.0",
"notes": "First realistic-testing release of Decky with Nextcloud Deck editing, live drag-and-drop, and signed in-app updates.",
"pub_date": "2026-07-19T22:25:48.1776589+00:00",
"platforms": {
"windows-x86_64": {
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVTSHZBY0NPTW9uRERoeFMyZWZLU2pRWmFudFh1ZWJKZVhJOUFRd2pMSVZGdmFYdUxwQUt5ZFFGcnQ5ek5FNGRZMmluMWIzdUJVZEIvTU15OGw1QXEySmh1ZWFJL2ZNUWdjPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg0NDk5ODgyCWZpbGU6RGVja3lfMC4yLjBfeDY0LXNldHVwLmV4ZQpKK1F2dFg0QWdoZ3dZcHpoTm9jTmIvaDVjeHJuQ1dhWTh3L0NJUEpQcnBmTmRWTk1Va3prSkVBdHo3V2RIMVlvMkxYMDdsZTY5M095Zjg0NjMrTHRBdz09Cg==",
"url": "https://git.elijahkuntz.com/Elijah/Decky/releases/download/v0.2.0/Decky_0.2.0_x64-setup.exe"
}
}
}
{
"version": "0.3.2",
"notes": "Removed the bottom status bar, moved the updated time beside Refresh, and cleaned malformed text encoding.",
"pub_date": "2026-07-20T16:13:33.7170490+00:00",
"platforms": {
"windows-x86_64": {
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVTSHZBY0NPTW9uREMyYUlZNUJnYzhqRHRlTE1qeDZCYlZtVXRGRDZtZER5V2p1V3ZBdXprVndkZ3FqNURzQitoUG1ZWFdFQytxRFFzQ1BMNnJnVE10ZlhCYm5UQk9NTFFFPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg0NTY0MDEyCWZpbGU6RGVja3lfMC4zLjJfeDY0LXNldHVwLmV4ZQpLOGZ4TXp6V0xYalp6czNDbitleVhBSVlrZHNLT0ZQSmhiTjFGeXlNTUVEYy8vSS9JdlZwQUdiNWdOL0NvcXVFcTVJWDhXWXZwQnhlUnh2Z0xER3dEdz09Cg==",
"url": "https://git.elijahkuntz.com/Elijah/Decky/releases/download/v0.3.2/Decky_0.3.2_x64-setup.exe"
}
}
}

View file

@ -0,0 +1,24 @@
@echo off
setlocal
title Publish Decky Release
cd /d "%~dp0.."
echo Decky Forgejo release publisher
echo.
echo This creates the Forgejo release, uploads the signed installer,
echo and updates releases/latest.json for Decky's in-app updater.
echo.
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0Publish-ForgejoRelease.ps1"
set "DECKY_RESULT=%ERRORLEVEL%"
echo.
if not "%DECKY_RESULT%"=="0" (
echo Publishing failed. The error is shown above.
echo No successful release should be assumed until the script says it was published.
) else (
echo Publishing completed successfully.
)
echo.
pause
exit /b %DECKY_RESULT%

View file

@ -12,13 +12,43 @@ param(
)
$ErrorActionPreference = 'Stop'
if (-not $Token) { throw 'FORGEJO_TOKEN or FORGEJO_PAT is required. Set it only for this PowerShell process.' }
$repositoryRoot = Split-Path -Parent $PSScriptRoot
$releaseDirectory = Join-Path $repositoryRoot "artifacts\release\v$Version"
$latestJsonPath = Join-Path $releaseDirectory 'latest.json'
if (-not (Test-Path -LiteralPath $latestJsonPath)) { throw "Run Prepare-Release.ps1 first: $latestJsonPath is missing." }
function Invoke-GitCommand {
param([Parameter(Mandatory)][string[]]$ArgumentList)
$output = @(& git -C $repositoryRoot @ArgumentList 2>&1)
if ($LASTEXITCODE) {
throw "git $($ArgumentList -join ' ') failed: $($output -join [Environment]::NewLine)"
}
return $output
}
$branch = ((Invoke-GitCommand @('rev-parse', '--abbrev-ref', 'HEAD')) | Select-Object -Last 1).Trim()
if ($branch -ne 'main') { throw "Releases must be published from main; the current branch is '$branch'." }
$workingChanges = @(Invoke-GitCommand @('status', '--porcelain'))
if ($workingChanges.Count) {
throw "Commit or discard all source changes before publishing:`n$($workingChanges -join [Environment]::NewLine)"
}
Invoke-GitCommand @('fetch', 'origin', 'main') | Out-Null
$localHead = ((Invoke-GitCommand @('rev-parse', 'HEAD')) | Select-Object -Last 1).Trim()
$remoteHead = ((Invoke-GitCommand @('rev-parse', 'origin/main')) | Select-Object -Last 1).Trim()
if ($localHead -ne $remoteHead) {
throw 'Local main does not exactly match origin/main. Pull/rebase or push the source before publishing.'
}
if (-not $Token) {
Write-Host 'A Forgejo personal access token is required to create the release.' -ForegroundColor Cyan
Write-Host 'The token is used only by this process and is not saved by Decky.' -ForegroundColor DarkGray
$secureToken = Read-Host 'Forgejo personal access token' -AsSecureString
$Token = [Net.NetworkCredential]::new('', $secureToken).Password
}
if (-not $Token) { throw 'No Forgejo personal access token was entered.' }
$headers = @{ Authorization = "token $Token"; Accept = 'application/json' }
$apiRoot = "$($ForgejoBaseUrl.TrimEnd('/'))/api/v1/repos/$Repository"
$tag = "v$Version"
@ -76,3 +106,14 @@ $contentBody = @{
if ($sha) { $contentBody.sha = $sha }
Invoke-RestMethod -Method Put -Uri "$apiRoot/contents/releases/latest.json" -Headers $headers -ContentType 'application/json' -Body ($contentBody | ConvertTo-Json) | Out-Null
Write-Host "Published Decky $Version and updated releases/latest.json."
Invoke-GitCommand @('fetch', 'origin', 'main') | Out-Null
$publishedHead = ((Invoke-GitCommand @('rev-parse', 'origin/main')) | Select-Object -Last 1).Trim()
if ($publishedHead -ne $localHead) {
$mergeBase = ((Invoke-GitCommand @('merge-base', $localHead, $publishedHead)) | Select-Object -Last 1).Trim()
if ($mergeBase -ne $localHead) {
throw 'The release was published, but origin/main also changed independently. Fetch and reconcile it manually.'
}
Invoke-GitCommand @('merge', '--ff-only', 'origin/main') | Out-Null
}
Write-Host 'Local main is synchronized with the Forgejo release metadata commit.'