Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7b2326521 | |||
| a0b46497c1 | |||
| 134a0ea05b | |||
| 0055fb196b | |||
| c5287352fd | |||
| 42ed790956 | |||
| ee2ef03982 | |||
| e9d76cc4a2 | |||
| 1bea5547a6 |
15 changed files with 316 additions and 54 deletions
54
AGENTS.md
Normal file
54
AGENTS.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
4
app/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "decky",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --configLoader runner",
|
||||
|
|
|
|||
2
app/src-tauri/Cargo.lock
generated
2
app/src-tauri/Cargo.lock
generated
|
|
@ -590,7 +590,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "decky"
|
||||
version = "0.3.0"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"keyring",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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); } }
|
||||
|
||||
|
|
|
|||
37
app/src/lib/auto-refresh.test.ts
Normal file
37
app/src/lib/auto-refresh.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
23
app/src/lib/auto-refresh.ts
Normal file
23
app/src/lib/auto-refresh.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
24
scripts/Publish-DeckyRelease.cmd
Normal file
24
scripts/Publish-DeckyRelease.cmd
Normal 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%
|
||||
|
|
@ -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.'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue