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 fs = require('fs')
const path = require('path') const path = require('path')
const isDev = !app.isPackaged const isDev = !app.isPackaged
const credentialFileName = 'credentials.enc' const credentialFileName = 'credentials.enc'
app.setName('Nextcloud Notes')
const gotLock = app.requestSingleInstanceLock()
if (!gotLock) app.exit(0)
function userDataPath(fileName) { function userDataPath(fileName) {
return path.join(app.getPath('userData'), 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) { function writeCredentials(credentials) {
if (!safeStorage.isEncryptionAvailable()) { 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}`)
} }
const encrypted = safeStorage.encryptString(JSON.stringify(credentials))
fs.writeFileSync(userDataPath(credentialFileName), encrypted)
} }
function clearCredentials() { function clearCredentials() {
@ -84,18 +64,11 @@ async function request(credentials, method, apiPath, { query, body, etag } = {})
if (body !== undefined) headers['Content-Type'] = 'application/json' if (body !== undefined) headers['Content-Type'] = 'application/json'
if (etag) headers['If-Match'] = `"${etag}"` if (etag) headers['If-Match'] = `"${etag}"`
let response const response = await fetch(url, {
try { method,
response = await fetch(url, { headers,
method, body: body === undefined ? undefined : JSON.stringify(body),
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) { if (!response.ok) {
const text = await response.text().catch(() => '') 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) { async function syncNotes(credentials, pruneBefore) {
let cursor = '' let cursor = ''
const changed = [] const changed = []
@ -166,7 +114,6 @@ async function syncNotes(credentials, pruneBefore) {
} }
function createWindow() { function createWindow() {
const appIndex = path.join(__dirname, '..', 'dist', 'index.html')
const window = new BrowserWindow({ const window = new BrowserWindow({
width: 1180, width: 1180,
height: 780, height: 780,
@ -178,25 +125,9 @@ function createWindow() {
preload: path.join(__dirname, 'preload.cjs'), preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, 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) => { window.webContents.on('render-process-gone', (_event, details) => {
console.error('Renderer process gone:', details.reason, details.exitCode) console.error('Renderer process gone:', details.reason, details.exitCode)
}) })
@ -208,7 +139,7 @@ function createWindow() {
if (isDev) { if (isDev) {
window.loadURL('http://127.0.0.1:5173') window.loadURL('http://127.0.0.1:5173')
} else { } 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', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit() if (process.platform !== 'darwin') app.quit()
}) })
registerHandler('credentials:get', () => publicAccount(readCredentials())) ipcMain.handle('credentials:get', () => readCredentials())
registerHandler('credentials:save', async credentials => { ipcMain.handle('credentials:save', async (_event, credentials) => {
const normalized = { const normalized = {
baseUrl: normalizeBaseUrl(credentials.baseUrl), baseUrl: normalizeBaseUrl(credentials.baseUrl),
username: String(credentials.username || '').trim(), 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') if (!normalized.username || !normalized.password) throw new Error('Username and app password are required')
await request(normalized, 'GET', '/settings') await request(normalized, 'GET', '/settings')
writeCredentials(normalized) writeCredentials(normalized)
return publicAccount(normalized) return { baseUrl: normalized.baseUrl, username: normalized.username }
}) })
registerHandler('credentials:clear', () => { ipcMain.handle('credentials:clear', () => {
clearCredentials() clearCredentials()
return true return true
}) })
registerHandler('notes:sync', ({ pruneBefore }) => syncNotes(getStoredCredentials(), pruneBefore)) ipcMain.handle('notes:sync', (_event, { credentials, pruneBefore }) => syncNotes(credentials, pruneBefore))
registerHandler('notes:create', async ({ note }) => { ipcMain.handle('notes:create', async (_event, { credentials, note }) => {
const { data } = await request(getStoredCredentials(), 'POST', '/notes', { body: expectNotePayload(note) }) const { data } = await request(credentials, 'POST', '/notes', { body: note })
return Array.isArray(data) ? data[0] : data return Array.isArray(data) ? data[0] : data
}) })
registerHandler('notes:update', async ({ id, etag, note }) => { ipcMain.handle('notes:update', async (_event, { credentials, id, etag, note }) => {
if (!Number.isInteger(id)) throw new Error('Valid note ID is required') const { data } = await request(credentials, 'PUT', `/notes/${id}`, { body: note, etag })
const { data } = await request(getStoredCredentials(), 'PUT', `/notes/${id}`, { body: expectNotePayload(note), etag })
return Array.isArray(data) ? data[0] : data return Array.isArray(data) ? data[0] : data
}) })
registerHandler('notes:delete', async ({ id }) => { ipcMain.handle('notes:delete', async (_event, { credentials, id }) => {
if (!Number.isInteger(id)) throw new Error('Valid note ID is required') const { data } = await request(credentials, 'DELETE', `/notes/${id}`)
const { data } = await request(getStoredCredentials(), 'DELETE', `/notes/${id}`)
return data || { deleted: true } return data || { deleted: true }
}) })

View file

@ -1,19 +1,11 @@
const { contextBridge, ipcRenderer } = require('electron') 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', { contextBridge.exposeInMainWorld('notesBridge', {
getAccount: () => invoke('credentials:get'), getCredentials: () => ipcRenderer.invoke('credentials:get'),
saveCredentials: credentials => invoke('credentials:save', credentials), saveCredentials: credentials => ipcRenderer.invoke('credentials:save', credentials),
clearCredentials: () => invoke('credentials:clear'), clearCredentials: () => ipcRenderer.invoke('credentials:clear'),
syncNotes: payload => invoke('notes:sync', payload), syncNotes: payload => ipcRenderer.invoke('notes:sync', payload),
createNote: payload => invoke('notes:create', payload), createNote: payload => ipcRenderer.invoke('notes:create', payload),
updateNote: payload => invoke('notes:update', payload), updateNote: payload => ipcRenderer.invoke('notes:update', payload),
deleteNote: payload => invoke('notes:delete', payload), deleteNote: payload => ipcRenderer.invoke('notes:delete', payload),
}) })

View file

@ -2,7 +2,6 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nextcloud Notes</title> <title>Nextcloud Notes</title>
</head> </head>

152
package-lock.json generated
View file

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

View file

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

View file

@ -13,23 +13,14 @@ import {
} from './db.js' } from './db.js'
import './styles.css' import './styles.css'
const NEW_NOTE_ID = 'new' const NEW_NOTE = {
id: 'new',
function nowSeconds() { title: 'Untitled note',
return Math.floor(Date.now() / 1000) content: '',
} category: '',
modified: 0,
function emptyNote() { favorite: false,
return { etag: '',
id: NEW_NOTE_ID,
title: 'Untitled note',
content: '# Untitled note\n',
category: '',
modified: nowSeconds(),
favorite: false,
etag: '',
readonly: false,
}
} }
function formatDate(timestamp) { function formatDate(timestamp) {
@ -48,60 +39,13 @@ function normalizeNote(note) {
title: note.title || deriveTitle(note.content || ''), title: note.title || deriveTitle(note.content || ''),
content: note.content || '', content: note.content || '',
category: note.category || '', category: note.category || '',
modified: Number(note.modified || nowSeconds()), modified: Number(note.modified || Math.floor(Date.now() / 1000)),
favorite: Boolean(note.favorite), favorite: Boolean(note.favorite),
etag: note.etag || '', etag: note.etag || '',
readonly: Boolean(note.readonly), 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 { class ErrorBoundary extends React.Component {
constructor(props) { constructor(props) {
super(props) super(props)
@ -123,7 +67,7 @@ class ErrorBoundary extends React.Component {
<section className="login-card"> <section className="login-card">
<div className="brand-mark">!</div> <div className="brand-mark">!</div>
<h1>Nextcloud Notes hit an error</h1> <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> <div className="error-box">{this.state.error.message || String(this.state.error)}</div>
</section> </section>
</main> </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() { function App() {
const [account, setAccount] = React.useState(null) const [credentials, setCredentials] = React.useState(null)
const [notes, setNotes] = React.useState([]) const [notes, setNotes] = React.useState([])
const [selectedId, setSelectedId] = React.useState(null) const [selectedId, setSelectedId] = React.useState(null)
const [query, setQuery] = React.useState('') const [query, setQuery] = React.useState('')
@ -143,75 +133,56 @@ function App() {
const [syncStatus, setSyncStatus] = React.useState('Loading local cache...') const [syncStatus, setSyncStatus] = React.useState('Loading local cache...')
const [error, setError] = React.useState('') const [error, setError] = React.useState('')
const [draft, setDraft] = React.useState(null) 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 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 syncing = React.useRef(false)
const selected = draft || notes.find(note => note.id === selectedId) || null 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 categories = React.useMemo(() => Array.from(new Set(notes.map(note => note.category || 'Uncategorized'))).sort(), [notes])
const filteredNotes = React.useMemo(() => { const filteredNotes = notes.filter(note => {
const normalizedQuery = deferredQuery.trim().toLowerCase() const text = `${note.title}\n${note.content}\n${note.category}`.toLowerCase()
return notes.filter(note => { const matchesQuery = !query || text.includes(query.toLowerCase())
const text = `${note.title}\n${note.content}\n${note.category}`.toLowerCase() const normalizedCategory = note.category || 'Uncategorized'
const matchesQuery = !normalizedQuery || text.includes(normalizedQuery) const matchesCategory = category === 'all' || normalizedCategory === category
const normalizedCategory = note.category || 'Uncategorized' return matchesQuery && matchesCategory
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(() => () => clearTimeout(saveTimer.current), [])
React.useEffect(() => { React.useEffect(() => {
async function boot() { async function boot() {
const cached = await getAllCachedNotes() const cached = await getAllCachedNotes()
const savedAccount = await window.notesBridge.getAccount()
setNotes(cached) setNotes(cached)
setSelectedId(cached[0]?.id || null) setSelectedId(cached[0]?.id || null)
if (savedAccount) setAccount(savedAccount) const savedCredentials = await window.notesBridge.getCredentials()
setSyncStatus(savedAccount ? 'Ready to sync' : 'Sign in to sync') if (savedCredentials) setCredentials(savedCredentials)
setSyncStatus(savedCredentials ? 'Ready to sync' : 'Sign in to sync')
} }
boot().catch(err => setError(err.message || 'Startup failed')) boot().catch(err => setError(err.message || 'Startup failed'))
}, []) }, [])
React.useEffect(() => { React.useEffect(() => {
if (account) syncNow(false) if (credentials) syncNow(false)
}, [account]) }, [credentials])
React.useEffect(() => { React.useEffect(() => {
if (dirtyRef.current) return const next = notes.find(note => note.id === selectedId) || null
setDraft(notes.find(note => note.id === selectedId) || null) setDraft(next)
}, [notes, selectedId]) }, [notes, selectedId])
async function refreshNotes() {
const cached = await getAllCachedNotes()
setNotes(cached)
return cached
}
async function syncNow(forceFull = false) { async function syncNow(forceFull = false) {
if (!account || syncing.current) return if (!credentials || syncing.current) return
await flushSave()
syncing.current = true syncing.current = true
setError('') setError('')
setSyncStatus('Syncing...') setSyncStatus('Syncing...')
try { try {
const pruneBefore = forceFull ? null : await getMeta('lastSync', null) 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) const changed = result.changed.map(normalizeNote)
await upsertCachedNotes(changed) await upsertCachedNotes(changed)
const deletedIds = await pruneDeletedNotes(result.serverIds) const deletedIds = await pruneDeletedNotes(result.serverIds)
const cached = await refreshNotes() const cached = await getAllCachedNotes()
await setMeta('lastSync', result.syncedAt) await setMeta('lastSync', result.syncedAt)
setNotes(cached)
if (!selectedId || deletedIds.includes(selectedId)) setSelectedId(cached[0]?.id || null) if (!selectedId || deletedIds.includes(selectedId)) setSelectedId(cached[0]?.id || null)
setSyncStatus(`Synced ${formatDate(result.syncedAt)}`) setSyncStatus(`Synced ${formatDate(result.syncedAt)}`)
} catch (err) { } catch (err) {
@ -222,91 +193,57 @@ function App() {
} }
} }
function updateDraft(patch, { autosave = true } = {}) { function updateDraft(patch) {
const next = { ...(draftRef.current || emptyNote()), ...patch, modified: nowSeconds() } setDraft(current => ({ ...(current || NEW_NOTE), ...patch, modified: Math.floor(Date.now() / 1000) }))
dirtyRef.current = true
saveVersion.current += 1
setDraft(next)
if (autosave) scheduleSave(next, saveVersion.current)
} }
async function saveDraft(note = draftRef.current, version = saveVersion.current) { async function saveDraft(note = draft) {
if (!account || !note || note.readonly) return if (!credentials || !note) return
clearTimeout(saveTimer.current) 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 || '' } const payload = { title: note.title || deriveTitle(note.content || ''), content: note.content || '', category: note.category || '' }
setError('')
setSyncStatus('Saving...') setSyncStatus('Saving...')
try { try {
const saved = note.id === NEW_NOTE_ID const saved = note.id === 'new'
? await window.notesBridge.createNote({ note: payload }) ? await window.notesBridge.createNote({ credentials, note: payload })
: await window.notesBridge.updateNote({ id: note.id, etag: note.etag, note: payload }) : await window.notesBridge.updateNote({ credentials, id: note.id, etag: note.etag, note: payload })
const normalized = normalizeNote({ ...note, ...payload, ...saved }) const normalized = normalizeNote({ ...note, ...payload, ...saved })
await upsertCachedNotes([normalized]) await upsertCachedNotes([normalized])
await refreshNotes() setNotes(await getAllCachedNotes())
if (version === saveVersion.current) { setSelectedId(normalized.id)
dirtyRef.current = false setDraft(normalized)
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)}`) setSyncStatus(`Saved ${formatDate(normalized.modified)}`)
} catch (err) { } 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') else setError(err.message || 'Save failed')
setSyncStatus('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) clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => saveDraft(nextDraft, version), 700) saveTimer.current = setTimeout(() => saveDraft(nextDraft), 650)
} }
async function flushSave() { function handleContentChange(content) {
clearTimeout(saveTimer.current) const nextDraft = { ...(draft || NEW_NOTE), content, title: deriveTitle(content), modified: Math.floor(Date.now() / 1000) }
if (dirtyRef.current) await saveDraft(draftRef.current, saveVersion.current) setDraft(nextDraft)
} scheduleSave(nextDraft)
async function selectNote(id) {
await flushSave()
dirtyRef.current = false
setSelectedId(id)
} }
async function createNewNote() { async function createNewNote() {
await flushSave() const note = { ...NEW_NOTE, id: 'new', content: '# Untitled note\n', modified: Math.floor(Date.now() / 1000) }
const note = emptyNote() setSelectedId('new')
dirtyRef.current = true
saveVersion.current += 1
setSelectedId(NEW_NOTE_ID)
setDraft(note) setDraft(note)
scheduleSave(note, saveVersion.current)
} }
async function deleteSelected() { async function deleteSelected() {
if (!account || !selected || selected.id === NEW_NOTE_ID) return if (!credentials || !selected || selected.id === 'new') return
clearTimeout(saveTimer.current)
setSyncStatus('Deleting...') setSyncStatus('Deleting...')
try { try {
await window.notesBridge.deleteNote({ id: selected.id }) await window.notesBridge.deleteNote({ credentials, id: selected.id })
await deleteCachedNote(selected.id) await deleteCachedNote(selected.id)
dirtyRef.current = false const cached = await getAllCachedNotes()
const cached = await refreshNotes() setNotes(cached)
setSelectedId(cached[0]?.id || null) setSelectedId(cached[0]?.id || null)
setSyncStatus('Deleted') setSyncStatus('Deleted')
} catch (err) { } catch (err) {
@ -319,16 +256,13 @@ function App() {
clearTimeout(saveTimer.current) clearTimeout(saveTimer.current)
await window.notesBridge.clearCredentials() await window.notesBridge.clearCredentials()
await clearCache() await clearCache()
dirtyRef.current = false setCredentials(null)
setAccount(null)
setNotes([]) setNotes([])
setSelectedId(null) setSelectedId(null)
setDraft(null) setDraft(null)
setError('')
setSyncStatus('Sign in to sync')
} }
if (!account) return <Login onLogin={setAccount} /> if (!credentials) return <Login onLogin={setCredentials} />
return ( return (
<main className="app-shell"> <main className="app-shell">
@ -340,7 +274,6 @@ function App() {
</div> </div>
<button onClick={createNewNote}>New</button> <button onClick={createNewNote}>New</button>
</div> </div>
<div className="account-line">{account.username} · {account.baseUrl}</div>
<input className="search" value={query} onChange={event => setQuery(event.target.value)} placeholder="Search notes" /> <input className="search" value={query} onChange={event => setQuery(event.target.value)} placeholder="Search notes" />
<div className="categories"> <div className="categories">
<button className={category === 'all' ? 'active' : ''} onClick={() => setCategory('all')}>All</button> <button className={category === 'all' ? 'active' : ''} onClick={() => setCategory('all')}>All</button>
@ -348,7 +281,7 @@ function App() {
</div> </div>
<div className="note-list"> <div className="note-list">
{filteredNotes.map(note => ( {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> <strong>{note.favorite ? '★ ' : ''}{note.title}</strong>
<span>{note.category || 'Uncategorized'} · {formatDate(note.modified)}</span> <span>{note.category || 'Uncategorized'} · {formatDate(note.modified)}</span>
</button> </button>
@ -362,24 +295,24 @@ function App() {
className="title-input" className="title-input"
value={selected?.title || ''} value={selected?.title || ''}
onChange={event => updateDraft({ title: event.target.value })} onChange={event => updateDraft({ title: event.target.value })}
onBlur={flushSave} onBlur={() => saveDraft()}
placeholder="Select or create a note" placeholder="Select or create a note"
disabled={!selected || selected.readonly} disabled={!selected}
/> />
<input <input
className="category-input" className="category-input"
value={selected?.category || ''} value={selected?.category || ''}
onChange={event => updateDraft({ category: event.target.value })} onChange={event => updateDraft({ category: event.target.value })}
onBlur={flushSave} onBlur={() => saveDraft()}
placeholder="Category" placeholder="Category"
disabled={!selected || selected.readonly} disabled={!selected}
/> />
</div> </div>
<div className="toolbar-actions"> <div className="toolbar-actions">
<button onClick={() => syncNow(true)}>Full sync</button> <button onClick={() => syncNow(true)}>Full sync</button>
<button onClick={() => syncNow(false)}>Sync</button> <button onClick={() => syncNow(false)}>Sync</button>
<button onClick={() => setMode(mode === 'edit' ? 'preview' : 'edit')}>{mode === 'edit' ? 'Preview' : 'Edit'}</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> <button onClick={logout}>Logout</button>
</div> </div>
</header> </header>
@ -390,9 +323,8 @@ function App() {
<textarea <textarea
className="note-editor" className="note-editor"
value={selected.content} value={selected.content}
onChange={event => updateDraft({ content: event.target.value, title: deriveTitle(event.target.value) })} onChange={event => handleContentChange(event.target.value)}
spellCheck="true" spellCheck="true"
disabled={selected.readonly}
/> />
) : ( ) : (
<article className="markdown-preview"> <article className="markdown-preview">
@ -405,9 +337,7 @@ function App() {
} }
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<React.StrictMode> <ErrorBoundary>
<ErrorBoundary> <App />
<App /> </ErrorBoundary>
</ErrorBoundary>
</React.StrictMode>
) )