diff --git a/Makefile b/Makefile index 6d298fb..52d1691 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -# Extension: make install | make update +# Extension: make install | make update | make sync-files (Claude-Sitzungen sofort abgleichen) # Server: make dev (lokal, Port $(PORT)) | make prod (auf dem Server) | make stop | make logs | make down | make test # Android: make apk (bauen) | make apk-install (aufs Handy per USB) UUID = desktop@marha.de @@ -10,7 +10,7 @@ SYNC_URL = https://planer.marha.de SYNC_JSON = $(HOME)/.local/share/desktop-app/sync.json .ONESHELL: -.PHONY: install update enable sync-config reload venv dev prod stop logs down test apk apk-install +.PHONY: install update enable sync-config reload sync-files venv dev prod stop logs down test apk apk-install install: @if [ -e "$(DEST)" ]; then @@ -62,6 +62,11 @@ reload: echo "Extension neu gestartet." fi +# Dateiabgleich (Claude-Sitzungen und Memory) sofort anstoßen, wartet bis er durch ist +sync-files: + @gdbus call --session --dest org.gnome.Shell --object-path /de/marha/desktop --method de.marha.Desktop.SyncFiles --timeout 3600 \ + | sed -e "s/^('//" -e "s/',)$$//" + # ── Server ─────────────────────────────────────────────────────────────────── venv: $(VENV)/bin/uvicorn diff --git a/README.md b/README.md index 5676bdc..d1ab38b 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ einer Stunde nicht angefasst wurde. Vom Server kommt sie, wenn sie dort neuer is Änderungszeit. Gelöschte Dateien verschwinden auch auf dem anderen PC. Die App fragt diese Dateien nie an. Bedingung: gleicher Pfad auf beiden PCs, der Ordnername enthält ihn. `"files": false` in `sync.json` schaltet es ab, ein Pfad statt `false` ersetzt den Ordner. +`make sync-files` stößt den Abgleich sofort an und wartet, bis er durch ist. Zugang auf jedem Gerät in `~/.local/share/desktop-app/sync.json`: diff --git a/desktop@marha.de/sync.js b/desktop@marha.de/sync.js index 34f9dba..a704529 100644 --- a/desktop@marha.de/sync.js +++ b/desktop@marha.de/sync.js @@ -30,6 +30,14 @@ const FILES_INTERVAL_SECONDS = 10 * 60; const FILES_IDLE_SECONDS = 60 * 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; @@ -70,6 +78,20 @@ export class Sync { 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() + .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. @@ -109,6 +131,8 @@ export class Sync { } stop() { + this._dbus?.unexport(); + this._dbus = null; if (this._timerId) { GLib.source_remove(this._timerId); this._timerId = null; @@ -250,10 +274,12 @@ export class Sync { } // 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. async _filesCycle() { if (this._filesBusy || !this._filesRoot) - return; + 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); @@ -275,17 +301,20 @@ export class Sync { // 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 files) + for (const item of files) { state.pushed[item.id] = item.deleted ? null : item.updated; - this._applyFiles(response, local); + result[item.deleted ? 'removed' : 'uploaded']++; + } + this._applyFiles(response, local, result); writeJson(this._statePath, this._state); } } finally { this._filesBusy = false; } + return result; } - _applyFiles(response, local) { + _applyFiles(response, local, result) { const state = this._state.files; for (const {file, action, mine} of planPull(response.files ?? [], local)) { const path = GLib.build_filenamev([this._filesRoot, file.id]); @@ -296,10 +325,12 @@ export class Sync { 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] = file.updated; + 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) { diff --git a/dev/tests/sync.sh b/dev/tests/sync.sh index 65f093a..c68a439 100755 --- a/dev/tests/sync.sh +++ b/dev/tests/sync.sh @@ -156,28 +156,29 @@ check "Lokal gelöschter Ordner auf dem Server als gelöscht" "$(folders | pytho echo "=== Dateien: Claude-Sitzungen zwischen PCs" FILES=/tmp/desktop-app-nested/claude-projects files() { api -X POST "$URL/api/sync" -d '{"since": 0, "files": []}' | python3 -c 'import json,sys; print(sorted((f["id"], bool(f.get("deleted"))) for f in json.load(sys.stdin)["files"]))'; } -filescycle() { "$E" "$PRE ext._sync._filesCycle(); 'ok'" >/dev/null; sleep 3; } +# Wie make sync-files, nur auf dem Bus der Test-Shell. Wartet, bis der Lauf durch ist. +filescycle() { DBUS_SESSION_BUS_ADDRESS=$(cat /tmp/desktop-app-nested/bus) gdbus call --session --dest org.gnome.Shell --object-path /de/marha/desktop --method de.marha.Desktop.SyncFiles | sed -e "s/^('//" -e "s/',)$//"; } mkdir -p "$FILES/-home-x/memory" echo '{"a":1}' > "$FILES/-home-x/alt.jsonl"; touch -d '2 hours ago' "$FILES/-home-x/alt.jsonl" echo '# Memo' > "$FILES/-home-x/memory/MEMORY.md"; touch -d '90 minutes ago' "$FILES/-home-x/memory/MEMORY.md" echo 'läuft noch' > "$FILES/-home-x/frisch.jsonl" -filescycle +check "Sofort-Abgleich meldet, was übertragen wurde" "$(filescycle)" "hoch 2, gelöscht gemeldet 0, runter 0, lokal gelöscht 0" check "Ruhende Dateien auf dem Server, frische nicht" "$(files)" "[('-home-x/alt.jsonl', False), ('-home-x/memory/MEMORY.md', False)]" check "Normale Anfrage bekommt keine Dateien" "$(api -X POST "$URL/api/sync" -d '{"since": 0}' | python3 -c 'import json,sys; d=json.load(sys.stdin); print("files" in d, "events" in d)')" "False True" check "Stand der Dateien getrennt gemerkt" "$(val 'Object.keys(state().files.pushed).sort().join(",") + " " + (state().files.cursor > 0)')" "-home-x/alt.jsonl,-home-x/memory/MEMORY.md true" push '{"files": [{"id": "-home-y/s2.jsonl", "updated": "2026-09-01T00:00:00.000Z", "content": "vom anderen PC"}]}' -filescycle +check "Abgleich holt die Datei" "$(filescycle)" "hoch 0, gelöscht gemeldet 0, runter 1, lokal gelöscht 0" check "Datei vom Server geschrieben, Änderungszeit übernommen" "$(cat "$FILES/-home-y/s2.jsonl") $(stat -c %Y "$FILES/-home-y/s2.jsonl")" "vom anderen PC $(date -u -d 2026-09-01T00:00:00Z +%s)" echo '{"a":2}' > "$FILES/-home-x/alt.jsonl"; touch -d '70 minutes ago' "$FILES/-home-x/alt.jsonl" push '{"files": [{"id": "-home-x/alt.jsonl", "updated": "'$(date -u -d '3 hours ago' +%Y-%m-%dT%H:%M:%S.000Z)'", "content": "veraltet"}]}' -filescycle +filescycle >/dev/null check "Lokal neuer gewinnt und geht hoch" "$(api -X POST "$URL/api/sync" -d '{"since": 0, "files": []}' | python3 -c 'import json,sys; print([f["content"].strip() for f in json.load(sys.stdin)["files"] if f["id"] == "-home-x/alt.jsonl"])')" "['{\"a\":2}']" rm "$FILES/-home-x/memory/MEMORY.md" push '{"files": [{"id": "-home-y/s2.jsonl", "updated": "'$(now)'", "deleted": true}]}' -filescycle +check "Abgleich meldet Löschungen" "$(filescycle)" "hoch 0, gelöscht gemeldet 1, runter 0, lokal gelöscht 1" check "Gelöscht in beide Richtungen" "$(files | python3 -c 'import sys; print("MEMORY.md\x27, True" in sys.stdin.read())') $([ -e "$FILES/-home-y/s2.jsonl" ] && echo da || echo weg)" "True weg" check "Gemeldete Löschung nicht nochmal" "$(val 'state().files.pushed["-home-x/memory/MEMORY.md"] === null && state().files.pushed["-home-y/s2.jsonl"] === null')" "true" -filescycle +check "Zweiter Lauf überträgt nichts" "$(filescycle)" "hoch 0, gelöscht gemeldet 0, runter 0, lokal gelöscht 0" check "Zweiter Lauf schickt nichts erneut" "$(files)" "[('-home-x/alt.jsonl', False), ('-home-x/memory/MEMORY.md', True), ('-home-y/s2.jsonl', True)]" echo "=== Server weg und wieder da"