263 lines
8 KiB
JavaScript
263 lines
8 KiB
JavaScript
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('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}`)
|
|
}
|
|
}
|
|
|
|
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 appIndex = path.join(__dirname, '..', 'dist', 'index.html')
|
|
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, 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)
|
|
})
|
|
|
|
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(appIndex)
|
|
}
|
|
}
|
|
|
|
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 }
|
|
})
|