import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import Soup from 'gi://Soup'; import {batches, concat, planPull, planPush, stampToSeconds, tailHash, uploadItem} from './files.js'; import {readJson, writeJson} from './store.js'; // Abgleich mit dem Server. Zugang steht in sync.json im Datenordner: // {"url": "https://planer.marha.de", "token": "...", "interval": 5} // Kurz nach jeder eigenen Änderung und in einem Takt, der sich nach der Aktivität richtet: // eigene Änderungen hochladen, Änderungen des Servers seit dem letzten Stand holen. // Bei Konflikten gewinnt der Eintrag mit dem neueren updated. // sync-state.json merkt sich den Server-Stand (cursor) und was zuletzt übertragen wurde (pushed). // Takt nach der letzten Aktivität (eigene Änderung oder Änderung vom Server): // erste Minute jede Sekunde, dann alle 5 Sekunden, nach 5 Minuten Ruhe alle 10 Sekunden. // "interval" in sync.json ersetzt den Ruhetakt. const INTERVALS = [ {after: 0, seconds: 1}, {after: 60, seconds: 5}, {after: 5 * 60, seconds: 10}, ]; // Kurze Wartezeit nach einer eigenen Änderung, damit mehrere Klicks in einem Lauf landen const SOON_MS = 500; // Dateien (Claude-Sitzungen und Memory): gleicher Takt wie die Einträge, hoch geht, was eine Minute ruht. // Meist nur der neue Rest einer Datei, siehe files.js. // "files" in sync.json: false schaltet ab, ein Pfad ersetzt ~/.claude/projects. const FILES_FIRST_SECONDS = 10; const FILES_IDLE_SECONDS = 60; const FILES_BATCH_BYTES = 16 * 1024 * 1024; // Von außen anstoßbar: make sync-files ruft diese Methode über D-Bus auf const DBUS_IFACE = ` `; export class Sync { constructor(store) { this._store = store; this._config = readJson(GLib.build_filenamev([store.dir, 'sync.json']), null); this._statePath = GLib.build_filenamev([store.dir, 'sync-state.json']); this._state = readJson(this._statePath, {cursor: 0, pushed: {events: {}, todos: {}, notes: {}, folders: {}}}); // Ältere Stände kennen keine Ordner. Einmal alles neu holen, sonst fehlen Ordner, // die der Server schon vor dem Update verteilt hat. Bekanntes wird dabei übersprungen. if (!this._state.pushed.folders) { this._state.pushed.folders = {}; this._state.cursor = 0; } this._state.files ??= {cursor: 0, pushed: {}}; const files = this._config?.files; this._filesRoot = files === false ? null : (files ?? GLib.build_filenamev([GLib.get_home_dir(), '.claude', 'projects'])); this._filesTimerId = null; this._filesBusy = false; this._session = new Soup.Session({timeout: 10}); // Dateien sind groß, der Server braucht für ein Paket länger als für Einträge this._filesSession = new Soup.Session({timeout: 120}); this._busy = false; this._failing = false; this._timerId = null; this._soonId = null; this._applying = false; // Beim Start gilt: lange nichts passiert this._lastActivity = 0; } get active() { return Boolean(this._config?.url && this._config?.token); } start() { if (!this.active) return; // Eigene Änderungen gehen sofort raus, nicht erst beim nächsten Takt this._store.onChange(() => this._scheduleSoon()); this._cycle().catch(e => this._fail(e.message)); this._scheduleNext(); if (this._filesRoot) this._scheduleFiles(FILES_FIRST_SECONDS); this._dbus = Gio.DBusExportedObject.wrapJSObject(DBUS_IFACE, this); this._dbus.export(Gio.DBus.session, '/de/marha/desktop'); } // Dateiabgleich sofort und erzwungen: auch Dateien, die noch keine Stunde ruhen. // Die Antwort kommt erst, wenn er durch ist. SyncFilesAsync(_params, invocation) { const reply = text => invocation.return_value(new GLib.Variant('(s)', [text])); if (!this._filesRoot) { reply('Dateiabgleich ist aus ("files": false in sync.json)'); return; } this._filesCycle(true) .then(r => reply(r ? `hoch ${r.uploaded}, gelöscht gemeldet ${r.removed}, runter ${r.written}, lokal gelöscht ${r.deleted}` : 'läuft gerade schon')) .catch(e => reply(`Fehler: ${e.message}`)); } // Aktueller Takt in Sekunden, je nachdem wie lange die letzte Aktivität her ist. get interval() { const idle = (GLib.get_monotonic_time() - this._lastActivity) / 1e6; const step = [...INTERVALS].reverse().find(s => idle >= s.after); const idleInterval = this._config.interval ?? INTERVALS.at(-1).seconds; return step === INTERVALS.at(-1) ? idleInterval : step.seconds; } _touchActivity() { this._lastActivity = GLib.get_monotonic_time(); } // Nächsten Lauf planen. Der Abstand wird jedes Mal neu bestimmt. _scheduleNext() { if (this._stopped) return; this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, this.interval, () => { this._timerId = null; this._cycle().catch(e => this._fail(e.message)).finally(() => this._scheduleNext()); return GLib.SOURCE_REMOVE; }); } _scheduleSoon() { if (this._stopped || this._applying) return; this._touchActivity(); if (this._soonId) return; this._soonId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, SOON_MS, () => { this._soonId = null; this._cycle().catch(e => this._fail(e.message)); return GLib.SOURCE_REMOVE; }); } stop() { this._dbus?.unexport(); this._dbus = null; if (this._timerId) { GLib.source_remove(this._timerId); this._timerId = null; } if (this._filesTimerId) { GLib.source_remove(this._filesTimerId); this._filesTimerId = null; } if (this._soonId) { GLib.source_remove(this._soonId); this._soonId = null; } // Laufende Anfrage abbrechen, ohne das als Verbindungsfehler zu melden this._stopped = true; this._session.abort(); this._filesSession.abort(); } async _cycle() { if (this._busy) return; this._busy = true; try { const payload = this._collect(); const response = await this._post(payload); // Kam etwas vom Server, ist das andere Gerät gerade aktiv: schneller nachfragen if (['events', 'todos', 'notes', 'folders'].some(c => (response[c] ?? []).length)) this._touchActivity(); // Übernommene Änderungen lösen selbst onChange aus, das soll keinen neuen Lauf starten this._applying = true; try { this._apply(response, payload); } finally { this._applying = false; } if (this._failing) { this._failing = false; console.log('desktop-app sync: Verbindung wieder da'); } } finally { this._busy = false; } } // Alles, was seit dem letzten Lauf lokal geändert wurde. _collect() { const pushed = this._state.pushed; const store = this._store; const changed = (collection, items) => items.filter(item => pushed[collection][item.id] !== item.updated); const notes = changed('notes', store.listNotes()).map(({id, updated}) => ({id, updated, content: store.readNote(store.absolute(id))})); for (const [id, updated] of Object.entries(store.deletedNotes)) { if (pushed.notes[id] !== updated) notes.push({id, updated, deleted: true}); } return { since: this._state.cursor, events: changed('events', store.events), todos: changed('todos', store.todos), notes, folders: changed('folders', store.listFolders()), }; } _post(payload, session = this._session) { const message = Soup.Message.new('POST', `${this._config.url}/api/sync`); message.request_headers.append('Authorization', `Bearer ${this._config.token}`); message.set_request_body_from_bytes('application/json', new GLib.Bytes(new TextEncoder().encode(JSON.stringify(payload)))); return new Promise((resolve, reject) => { session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, null, (session, result) => { try { const bytes = session.send_and_read_finish(result); if (message.get_status() !== Soup.Status.OK) throw new Error(`HTTP ${message.get_status()} ${message.get_reason_phrase()}`); resolve(JSON.parse(new TextDecoder().decode(bytes.get_data()))); } catch (e) { reject(e); } }); }); } // Antwort einarbeiten. Was der Server zurückschickt und wir gerade hochgeladen haben, kennt er jetzt. _apply(response, payload) { const store = this._store; const pushed = this._state.pushed; for (const collection of ['events', 'todos']) { for (const item of payload[collection]) pushed[collection][item.id] = item.updated; store.applyRemote(collection, response[collection]); for (const item of response[collection]) pushed[collection][item.id] = item.updated; } for (const note of payload.notes) pushed.notes[note.id] = note.updated; for (const folder of payload.folders) pushed.folders[folder.id] = folder.updated; // Ordner: neuester Eintrag gewinnt, Anlegen und Löschen wirken auf das Dateisystem. for (const folder of response.folders ?? []) { const mine = store.folders[folder.id]; if (!mine || mine.updated < folder.updated) store.applyRemoteFolder(folder); pushed.folders[folder.id] = store.folders[folder.id].updated; } // Notizen: ganze Datei, neuere Änderungszeit gewinnt. Eine offene Notiz bleibt unangetastet, // dann rückt der Stand nicht vor und der Server liefert sie beim nächsten Lauf erneut. const local = new Map(store.listNotes().map(n => [n.id, n.updated])); let skipped = false; for (const note of response.notes) { if (store.lockedNote === store.absolute(note.id)) { skipped = true; continue; } const mine = local.get(note.id) ?? store.deletedNotes[note.id]; if (mine && mine >= note.updated) { pushed.notes[note.id] = mine; continue; } if (note.deleted) store.deleteRemoteNote(note.id); else store.writeRemoteNote(note.id, note.content, note.updated); pushed.notes[note.id] = note.updated; } if (!skipped) this._state.cursor = response.cursor; writeJson(this._statePath, this._state); } // ── Dateien ────────────────────────────────────────────────────────────── // Nach jedem Lauf neu planen, im Takt der Einträge (1 s, 5 s, 10 s je nach Aktivität). _scheduleFiles(seconds) { if (this._stopped) return; this._filesTimerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, seconds, () => { this._filesTimerId = null; this._filesCycle().catch(e => this._fail(e.message)).finally(() => this._scheduleFiles(this.interval)); return GLib.SOURCE_REMOVE; }); } _path(id) { return GLib.build_filenamev([this._filesRoot, id]); } // Eigener Lauf mit eigenem Stand: die Antwort enthält nur Dateien, der Takt der Einträge bleibt unberührt. // Gibt zurück, was übertragen wurde, oder null, wenn gerade ein Lauf aktiv ist. // force: Ruhezeit ignorieren, alles Geänderte geht hoch. async _filesCycle(force = false) { if (this._filesBusy || !this._filesRoot) return null; this._filesBusy = true; const result = {uploaded: 0, removed: 0, written: 0, deleted: 0}; try { const state = this._state.files; const local = scanFiles(this._filesRoot); const now = Math.floor(GLib.get_real_time() / 1e6); const {upload, remove} = planPush(local, state.pushed, now, force ? 0 : FILES_IDLE_SECONDS); const items = []; for (const {id, updated} of upload) { const bytes = readBytes(this._path(id)); const item = bytes && uploadItem(id, updated, bytes, state.pushed[id]); if (item) items.push(Object.assign(item, {_size: bytes.length, _hash: tailHash(bytes)})); } const nowStamp = new Date(now * 1000).toISOString(); for (const id of remove) items.push({id, updated: nowStamp, deleted: true}); // Mindestens ein Lauf, auch ohne eigene Änderungen: holt Neues vom Server const packets = items.length ? batches(items, item => (item.content ?? item.append ?? '').length, FILES_BATCH_BYTES) : [[]]; for (const packet of packets) { const files = packet.map(({_size, _hash, ...item}) => item); const response = await this._post({since: state.cursor, files}, this._filesSession); // Ein Server ohne Dateien-Sammlung würde alles verwerfen, der Stand bliebe sonst falsch if (!Array.isArray(response.files)) throw new Error('Server kennt keine Dateien, dort erst make prod ausführen'); for (const item of packet) { // Kommt der Eintrag mit anderem updated zurück, hat der Server abgelehnt. Sein Stand gilt dann als bekannt, // beim nächsten Lauf passt der Anhang oder es geht die ganze Datei. const echo = response.files.find(f => f.id === item.id && f.updated !== item.updated); if (echo) { state.pushed[item.id] = echo.deleted ? null : {updated: echo.updated, size: echo.size, hash: echo.hash}; continue; } state.pushed[item.id] = item.deleted ? null : {updated: item.updated, size: item._size, hash: item._hash}; result[item.deleted ? 'removed' : 'uploaded']++; } await this._applyFiles(response, local, result); writeJson(this._statePath, this._state); } if (result.uploaded + result.removed + result.written + result.deleted) this._touchActivity(); } finally { this._filesBusy = false; } return result; } async _applyFiles(response, local, result) { const state = this._state.files; for (const {file, action, mine} of planPull(response.files, local)) { const path = this._path(file.id); if (action === 'write') { // Nur den Rest holen, wenn der eigene Anfang noch zum Server passt const have = readBytes(path) ?? new Uint8Array(0); const {bytes, from} = await this._download(file.id, have.length, tailHash(have)); const appended = from > 0 && from === have.length; const content = appended ? concat(have, bytes) : bytes; GLib.mkdir_with_parents(GLib.path_get_dirname(path), 0o700); if (appended) appendBytes(path, bytes); else GLib.file_set_contents(path, content); const seconds = stampToSeconds(file.updated); Gio.File.new_for_path(path).set_attribute_uint64('time::modified', seconds, Gio.FileQueryInfoFlags.NONE, null); local.set(file.id, seconds); state.pushed[file.id] = {updated: file.updated, size: content.length, hash: tailHash(content)}; result.written++; } else if (action === 'delete') { GLib.unlink(path); local.delete(file.id); state.pushed[file.id] = null; result.deleted++; } else if (file.deleted && mine === null) { state.pushed[file.id] = null; } else if (mine === file.updated && typeof state.pushed[file.id] !== 'object') { // Gleicher Stand wie der Server, aber noch ohne Größe und Hash gemerkt const bytes = readBytes(path); if (bytes) state.pushed[file.id] = {updated: mine, size: bytes.length, hash: tailHash(bytes)}; } // Sonst ist die lokale Datei neuer und geht hoch, sobald sie eine Minute ruht. } state.cursor = response.cursor; } // Inhalt vom Server ab dem eigenen Stand. X-From sagt, ab wo er wirklich liefert (0 = ganze Datei). _download(id, from, hash) { const path = id.split('/').map(encodeURIComponent).join('/'); const message = Soup.Message.new('GET', `${this._config.url}/api/files/${path}?from=${from}&hash=${hash}`); message.request_headers.append('Authorization', `Bearer ${this._config.token}`); return new Promise((resolve, reject) => { this._filesSession.send_and_read_async(message, GLib.PRIORITY_DEFAULT, null, (session, result) => { try { const bytes = session.send_and_read_finish(result); if (message.get_status() !== Soup.Status.OK) throw new Error(`HTTP ${message.get_status()} ${message.get_reason_phrase()}`); resolve({bytes: bytes.get_data() ?? new Uint8Array(0), from: Number(message.get_response_headers().get_one('X-From'))}); } catch (e) { reject(e); } }); }); } _fail(message) { if (this._stopped) return; if (!this._failing) console.log(`desktop-app sync: ${message}`); this._failing = true; } } // Alle Dateien unter root: id (Pfad relativ zu root) → Änderungszeit in Sekunden function scanFiles(root) { const local = new Map(); const walk = (dir, prefix) => { let enumerator; try { enumerator = dir.enumerate_children('standard::name,standard::type,time::modified', Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); } catch { return; } let info; while ((info = enumerator.next_file(null))) { const name = info.get_name(); const id = prefix ? `${prefix}/${name}` : name; if (info.get_file_type() === Gio.FileType.DIRECTORY) walk(dir.get_child(name), id); else if (info.get_file_type() === Gio.FileType.REGULAR) local.set(id, Number(info.get_attribute_uint64('time::modified'))); } enumerator.close(null); }; walk(Gio.File.new_for_path(root), ''); return local; } // Datei als Bytes lesen. null, wenn sie fehlt. function readBytes(path) { try { const [, bytes] = GLib.file_get_contents(path); return bytes; } catch { return null; } } function appendBytes(path, bytes) { const stream = Gio.File.new_for_path(path).append_to(Gio.FileCreateFlags.NONE, null); stream.write_bytes(new GLib.Bytes(bytes), null); stream.close(null); }