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.')
}
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}`)
throw new Error('Secure credential storage is not available on this system. Unlock macOS Keychain and try again.')
}
const encrypted = safeStorage.encryptString(JSON.stringify(credentials))
fs.writeFileSync(userDataPath(credentialFileName), encrypted)
}
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, {
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
}
const response = await fetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
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,
title: 'Untitled note',
content: '# Untitled note\n',
category: '',
modified: nowSeconds(),
favorite: false,
etag: '',
readonly: false,
}
const NEW_NOTE = {
id: 'new',
title: 'Untitled note',
content: '',
category: '',
modified: 0,
favorite: false,
etag: '',
}
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 text = `${note.title}\n${note.content}\n${note.category}`.toLowerCase()
const matchesQuery = !normalizedQuery || text.includes(normalizedQuery)
const normalizedCategory = note.category || 'Uncategorized'
const matchesCategory = category === 'all' || normalizedCategory === category
return matchesQuery && matchesCategory
})
}, [notes, deferredQuery, category])
React.useEffect(() => {
draftRef.current = draft
}, [draft])
const filteredNotes = notes.filter(note => {
const text = `${note.title}\n${note.content}\n${note.category}`.toLowerCase()
const matchesQuery = !query || text.includes(query.toLowerCase())
const normalizedCategory = note.category || 'Uncategorized'
const matchesCategory = category === 'all' || normalizedCategory === category
return matchesQuery && matchesCategory
})
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
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
}
setNotes(await getAllCachedNotes())
setSelectedId(normalized.id)
setDraft(normalized)
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>
<ErrorBoundary>
<App />
</ErrorBoundary>
)