467 lines
21 KiB
JavaScript
467 lines
21 KiB
JavaScript
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, jede Änderung geht beim
|
|
// nächsten Lauf hoch, 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_BATCH_BYTES = 16 * 1024 * 1024;
|
|
// So lange über das Soup-Timeout hinaus warten wir auf eine Antwort, siehe request()
|
|
const REQUEST_GRACE_SECONDS = 10;
|
|
|
|
// Von außen anstoßbar: make sync-files ruft diese Methode über D-Bus auf
|
|
const DBUS_IFACE = `
|
|
<node>
|
|
<interface name="de.marha.Desktop">
|
|
<method name="SyncFiles"><arg type="s" direction="out" name="summary"/></method>
|
|
</interface>
|
|
</node>`;
|
|
|
|
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, 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 request(session, message, bytes => JSON.parse(new TextDecoder().decode(bytes.get_data())));
|
|
}
|
|
|
|
// 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 oder die Verbindung getaktet ist.
|
|
// Getaktet (Handy-Hotspot, in GNOME als getaktete Verbindung erkannt oder markiert): Sitzungen sind groß,
|
|
// sie warten auf echtes WLAN. Einträge laufen weiter. force: Aufruf von Hand (make sync-files) läuft trotzdem.
|
|
async _filesCycle(force = false) {
|
|
if (this._filesBusy || !this._filesRoot)
|
|
return null;
|
|
if (!force && this._metered())
|
|
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, 0);
|
|
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 beim nächsten Lauf hoch.
|
|
}
|
|
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 request(this._filesSession, message, bytes => ({
|
|
bytes: bytes.get_data() ?? new Uint8Array(0),
|
|
from: Number(message.get_response_headers().get_one('X-From')),
|
|
}));
|
|
}
|
|
|
|
// Meldet den Wechsel einmal im Journal, damit klar ist, warum nichts hochgeht
|
|
_metered() {
|
|
const metered = Gio.NetworkMonitor.get_default().network_metered;
|
|
if (metered !== this._wasMetered) {
|
|
this._wasMetered = metered;
|
|
console.log(`desktop-app sync: Verbindung ${metered ? 'getaktet, Dateiabgleich pausiert' : 'nicht getaktet, Dateiabgleich läuft'}`);
|
|
}
|
|
return metered;
|
|
}
|
|
|
|
_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;
|
|
}
|
|
|
|
// Anfrage schicken und die Antwort lesen. GJS verwirft den Soup-Callback gelegentlich, wenn er
|
|
// während der Garbage Collection fällig wird. Dann käme nie eine Antwort, der Lauf bliebe für immer
|
|
// "beschäftigt" und der Takt stünde still. Deshalb eine eigene Frist über dem Soup-Timeout:
|
|
// danach gilt die Anfrage als gescheitert, der nächste Lauf kommt wie geplant.
|
|
function request(session, message, read) {
|
|
return new Promise((resolve, reject) => {
|
|
let watchdog = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, session.timeout + REQUEST_GRACE_SECONDS, () => {
|
|
watchdog = null;
|
|
reject(new Error('keine Antwort, Anfrage verworfen'));
|
|
return GLib.SOURCE_REMOVE;
|
|
});
|
|
session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, null, (_session, result) => {
|
|
if (watchdog)
|
|
GLib.source_remove(watchdog);
|
|
watchdog = null;
|
|
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(read(bytes));
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// 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) {
|
|
if (!bytes.length)
|
|
return;
|
|
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);
|
|
}
|