Restore stable desktop runtime
Some checks failed
Release / Build Linux (push) Has been cancelled
Release / Build macOS (push) Has been cancelled

This commit is contained in:
Daniel 2026-05-15 18:29:06 +02:00
parent ead14077f1
commit 6bc84078f0
6 changed files with 240 additions and 353 deletions

View file

@ -1,15 +1,10 @@
const { app, BrowserWindow, ipcMain, safeStorage, shell } = require('electron')
const { app, BrowserWindow, ipcMain, safeStorage } = require('electron')
const fs = require('fs')
const path = require('path')
const isDev = !app.isPackaged
const credentialFileName = 'credentials.enc'
app.setName('Nextcloud Notes')
const gotLock = app.requestSingleInstanceLock()
if (!gotLock) app.exit(0)
function userDataPath(fileName) {
return path.join(app.getPath('userData'), fileName)
}
@ -41,27 +36,12 @@ function readCredentials() {
}
}
function publicAccount(credentials) {
if (!credentials) return null
return { baseUrl: credentials.baseUrl, username: credentials.username }
}
function getStoredCredentials() {
const credentials = readCredentials()
if (!credentials) throw new Error('Sign in before syncing notes')
return credentials
}
function writeCredentials(credentials) {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error('macOS Keychain is locked or unavailable. Unlock Keychain and try signing in again.')
throw new Error('Secure credential storage is not available on this system. Unlock macOS Keychain and try again.')
}
try {
const encrypted = safeStorage.encryptString(JSON.stringify(credentials))
fs.writeFileSync(userDataPath(credentialFileName), encrypted)
} catch (err) {
throw new Error(`Could not save credentials to secure storage: ${err.message || err}`)
}
}
function clearCredentials() {
@ -84,18 +64,11 @@ async function request(credentials, method, apiPath, { query, body, etag } = {})
if (body !== undefined) headers['Content-Type'] = 'application/json'
if (etag) headers['If-Match'] = `"${etag}"`
let response
try {
response = await fetch(url, {
const response = await fetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(30000),
})
} catch (err) {
if (err.name === 'TimeoutError') throw new Error('Nextcloud request timed out')
throw err
}
if (!response.ok) {
const text = await response.text().catch(() => '')
@ -111,31 +84,6 @@ async function request(credentials, method, apiPath, { query, body, etag } = {})
}
}
function expectNotePayload(note) {
if (!note || typeof note !== 'object') throw new Error('Note payload is required')
return {
title: String(note.title || ''),
content: String(note.content || ''),
category: String(note.category || ''),
}
}
function registerHandler(channel, handler) {
ipcMain.handle(channel, async (_event, payload) => {
try {
return { ok: true, data: await handler(payload || {}) }
} catch (err) {
return {
ok: false,
error: {
message: err.message || 'Request failed',
status: err.status || null,
},
}
}
})
}
async function syncNotes(credentials, pruneBefore) {
let cursor = ''
const changed = []
@ -166,7 +114,6 @@ async function syncNotes(credentials, pruneBefore) {
}
function createWindow() {
const appIndex = path.join(__dirname, '..', 'dist', 'index.html')
const window = new BrowserWindow({
width: 1180,
height: 780,
@ -178,25 +125,9 @@ function createWindow() {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
},
})
window.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:\/\//i.test(url)) shell.openExternal(url)
return { action: 'deny' }
})
window.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => {
callback(false)
})
window.webContents.on('will-navigate', (event, url) => {
const allowedUrl = isDev ? 'http://127.0.0.1:5173/' : `file://${appIndex}`
if (url !== allowedUrl) event.preventDefault()
})
window.webContents.on('render-process-gone', (_event, details) => {
console.error('Renderer process gone:', details.reason, details.exitCode)
})
@ -208,7 +139,7 @@ function createWindow() {
if (isDev) {
window.loadURL('http://127.0.0.1:5173')
} else {
window.loadFile(appIndex)
window.loadFile(path.join(__dirname, '..', 'dist', 'index.html'))
}
}
@ -219,19 +150,12 @@ app.whenReady().then(() => {
})
})
app.on('second-instance', () => {
const [window] = BrowserWindow.getAllWindows()
if (!window) return
if (window.isMinimized()) window.restore()
window.focus()
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
registerHandler('credentials:get', () => publicAccount(readCredentials()))
registerHandler('credentials:save', async credentials => {
ipcMain.handle('credentials:get', () => readCredentials())
ipcMain.handle('credentials:save', async (_event, credentials) => {
const normalized = {
baseUrl: normalizeBaseUrl(credentials.baseUrl),
username: String(credentials.username || '').trim(),
@ -240,24 +164,22 @@ registerHandler('credentials:save', async credentials => {
if (!normalized.username || !normalized.password) throw new Error('Username and app password are required')
await request(normalized, 'GET', '/settings')
writeCredentials(normalized)
return publicAccount(normalized)
return { baseUrl: normalized.baseUrl, username: normalized.username }
})
registerHandler('credentials:clear', () => {
ipcMain.handle('credentials:clear', () => {
clearCredentials()
return true
})
registerHandler('notes:sync', ({ pruneBefore }) => syncNotes(getStoredCredentials(), pruneBefore))
registerHandler('notes:create', async ({ note }) => {
const { data } = await request(getStoredCredentials(), 'POST', '/notes', { body: expectNotePayload(note) })
ipcMain.handle('notes:sync', (_event, { credentials, pruneBefore }) => syncNotes(credentials, pruneBefore))
ipcMain.handle('notes:create', async (_event, { credentials, note }) => {
const { data } = await request(credentials, 'POST', '/notes', { body: note })
return Array.isArray(data) ? data[0] : data
})
registerHandler('notes:update', async ({ id, etag, note }) => {
if (!Number.isInteger(id)) throw new Error('Valid note ID is required')
const { data } = await request(getStoredCredentials(), 'PUT', `/notes/${id}`, { body: expectNotePayload(note), etag })
ipcMain.handle('notes:update', async (_event, { credentials, id, etag, note }) => {
const { data } = await request(credentials, 'PUT', `/notes/${id}`, { body: note, etag })
return Array.isArray(data) ? data[0] : data
})
registerHandler('notes:delete', async ({ id }) => {
if (!Number.isInteger(id)) throw new Error('Valid note ID is required')
const { data } = await request(getStoredCredentials(), 'DELETE', `/notes/${id}`)
ipcMain.handle('notes:delete', async (_event, { credentials, id }) => {
const { data } = await request(credentials, 'DELETE', `/notes/${id}`)
return data || { deleted: true }
})

View file

@ -1,19 +1,11 @@
const { contextBridge, ipcRenderer } = require('electron')
async function invoke(channel, payload) {
const result = await ipcRenderer.invoke(channel, payload)
if (result?.ok) return result.data
const error = new Error(result?.error?.message || 'Request failed')
error.status = result?.error?.status || null
throw error
}
contextBridge.exposeInMainWorld('notesBridge', {
getAccount: () => invoke('credentials:get'),
saveCredentials: credentials => invoke('credentials:save', credentials),
clearCredentials: () => invoke('credentials:clear'),
syncNotes: payload => invoke('notes:sync', payload),
createNote: payload => invoke('notes:create', payload),
updateNote: payload => invoke('notes:update', payload),
deleteNote: payload => invoke('notes:delete', payload),
getCredentials: () => ipcRenderer.invoke('credentials:get'),
saveCredentials: credentials => ipcRenderer.invoke('credentials:save', credentials),
clearCredentials: () => ipcRenderer.invoke('credentials:clear'),
syncNotes: payload => ipcRenderer.invoke('notes:sync', payload),
createNote: payload => ipcRenderer.invoke('notes:create', payload),
updateNote: payload => ipcRenderer.invoke('notes:update', payload),
deleteNote: payload => ipcRenderer.invoke('notes:delete', payload),
})

View file

@ -2,7 +2,6 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' http://127.0.0.1:5173 ws://127.0.0.1:5173; base-uri 'none'; form-action 'none'" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nextcloud Notes</title>
</head>

152
package-lock.json generated
View file

@ -1,22 +1,22 @@
{
"name": "nextcloud-notes-desktop",
"version": "0.1.3",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nextcloud-notes-desktop",
"version": "0.1.3",
"version": "0.1.4",
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.2",
"concurrently": "^9.2.1",
"electron": "^42.0.1",
"electron": "^39.8.10",
"electron-builder": "^26.8.1",
"vite": "^8.0.13",
"wait-on": "^9.0.10"
@ -150,48 +150,35 @@
}
},
"node_modules/@electron/get": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz",
"integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"env-paths": "^3.0.0",
"graceful-fs": "^4.2.11",
"env-paths": "^2.2.0",
"fs-extra": "^8.1.0",
"got": "^11.8.5",
"progress": "^2.0.3",
"semver": "^7.6.3",
"semver": "^6.2.0",
"sumchecker": "^3.0.1"
},
"engines": {
"node": ">=22.12.0"
"node": ">=12"
},
"optionalDependencies": {
"undici": "^7.24.4"
"global-agent": "^3.0.0"
}
},
"node_modules/@electron/get/node_modules/env-paths": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
"node_modules/@electron/get/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@electron/get/node_modules/undici": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=20.18.1"
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@electron/notarize": {
@ -2453,22 +2440,22 @@
}
},
"node_modules/electron": {
"version": "42.0.1",
"resolved": "https://registry.npmjs.org/electron/-/electron-42.0.1.tgz",
"integrity": "sha512-d8HnycE970DGESe91Nj30eonFBUcAI9EZ1TwUGJVzSAnJZdh0BkFEinAXjdklvDYst+bVDc8HsksCuqVLrnqdg==",
"version": "39.8.10",
"resolved": "https://registry.npmjs.org/electron/-/electron-39.8.10.tgz",
"integrity": "sha512-zbYtGPYUI7PzqLAzkk21Rk6j67WN0hxn0Mq/njErZo1d0HSf33is4f8ICI5fMLy5vYe0JtCtM5sYunNOaochSQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@electron/get": "^5.0.0",
"@types/node": "^24.9.0",
"@electron/get": "^2.0.0",
"@types/node": "^22.7.7",
"extract-zip": "^2.0.1"
},
"bin": {
"electron": "cli.js",
"install-electron": "install.js"
"electron": "cli.js"
},
"engines": {
"node": ">= 22.12.0"
"node": ">= 12.20.55"
}
},
"node_modules/electron-builder": {
@ -2641,6 +2628,23 @@
"node": ">=6 <7 || >=8"
}
},
"node_modules/electron/node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/electron/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@ -2930,6 +2934,21 @@
"node": ">= 6"
}
},
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -3553,6 +3572,12 @@
"node": ">= 20"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@ -3906,6 +3931,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
@ -5361,24 +5398,28 @@
}
},
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^19.2.6"
"react": "^18.3.1"
}
},
"node_modules/react-markdown": {
@ -5651,10 +5692,13 @@
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/semver": {
"version": "7.8.0",

View file

@ -1,6 +1,6 @@
{
"name": "nextcloud-notes-desktop",
"version": "0.1.3",
"version": "0.1.4",
"private": true,
"description": "Fast cross-platform desktop client for Nextcloud Notes",
"author": {
@ -20,8 +20,8 @@
"dist:linux": "npm run build && electron-builder --linux"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
@ -31,7 +31,7 @@
"devDependencies": {
"@vitejs/plugin-react": "^6.0.2",
"concurrently": "^9.2.1",
"electron": "^42.0.1",
"electron": "^39.8.10",
"electron-builder": "^26.8.1",
"vite": "^8.0.13",
"wait-on": "^9.0.10"

View file

@ -13,23 +13,14 @@ import {
} from './db.js'
import './styles.css'
const NEW_NOTE_ID = 'new'
function nowSeconds() {
return Math.floor(Date.now() / 1000)
}
function emptyNote() {
return {
id: NEW_NOTE_ID,
const NEW_NOTE = {
id: 'new',
title: 'Untitled note',
content: '# Untitled note\n',
content: '',
category: '',
modified: nowSeconds(),
modified: 0,
favorite: false,
etag: '',
readonly: false,
}
}
function formatDate(timestamp) {
@ -48,60 +39,13 @@ function normalizeNote(note) {
title: note.title || deriveTitle(note.content || ''),
content: note.content || '',
category: note.category || '',
modified: Number(note.modified || nowSeconds()),
modified: Number(note.modified || Math.floor(Date.now() / 1000)),
favorite: Boolean(note.favorite),
etag: note.etag || '',
readonly: Boolean(note.readonly),
}
}
function Login({ onLogin }) {
const [baseUrl, setBaseUrl] = React.useState('https://cloud.danvics.com')
const [username, setUsername] = React.useState('')
const [password, setPassword] = React.useState('')
const [error, setError] = React.useState('')
const [busy, setBusy] = React.useState(false)
async function submit(event) {
event.preventDefault()
setBusy(true)
setError('')
try {
const account = await window.notesBridge.saveCredentials({ baseUrl, username, password })
setPassword('')
onLogin(account)
} catch (err) {
setError(err.message || 'Could not connect to Nextcloud Notes')
} finally {
setBusy(false)
}
}
return (
<main className="login-shell">
<form className="login-card" onSubmit={submit}>
<div className="brand-mark">N</div>
<h1>Nextcloud Notes</h1>
<p>Connect with a Nextcloud app password. The password stays in the Electron main process after login.</p>
<label>
Server URL
<input value={baseUrl} onChange={event => setBaseUrl(event.target.value)} placeholder="https://cloud.example.com" />
</label>
<label>
Username
<input value={username} onChange={event => setUsername(event.target.value)} autoComplete="username" />
</label>
<label>
App password
<input value={password} onChange={event => setPassword(event.target.value)} type="password" autoComplete="current-password" />
</label>
{error && <div className="error-box">{error}</div>}
<button className="primary-button" disabled={busy}>{busy ? 'Connecting...' : 'Connect'}</button>
</form>
</main>
)
}
class ErrorBoundary extends React.Component {
constructor(props) {
super(props)
@ -123,7 +67,7 @@ class ErrorBoundary extends React.Component {
<section className="login-card">
<div className="brand-mark">!</div>
<h1>Nextcloud Notes hit an error</h1>
<p>The app did not start cleanly. This message replaces the blank screen so the issue can be diagnosed.</p>
<p>The app did not start cleanly. This replaces the blank screen so the issue can be diagnosed.</p>
<div className="error-box">{this.state.error.message || String(this.state.error)}</div>
</section>
</main>
@ -133,8 +77,54 @@ class ErrorBoundary extends React.Component {
}
}
function Login({ onLogin }) {
const [baseUrl, setBaseUrl] = React.useState('https://cloud.danvics.com')
const [username, setUsername] = React.useState('')
const [password, setPassword] = React.useState('')
const [error, setError] = React.useState('')
const [busy, setBusy] = React.useState(false)
async function submit(event) {
event.preventDefault()
setBusy(true)
setError('')
try {
const saved = await window.notesBridge.saveCredentials({ baseUrl, username, password })
onLogin({ ...saved, password })
} catch (err) {
setError(err.message || 'Could not connect to Nextcloud Notes')
} finally {
setBusy(false)
}
}
return (
<main className="login-shell">
<form className="login-card" onSubmit={submit}>
<div className="brand-mark">N</div>
<h1>Nextcloud Notes</h1>
<p>Connect with a Nextcloud app password. Notes load locally first, then sync incrementally.</p>
<label>
Server URL
<input value={baseUrl} onChange={event => setBaseUrl(event.target.value)} placeholder="https://cloud.example.com" />
</label>
<label>
Username
<input value={username} onChange={event => setUsername(event.target.value)} autoComplete="username" />
</label>
<label>
App password
<input value={password} onChange={event => setPassword(event.target.value)} type="password" autoComplete="current-password" />
</label>
{error && <div className="error-box">{error}</div>}
<button className="primary-button" disabled={busy}>{busy ? 'Connecting...' : 'Connect'}</button>
</form>
</main>
)
}
function App() {
const [account, setAccount] = React.useState(null)
const [credentials, setCredentials] = React.useState(null)
const [notes, setNotes] = React.useState([])
const [selectedId, setSelectedId] = React.useState(null)
const [query, setQuery] = React.useState('')
@ -143,75 +133,56 @@ function App() {
const [syncStatus, setSyncStatus] = React.useState('Loading local cache...')
const [error, setError] = React.useState('')
const [draft, setDraft] = React.useState(null)
const deferredQuery = React.useDeferredValue(query)
const dirtyRef = React.useRef(false)
const draftRef = React.useRef(null)
const saveTimer = React.useRef(null)
const saveVersion = React.useRef(0)
const saveInFlight = React.useRef(false)
const saveQueued = React.useRef(false)
const syncing = React.useRef(false)
const selected = draft || notes.find(note => note.id === selectedId) || null
const categories = React.useMemo(() => Array.from(new Set(notes.map(note => note.category || 'Uncategorized'))).sort(), [notes])
const filteredNotes = React.useMemo(() => {
const normalizedQuery = deferredQuery.trim().toLowerCase()
return notes.filter(note => {
const filteredNotes = notes.filter(note => {
const text = `${note.title}\n${note.content}\n${note.category}`.toLowerCase()
const matchesQuery = !normalizedQuery || text.includes(normalizedQuery)
const matchesQuery = !query || text.includes(query.toLowerCase())
const normalizedCategory = note.category || 'Uncategorized'
const matchesCategory = category === 'all' || normalizedCategory === category
return matchesQuery && matchesCategory
})
}, [notes, deferredQuery, category])
React.useEffect(() => {
draftRef.current = draft
}, [draft])
React.useEffect(() => () => clearTimeout(saveTimer.current), [])
React.useEffect(() => {
async function boot() {
const cached = await getAllCachedNotes()
const savedAccount = await window.notesBridge.getAccount()
setNotes(cached)
setSelectedId(cached[0]?.id || null)
if (savedAccount) setAccount(savedAccount)
setSyncStatus(savedAccount ? 'Ready to sync' : 'Sign in to sync')
const savedCredentials = await window.notesBridge.getCredentials()
if (savedCredentials) setCredentials(savedCredentials)
setSyncStatus(savedCredentials ? 'Ready to sync' : 'Sign in to sync')
}
boot().catch(err => setError(err.message || 'Startup failed'))
}, [])
React.useEffect(() => {
if (account) syncNow(false)
}, [account])
if (credentials) syncNow(false)
}, [credentials])
React.useEffect(() => {
if (dirtyRef.current) return
setDraft(notes.find(note => note.id === selectedId) || null)
const next = notes.find(note => note.id === selectedId) || null
setDraft(next)
}, [notes, selectedId])
async function refreshNotes() {
const cached = await getAllCachedNotes()
setNotes(cached)
return cached
}
async function syncNow(forceFull = false) {
if (!account || syncing.current) return
await flushSave()
if (!credentials || syncing.current) return
syncing.current = true
setError('')
setSyncStatus('Syncing...')
try {
const pruneBefore = forceFull ? null : await getMeta('lastSync', null)
const result = await window.notesBridge.syncNotes({ pruneBefore })
const result = await window.notesBridge.syncNotes({ credentials, pruneBefore })
const changed = result.changed.map(normalizeNote)
await upsertCachedNotes(changed)
const deletedIds = await pruneDeletedNotes(result.serverIds)
const cached = await refreshNotes()
const cached = await getAllCachedNotes()
await setMeta('lastSync', result.syncedAt)
setNotes(cached)
if (!selectedId || deletedIds.includes(selectedId)) setSelectedId(cached[0]?.id || null)
setSyncStatus(`Synced ${formatDate(result.syncedAt)}`)
} catch (err) {
@ -222,91 +193,57 @@ function App() {
}
}
function updateDraft(patch, { autosave = true } = {}) {
const next = { ...(draftRef.current || emptyNote()), ...patch, modified: nowSeconds() }
dirtyRef.current = true
saveVersion.current += 1
setDraft(next)
if (autosave) scheduleSave(next, saveVersion.current)
function updateDraft(patch) {
setDraft(current => ({ ...(current || NEW_NOTE), ...patch, modified: Math.floor(Date.now() / 1000) }))
}
async function saveDraft(note = draftRef.current, version = saveVersion.current) {
if (!account || !note || note.readonly) return
async function saveDraft(note = draft) {
if (!credentials || !note) return
clearTimeout(saveTimer.current)
if (saveInFlight.current) {
saveQueued.current = true
return
}
saveInFlight.current = true
const payload = { title: note.title || deriveTitle(note.content || ''), content: note.content || '', category: note.category || '' }
setError('')
setSyncStatus('Saving...')
try {
const saved = note.id === NEW_NOTE_ID
? await window.notesBridge.createNote({ note: payload })
: await window.notesBridge.updateNote({ id: note.id, etag: note.etag, note: payload })
const saved = note.id === 'new'
? await window.notesBridge.createNote({ credentials, note: payload })
: await window.notesBridge.updateNote({ credentials, id: note.id, etag: note.etag, note: payload })
const normalized = normalizeNote({ ...note, ...payload, ...saved })
await upsertCachedNotes([normalized])
await refreshNotes()
if (version === saveVersion.current) {
dirtyRef.current = false
setNotes(await getAllCachedNotes())
setSelectedId(normalized.id)
setDraft(normalized)
} else if (note.id === NEW_NOTE_ID && draftRef.current?.id === NEW_NOTE_ID) {
const migratedDraft = { ...draftRef.current, id: normalized.id, etag: normalized.etag }
setSelectedId(normalized.id)
setDraft(migratedDraft)
draftRef.current = migratedDraft
}
setSyncStatus(`Saved ${formatDate(normalized.modified)}`)
} catch (err) {
if (err.status === 412) setError('This note changed on the server. Run sync before saving again to avoid overwriting it.')
if (err.status === 412) setError('This note changed on the server. Sync before saving again to avoid overwriting it.')
else setError(err.message || 'Save failed')
setSyncStatus('Save failed')
} finally {
saveInFlight.current = false
if (saveQueued.current) {
saveQueued.current = false
await saveDraft(draftRef.current, saveVersion.current)
}
}
}
function scheduleSave(nextDraft, version) {
function scheduleSave(nextDraft) {
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => saveDraft(nextDraft, version), 700)
saveTimer.current = setTimeout(() => saveDraft(nextDraft), 650)
}
async function flushSave() {
clearTimeout(saveTimer.current)
if (dirtyRef.current) await saveDraft(draftRef.current, saveVersion.current)
}
async function selectNote(id) {
await flushSave()
dirtyRef.current = false
setSelectedId(id)
function handleContentChange(content) {
const nextDraft = { ...(draft || NEW_NOTE), content, title: deriveTitle(content), modified: Math.floor(Date.now() / 1000) }
setDraft(nextDraft)
scheduleSave(nextDraft)
}
async function createNewNote() {
await flushSave()
const note = emptyNote()
dirtyRef.current = true
saveVersion.current += 1
setSelectedId(NEW_NOTE_ID)
const note = { ...NEW_NOTE, id: 'new', content: '# Untitled note\n', modified: Math.floor(Date.now() / 1000) }
setSelectedId('new')
setDraft(note)
scheduleSave(note, saveVersion.current)
}
async function deleteSelected() {
if (!account || !selected || selected.id === NEW_NOTE_ID) return
clearTimeout(saveTimer.current)
if (!credentials || !selected || selected.id === 'new') return
setSyncStatus('Deleting...')
try {
await window.notesBridge.deleteNote({ id: selected.id })
await window.notesBridge.deleteNote({ credentials, id: selected.id })
await deleteCachedNote(selected.id)
dirtyRef.current = false
const cached = await refreshNotes()
const cached = await getAllCachedNotes()
setNotes(cached)
setSelectedId(cached[0]?.id || null)
setSyncStatus('Deleted')
} catch (err) {
@ -319,16 +256,13 @@ function App() {
clearTimeout(saveTimer.current)
await window.notesBridge.clearCredentials()
await clearCache()
dirtyRef.current = false
setAccount(null)
setCredentials(null)
setNotes([])
setSelectedId(null)
setDraft(null)
setError('')
setSyncStatus('Sign in to sync')
}
if (!account) return <Login onLogin={setAccount} />
if (!credentials) return <Login onLogin={setCredentials} />
return (
<main className="app-shell">
@ -340,7 +274,6 @@ function App() {
</div>
<button onClick={createNewNote}>New</button>
</div>
<div className="account-line">{account.username} · {account.baseUrl}</div>
<input className="search" value={query} onChange={event => setQuery(event.target.value)} placeholder="Search notes" />
<div className="categories">
<button className={category === 'all' ? 'active' : ''} onClick={() => setCategory('all')}>All</button>
@ -348,7 +281,7 @@ function App() {
</div>
<div className="note-list">
{filteredNotes.map(note => (
<button key={note.id} className={note.id === selectedId ? 'note-row active' : 'note-row'} onClick={() => selectNote(note.id)}>
<button key={note.id} className={note.id === selectedId ? 'note-row active' : 'note-row'} onClick={() => setSelectedId(note.id)}>
<strong>{note.favorite ? '★ ' : ''}{note.title}</strong>
<span>{note.category || 'Uncategorized'} · {formatDate(note.modified)}</span>
</button>
@ -362,24 +295,24 @@ function App() {
className="title-input"
value={selected?.title || ''}
onChange={event => updateDraft({ title: event.target.value })}
onBlur={flushSave}
onBlur={() => saveDraft()}
placeholder="Select or create a note"
disabled={!selected || selected.readonly}
disabled={!selected}
/>
<input
className="category-input"
value={selected?.category || ''}
onChange={event => updateDraft({ category: event.target.value })}
onBlur={flushSave}
onBlur={() => saveDraft()}
placeholder="Category"
disabled={!selected || selected.readonly}
disabled={!selected}
/>
</div>
<div className="toolbar-actions">
<button onClick={() => syncNow(true)}>Full sync</button>
<button onClick={() => syncNow(false)}>Sync</button>
<button onClick={() => setMode(mode === 'edit' ? 'preview' : 'edit')}>{mode === 'edit' ? 'Preview' : 'Edit'}</button>
<button onClick={deleteSelected} disabled={!selected || selected.id === NEW_NOTE_ID}>Delete</button>
<button onClick={deleteSelected} disabled={!selected || selected.id === 'new'}>Delete</button>
<button onClick={logout}>Logout</button>
</div>
</header>
@ -390,9 +323,8 @@ function App() {
<textarea
className="note-editor"
value={selected.content}
onChange={event => updateDraft({ content: event.target.value, title: deriveTitle(event.target.value) })}
onChange={event => handleContentChange(event.target.value)}
spellCheck="true"
disabled={selected.readonly}
/>
) : (
<article className="markdown-preview">
@ -405,9 +337,7 @@ function App() {
}
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>
)