nextcloud-notes-desktop/electron/main.cjs
Daniel 6bc84078f0
Some checks failed
Release / Build Linux (push) Has been cancelled
Release / Build macOS (push) Has been cancelled
Restore stable desktop runtime
2026-05-15 18:29:06 +02:00

185 lines
5.9 KiB
JavaScript

const { app, BrowserWindow, ipcMain, safeStorage } = require('electron')
const fs = require('fs')
const path = require('path')
const isDev = !app.isPackaged
const credentialFileName = 'credentials.enc'
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 writeCredentials(credentials) {
if (!safeStorage.isEncryptionAvailable()) {
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() {
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}"`
const response = await fetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
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()),
}
}
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,
},
})
window.webContents.on('render-process-gone', (_event, details) => {
console.error('Renderer process gone:', details.reason, details.exitCode)
})
window.webContents.on('console-message', (_event, level, message, line, sourceId) => {
console.log(`[renderer:${level}] ${message} (${sourceId}:${line})`)
})
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('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
ipcMain.handle('credentials:get', () => readCredentials())
ipcMain.handle('credentials:save', async (_event, 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 { baseUrl: normalized.baseUrl, username: normalized.username }
})
ipcMain.handle('credentials:clear', () => {
clearCredentials()
return true
})
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
})
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
})
ipcMain.handle('notes:delete', async (_event, { credentials, id }) => {
const { data } = await request(credentials, 'DELETE', `/notes/${id}`)
return data || { deleted: true }
})