const { app, BrowserWindow, ipcMain, safeStorage, shell } = 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) } function normalizeBaseUrl(baseUrl) { const trimmed = String(baseUrl || '').trim().replace(/\/+$/, '') if (!trimmed) throw new Error('Nextcloud URL is required') return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}` } function notesApiPath(apiPath) { return `/index.php/apps/notes/api/v1${apiPath}` } function authHeader(username, password) { return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` } function readCredentials() { const filePath = userDataPath(credentialFileName) if (!fs.existsSync(filePath)) return null if (!safeStorage.isEncryptionAvailable()) return null try { const encrypted = fs.readFileSync(filePath) return JSON.parse(safeStorage.decryptString(encrypted)) } catch { clearCredentials() return null } } 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('Secure credential storage is not available on this system') } const encrypted = safeStorage.encryptString(JSON.stringify(credentials)) fs.writeFileSync(userDataPath(credentialFileName), encrypted) } function clearCredentials() { const filePath = userDataPath(credentialFileName) if (fs.existsSync(filePath)) fs.unlinkSync(filePath) } async function request(credentials, method, apiPath, { query, body, etag } = {}) { const baseUrl = normalizeBaseUrl(credentials.baseUrl) const url = new URL(baseUrl + notesApiPath(apiPath)) Object.entries(query || {}).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, value) }) const headers = { Authorization: authHeader(credentials.username, credentials.password), Accept: 'application/json', 'OCS-APIRequest': 'true', } 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 } if (!response.ok) { const text = await response.text().catch(() => '') const error = new Error(text || `${response.status} ${response.statusText}`) error.status = response.status throw error } const text = await response.text() return { data: text ? JSON.parse(text) : null, headers: Object.fromEntries(response.headers.entries()), } } 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 = [] const serverIds = new Set() const seenChangedIds = new Set() do { const query = { chunkSize: 100 } if (cursor) query.chunkCursor = cursor if (pruneBefore) query.pruneBefore = pruneBefore const { data, headers } = await request(credentials, 'GET', '/notes', { query }) for (const note of Array.isArray(data) ? data : []) { if (!note || note.id == null) continue serverIds.add(note.id) if (note.content === undefined || seenChangedIds.has(note.id)) continue seenChangedIds.add(note.id) changed.push(note) } cursor = headers['x-notes-chunk-cursor'] || '' } while (cursor) return { changed, serverIds: Array.from(serverIds), syncedAt: Math.floor(Date.now() / 1000), } } function createWindow() { const window = new BrowserWindow({ width: 1180, height: 780, minWidth: 900, minHeight: 600, title: 'Nextcloud Notes', backgroundColor: '#0f172a', webPreferences: { 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 => { if (!isDev) event.preventDefault() }) if (isDev) { window.loadURL('http://127.0.0.1:5173') } else { window.loadFile(path.join(__dirname, '..', 'dist', 'index.html')) } } app.whenReady().then(() => { createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) 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 => { const normalized = { baseUrl: normalizeBaseUrl(credentials.baseUrl), username: String(credentials.username || '').trim(), password: String(credentials.password || ''), } if (!normalized.username || !normalized.password) throw new Error('Username and app password are required') await request(normalized, 'GET', '/settings') writeCredentials(normalized) return publicAccount(normalized) }) registerHandler('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) }) 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 }) 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}`) return data || { deleted: true } })