From 4548178a7c1c34fef5bef2c83f6d8fce1b546c00 Mon Sep 17 00:00:00 2001 From: team3 Date: Sat, 19 Sep 2026 20:59:47 +0200 Subject: [PATCH] update --- .env.example | 2 + .gitignore | 6 ++ Dockerfile | 15 ++++ Makefile | 42 +++++++++- README.md | 40 ++++++++- backend/app/__init__.py | 0 backend/app/config.py | 12 +++ backend/app/db.py | 82 +++++++++++++++++++ backend/app/main.py | 91 +++++++++++++++++++++ backend/pytest.ini | 2 + backend/requirements.txt | 4 + backend/tests/test_sync.py | 85 ++++++++++++++++++++ desktop@marha.de/extension.js | 6 ++ desktop@marha.de/notes.js | 12 ++- desktop@marha.de/store.js | 139 ++++++++++++++++++++++++++++---- desktop@marha.de/sync.js | 147 ++++++++++++++++++++++++++++++++++ dev/restart.sh | 5 ++ dev/tests/input.sh | 4 +- dev/tests/sync.sh | 130 ++++++++++++++++++++++++++++++ docker-compose.yml | 31 +++++++ 20 files changed, 832 insertions(+), 23 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/db.py create mode 100644 backend/app/main.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements.txt create mode 100644 backend/tests/test_sync.py create mode 100644 desktop@marha.de/sync.js create mode 100755 dev/tests/sync.sh create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f0b38c7 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Gemeinsamer Token für alle Geräte. Steht auf jedem Gerät in ~/.local/share/desktop-app/sync.json +SYNC_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b517ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +backend/.venv/ +backend/data/ +data/ +.env +__pycache__/ +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5460211 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim +WORKDIR /app + +COPY backend/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/app ./app + +# Datenbank liegt im Volume, nicht im Image +ENV DATA_DIR=/data +RUN mkdir -p /data +VOLUME /data + +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Makefile b/Makefile index 8793691..2ccc0fe 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,13 @@ -# make install – Extension installieren, falls noch nicht vorhanden, und aktivieren -# make update – installierte Extension mit dem Stand aus diesem Ordner überschreiben +# Extension: make install | make update +# Server: make dev (lokal, Port $(PORT)) | make prod (auf dem Server) | make stop | make logs | make down | make test UUID = desktop@marha.de SRC = $(CURDIR)/$(UUID) DEST = $(HOME)/.local/share/gnome-shell/extensions/$(UUID) +VENV = backend/.venv +PORT ?= 8040 .ONESHELL: -.PHONY: install update enable +.PHONY: install update enable venv dev prod stop logs down test install: @if [ -e "$(DEST)" ]; then @@ -35,3 +37,37 @@ update: # UUID in die aktiven Extensions eintragen, falls sie fehlt enable: @python3 dev/enable.py "$(UUID)" + +# ── Server ─────────────────────────────────────────────────────────────────── +venv: $(VENV)/bin/uvicorn + +$(VENV)/bin/uvicorn: backend/requirements.txt + python3 -m venv $(VENV) + $(VENV)/bin/pip install -q -r backend/requirements.txt + +# Lokal entwickeln. Token kommt aus .env (Vorlage: .env.example). +dev: venv + @[ -f .env ] || { echo ".env fehlt. Vorlage: cp .env.example .env, dann SYNC_TOKEN setzen."; exit 1; } + set -a; . ./.env; set +a + echo "Server: http://localhost:$(PORT)" + cd backend && .venv/bin/uvicorn app.main:app --reload --port $(PORT) + +# Produktion (auf dem Server ausführen, im Projektverzeichnis) +prod: + @[ -f .env ] || { echo ".env fehlt. Vorlage: cp .env.example .env, dann SYNC_TOKEN setzen."; exit 1; } + docker compose up -d --build + echo "läuft: https://planer.marha.de" + +# [u] statt u: so trifft das Muster nicht die eigene Shell, die es im Aufruf trägt +stop: + -pkill -f "$(CURDIR)/$(VENV)/bin/python3 .venv/bin/[u]vicorn" 2>/dev/null + echo "gestoppt." + +logs: + docker compose logs -f + +down: + docker compose down + +test: venv + cd backend && .venv/bin/python -m pytest -q diff --git a/README.md b/README.md index 5b9595d..fe2b0b6 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ unter allen Fenstern, über dem Wallpaper. - `notes.js` – Ordnerbaum, Markdown-Anzeige, Inline-Editor, Kontextmenü - `widgets.js` – Popover (Grab, Esc, Tab, Klick daneben), Kontextmenü, Datums- und Zeitauswahl - `markdown.js`, `dates.js` – reine Logik, ohne Shell testbar - - `store.js` – Ablage + - `store.js` – Ablage, Tombstones, Übernahme vom Server + - `sync.js` – Abgleich mit dem Server (Soup, Timer) +- `backend/` – Sync-Server (FastAPI, SQLite), `Dockerfile`, `docker-compose.yml` - `dev/` – Test-Werkzeuge ## Daten @@ -38,6 +40,41 @@ unter allen Fenstern, über dem Wallpaper. - `events.json` – Termine - `todos.json` – Todos - `notes/` – Notizen als `.md`, Ordner beliebig verschachtelt (Obsidian kann den Ordner öffnen) +- `deleted-notes.json` – gelöschte Notizen mit Zeitpunkt, damit der Sync sie auch woanders löscht +- `sync.json` – Server-Zugang, siehe Sync +- `sync-state.json` – letzter Server-Stand + +Gelöschte Termine und Todos bleiben mit `deleted: true` in der Datei und werden nur ausgeblendet. + +## Sync + +Der Server (`backend/`, FastAPI + SQLite) hält den Kern. Jedes Gerät gleicht alle paar Sekunden ab: +eigene Änderungen hoch, fremde Änderungen runter. Bei Konflikten gewinnt der Eintrag mit dem +neueren `updated` (Notizen: Änderungszeit der Datei, ganze Datei). Offline geht weiter, beim +Wiederverbinden wird nachgeliefert. Eine gerade offene Notiz wird nicht von außen überschrieben. + +Zugang auf jedem Gerät in `~/.local/share/desktop-app/sync.json`: + +``` +{"url": "https://planer.marha.de", "token": "", "interval": 5} +``` + +Ohne diese Datei läuft die Extension rein lokal. + +Server-API (alle Aufrufe mit `Authorization: Bearer `): + +- `POST /api/sync` – `{"since": , "events": [...], "todos": [...], "notes": [...]}`; Antwort: Änderungen seit `since` plus neuer `cursor` +- `GET /api/events`, `/api/todos`, `/api/notes` – aktueller Stand ohne Gelöschte, für andere Anwendungen + +### Server starten + +``` +cp .env.example .env # SYNC_TOKEN setzen +make dev # lokal, http://localhost:8040 (anderer Port: make dev PORT=8050) +make prod # auf dem Server: Docker Compose hinter Traefik, https://planer.marha.de +make stop | make logs | make down +make test # Server-Tests +``` ## Installation @@ -56,6 +93,7 @@ gjs -m dev/tests/run.js # Unit-Tests für dates.js und markdown.js dev/restart.sh # Test-Shell als Fenster starten (zum Selbst-Ausprobieren) dev/restart.sh headless # Test-Shell unsichtbar, für simulierte Eingaben dev/tests/input.sh # Ablauf-Tests mit echter Maus und Tastatur (headless) +dev/tests/sync.sh # Sync-Test: lokaler Server plus headless Shell, beide Richtungen dev/eval.sh dev/seed.js # Testdaten einspielen dev/eval.sh 'js' # JavaScript in der Test-Shell ausführen (oder Dateipfad) dev/shot.sh [datei.png] # Screenshot der Test-Shell diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..c771e52 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,12 @@ +import os +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + +DATA_DIR = Path(os.getenv("DATA_DIR", ROOT / "data")) +DB_PATH = Path(os.getenv("DB_PATH", DATA_DIR / "desktop.db")) + +# Jeder Client schickt diesen Token als "Authorization: Bearer " +SYNC_TOKEN = os.getenv("SYNC_TOKEN", "") + +COLLECTIONS = ("events", "todos", "notes") diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..ee2cc00 --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,82 @@ +import json +import sqlite3 +from contextlib import contextmanager + +from .config import DB_PATH + +# Ein Eintrag pro Termin, Todo oder Notiz. Gelöschte bleiben als Tombstone erhalten, +# sonst würde ein anderes Gerät sie wieder hochladen. +# updated: Zeitstempel des Geräts, entscheidet bei Konflikten (neuester gewinnt). +# seq: Server-Zähler, damit Clients "alles seit meinem letzten Stand" abholen können. +SCHEMA = """ +CREATE TABLE IF NOT EXISTS items ( + collection TEXT NOT NULL, + id TEXT NOT NULL, + updated TEXT NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0, + data TEXT NOT NULL, + seq INTEGER NOT NULL, + PRIMARY KEY (collection, id) +); +CREATE INDEX IF NOT EXISTS items_seq ON items(seq); +""" + + +@contextmanager +def connect(): + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + try: + yield conn + conn.commit() + finally: + conn.close() + + +def init(): + with connect() as conn: + conn.executescript(SCHEMA) + + +def cursor(conn): + return conn.execute("SELECT COALESCE(MAX(seq), 0) FROM items").fetchone()[0] + + +def merge(conn, collection, item): + """Übernimmt den Eintrag, wenn er neuer ist als der gespeicherte. + + Gibt (True, None) zurück, wenn er übernommen wurde, sonst (False, gespeicherter Eintrag), + damit der Client den gültigen Stand bekommt. + """ + row = conn.execute( + "SELECT updated, data FROM items WHERE collection = ? AND id = ?", (collection, item["id"]) + ).fetchone() + if row and row["updated"] >= item["updated"]: + return False, json.loads(row["data"]) + conn.execute( + "INSERT OR REPLACE INTO items (collection, id, updated, deleted, data, seq) VALUES (?, ?, ?, ?, ?, ?)", + ( + collection, + item["id"], + item["updated"], + 1 if item.get("deleted") else 0, + json.dumps(item, ensure_ascii=False), + cursor(conn) + 1, + ), + ) + return True, None + + +def since(conn, collection, seq): + rows = conn.execute( + "SELECT data FROM items WHERE collection = ? AND seq > ? ORDER BY seq", (collection, seq) + ) + return [json.loads(r["data"]) for r in rows] + + +def alive(conn, collection): + rows = conn.execute( + "SELECT data FROM items WHERE collection = ? AND deleted = 0 ORDER BY id", (collection,) + ) + return [json.loads(r["data"]) for r in rows] diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..68643ca --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,91 @@ +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Header, HTTPException +from pydantic import BaseModel + +from . import db +from .config import COLLECTIONS, SYNC_TOKEN + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger("app") + + +@asynccontextmanager +async def lifespan(_app): + if not SYNC_TOKEN: + log.warning("SYNC_TOKEN ist leer, jede Anfrage wird abgelehnt.") + db.init() + yield + + +app = FastAPI(title="Desktop Sync", lifespan=lifespan) + + +def check_token(authorization: str): + if not SYNC_TOKEN or authorization != f"Bearer {SYNC_TOKEN}": + raise HTTPException(status_code=401, detail="Token fehlt oder ist falsch") + + +class SyncIn(BaseModel): + since: int = 0 + events: list[dict] = [] + todos: list[dict] = [] + notes: list[dict] = [] + + +@app.get("/api/health") +def health(): + return {"ok": True} + + +@app.post("/api/sync") +def sync(body: SyncIn, authorization: str = Header(default="")): + """Nimmt Änderungen des Clients an und gibt alles zurück, was sich seit seinem Stand geändert hat. + + Konflikte: der Eintrag mit dem neueren updated gewinnt. Verliert der Client, bekommt er den + Server-Stand zurück. Die Antwort enthält auch die gerade angenommenen Einträge; der Client + erkennt sie am gleichen updated und überspringt sie. + """ + check_token(authorization) + with db.connect() as conn: + accepted = 0 + rejected = {collection: [] for collection in COLLECTIONS} + for collection in COLLECTIONS: + for item in getattr(body, collection): + ok, stored = db.merge(conn, collection, item) + if ok: + accepted += 1 + else: + rejected[collection].append(stored) + out = {} + for collection in COLLECTIONS: + changed = db.since(conn, collection, body.since) + ids = {item["id"] for item in changed} + out[collection] = changed + [item for item in rejected[collection] if item["id"] not in ids] + out["cursor"] = db.cursor(conn) + if accepted: + log.info("sync: %d Einträge übernommen", accepted) + return out + + +# Lesezugriff für andere Anwendungen +@app.get("/api/events") +def events(authorization: str = Header(default="")): + check_token(authorization) + with db.connect() as conn: + return db.alive(conn, "events") + + +@app.get("/api/todos") +def todos(authorization: str = Header(default="")): + check_token(authorization) + with db.connect() as conn: + return db.alive(conn, "todos") + + +@app.get("/api/notes") +def notes(authorization: str = Header(default="")): + check_token(authorization) + with db.connect() as conn: + return db.alive(conn, "notes") diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..5ee6477 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +testpaths = tests diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..ca8d29e --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +httpx==0.28.1 +pytest==8.3.4 diff --git a/backend/tests/test_sync.py b/backend/tests/test_sync.py new file mode 100644 index 0000000..d6cbe7b --- /dev/null +++ b/backend/tests/test_sync.py @@ -0,0 +1,85 @@ +import importlib +import os + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(tmp_path): + os.environ["DB_PATH"] = str(tmp_path / "test.db") + os.environ["SYNC_TOKEN"] = "geheim" + from app import config, db, main + importlib.reload(config) + importlib.reload(db) + importlib.reload(main) + with TestClient(main.app) as c: + yield c + + +AUTH = {"Authorization": "Bearer geheim"} + + +def sync(client, since=0, **collections): + body = {"since": since, **collections} + r = client.post("/api/sync", json=body, headers=AUTH) + assert r.status_code == 200, r.text + return r.json() + + +def test_health_ohne_token(client): + assert client.get("/api/health").json() == {"ok": True} + + +def test_falscher_token(client): + assert client.post("/api/sync", json={}).status_code == 401 + assert client.post("/api/sync", json={}, headers={"Authorization": "Bearer falsch"}).status_code == 401 + assert client.get("/api/events").status_code == 401 + + +def test_push_und_pull(client): + a = sync(client, events=[{"id": "e1", "updated": "2026-09-19T10:00:00.000Z", "title": "Zahnarzt"}]) + assert a["cursor"] == 1 + assert [e["id"] for e in a["events"]] == ["e1"] + + # Anderes Gerät ohne Stand bekommt alles + b = sync(client) + assert [e["title"] for e in b["events"]] == ["Zahnarzt"] + assert b["cursor"] == 1 + + # Mit aktuellem Stand kommt nichts Neues + c = sync(client, since=1) + assert c["events"] == [] and c["cursor"] == 1 + + +def test_neuester_gewinnt(client): + sync(client, todos=[{"id": "t1", "updated": "2026-09-19T10:00:00.000Z", "title": "alt"}]) + sync(client, todos=[{"id": "t1", "updated": "2026-09-19T09:00:00.000Z", "title": "älter"}]) + assert client.get("/api/todos", headers=AUTH).json()[0]["title"] == "alt" + r = sync(client, since=0, todos=[{"id": "t1", "updated": "2026-09-19T11:00:00.000Z", "title": "neu"}]) + assert r["todos"][0]["title"] == "neu" + assert r["cursor"] == 2 + + +def test_abgelehnte_aenderung_bekommt_serverstand(client): + sync(client, todos=[{"id": "t1", "updated": "2026-09-19T10:00:00.000Z", "title": "server"}]) + r = sync(client, since=1, todos=[{"id": "t1", "updated": "2026-09-19T09:00:00.000Z", "title": "client"}]) + assert [t["title"] for t in r["todos"]] == ["server"] + assert r["cursor"] == 1 + + +def test_tombstone(client): + sync(client, notes=[{"id": "Plan.md", "updated": "2026-09-19T10:00:00.000Z", "content": "x"}]) + r = sync(client, since=1, notes=[{"id": "Plan.md", "updated": "2026-09-19T10:05:00.000Z", "deleted": True}]) + assert r["notes"][0]["deleted"] is True + assert client.get("/api/notes", headers=AUTH).json() == [] + # Ein verspäteter älterer Stand belebt die Notiz nicht wieder + sync(client, notes=[{"id": "Plan.md", "updated": "2026-09-19T10:01:00.000Z", "content": "y"}]) + assert client.get("/api/notes", headers=AUTH).json() == [] + + +def test_seit_cursor_liefert_nur_neues(client): + sync(client, events=[{"id": "e1", "updated": "2026-09-19T10:00:00.000Z", "title": "a"}]) + sync(client, events=[{"id": "e2", "updated": "2026-09-19T10:00:00.000Z", "title": "b"}]) + r = sync(client, since=1) + assert [e["id"] for e in r["events"]] == ["e2"] diff --git a/desktop@marha.de/extension.js b/desktop@marha.de/extension.js index 547b601..9a67820 100644 --- a/desktop@marha.de/extension.js +++ b/desktop@marha.de/extension.js @@ -7,6 +7,7 @@ import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import {CalendarPanel} from './calendar.js'; import {NotesPanel} from './notes.js'; import {Store} from './store.js'; +import {Sync} from './sync.js'; import {TodoPanel} from './todos.js'; const MARGIN = 24; @@ -31,9 +32,14 @@ export default class DesktopExtension extends Extension { this._workareaId = global.display.connect('workareas-changed', () => this._layout()); this._layout(); this._restack(); + + this._sync = new Sync(this._store); + this._sync.start(); } disable() { + this._sync.stop(); + this._sync = null; global.display.disconnect(this._restackId); global.window_manager.disconnect(this._mapId); global.display.disconnect(this._workareaId); diff --git a/desktop@marha.de/notes.js b/desktop@marha.de/notes.js index d3bdb8a..2ba4b94 100644 --- a/desktop@marha.de/notes.js +++ b/desktop@marha.de/notes.js @@ -61,6 +61,11 @@ class NotesPanel extends St.BoxLayout { return Clutter.EVENT_STOP; }); this.connect('destroy', () => this._stopAutosave()); + // Änderungen vom Sync. Während des Tippens bleibt der Editor, wie er ist. + store.onChange(() => { + if (!this._editing) + this._refresh(); + }); this._refresh(); } @@ -143,8 +148,10 @@ class NotesPanel extends St.BoxLayout { if (this._editing || this._selected?.type !== 'note') return; this._editing = true; - this._dirty = false; + this._store.lockedNote = this._selected.path; this._editor.set_text(this._store.readNote(this._selected.path)); + // Erst nach set_text, das löst selbst text-changed aus. Sonst gilt jedes Öffnen als Änderung. + this._dirty = false; this._viewScroll.hide(); this._editScroll.show(); this._editor.clutter_text.grab_key_focus(); @@ -184,7 +191,9 @@ class NotesPanel extends St.BoxLayout { return; this._flush(); this._editing = false; + this._store.lockedNote = null; this._stopAutosave(); + // Nur die Anzeige wechseln. Ein Neuaufbau des Baums würde die gerade geklickte Zeile zerstören. this._showSelected(); } @@ -265,6 +274,7 @@ class NotesPanel extends St.BoxLayout { _delete(type, node) { if (this._selected?.path === node.path) { this._editing = false; + this._store.lockedNote = null; this._stopAutosave(); this._selected = null; } diff --git a/desktop@marha.de/store.js b/desktop@marha.de/store.js index 94499b8..a004980 100644 --- a/desktop@marha.de/store.js +++ b/desktop@marha.de/store.js @@ -2,26 +2,44 @@ import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; // Ablage: ~/.local/share/desktop-app/ -// events.json Termine -// todos.json Todos -// notes/ Notizen als .md-Dateien, Ordner beliebig verschachtelt +// events.json Termine +// todos.json Todos +// notes/ Notizen als .md-Dateien, Ordner beliebig verschachtelt +// deleted-notes.json gelöschte Notizen mit Zeitpunkt, damit der Sync sie auch woanders löscht +// sync.json Server-Zugang (url, token), siehe sync.js +// Gelöschte Termine und Todos bleiben mit deleted: true in der Liste (Tombstone) und werden nur ausgeblendet. const decoder = new TextDecoder(); -function readJson(path) { +export function readJson(path, fallback = []) { try { const [ok, bytes] = GLib.file_get_contents(path); - return ok ? JSON.parse(decoder.decode(bytes)) : []; + return ok ? JSON.parse(decoder.decode(bytes)) : fallback; } catch { // Datei gibt es beim ersten Start noch nicht - return []; + return fallback; } } -function writeJson(path, data) { +export function writeJson(path, data) { GLib.file_set_contents(path, JSON.stringify(data, null, 2)); } +// Änderungszeit einer Datei als ISO-String mit Millisekunden. +function modifiedIso(file) { + const info = file.query_info('time::modified,time::modified-usec', Gio.FileQueryInfoFlags.NONE, null); + const usec = info.get_attribute_uint32('time::modified-usec'); + return new Date(Number(info.get_attribute_uint64('time::modified')) * 1000 + Math.floor(usec / 1000)).toISOString(); +} + +function setModified(file, iso) { + const ms = new Date(iso).getTime(); + const info = new Gio.FileInfo(); + info.set_attribute_uint64('time::modified', Math.floor(ms / 1000)); + info.set_attribute_uint32('time::modified-usec', (ms % 1000) * 1000); + file.set_attributes_from_info(info, Gio.FileQueryInfoFlags.NONE, null); +} + function compareArrays(a, b) { for (let i = 0; i < a.length; i++) { if (a[i] < b[i]) @@ -67,9 +85,13 @@ export class Store { GLib.mkdir_with_parents(this.notesDir, 0o755); this._eventsPath = GLib.build_filenamev([this.dir, 'events.json']); this._todosPath = GLib.build_filenamev([this.dir, 'todos.json']); + this._deletedNotesPath = GLib.build_filenamev([this.dir, 'deleted-notes.json']); this.events = readJson(this._eventsPath); this.todos = readJson(this._todosPath); + this.deletedNotes = readJson(this._deletedNotesPath, {}); this._listeners = []; + // Notiz, die gerade im Editor offen ist. Der Sync fasst sie nicht an. + this.lockedNote = null; } onChange(callback) { @@ -86,7 +108,7 @@ export class Store { // Termine, die den Tag berühren. Mehrtägige laufen von date bis endDate. eventsOn(date) { return this.events - .filter(e => e.date <= date && date <= (e.endDate ?? e.date)) + .filter(e => !e.deleted && e.date <= date && date <= (e.endDate ?? e.date)) .sort((a, b) => compareArrays([a.start ?? '', a.title], [b.start ?? '', b.title])); } @@ -103,23 +125,45 @@ export class Store { } removeEvent(id) { - this.events = this.events.filter(e => e.id !== id); - writeJson(this._eventsPath, this.events); - this._changed(); + const event = this.events.find(e => e.id === id); + this.saveEvent({...event, deleted: true}); + } + + // ---- Sync: Änderungen von außen übernehmen, neuester Stand gewinnt ---- + + // Gibt true zurück, wenn sich lokal etwas geändert hat. + applyRemote(collection, items) { + const list = this[collection]; + let changed = false; + for (const item of items) { + const index = list.findIndex(e => e.id === item.id); + if (index >= 0 && list[index].updated >= item.updated) + continue; + if (index >= 0) + list[index] = item; + else + list.push(item); + changed = true; + } + if (changed) { + writeJson(collection === 'events' ? this._eventsPath : this._todosPath, list); + this._changed(); + } + return changed; } // ---- Todos ---- todosOn(date) { return this.todos - .filter(t => t.date === date) + .filter(t => !t.deleted && t.date === date) .sort((a, b) => compareArrays([a.time ?? '', a.title], [b.time ?? '', b.title])); } // Offene zuerst (terminierte nach Datum, dann allgemeine), erledigte zuletzt. sortedTodos() { const key = t => [t.done ? 1 : 0, t.date ? 0 : 1, t.date ?? '', t.time ?? '', t.created ?? '']; - return [...this.todos].sort((a, b) => compareArrays(key(a), key(b))); + return this.todos.filter(t => !t.deleted).sort((a, b) => compareArrays(key(a), key(b))); } saveTodo(todo) { @@ -140,9 +184,8 @@ export class Store { } removeTodo(id) { - this.todos = this.todos.filter(t => t.id !== id); - writeJson(this._todosPath, this.todos); - this._changed(); + const todo = this.todos.find(t => t.id === id); + this.saveTodo({...todo, deleted: true}); } // ---- Notizen ---- @@ -151,6 +194,50 @@ export class Store { return listFolder(this.notesDir); } + // Pfad relativ zum Notizordner, dient dem Sync als id. + relative(path) { + return path.slice(this.notesDir.length + 1); + } + + absolute(relative) { + return GLib.build_filenamev([this.notesDir, relative]); + } + + // Alle Notizen mit Änderungszeit, für den Sync. + listNotes() { + const out = []; + const walk = folder => { + folder.folders.forEach(walk); + for (const note of folder.notes) + out.push({id: this.relative(note.path), updated: modifiedIso(Gio.File.new_for_path(note.path))}); + }; + walk(this.tree()); + return out; + } + + // Notiz vom Server übernehmen. Die Änderungszeit wird übernommen, damit sie nicht als neu gilt. + writeRemoteNote(relative, content, updated) { + const path = this.absolute(relative); + GLib.mkdir_with_parents(GLib.path_get_dirname(path), 0o755); + GLib.file_set_contents(path, content); + setModified(Gio.File.new_for_path(path), updated); + delete this.deletedNotes[relative]; + writeJson(this._deletedNotesPath, this.deletedNotes); + this._changed(); + } + + deleteRemoteNote(relative) { + const path = this.absolute(relative); + if (GLib.file_test(path, GLib.FileTest.EXISTS)) + Gio.File.new_for_path(path).delete(null); + this._changed(); + } + + _rememberDeleted(path) { + this.deletedNotes[this.relative(path)] = new Date().toISOString(); + writeJson(this._deletedNotesPath, this.deletedNotes); + } + readNote(path) { const [, bytes] = GLib.file_get_contents(path); return decoder.decode(bytes); @@ -166,6 +253,8 @@ export class Store { if (GLib.file_test(path, GLib.FileTest.EXISTS)) throw new Error('Diese Notiz gibt es schon'); GLib.file_set_contents(path, ''); + delete this.deletedNotes[this.relative(path)]; + writeJson(this._deletedNotesPath, this.deletedNotes); return path; } @@ -184,20 +273,38 @@ export class Store { if (GLib.file_test(target, GLib.FileTest.EXISTS)) throw new Error('Diese Notiz gibt es schon'); Gio.File.new_for_path(path).move(Gio.File.new_for_path(target), Gio.FileCopyFlags.NONE, null, null); + // Für den Sync ist das löschen plus neu anlegen + this._rememberDeleted(path); + this._touch(target); return target; } + _touch(path) { + setModified(Gio.File.new_for_path(path), new Date().toISOString()); + } + renameFolder(path, name) { checkName(name); const target = GLib.build_filenamev([GLib.path_get_dirname(path), name]); if (GLib.file_test(target, GLib.FileTest.EXISTS)) throw new Error('Diesen Ordner gibt es schon'); + const notes = []; + const collect = folder => { + folder.folders.forEach(collect); + notes.push(...folder.notes.map(n => n.path)); + }; + collect(listFolder(path)); Gio.File.new_for_path(path).move(Gio.File.new_for_path(target), Gio.FileCopyFlags.NONE, null, null); + for (const note of notes) { + this._rememberDeleted(note); + this._touch(target + note.slice(path.length)); + } return target; } deleteNote(path) { Gio.File.new_for_path(path).delete(null); + this._rememberDeleted(path); } folderEmpty(path) { diff --git a/desktop@marha.de/sync.js b/desktop@marha.de/sync.js new file mode 100644 index 0000000..78b8716 --- /dev/null +++ b/desktop@marha.de/sync.js @@ -0,0 +1,147 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import Soup from 'gi://Soup'; + +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} +// Alle paar Sekunden: eigene Änderungen seit dem letzten Lauf 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). + +const DEFAULT_INTERVAL = 5; + +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: {}}}); + this._session = new Soup.Session({timeout: 10}); + this._busy = false; + this._failing = false; + this._timerId = null; + } + + get active() { + return Boolean(this._config?.url && this._config?.token); + } + + start() { + if (!this.active) + return; + const interval = this._config.interval ?? DEFAULT_INTERVAL; + this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, interval, () => { + this._cycle().catch(e => this._fail(e.message)); + return GLib.SOURCE_CONTINUE; + }); + this._cycle().catch(e => this._fail(e.message)); + } + + stop() { + if (this._timerId) { + GLib.source_remove(this._timerId); + this._timerId = null; + } + this._session.abort(); + } + + async _cycle() { + if (this._busy) + return; + this._busy = true; + try { + const payload = this._collect(); + const response = await this._post(payload); + this._apply(response, payload); + 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, + }; + } + + _post(payload) { + 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) => { + this._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; + + // 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); + } + + _fail(message) { + if (!this._failing) + console.log(`desktop-app sync: ${message}`); + this._failing = true; + } +} diff --git a/dev/restart.sh b/dev/restart.sh index 017017e..1e9a31f 100755 --- a/dev/restart.sh +++ b/dev/restart.sh @@ -4,6 +4,11 @@ DIR=$(cd "$(dirname "$0")" && pwd) "$DIR/stop.sh" pgrep -f "^gnome-shell --(nested|headless)" | xargs -r kill 2>/dev/null rm -rf /tmp/desktop-app-nested/data/desktop-app +# Mit SYNC_URL bekommt die Test-Shell einen Server-Zugang (Token "test", alle 2 Sekunden) +if [ -n "$SYNC_URL" ]; then + mkdir -p /tmp/desktop-app-nested/data/desktop-app + echo "{\"url\": \"$SYNC_URL\", \"token\": \"test\", \"interval\": 2}" > /tmp/desktop-app-nested/data/desktop-app/sync.json +fi sleep 2 (nohup "$DIR/nested.sh" "$1" > /tmp/desktop-app-nested.log 2>&1 &) sleep 9 diff --git a/dev/tests/input.sh b/dev/tests/input.sh index 10bd040..1bd89ab 100755 --- a/dev/tests/input.sh +++ b/dev/tests/input.sh @@ -82,7 +82,7 @@ rclick 'cal._blocks.find(b => b.event.title === "Sport").block' check "Kontextmenü offen" "$(val 'labels(grabbed()).join("|")')" "Löschen" shot "03-context-menu" click 'find(grabbed(), "Löschen")' -check "Termin gelöscht" "$(val 'store.events.filter(e => e.title === "Sport").length')" "0" +check "Termin gelöscht (Tombstone bleibt)" "$(val 'store.events.filter(e => e.title === "Sport").map(e => e.deleted).join(",") + " " + store.eventsOn("2026-09-18").filter(e => e.title === "Sport").length')" "true 0" echo "=== Woche: ganztägiger Termin, Klick daneben schließt" click 'cal._alldaySlots[6]' @@ -118,7 +118,7 @@ check "Todo bearbeiten zeigt Text" "$(val 'entries(grabbed())[0]')" "Milch kaufe key Escape rclick 'find(todos, "Milch kaufen")' click 'find(grabbed(), "Löschen")' -check "Todo gelöscht" "$(val 'store.todos.filter(t => t.title === "Milch kaufen").length')" "0" +check "Todo gelöscht (Tombstone bleibt)" "$(val 'store.todos.filter(t => t.title === "Milch kaufen").map(t => t.deleted).join(",") + " " + store.sortedTodos().filter(t => t.title === "Milch kaufen").length')" "true 0" rclickb 'todos' check "Rechtsklick Todo-Fläche" "$(val 'labels(grabbed()).join("|")')" "Erstellen" click 'find(grabbed(), "Erstellen")' diff --git a/dev/tests/sync.sh b/dev/tests/sync.sh new file mode 100755 index 0000000..fe52c03 --- /dev/null +++ b/dev/tests/sync.sh @@ -0,0 +1,130 @@ +#!/bin/bash +# Sync-Test: lokaler Server plus headless Shell, Änderungen in beide Richtungen: dev/tests/sync.sh +DIR=$(cd "$(dirname "$0")/.." && pwd) +ROOT=$(cd "$DIR/.." && pwd) +E="$DIR/eval.sh" +OUT=/tmp/desktop-app-nested/out.txt +PORT=8041 +URL="http://localhost:$PORT" +SERVER_DATA=/tmp/desktop-app-sync-server +AUTH="Authorization: Bearer test" +FAILED=0 + +PRE='const ext = Main.extensionManager.lookup("desktop@marha.de").stateObj; +const notes = ext._root.get_child_at_index(0); +const todos = ext._root.get_child_at_index(1).get_child_at_index(1); +const store = ext._store; +const read = p => new TextDecoder().decode(GLib.file_get_contents(p)[1]); +const exists = p => GLib.file_test(p, GLib.FileTest.EXISTS); +const state = () => JSON.parse(read(store.dir + "/sync-state.json")); +const St = imports.gi.St; +const labels = a => { const out = []; const walk = n => { if (n instanceof St.Label && n.text) out.push(n.text); n.get_children().forEach(walk); }; walk(a); return out; }; +' +val() { + "$E" "$PRE GLib.file_set_contents('$OUT', String($1)); 'w'" | grep -v "^OK '\"w\"')$" + cat "$OUT" +} +api() { curl -s -H "$AUTH" -H "Content-Type: application/json" "$@"; } +now() { date -u +%Y-%m-%dT%H:%M:%S.000Z; } +push() { api -X POST "$URL/api/sync" -d "$1" >/dev/null; } +check() { + local name=$1 actual=$2 expected=$3 + if [ "$actual" == "$expected" ]; then + echo "ok $name" + else + echo "FAIL $name"; echo " ist: $actual"; echo " soll: $expected" + FAILED=$((FAILED + 1)) + fi +} +cleanup() { + kill $SERVER_PID 2>/dev/null + "$DIR/stop.sh" + pkill -f "^gnome-shell --(nested|headless)" 2>/dev/null + wait 2>/dev/null +} +trap cleanup EXIT + +echo "=== Server starten" +rm -rf "$SERVER_DATA" +(cd "$ROOT/backend" && DATA_DIR="$SERVER_DATA" SYNC_TOKEN=test .venv/bin/uvicorn app.main:app --port $PORT > /tmp/desktop-app-sync-server.log 2>&1 &) +for i in $(seq 1 30); do curl -s "$URL/api/health" >/dev/null && break; sleep 0.5; done +SERVER_PID=$(pgrep -f "uvicorn app.main:app --port $PORT") +check "Server läuft" "$(curl -s $URL/api/health)" '{"ok":true}' + +echo "=== Shell mit Sync starten" +SYNC_URL="$URL" "$DIR/restart.sh" headless >/dev/null +check "Sync aktiv" "$(val 'ext._sync.active')" "true" + +echo "=== Lokal → Server" +"$E" "$PRE store.saveEvent({title: 'Zahnarzt', date: '2026-09-21', start: '09:00', end: '10:00', endDate: null}); store.saveTodo({title: 'Einkaufen', date: null, time: null}); 'ok'" >/dev/null +sleep 3 +check "Termin auf dem Server" "$(api "$URL/api/events" | python3 -c 'import json,sys; print([e["title"] for e in json.load(sys.stdin)])')" "['Zahnarzt']" +check "Todo auf dem Server" "$(api "$URL/api/todos" | python3 -c 'import json,sys; print([t["title"] for t in json.load(sys.stdin)])')" "['Einkaufen']" +check "Stand gemerkt" "$(val 'state().cursor > 0')" "true" + +echo "=== Server → lokal" +push '{"todos": [{"id": "r1", "updated": "2026-09-19T10:00:00.000Z", "title": "Vom Handy", "date": null, "time": null, "done": false}], "notes": [{"id": "Remote/Idee.md", "updated": "2026-09-19T10:00:00.000Z", "content": "# Vom Server"}]}' +sleep 3 +check "Todo vom Server in der Liste" "$(val 'store.sortedTodos().map(t => t.title).sort().join(",")')" "Einkaufen,Vom Handy" +check "Todo vom Server im Panel" "$(val 'labels(todos).includes("Vom Handy")')" "true" +check "Notiz vom Server als Datei" "$(val 'exists(store.notesDir + "/Remote/Idee.md") + " " + JSON.stringify(read(store.notesDir + "/Remote/Idee.md"))')" 'true "# Vom Server"' +check "Notiz vom Server im Baum" "$(val 'labels(notes).includes("▸ Remote")')" "true" +check "Übernommene Notiz wird nicht zurückgeschickt" "$(val 'state().pushed.notes["Remote/Idee.md"]')" "2026-09-19T10:00:00.000Z" + +echo "=== Konflikt: neuester gewinnt" +push '{"todos": [{"id": "r1", "updated": "2026-09-19T09:00:00.000Z", "title": "Älter", "date": null, "time": null, "done": false}]}' +sleep 3 +check "Älterer Stand verliert" "$(val 'store.todos.find(t => t.id === "r1").title')" "Vom Handy" +push '{"todos": [{"id": "r1", "updated": "'$(now)'", "title": "Neuer", "date": null, "time": null, "done": false}]}' +sleep 3 +check "Neuerer Stand gewinnt" "$(val 'store.todos.find(t => t.id === "r1").title')" "Neuer" +sleep 1 +"$E" "$PRE store.toggleTodo('r1'); 'ok'" >/dev/null +sleep 3 +check "Lokale Änderung ist neuer und landet auf dem Server" "$(api "$URL/api/todos" | python3 -c 'import json,sys; print([t["done"] for t in json.load(sys.stdin) if t["id"] == "r1"])')" "[True]" +push '{"todos": [{"id": "r1", "updated": "2026-09-19T09:30:00.000Z", "title": "Verspätet", "date": null, "time": null, "done": false}]}' +sleep 3 +check "Verspäteter älterer Stand ändert nichts" "$(val 'store.todos.find(t => t.id === "r1").title + " " + store.todos.find(t => t.id === "r1").done')" "Neuer true" + +echo "=== Löschen in beide Richtungen" +"$E" "$PRE store.removeTodo(store.todos.find(t => t.title === 'Einkaufen').id); store.deleteNote(store.notesDir + '/Remote/Idee.md'); 'ok'" >/dev/null +sleep 3 +check "Gelöschtes Todo auf dem Server weg" "$(api "$URL/api/todos" | python3 -c 'import json,sys; print(sorted(t["title"] for t in json.load(sys.stdin)))')" "['Neuer']" +check "Gelöschte Notiz auf dem Server weg" "$(api "$URL/api/notes" | python3 -c 'import json,sys; print([n["id"] for n in json.load(sys.stdin)])')" "[]" +"$E" "$PRE store.createNote(store.notesDir, 'Lokal'); store.writeNote(store.notesDir + '/Lokal.md', 'lokal'); 'ok'" >/dev/null +sleep 3 +check "Lokale Notiz auf dem Server" "$(api "$URL/api/notes" | python3 -c 'import json,sys; print([(n["id"], n["content"]) for n in json.load(sys.stdin)])')" "[('Lokal.md', 'lokal')]" +push '{"notes": [{"id": "Lokal.md", "updated": "'$(now)'", "deleted": true}]}' +sleep 3 +check "Vom Server gelöschte Notiz lokal weg" "$(val 'exists(store.notesDir + "/Lokal.md")')" "false" + +echo "=== Offene Notiz bleibt unangetastet" +"$E" "$PRE store.createNote(store.notesDir, 'Offen'); store.writeNote(store.notesDir + '/Offen.md', 'meins'); notes._select({type: 'note', path: store.notesDir + '/Offen.md', name: 'Offen'}); 'ok'" >/dev/null +sleep 3 +"$E" "$PRE notes._startEdit(); 'ok'" >/dev/null +CURSOR=$(val 'state().cursor') +push '{"notes": [{"id": "Offen.md", "updated": "'$(now)'", "content": "vom server"}]}' +sleep 3 +check "Datei während des Tippens unverändert" "$(val 'read(store.notesDir + "/Offen.md") + " " + (state().cursor === '"$CURSOR"')')" "meins true" +"$E" "$PRE notes._stopEdit(); 'ok'" >/dev/null +sleep 3 +check "Nach dem Schließen übernommen" "$(val 'read(store.notesDir + "/Offen.md") + " " + notes._content.text')" "vom server vom server" + +echo "=== Umbenennen" +sleep 1 +"$E" "$PRE store.renameNote(store.notesDir + '/Offen.md', 'Neu'); 'ok'" >/dev/null +sleep 3 +check "Umbenannt auf dem Server" "$(api "$URL/api/notes" | python3 -c 'import json,sys; print([n["id"] for n in json.load(sys.stdin)])')" "['Neu.md']" + +echo "=== Server weg und wieder da" +kill $SERVER_PID; sleep 1 +"$E" "$PRE store.saveTodo({title: 'Offline', date: null, time: null}); 'ok'" >/dev/null +sleep 3 +(cd "$ROOT/backend" && DATA_DIR="$SERVER_DATA" SYNC_TOKEN=test .venv/bin/uvicorn app.main:app --port $PORT >> /tmp/desktop-app-sync-server.log 2>&1 &) +for i in $(seq 1 30); do curl -s "$URL/api/health" >/dev/null && break; sleep 0.5; done +SERVER_PID=$(pgrep -f "uvicorn app.main:app --port $PORT") +sleep 4 +check "Offline-Änderung nachgeliefert" "$(api "$URL/api/todos" | python3 -c 'import json,sys; print(sorted(t["title"] for t in json.load(sys.stdin)))')" "['Neuer', 'Offline']" +check "Keine JS-Fehler" "$(grep -c 'JS ERROR' /tmp/desktop-app-nested.log)" "0" +echo +if [ $FAILED -eq 0 ]; then echo "alle ok"; else echo "$FAILED FEHLER"; grep -n "JS ERROR" -A5 /tmp/desktop-app-nested.log | head -30; grep "desktop-app sync" /tmp/desktop-app-nested.log | tail -5; fi diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b23e668 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + desktop: + build: + context: . + container_name: desktop + restart: unless-stopped + env_file: .env + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"] + interval: 30s + timeout: 10s + retries: 3 + # Traefik routet erst ab "healthy" — ohne dichten Start-Takt ist die Seite anfangs 404 + start_period: 30s + start_interval: 2s + networks: + - web + volumes: + - ./data:/data + environment: + DATA_DIR: /data + labels: + - "traefik.enable=true" + - "traefik.http.routers.desktopapp.rule=Host(`planer.marha.de`)" + - "traefik.http.routers.desktopapp.entrypoints=websecure" + - "traefik.http.routers.desktopapp.tls.certresolver=letsencrypt" + - "traefik.http.services.desktopapp.loadbalancer.server.port=8000" + +networks: + web: + external: true