docs: Deployment-, Architektur- und Release-Doku; README aktualisiert

docs/ war leer. Jetzt drei Dateien, jede Behauptung am Code verifiziert:

* deployment.md — docker compose, Migrationsweg, vollständige Env-Referenz
  mit den Fallstricken (P-256-taugliches PDS_JWT_SECRET, Pflicht-aber-tot
  S3_BUCKET_APPVIEW), Release-Build, systemd-Units, Reverse-Proxy inkl. des
  Hinweises, dass die AppView CORS Any liefert und der Proxy den Header
  ersetzen statt ergänzen muss, Health-Checks und Cursor-Verhalten beim
  Neustart.
* architecture.md — Crate-Verantwortlichkeiten, ASCII-Datenfluss, beide
  DB-Schemata. Hält fest, was die Topologie erklärt: die eigene PDS speist
  keinen Firehose, eigene Records erreichen die AppView nur über den
  Best-Effort-Push.
* tauri-release.md — Signing-Keys, v2-Updater-Config, latest.json, Build pro
  Plattform. Der _comment in tauri.conf.json war irreführend: active/dialog
  sind v1-Reste, die der v2-Updater ignoriert; dass nichts passiert, liegt
  daran, dass niemand check() aufruft und das Plugin nicht installiert ist.

README bekommt Phase 8, eine Doku-Übersicht, korrigierte Testanleitung
(src-tauri ist ein eigener Workspace und wird von cargo test --workspace
nicht erfasst) und einen Abschnitt "Bekannte Lücken" statt der bisher
lückenlosen Erfolgsmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
tomdebone
2026-09-09 21:37:12 +02:00
co-authored by Claude Opus 5
parent 31880e1005
commit ec8fe187fe
4 changed files with 1137 additions and 18 deletions
+247
View File
@@ -0,0 +1,247 @@
# Architektur
Vertiefung zum Überblick im README: wer schreibt was, wo liegen die Daten, und
auf welchem Weg kommt ein Post vom Client bis in die Timeline zurück.
## Prozesse und Datenflüsse
```
┌───────────────────────────────────┐
│ Tauri-Client (Desktop) │
│ Svelte 5 (Webview) │
│ │ invoke() │
│ src-tauri (Rust-IPC) │
│ pds_client.rs appview_client.rs│
└───────┬───────────────────┬───────┘
Schreiben │ │ Lesen
XRPC/HTTPS │ │ REST/HTTP
▼ ▼
┌──────────────────────────────────┐ ┌──────────────────────────────┐
│ pds-server (axum, :2583) │ │ appview (axum, :2584) │
│ │ │ │
│ /xrpc/com.atproto.* │ │ GET /api/timeline/home │
│ /xrpc/app.bsky.actor.profile.* │ │ GET /api/profile[/:handle] │
│ /blob/:cid │ │ GET /api/search │
│ /healthz │ │ GET /api/post|thread/*uri │
│ │ │ GET /api/notifications… │
│ │ │ GET /api/followers|following│
│ │ │ GET /healthz │
│ at-lexicon Validierung (160) │ │ │
│ at-repo/at-mst MST + Commit │ │ indexer.rs Upserts │
│ at-crypto Signatur, CID, JWT │ │ handle_sync.rs Worker │
│ at-identity PLC / did:web │ │ ingest.rs interner Writer │
│ at-blob MinIO │ │ │
└───┬────────────┬───────────┬─────┘ └──────┬──────────────┬────────┘
│ │ │ │ ▲
│ │ │ POST /internal/ingest-commit │
│ │ └───────────────────────────────┤
│ │ │ │
▼ ▼ ▼ │
┌───────────┐ ┌────────┐ ┌─────────────────┐ │
│ Postgres │ │ MinIO │ │ Postgres │ │
│ pds :5434 │ │ :9100 │ │ appview :5435 │ │
└───────────┘ └────────┘ └─────────────────┘ │
│ │
│ (heute: kein eigener Firehose-Ausgang) │
▼ │
┌──────────────────────────────────────────┐ │
│ Jetstream-Relay (extern, WebSocket) │──────────────────┘
│ JETSTREAM_URL │ at-firehose
└──────────────────────────────────────────┘ JetstreamConsumer
```
Zwei Wege führen in die AppView, und das ist Absicht:
1. **Direkter Push (schnell, lokal).** Jeder erfolgreiche Commit auf der PDS
wird per `POST /internal/ingest-commit` an die AppView geschoben
(`crates/pds-server/src/appview_push.rs`). Best effort, 5 s Timeout, blockiert
den Record-Write nie. Damit sieht der Nutzer seinen eigenen Post sofort.
2. **Jetstream (global, verzögert).** `at-firehose::JetstreamConsumer` hängt an
einem externen Jetstream-Relay und liefert alles, was in den konfigurierten
Collections weltweit passiert.
Wichtig für das Verständnis der Topologie: **die eigene PDS speist den
Jetstream nicht.** Es gibt keinen `com.atproto.sync.subscribeRepos`-Endpoint im
PDS-Router. Der Firehose-Weg ist ein reiner Konsum-Pfad für fremde Repos; die
eigenen Records erreichen die AppView ausschließlich über den Push aus
Punkt 1 (noch offen).
## Crates
| Crate | Typ | Aufgabe |
|---|---|---|
| `at-lexicon` | lib | Lexicon-Schemas laden (`Lex::from_json`) und Records validieren. `LexRegistry` in der PDS kennt `app.twi.post` (160 Zeichen), `app.bsky.feed.like`, `app.bsky.feed.repost`, `app.bsky.actor.profile` — alle vier per `include_str!` einkompiliert |
| `at-crypto` | lib | secp256k1/P-256-Keypairs, DAG-CBOR-CIDs, multibase/base58btc, JWT (`issue_jwt` / `verify_jwt`), PLC-Operationen inkl. `did_plc_from_op` |
| `at-identity` | lib | Handle- und DID-Auflösung. Drei Resolver hinter dem Trait `DidHandleResolver`: `PlcClient` (PLC-Directory), `WebResolver` (`.well-known/did.json`), `PdsHandleResolver` (fragt die lokale PDS) |
| `at-mst` | lib | Merkle-Search-Tree: Knoten, `split_around`, `wrap_with_split`, spec-konformes `encode_key` |
| `at-repo` | lib | Repo-Abstraktion über einem `Blockstore`, Commit-Erzeugung + Signatur, TID-Revisionen (`rev.rs`) |
| `at-blob` | lib | `S3BlobStore` gegen MinIO (unsignierte PUT/GET/DELETE) und `InMemoryBlobStore` (gleiche CID-Semantik, für Tests/Single-Node ohne Objektspeicher), MIME-Sniffing (`infer`), `ping()` für den Startup-Check |
| `at-firehose` | lib | `JetstreamConsumer`: WebSocket, Collection-Filter, Cursor-Resume, exponentielles Reconnect-Backoff, `connected`-Flag für `/healthz` |
| `at-shared` | lib | `AppConfig::from_env()`, `AtError` mit HTTP-Status-Mapping, DID- und Zeit-Helfer |
| `pds-server` | bin | axum-HTTP-PDS. Routen, Auth/JWT, Repo-Writes, Blob-Upload, AppView-Push |
| `appview` | bin | axum-REST-API + Jetstream-Indexer + Handle-Sync-Worker + interner Ingest-Endpoint |
| `tauri-app` | bin (eigener Workspace) | Desktop-Client. Svelte-5-Frontend, Rust-IPC-Layer mit HTTP-Clients für PDS und AppView |
`crates/tauri-app/src-tauri/Cargo.toml` deklariert ein eigenes `[workspace]`,
das Crate ist also **nicht** Teil des Root-Workspaces. `cargo build --workspace`
im Repo-Root baut den Client nicht mit, und sein Build-Output landet in
`crates/tauri-app/src-tauri/target/`.
## Schreibpfad: ein Post entsteht
`ComposeBox``invoke("post_create")``src-tauri/src/lib.rs`
`PdsHttpClient::create_record``POST /xrpc/com.atproto.repo.createRecord`.
Serverseitig (`crates/pds-server/src/routes/repo.rs` +
`routes/helpers.rs::apply_repo_write`):
1. `Authorization: Bearer <access_jwt>` prüfen — verifiziert gegen den aus
`PDS_JWT_SECRET` abgeleiteten P-256-Serverschlüssel. `claims.sub` muss der
`repo`-DID entsprechen.
2. Lexicon-Validierung über `state.lex` (abschaltbar mit `validate: false` im
Request-Body). Hier greift das 160-Zeichen-Limit von `app.twi.post`.
3. Transaktion öffnen und `SELECT … FROM repos WHERE did = $1 FOR UPDATE`
der Row-Lock serialisiert konkurrierende Writes desselben Repos.
4. Record als DAG-CBOR kodieren, CID bilden, Block in den Blockstore legen,
MST aktualisieren.
5. `repo.commit()` signiert den neuen Commit mit dem User-Signing-Key; `rev`
ist eine TID.
6. Blocks + neuer Head landen in `repo_blocks` / `repos`, Commit der
Transaktion.
7. Antwort mit `uri` (`at://did/collection/rkey`), `cid` und dem Commit-Objekt.
8. Danach — außerhalb des kritischen Pfads — der Push an die AppView.
Der Blockstore in `AppState` ist ein `MemoryBlockstore`; Persistenz kommt aus
`repo_blocks`. Nach einem Neustart wird der Speicher pro Write aus der DB
rehydriert.
## Lesepfad: die Timeline
`GET /api/timeline/home?did=…&limit=…&cursor=…`
(`crates/appview/src/routes.rs`):
1. `SELECT subject_did FROM follows WHERE follower_did = $1`.
2. Zielmenge = Followees + eigene DID, dedupliziert, auf
`MAX_FOLLOWED_DIDS = 1000` gedeckelt (sortiert, damit die Kürzung stabil
ist; die eigene DID bleibt garantiert drin).
3. Ist die Menge leer bzw. nur die eigene DID, fällt die Abfrage auf den
globalen Recent-Feed zurück — Cold-Start für neue Accounts.
4. Posts aus `posts` mit
`collection IN ('app.twi.post','app.bsky.feed.post')`.
5. Paginierung über einen opaken Cursor `base64url(micros):uri`
(`routes/cursor.rs`), passend zu den Indizes aus
`0002_pagination_indexes.sql`.
6. `decorate_handles` ersetzt leere Handles anzeigeseitig durch
`@<erste-12-Zeichen-der-DID>…` — die DB-Zeile bleibt unangetastet, das
Nachfüllen erledigt der Handle-Sync-Worker.
Limits: Default 30, Maximum 100.
Die vollständige, aktuelle Routenliste steht in
`crates/appview/src/routes.rs::router` — sie wächst gerade (Thread-Kontext,
Notifications, Follower-Listen) und wird hier bewusst nicht dupliziert.
## Indexer
`crates/appview/src/firehose.rs` ist der Event-Dispatcher, `indexer.rs` macht
die Schreibarbeit. Behandelte Event-Arten:
* `commit``apply_commit`, dispatcht auf die Collection:
`app.twi.post` / `app.bsky.feed.post`, `app.bsky.feed.like`,
`app.bsky.feed.repost`, `app.bsky.graph.follow`, `app.bsky.actor.profile`.
Alles andere wird übersprungen (der Cursor darf trotzdem weiterlaufen).
* `identity``backfill_handle`: schreibt den neuen Handle in alle
`posts`-Zeilen der DID.
* `account``handle_account`.
Alle Schreibpfade sind Upserts, das Replay nach einem Reconnect ist damit
unschädlich. Fehlerhafte Events rücken den Cursor **nicht** vor.
`ingest.rs` bedient denselben Indexer über HTTP, mit den Aktionen
`create` / `delete`; für `app.bsky.graph.follow`-Deletes braucht der Aufrufer
`subject_did` im Body, weil der Record-Wert bei Deletes nicht garantiert
mitkommt.
## Datenbanken und Tabellen
### PDS-DB (`DATABASE_URL_PDS`, Compose-Port 5434)
| Tabelle | Inhalt |
|---|---|
| `users` | `did` (PK), `handle` (unique), `email`, `password_hash` (argon2id), `signing_key`, `rotation_key` |
| `repos` | Ein Head pro DID: `rev` (TID), `head_cid`, `head_commit` (CBOR), `prev_commit` |
| `repo_blocks` | `(did, cid)` → CBOR-Block. MST-Knoten **und** Records. `0002_blob_mime.sql` ergänzt einen `cid`-Index |
| `blobs` | `cid` (PK), `did`, `mime_type`, `size`, `storage_key` (S3-Key) |
| `sessions` | Access-/Refresh-JWT mit Ablaufzeiten und `revoked_at` |
| `plc_ops` | Audit-Log der signierten PLC-Operationen, `submitted`-Flag |
Trigger `users_touch` pflegt `users.updated_at`.
### AppView-DB (`DATABASE_URL_APPVIEW`, Compose-Port 5435)
Stand der Migrationen zum Zeitpunkt dieses Dokuments; maßgeblich bleibt
`migrations/appview/`.
| Tabelle | Inhalt |
|---|---|
| `posts` | `uri` (PK), `did`, `handle`, `rkey`, `collection`, `text`, `cid`, `parent_uri`, `root_uri`, `langs`, `created_at`, `indexed_at`. Später ergänzt: `embed` (JSONB), `reply_parent_handle`, `reply_root_handle`, `reply_root_uri` (0003), `like_count`, `repost_count` (0004), `avatar_cid` (0005), `handle_sync_attempted_at` (0006) |
| `likes` / `reposts` | `uri` (PK), `did`, `post_uri`, `post_cid`. `0004_like_repost_counters.sql` ergänzt je einen Unique-Index auf `(did, post_uri)` gegen Doppel-Likes und dedupliziert vorhandene Zeilen. Die Zähler auf `posts` pflegt der Indexer selbst (`UPDATE posts SET like_count = like_count + 1 …`, beim Entfernen mit `GREATEST(…, 0)`) — es gibt keine DB-Trigger dafür |
| `follows` | `(follower_did, subject_did)` als PK — die Basis des Timeline-Filters |
| `profiles` | `did` (PK), `handle`, `display_name`, `description`, `avatar_cid`, `banner_cid`, drei Counter. Angelegt in `0005_profiles.sql` inkl. Backfill aus `posts` |
| `jetstream_cursor` | Genau eine Zeile (`id = 1`), `cursor` = Mikrosekunden seit Epoch |
| `timeline_cache` | In `0001_init.sql` angelegt, aber von keinem Code-Pfad gelesen oder geschrieben — Platzhalter für materialisierte Timelines |
Suche läuft über den Trigram-GIN-Index `posts_text_trgm_idx` (`pg_trgm`), nicht
über Full-Text-Search.
### MinIO
Zwei Buckets aus dem Compose-Init: `maarcadetweet-pds` und
`maarcadetweet-appview`. Genutzt wird faktisch nur der erste — `S3_BUCKET_APPVIEW`
ist zwar Pflichtvariable in `AppConfig`, wird aber von keinem Code-Pfad gelesen.
`at-blob` spricht MinIO ohne Signature V4, die Buckets brauchen deshalb
`anonymous download`.
Blobs sind doppelt referenziert: der Objekt-Key liegt in `blobs.storage_key`,
zusätzlich hält die PDS den Block im lokalen Blockstore. Fällt MinIO aus, kann
`uploadBlob` weiterlaufen und `GET /blob/:cid` aus dem lokalen Bestand bedienen
(die Startup-Warnung sagt genau das).
## Identität
* `createAccount` erzeugt zwei secp256k1-Keypairs (Signing + Rotation), baut
daraus eine `PlcOperation`, signiert sie und leitet die DID **deterministisch
aus der CID der signierten Op** ab (`did_plc_from_op`).
* Das Submit ans PLC-Directory ist best effort. Schlägt es fehl, bleibt die DID
lokal gültig — sie ist dann nur global nicht auflösbar
(`plc submit failed (dev ok)` im Log).
* `com.atproto.identity.resolveHandle` ist polymorph: beginnt der Wert mit
`did:`, wird per Primärschlüssel in `users` gesucht, sonst über den Handle
(mit Suffix-Matching gegen `PDS_HANDLE_DNS_ZONE`). Genau diesen Pfad nutzt
der `PdsHandleResolver` der AppView, damit lokale Nutzer — auch
`did:key:`-Accounts — ohne Umweg über plc.directory einen Handle bekommen.
* Reihenfolge im Handle-Sync-Worker: lokale PDS → PLC → `did:web`. Andere
DID-Methoden werden übersprungen.
## Client
Der Webview ruft nie direkt HTTP: die CSP erlaubt `connect-src 'self' ipc:
http://ipc.localhost`, alle Netzwerkaufrufe laufen über `invoke()` in den
Rust-Layer (`pds_client.rs` für Schreiben/Auth, `appview_client.rs` für Lesen).
`store.rs` hält die Session.
Die Backend-URLs kommen aus `MAARCADETWEET_PDS_URL` und
`MAARCADETWEET_APPVIEW_URL` mit den Defaults `http://127.0.0.1:2583` und
`http://127.0.0.1:2584`; sie werden beim Start in `AppState` eingefroren und
sind zur Laufzeit nicht umschaltbar.
Registrierte Tauri-Plugins: `notification`, `dialog`, `shell`, `updater`,
`window-state`. Die Capability `default` (`capabilities/default.json`) gibt dem
`main`-Window unter anderem `updater:default` frei — der Updater ist damit
vorbereitet, aber in `tauri.conf.json` deaktiviert (siehe
[tauri-release.md](tauri-release.md)).
## Lesehinweise für den Betrieb
Ports, Umgebungsvariablen, Health-Checks und das Neustart-Verhalten des
Jetstream-Cursors stehen in [deployment.md](deployment.md).
+528
View File
@@ -0,0 +1,528 @@
# Deployment
Wie der Stack real betrieben wird: zwei Rust-Binaries (`pds-server`, `appview`),
zwei Postgres-Datenbanken und ein MinIO-Bucket-Paar. Der Tauri-Client wird
separat gebaut (siehe [tauri-release.md](tauri-release.md)), der Aufbau der
Komponenten steht in [architecture.md](architecture.md).
## Voraussetzungen
| Komponente | Version | Woher belegt |
|---|---|---|
| Rust-Toolchain | ≥ 1.80 | `rust-version` in `Cargo.toml` |
| Docker + Compose | aktuell | `docker-compose.yml` |
| PostgreSQL | 16 | `postgres:16-alpine` im Compose-File |
| MinIO | latest | `minio/minio` im Compose-File |
| Node + npm | Node 20, npm 10.8.2 | `packageManager` in `crates/tauri-app/package.json` — nur für den Desktop-Client nötig |
Ein C-Compiler/Linker wird für `rusqlite` (Feature `bundled`) und die
Crypto-Crates gebraucht.
## 1. Infrastruktur starten
```bash
docker compose up -d
```
Das Compose-File startet vier Container:
| Container | Dienst | Host-Port | Zugangsdaten (Default) |
|---|---|---|---|
| `maarcadetweet-pds-db` | Postgres für die PDS | `5434` → 5432 | `pds` / `pds`, DB `pds` |
| `maarcadetweet-appview-db` | Postgres für die AppView | `5435` → 5432 | `appview` / `appview`, DB `appview` |
| `maarcadetweet-minio` | S3-kompatibler Blob-Store | `9100` (API), `9101` (Konsole) | `minioadmin` / `minioadmin` |
| `maarcadetweet-minio-init` | Einmal-Job | — | legt die Buckets an und beendet sich |
`minio-init` legt `maarcadetweet-pds` und `maarcadetweet-appview` an und setzt
beide auf `anonymous download`. Das ist kein Zufall: `at-blob` liest Blobs über
ein einfaches `GET ${endpoint}/${bucket}/${key}` ohne Signatur.
Alle drei Langläufer haben Healthchecks (`pg_isready` bzw.
`/minio/health/live`), Zustand prüfen mit `docker compose ps`.
**Für Produktion ändern:** Die Passwörter im Compose-File sind Dev-Defaults und
stehen im Klartext. Wer den Stack öffentlich betreibt, ersetzt sie (und die
korrespondierenden Werte in `.env`) oder betreibt Postgres/MinIO ganz außerhalb
von Compose. Die Ports 5434/5435/9100/9101 sind auf allen Interfaces gemappt —
in Produktion auf `127.0.0.1:` binden.
## 2. Migrationen
**Migrationen laufen automatisch beim Start der Binaries.** Es gibt keinen
separaten Migrationsschritt und kein Skript in `scripts/` (das Verzeichnis ist
leer).
* `crates/pds-server/src/main.rs`: `sqlx::migrate!("../../migrations/pds").run(&db)`
* `crates/appview/src/main.rs`: `sqlx::migrate!("../../migrations/appview").run(&db)`
`sqlx::migrate!` ist ein Makro: die `.sql`-Dateien werden **zur Compile-Zeit in
das Binary eingebettet**. Konsequenzen für den Betrieb:
* Eine neue Migration wird erst nach einem Rebuild wirksam — das Binary allein
neu zu starten reicht nicht.
* Der Migrationsstand liegt in der Tabelle `_sqlx_migrations` der jeweiligen DB.
Wird eine bereits angewendete Datei nachträglich geändert, bricht der Start
mit einem Checksum-Fehler ab.
* Die Datenbanken müssen die Extensions `uuid-ossp`, `pgcrypto` (PDS) bzw.
`uuid-ossp`, `pg_trgm` (AppView) anlegen dürfen (`CREATE EXTENSION` in
`0001_init.sql`). Beim offiziellen Postgres-Image ist der Compose-User
Superuser; bei managed Postgres müssen die Extensions vorab freigeschaltet
sein.
* `migrations/appview/0001_init.sql` legt die Cursor-Zeile an
(`INSERT INTO jetstream_cursor (id, cursor) VALUES (1, 0)`) — ohne sie fällt
die AppView auf Cursor `0` zurück und startet ohne Resume.
Maßgeblich ist immer der Inhalt von `migrations/pds/` bzw.
`migrations/appview/` — beim Schreiben dieses Dokuments `0001_init` und
`0002_blob_mime` auf der PDS-Seite, `0001_init` bis `0008_notifications` auf
der AppView-Seite.
Die Dateinamen folgen dem `<version>_<name>.sql`-Schema von sqlx, ein manueller
Lauf mit `sqlx-cli` (`sqlx migrate run --source migrations/appview`) ist damit
prinzipiell kompatibel. `sqlx-cli` ist aber **nicht** Teil des Workspaces und
wird nirgends im Code oder in CI verwendet — der dokumentierte Weg bleibt der
automatische Lauf beim Start.
Im Code gibt es keine `sqlx::query!`-Makros (nur die Laufzeit-Varianten
`sqlx::query` / `query_as` / `query_scalar`). Für den Build wird also **keine**
erreichbare Datenbank und kein `.sqlx`-Offline-Cache gebraucht.
## 3. Umgebungsvariablen
`cp .env.example .env` und anpassen — das reicht für den Dev-Betrieb. Beide
Binaries rufen als erstes in `main()` `dotenvy::dotenv()` auf (`dotenvy` ist
Workspace-Dependency und in `crates/pds-server` wie `crates/appview`
eingebunden) und laden damit eine `.env` aus dem Arbeitsverzeichnis bzw. den
übergeordneten Verzeichnissen. **Echte Umgebungsvariablen gewinnen** über die
Werte aus der Datei; ist keine `.env` vorhanden, passiert schlicht nichts.
Im Serverbetrieb ist die Datei damit optional: systemd (`EnvironmentFile=`)
oder die Container-Runtime setzen die Variablen direkt, und sie überschreiben
eine versehentlich mitdeployte `.env`.
### Pflicht (fehlt eine, bricht `AppConfig::from_env()` mit `missing env: X` ab)
Quelle: `crates/at-shared/src/config.rs`. Beide Binaries lesen dieselbe
`AppConfig`, also braucht **auch die AppView die PDS-Variablen** und umgekehrt.
| Variable | Bedeutung |
|---|---|
| `PDS_HOST` | Listen-Adresse der PDS (`format!("{host}:{port}")``TcpListener::bind`) |
| `PDS_PORT` | Listen-Port der PDS, `u16` |
| `PDS_PUBLIC_URL` | Öffentliche Basis-URL der PDS. Geht in den DID-Doc-`serviceEndpoint` (`routes/auth.rs`), in den JWT-`iss` (`jwt_issuer.rs`), als `public_base` in den Blob-Store und als Fallback für `PDS_INTERNAL_URL` |
| `PDS_HANDLE_DNS_ZONE` | Handle-Suffix inkl. führendem Punkt, z. B. `.maarcadetweet.local`. Wird in `com.atproto.identity.resolveHandle` beim Suffix-Matching benutzt und (ohne Punkt) als `availableUserDomains` in `describeServer` gemeldet |
| `PDS_JWT_SECRET` | **Hex-String** (≥ 32 Bytes, gültiger P-256-Skalar), aus dem der Serverschlüssel für die Access-/Refresh-JWTs abgeleitet wird — siehe Fallstricke unten |
| `APPVIEW_HOST` | Listen-Adresse der AppView |
| `APPVIEW_PORT` | Listen-Port der AppView, `u16` |
| `APPVIEW_PUBLIC_URL` | Öffentliche Basis-URL der AppView; die PDS benutzt sie als Fallback-Ziel für den Ingest-Push |
| `JETSTREAM_URL` | WebSocket-URL des Jetstream-Relays |
| `JETSTREAM_COLLECTIONS` | Komma-separierte Liste der Collections im `subscribe`-Filter. Leere Einträge werden verworfen |
| `DATABASE_URL_PDS` | Postgres-DSN der PDS-DB |
| `DATABASE_URL_APPVIEW` | Postgres-DSN der AppView-DB |
| `S3_ENDPOINT` | Basis-URL des Blob-Stores (MinIO) |
| `S3_REGION` | Region-String; wird im aktuellen `at-blob` nur mitgeführt, nicht signiert |
| `S3_ACCESS_KEY` | MinIO-Access-Key |
| `S3_SECRET_KEY` | MinIO-Secret-Key |
| `S3_BUCKET_PDS` | Bucket, in den die PDS Blobs schreibt |
| `S3_BUCKET_APPVIEW` | Bucket für die AppView — Pflichtfeld in der Config, wird aber von keinem Code-Pfad gelesen |
| `PLC_DIRECTORY_URL` | PLC-Directory. Die PDS submittet dort neue `did:plc:`-Ops (best effort), die AppView löst darüber Handles auf |
### Optional
| Variable | Default | Bedeutung |
|---|---|---|
| `PDS_INTERNAL_URL` | `PDS_PUBLIC_URL` | Cluster-interne PDS-URL, die die AppView für ihren `PdsHandleResolver` nutzt |
| `APPVIEW_INTERNAL_URL` | `APPVIEW_PUBLIC_URL` | Cluster-interne AppView-URL für den Commit-Push der PDS (`crates/pds-server/src/state.rs`) |
| `APPVIEW_INGEST_SECRET` | unset | Shared Secret für `POST /internal/ingest-commit`. Unset = Dev-Modus, der Endpoint akzeptiert anonyme Requests. Gesetzt = Header `X-Ingest-Secret` muss (konstantzeitverglichen) passen. Muss auf **beiden** Seiten identisch gesetzt sein |
| `APPVIEW_HANDLE_SYNC_INTERVAL_SECS` | `300` | Intervall des Handle-Sync-Workers. Nicht parsbare Werte fallen still auf den Default zurück |
| `RUST_LOG` | `info` | `tracing_subscriber::EnvFilter::try_from_default_env()` in beiden Binaries und im Tauri-Client |
### Nur für den Desktop-Client
Gelesen in `crates/tauri-app/src-tauri/src/lib.rs`:
| Variable | Default |
|---|---|
| `MAARCADETWEET_PDS_URL` | `http://127.0.0.1:2583` |
| `MAARCADETWEET_APPVIEW_URL` | `http://127.0.0.1:2584` |
### Nur für Tests
Die Integrationstests in `crates/appview/tests/` und
`crates/pds-server/tests/` lesen `DATABASE_URL_APPVIEW` bzw. `DATABASE_URL_PDS`
direkt und überspringen sich selbst, wenn die Variable fehlt.
### Fallstricke
`.env.example` deckt inzwischen alle Variablen ab, die der Code liest — auch
die optionalen (`PDS_INTERNAL_URL`, `APPVIEW_INTERNAL_URL`,
`APPVIEW_HANDLE_SYNC_INTERVAL_SECS`, `APPVIEW_INGEST_SECRET`,
`MAARCADETWEET_PDS_URL`, `MAARCADETWEET_APPVIEW_URL`), die dort auskommentiert
mit ihrem Default stehen. Zwei Punkte bleiben trotzdem erklärungsbedürftig:
1. **`PDS_JWT_SECRET` ist kein beliebiger String, sondern ein P-256-Skalar.**
`jwt_issuer.rs::server_p256_keypair` macht
`hex::decode(secret.trim_start_matches("0x"))`, verlangt ≥ 32 Bytes und
gibt die ersten 32 Bytes an `p256::SecretKey::from_bytes()`. Der Wert muss
also **Hex sein und ein gültiger Skalar der P-256-Kurve** — lauter Nullen
(oder ein Wert ≥ Gruppenordnung) wird abgelehnt.
Der Fehler zeigt sich spät und breit: Der Prozess **startet normal**, weil
der Schlüssel erst beim ersten Zugriff abgeleitet wird. Danach antwortet
aber alles mit `500`, was `server_p256_public_multibase` bzw.
`server_p256_keypair` benutzt — nicht nur
`createAccount` / `createSession` / `refreshSession` (`routes/auth.rs`),
sondern auch jeder Record-Write, weil dort der Access-Token gegen genau
diesen Serverschlüssel verifiziert wird: `routes/repo.rs`,
`routes/feed.rs`, `routes/blob.rs`, `routes/profile.rs`.
`.env.example` enthält einen gültigen Zufallswert als Beispiel. Für jede
echte Instanz einen eigenen erzeugen:
```bash
openssl rand -hex 32
```
2. **`S3_BUCKET_APPVIEW` ist Pflicht, aber ungenutzt.** `AppConfig` verlangt
die Variable, kein Code-Pfad greift auf `cfg.s3_bucket_appview` zu. Der
Wert muss trotzdem gesetzt sein, sonst startet keins der Binaries.
(In `.env.example` ist das entsprechend vermerkt.)
Beachtenswert bleibt außerdem `JETSTREAM_COLLECTIONS`: Die Liste in
`.env.example` enthält jetzt alle Collections, die der Indexer kennt —
`app.twi.post` (das eigene 160-Zeichen-Lexicon, `POST_COLLECTIONS` in
`crates/appview/src/indexer.rs`), `app.bsky.feed.post`, `app.bsky.feed.like`,
`app.bsky.feed.repost`, `app.bsky.graph.follow` und
`app.bsky.actor.profile`. Wird `app.twi.post` daraus entfernt, tauchen eigene
Posts nur noch über den direkten PDS→AppView-Push auf, nicht über den
Firehose-Weg.
## 4. Release-Build
```bash
cargo build --release -p pds-server -p appview
```
Ergebnis: `target/release/pds-server` und `target/release/appview`. Das
Release-Profil im Workspace-`Cargo.toml` setzt `lto = "thin"`,
`codegen-units = 1`, `opt-level = 3`, `strip = true` — die Binaries sind ohne
Debug-Symbole, für Crash-Analysen also vorher separat sichern.
Da die Migrationen einkompiliert werden, gehören `migrations/` und die
`lexicons/*.json` (via `include_str!` in `crates/pds-server/src/state.rs`) zum
Build-Input, nicht zum Laufzeit-Deployment. Auf dem Zielhost muss nur das
Binary liegen.
## 5. systemd
Beispiel für einen Host, auf dem die Binaries unter `/opt/maarcadetweet/bin`
liegen und `/etc/maarcadetweet/env` die Variablen aus Abschnitt 3 enthält
(Modus `0600`, wegen `PDS_JWT_SECRET` und `APPVIEW_INGEST_SECRET`).
`/etc/systemd/system/maarcadetweet-pds.service`:
```ini
[Unit]
Description=maarcadetweet PDS
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=maarcadetweet
Group=maarcadetweet
EnvironmentFile=/etc/maarcadetweet/env
ExecStart=/opt/maarcadetweet/bin/pds-server
Restart=on-failure
RestartSec=5
# Logs gehen über tracing auf stdout/stderr ins Journal
StandardOutput=journal
StandardError=journal
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target
```
`/etc/systemd/system/maarcadetweet-appview.service`:
```ini
[Unit]
Description=maarcadetweet AppView
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=maarcadetweet
Group=maarcadetweet
EnvironmentFile=/etc/maarcadetweet/env
ExecStart=/opt/maarcadetweet/bin/appview
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target
```
Hinweise:
* Beide Units brauchen dieselbe `EnvironmentFile` — beide Binaries bauen die
vollständige `AppConfig`.
* `dotenvy::dotenv()` sucht die `.env` ab dem Arbeitsverzeichnis aufwärts.
Ohne `WorkingDirectory=` ist das unter systemd `/`, es wird also keine
gefunden — die `EnvironmentFile` ist die einzige Quelle. Läge doch eine
`.env` im Arbeitsverzeichnis, hätten die systemd-Variablen trotzdem Vorrang.
* `Type=simple` ist korrekt: keins der Binaries forkt oder meldet Readiness.
Eine `Type=notify`-Integration existiert nicht.
* Es gibt **keinen** Signal-Handler für graceful Shutdown. `systemctl stop`
beendet den Prozess hart; bei der AppView bedeutet das, dass der letzte
Cursor-Flush nur passiert, wenn der Kanal regulär geschlossen wird —
praktisch also mit bis zu 100 Events Verlust (siehe Abschnitt 8). Das ist
unkritisch, weil der Cursor beim Resume ohnehin leicht in die Vergangenheit
zeigt und Events idempotent verarbeitet werden.
* Eine Abhängigkeit `After=` auf Postgres/MinIO ist nur nötig, wenn diese auf
demselben Host laufen. Die AppView bricht beim Start ab, wenn ihre DB nicht
erreichbar ist (`PgPool::connect` ohne Retry); die PDS nutzt
`PgPoolOptions` mit `acquire_timeout(10s)`, bricht aber ebenfalls ab, wenn
der erste Connect scheitert. `Restart=on-failure` fängt das ab.
## 6. Reverse-Proxy
### PDS
Die PDS muss öffentlich erreichbar sein, damit andere AT-Protocol-Dienste und
der Client sie ansprechen können. Routen aus `crates/pds-server/src/main.rs`:
```
GET /
GET /healthz
GET /xrpc/com.atproto.server.describeServer
POST /xrpc/com.atproto.server.createAccount
POST /xrpc/com.atproto.server.createSession
POST /xrpc/com.atproto.server.refreshSession
POST /xrpc/com.atproto.identity.resolveHandle
POST /xrpc/com.atproto.repo.createRecord
POST /xrpc/com.atproto.repo.deleteRecord
POST /xrpc/com.atproto.feed.like.create
POST /xrpc/com.atproto.uploadBlob
GET /xrpc/com.atproto.sync.getRepo
GET /xrpc/com.atproto.sync.getBlocks
GET /xrpc/com.atproto.sync.getLatestCommit
GET /xrpc/com.atproto.sync.getRecord
GET /xrpc/com.atproto.sync.listRepos
GET /xrpc/com.atproto.sync.getBlob
GET /xrpc/app.bsky.actor.profile.get
POST /xrpc/app.bsky.actor.profile.set
GET /blob/:cid
```
nginx-Skizze:
```nginx
server {
listen 443 ssl http2;
server_name pds.example.org;
# TLS-Konfiguration hier
location / {
proxy_pass http://127.0.0.1:2583;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# uploadBlob: MAX_BLOB_SIZE in routes/blob.rs ist 1 MiB.
# Der Proxy darf nicht enger sein, sonst kommt statt der
# XRPC-Fehlerantwort ein nginx-413 beim Client an.
client_max_body_size 1m;
}
}
```
`PDS_PUBLIC_URL` muss auf genau diese öffentliche URL zeigen — der Wert landet
im DID-Doc-`serviceEndpoint`, im JWT-`iss` und als Basis der Blob-URLs.
**Noch offen:** Die PDS liefert selbst **kein** `/.well-known/did.json` und
kein `/.well-known/atproto-did` aus (im Router nicht vorhanden). `at-identity`
kann solche Dokumente *auflösen*, aber wer `did:web`-Handles auf dieser PDS
betreiben will, muss die Dateien vorerst statisch über den Proxy ausliefern.
Ebenfalls offen: `describeServer` gibt die DID hart als
`did:web:pds.maarcadetweet.local` zurück, unabhängig von `PDS_PUBLIC_URL`.
### AppView
Die AppView setzt ihr CORS selbst — `crates/appview/src/routes.rs`:
```rust
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
```
Also `Access-Control-Allow-Origin: *` für alle Routen, inklusive
`/internal/ingest-commit`. Der Grund steht im Code: der Tauri-Webview ruft die
AppView von einem anderen Origin aus auf (`http://127.0.0.1:1430` im Dev,
`tauri://` / `asset://` im Bundle), und die Read-Endpoints tragen keine
Auth-Cookies.
Für ein öffentliches Deployment heißt das:
* `POST /internal/ingest-commit` gehört **nicht** ins Internet. Entweder im
Proxy blocken (`location /internal/ { deny all; }`) oder die AppView nur auf
dem internen Interface lauschen lassen und den Public-Vhost nur auf `/api/`
und `/healthz` mappen.
* `APPVIEW_INGEST_SECRET` setzen, sobald die AppView irgendwie erreichbar ist —
ohne das Secret ist der Ingest-Endpoint unauthentifiziert.
* Wer den Wildcard-Origin einschränken will, muss den Header im Proxy
überschreiben, nicht nur ergänzen — sonst stehen zwei Werte drin:
```nginx
location /api/ {
proxy_pass http://127.0.0.1:2584;
proxy_hide_header Access-Control-Allow-Origin;
add_header Access-Control-Allow-Origin "tauri://localhost" always;
}
location /internal/ { deny all; }
```
Eine Konfigurierbarkeit der erlaubten Origins über eine Umgebungsvariable gibt
es im Code **nicht** — das ist noch offen.
### CSP im Client
`tauri.conf.json` setzt `connect-src 'self' ipc: http://ipc.localhost`. Die
HTTP-Aufrufe an PDS/AppView laufen über den Rust-IPC-Layer
(`src-tauri/src/pds_client.rs`, `appview_client.rs`), nicht aus dem Webview —
die CSP muss also für neue Backend-URLs nicht angefasst werden.
## 7. Health-Checks und Logs
### PDS
```
GET /healthz → {"ok": true}
GET / → {"name":"maarcadetweet-pds","version":"0.1.0"}
```
`/healthz` ist ein reiner Liveness-Probe ohne DB-Zugriff — ein `200` bedeutet
nicht, dass Postgres erreichbar ist.
Beim Start pingt die PDS zusätzlich MinIO (`S3BlobStore::ping`, ein `HEAD` auf
`${S3_ENDPOINT}/${S3_BUCKET_PDS}`; `2xx` und `404` gelten als erreichbar). Ein
Fehlschlag ist **kein** Startabbruch, sondern eine Warnung:
```
s3 ping failed at startup; uploadBlob will serve from local blockstore only
```
### AppView
```
GET /healthz → {"ok":true,"lag_ms":…,"events_processed":…,"jetstream_connected":…}
GET / → {"name":"maarcadetweet-appview","version":"0.1.0"}
```
Diese drei Felder sind der eigentliche Monitoring-Hook:
* `jetstream_connected` — `false` heißt: WebSocket ist weg, der Consumer
reconnectet mit Backoff (1 s, verdoppelnd bis `max_backoff_secs = 30`).
* `events_processed` — monoton steigender Zähler. Stagniert er bei
`jetstream_connected: true`, kommt nichts an.
* `lag_ms` — Differenz zwischen lokaler Uhr und `time_us` des letzten Events,
auf `0` geklemmt, wenn negativ. `0` heißt also auch „noch kein Event gesehen".
### Logs
Beide Binaries loggen über `tracing_subscriber::fmt()` mit
`EnvFilter::try_from_default_env()` auf stdout/stderr, Fallback-Filter `info`.
Unter systemd landet alles im Journal:
```bash
journalctl -u maarcadetweet-pds -f
journalctl -u maarcadetweet-appview -f
```
Empfehlung für den Betrieb (aus `.env.example` abgeleitet, `sqlx` sonst sehr
gesprächig):
```
RUST_LOG=info,sqlx=warn
```
Log-Zeilen, auf die es sich lohnt zu achten:
| Meldung | Bedeutung |
|---|---|
| `jetstream error: …` / `reconnecting in Ns` | Firehose-Verbindung verloren |
| `apply_commit failed; NOT advancing cursor` | Indexer-Fehler, Event wird beim nächsten Replay erneut versucht |
| `cursor flush failed` | Cursor-Schreibvorgang auf die DB schlug fehl |
| `s3 ping failed at startup` | MinIO beim PDS-Start nicht erreichbar |
| `plc submit failed (dev ok)` | PLC-Directory nicht erreichbar; die DID bleibt lokal gültig, ist aber global nicht registriert |
## 8. Neustart-Verhalten
**PDS.** Zustandslos bis auf Postgres und MinIO. Der In-Memory-Blockstore
(`MemoryBlockstore` in `state.rs`) wird beim Start neu aufgebaut; persistent
sind `repo_blocks`, `repos`, `users`, `blobs`, `sessions`, `plc_ops` in der
PDS-DB. Migrationen laufen bei jedem Start (idempotent über
`_sqlx_migrations`).
**AppView — Jetstream-Cursor.** Der Fortschritt liegt in der Tabelle
`jetstream_cursor` (genau eine Zeile, `id = 1`, Spalte `cursor` =
Mikrosekunden seit Epoch). Der Ablauf:
1. Beim Start liest `indexer::cursor_get` die Zeile. Ist der Wert `> 0`, wird
der Consumer mit `with_cursor(...)` gebaut und schickt `cursor` im
`subscribe`-Options-Frame an Jetstream — die Verbindung setzt dort fort
statt am Live-Ende. Bei `0` startet er ohne Cursor.
2. Der Handler zählt jedes Event. Nur jedes 100. Event schickt er einen Tick
in einen `mpsc`-Kanal (Kapazität 32, `try_send` — bei vollem Kanal wird der
Tick verworfen).
3. Ein Hintergrund-Task sammelt die Ticks und schreibt alle 500 ms das Maximum
per `UPDATE jetstream_cursor SET cursor = GREATEST(cursor, $1)`. Der Cursor
kann dadurch nie zurückspringen.
4. Schlägt `apply_commit` fehl, wird der Cursor für dieses Event **nicht**
vorgerückt — der Reconnect liefert es erneut.
Praktische Folge: Nach einem Neustart werden bis zu ~100 Events (plus die
letzten 500 ms) erneut verarbeitet. Alle Indexer-Schreibpfade sind als Upserts
gebaut, das Replay ist also unschädlich. Umgekehrt gilt: War die AppView länger
weg als Jetstreams Backfill-Fenster, sind die Events dazwischen verloren; ein
Backfill-Werkzeug dafür existiert nicht (noch offen).
**Eigene Posts sind vom Cursor unabhängig.** Die PDS pusht jeden lokalen Commit
direkt an `POST /internal/ingest-commit`. Der Push ist best effort mit 5 s
Timeout und blockiert den Record-Write nie — fällt er aus, kommt der Record
später über Jetstream (sofern die Collection in `JETSTREAM_COLLECTIONS` steht,
siehe Abschnitt 3).
**Handle-Sync.** Läuft als eigener Task alle
`APPVIEW_HANDLE_SYNC_INTERVAL_SECS` Sekunden und füllt leere `posts.handle`
nach — zuerst über die lokale PDS (`PdsHandleResolver`, 2 s Timeout), dann PLC
bzw. `did:web`. Nach einem Neustart holt der erste Durchlauf das nach; der
Zustand ist reine Anzeigekosmetik.
## 9. Was noch offen ist
* Kein Compose-Service für `pds-server` / `appview` — das Compose-File deckt nur
Postgres und MinIO ab. Es gibt kein Dockerfile im Repo.
* `at-blob` spricht ausschließlich MinIO ohne Signature V4 (siehe Modul-Doku in
`crates/at-blob/src/s3.rs`); echtes AWS S3 funktioniert damit nicht.
* Kein Graceful-Shutdown, keine Readiness- (im Unterschied zur Liveness-)Probe.
* Keine konfigurierbare CORS-Allowlist in der AppView.
* Kein `.well-known`-Handling in der PDS, `describeServer` liefert eine
hartkodierte DID.
* Kein Backfill-Werkzeug für Jetstream-Lücken.
+311
View File
@@ -0,0 +1,311 @@
# Tauri-Release und Auto-Update
Der Desktop-Client ist auf Auto-Update vorbereitet, aber nicht scharf
geschaltet. Dieses Dokument beschreibt den vollständigen Weg von den
Signing-Keys bis zum ausgelieferten `latest.json` — und was dafür im Repo noch
fehlt.
Alle Pfade sind relativ zu `crates/tauri-app/`, sofern nicht anders angegeben.
## Ist-Zustand
| Baustein | Stand |
|---|---|
| Rust-Plugin | `tauri_plugin_updater::Builder::new().build()` ist in `src-tauri/src/lib.rs` registriert |
| Capability | `src-tauri/capabilities/default.json` enthält `updater:default``allow-check`, `allow-download`, `allow-install`, `allow-download-and-install` für das `main`-Window |
| Config | `src-tauri/tauri.conf.json`, Block `plugins.updater`: leerer `pubkey`, Platzhalter-Endpoint `https://releases.maarcadetweet.local/{{target}}/{{arch}}/{{current_version}}` |
| Updater-Artefakte | `bundle.createUpdaterArtifacts` ist **nicht** gesetzt (Default: aus) — der Build erzeugt keine `.sig`-Dateien und kein `.app.tar.gz` |
| Aufruf im Client | **Fehlt.** Weder das npm-Paket `@tauri-apps/plugin-updater` noch ein `check()`-Aufruf existieren (weder in `src/` noch in `src-tauri/src/`) |
| CLI | `@tauri-apps/cli` ist als devDependency installiert, lokal geprüft: `npx tauri --version``tauri-cli 2.11.4` |
| Plugin-Version | `tauri-plugin-updater` 2.10.1 laut `src-tauri/Cargo.lock` |
**Wichtig:** Die Felder `active` und `dialog` in `plugins.updater` stammen aus
Tauri v1. Der v2-Updater kennt sie nicht — seine `Config` besteht aus
`endpoints`, `pubkey`, `windows`, `dangerousInsecureTransportProtocol`,
`dangerousAcceptInvalidCerts`, `dangerousAcceptInvalidHostnames`. Unbekannte
Schlüssel werden beim Deserialisieren still ignoriert. `"active": false`
schaltet den Updater also **nicht** ab; dass heute nichts passiert, liegt
allein daran, dass niemand `check()` aufruft. Wer den Updater wirklich
deaktivieren will, entfernt den `plugins.updater`-Block und die
`tauri_plugin_updater`-Registrierung.
## 1. Signing-Keys erzeugen
Der Updater akzeptiert nur signierte Artefakte. Das Schlüsselpaar wird einmal
erzeugt und danach nie wieder gewechselt — ein neuer Public Key macht alle
installierten Clients update-unfähig.
```bash
cd crates/tauri-app
npx tauri signer generate -w ~/.tauri/maarcadetweet.key
```
Optionen laut `npx tauri signer generate --help`:
| Flag | Bedeutung |
|---|---|
| `-w, --write-keys <PFAD>` | Privaten Schlüssel in eine Datei schreiben (sonst nur stdout) |
| `-p, --password <PW>` | Passwort für den privaten Schlüssel setzen |
| `-f, --force` | Vorhandene Datei überschreiben |
| `--ci` | Keine interaktiven Rückfragen |
Ergebnis: `~/.tauri/maarcadetweet.key` (privat, **niemals** ins Repo) und
`~/.tauri/maarcadetweet.key.pub` (öffentlich, base64). Der Inhalt der
`.pub`-Datei ist genau das, was in `pubkey` gehört.
Für den Build-Rechner bzw. die CI:
```bash
export TAURI_SIGNING_PRIVATE_KEY="$(cat ~/.tauri/maarcadetweet.key)"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="…"
```
`TAURI_SIGNING_PRIVATE_KEY` akzeptiert den Schlüsselinhalt oder einen Pfad.
Fehlt die Variable beim Build mit aktivierten Updater-Artefakten, bricht das
Bundling ab.
## 2. Konfiguration — ohne den Dev-Zustand zu brechen
Die laufende `src-tauri/tauri.conf.json` soll unangetastet bleiben (leerer
`pubkey`, keine Updater-Artefakte). Die CLI kann Konfigurationen zur Build-Zeit
zusammenführen:
```
-c, --config <CONFIG> JSON strings or paths to JSON, JSON5 or TOML files to
merge with the default configuration file
```
Der saubere Weg ist deshalb eine **Release-Overlay-Datei**, die nur die
Release-Unterschiede enthält. Anlegen als
`crates/tauri-app/src-tauri/tauri.release.conf.json` (existiert noch nicht):
```json
{
"bundle": {
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"endpoints": [
"https://releases.example.org/maarcadetweet/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6…HIER_DER_INHALT_VON_.key.pub…",
"windows": {
"installMode": "passive"
}
}
}
}
```
Build damit:
```bash
cd crates/tauri-app
npx tauri build --config src-tauri/tauri.release.conf.json
```
Feldbedeutungen (geprüft an `tauri-plugin-updater` 2.11 / `tauri-utils` 2.9):
| Feld | Bedeutung |
|---|---|
| `bundle.createUpdaterArtifacts` | `true` erzeugt die Updater-Artefakte (`.sig`, unter macOS zusätzlich das `.app.tar.gz`). Alternativ `"v1Compatible"` für v1-Clients |
| `plugins.updater.pubkey` | Public Key aus Schritt 1. Pflichtfeld — fehlt es ganz, scheitert das Deserialisieren der Plugin-Config |
| `plugins.updater.endpoints` | Liste von URLs, der Reihe nach probiert. Müssen `https` sein: in Release-Builds bricht ein `http`-Endpoint mit `InsecureTransportProtocol` ab (im Debug-Build nur eine Warnung), es sei denn `dangerousInsecureTransportProtocol` ist gesetzt |
| `plugins.updater.windows.installMode` | `passive` (Default), `basicUi` oder `quiet` |
### Endpoint-Platzhalter
In der Endpoint-URL werden ersetzt: `{{current_version}}`, `{{target}}`,
`{{arch}}` und `{{bundle_type}}`. Achtung auf einen Fallstrick — der bereits in
der Config stehende Platzhalter-Endpoint benutzt genau diese Form:
* `{{target}}` wird in der **URL** nur durch das Betriebssystem ersetzt
(`linux`, `darwin`, `windows`) — nicht durch `darwin-aarch64`.
* `{{arch}}` liefert die Architektur (`x86_64`, `aarch64`, `i686`, `armv7`,
`riscv64`).
* `{{bundle_type}}` liefert den laufenden Bundle-Typ: `app`, `msi`, `nsis`,
`appimage`, `deb` oder `rpm` — bzw. `unknown`, wenn er nicht bestimmbar ist.
Ein statisches `latest.json` (eine Datei für alle Plattformen) ist der
einfachere Weg und kommt ohne Platzhalter aus.
## 3. `latest.json`
Der Updater versteht zwei Formate. Für einen einfachen Static-File-Server ist
das **statische Format** richtig:
```json
{
"version": "0.2.0",
"notes": "Thread-Ansicht, schnellere Timeline",
"pub_date": "2026-01-15T10:00:00Z",
"platforms": {
"darwin-aarch64": {
"signature": "<Inhalt von maarcadetweet.app.tar.gz.sig>",
"url": "https://releases.example.org/maarcadetweet/0.2.0/maarcadetweet_aarch64.app.tar.gz"
},
"darwin-x86_64": {
"signature": "…",
"url": "https://releases.example.org/maarcadetweet/0.2.0/maarcadetweet_x64.app.tar.gz"
},
"linux-x86_64": {
"signature": "…",
"url": "https://releases.example.org/maarcadetweet/0.2.0/maarcadetweet_0.2.0_amd64.AppImage"
},
"windows-x86_64": {
"signature": "…",
"url": "https://releases.example.org/maarcadetweet/0.2.0/maarcadetweet_0.2.0_x64-setup.exe"
}
}
}
```
Regeln, die der Plugin-Code vorgibt:
* **Schlüssel der `platforms`-Map:** Der Updater sucht in dieser Reihenfolge
nach `{os}-{arch}-{installer}` und danach nach `{os}-{arch}`.
`os``linux` | `darwin` | `windows`, `arch``i686` | `x86_64` | `armv7` |
`aarch64` | `riscv64`, `installer``appimage` | `deb` | `rpm` | `app` |
`msi` | `nsis`. Die Kurzform `darwin-aarch64` reicht also; die lange Form
`windows-x86_64-nsis` ist nur nötig, wenn man MSI und NSIS gleichzeitig
ausliefert.
* **`version`** wird als Semver geparst. Ein Update wird nur angeboten, wenn
`release.version > current_version` — die Version kommt aus dem Feld
`version` in `tauri.conf.json` (heute `0.1.0`). Ein Release ohne
Versionsbump wird stillschweigend ignoriert.
* **`signature`** ist der **Inhalt** der zugehörigen `.sig`-Datei, nicht deren
URL.
* **`notes`** und **`pub_date`** sind optional; `pub_date` als RFC 3339.
* Ein dynamischer Endpoint darf mit **`204 No Content`** antworten, wenn es
kein Update gibt. Dann liefert er statt der `platforms`-Map ein flaches
Objekt mit `version`, `url` und `signature` für genau die anfragende
Plattform.
## 4. Build pro Plattform
`tauri build` ist ein Cross-Build nur eingeschränkt — jede Plattform wird auf
ihrem eigenen Host (oder Runner) gebaut.
```bash
cd crates/tauri-app
# macOS (Apple Silicon)
npx tauri build --config src-tauri/tauri.release.conf.json
# macOS Universal (beide Rust-Targets müssen installiert sein)
npx tauri build --target universal-apple-darwin \
--config src-tauri/tauri.release.conf.json
# Windows / Linux jeweils auf dem passenden Host, gleicher Aufruf
```
Ablauf laut `build.beforeBuildCommand` in `tauri.conf.json`: erst
`npm run build` (Vite → `crates/tauri-app/dist`, entspricht `frontendDist:
"../dist"`), dann der Cargo-Release-Build, dann das Bundling.
`bundle.targets` steht auf `"all"`.
Nützliche Flags (aus `npx tauri build --help`):
| Flag | Zweck |
|---|---|
| `-t, --target <TRIPLE>` | Ziel-Triple, oder `universal-apple-darwin` |
| `-b, --bundles <LISTE>` | Nur bestimmte Bundle-Typen erzeugen |
| `--no-bundle` | Nur das Binary, kein Installer |
| `-c, --config <PFAD>` | Config-Overlay (siehe oben) |
| `--ci` | Keine interaktiven Rückfragen |
## 5. Wo die Artefakte landen
`crates/tauri-app/src-tauri/Cargo.toml` deklariert ein eigenes `[workspace]`,
der Build-Output liegt deshalb **nicht** im Repo-Root-`target/`, sondern unter
`crates/tauri-app/src-tauri/target/release/bundle/`. Mit `--target <triple>`
schiebt sich das Triple dazwischen:
`src-tauri/target/<triple>/release/bundle/`.
| Plattform | Pfad unter `bundle/` | Für den Updater relevant |
|---|---|---|
| macOS | `macos/maarcadetweet.app` | `macos/maarcadetweet.app.tar.gz` + `.tar.gz.sig` |
| macOS | `dmg/maarcadetweet_<version>_<arch>.dmg` | nein (nur für die Erstinstallation) |
| Windows | `msi/maarcadetweet_<version>_<arch>_<lang>.msi` | `.msi` + `.msi.sig` |
| Windows | `nsis/maarcadetweet_<version>_<arch>-setup.exe` | `.exe` + `.exe.sig` |
| Linux | `appimage/maarcadetweet_<version>_<arch>.AppImage` | `.AppImage` + `.AppImage.sig` |
| Linux | `deb/…deb`, `rpm/…rpm` | nein |
Die `.sig`-Dateien entstehen nur, wenn `createUpdaterArtifacts` aktiv ist und
die Signing-Env-Variablen gesetzt sind. Für das `latest.json` wird der
**Dateiinhalt** der `.sig` gebraucht, das Artefakt selbst wird nicht
ausgeliefert.
## 6. Was im Client noch fehlt
Config und Signaturen allein bewirken nichts — der Client fragt nirgends nach
Updates. Es gibt zwei Wege:
### a) Vom Frontend aus
```bash
cd crates/tauri-app
npm install @tauri-apps/plugin-updater @tauri-apps/plugin-process
```
```ts
import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
const update = await check();
if (update) {
await update.downloadAndInstall();
await relaunch();
}
```
Die Capability erlaubt `check`, `download`, `install` und
`downloadAndInstall` bereits. **Zusätzlich nötig:** `tauri-plugin-process` als
Rust-Dependency, `tauri_plugin_process::init()` in `lib.rs` und
`"process:default"` in `capabilities/default.json` — sonst schlägt `relaunch()`
fehl.
### b) Vom Rust-Layer aus
```rust
use tauri_plugin_updater::UpdaterExt;
if let Ok(Some(update)) = app.updater()?.check().await {
update.download_and_install(|_, _| {}, || {}).await?;
}
```
Ohne npm-Abhängigkeit, dafür ohne UI-Kontrolle im Frontend. Der Aufruf gehört
in den `setup`-Hook oder hinter ein Tauri-Command, das die Svelte-Seite
aufrufen kann.
Welcher Weg genommen wird, ist **noch offen** — im Repo existiert keiner von
beiden.
## 7. Release-Checkliste
1. `version` in `src-tauri/tauri.conf.json` erhöhen (Semver; der Vergleich
entscheidet, ob ein Update angeboten wird). `package.json` und
`src-tauri/Cargo.toml` der Konsistenz halber mitziehen.
2. Auf jedem Plattform-Host mit gesetzten `TAURI_SIGNING_PRIVATE_KEY*`-Variablen
und dem Release-Overlay bauen.
3. Installer und Updater-Artefakte einsammeln, `.sig`-Inhalte auslesen.
4. `latest.json` schreiben und zusammen mit den Artefakten unter einer
`https`-URL veröffentlichen. Die URL muss zum `endpoints`-Eintrag passen.
5. Mit einer älteren installierten Version gegenprüfen, dass `check()` das
Update findet und die Signaturprüfung durchgeht.
## 8. Offene Punkte
* Kein Release-Overlay im Repo — die Datei aus Abschnitt 2 muss angelegt
werden. Der Endpoint `https://releases.maarcadetweet.local/…` in der aktuellen
Config ist ein Platzhalter und existiert nicht.
* Kein Update-Server, kein CI-Workflow, kein Skript, das `latest.json` erzeugt
(`scripts/` ist leer).
* Keine Code-Signierung/Notarisierung für macOS und keine Authenticode-Signatur
für Windows konfiguriert (`bundle` enthält weder `macOS.signingIdentity` noch
`windows.certificateThumbprint`). Der Tauri-Updater-Schlüssel ersetzt das
nicht — ohne Notarisierung meldet Gatekeeper die App als nicht verifiziert.
* Die Felder `active` und `dialog` in `plugins.updater` sind v1-Reste ohne
Wirkung (siehe oben) und sollten beim Scharfschalten entfernt werden.