This commit is contained in:
team3
2026-09-22 13:28:52 +02:00
parent 2472e89b9c
commit acfe29dae7
4 changed files with 50 additions and 12 deletions

View File

@@ -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

View File

@@ -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`:

View File

@@ -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 = `
<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;
@@ -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) {

View File

@@ -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"