3 Commits

Author SHA1 Message Date
5c76e12142 chore(prototype): state-model prototype for the realtime frontend
Throwaway, kept as a primary source rather than on main. Single self-contained
HTML file: free-play buttons plus six guided walkthroughs over a pure reducer.

It settled two things the spec had wrong or missing:

- The client must supply the message id. With a server-generated id there is
  nothing to reconcile the optimistically rendered message against when it
  comes back over the realtime channel, and it renders twice. Both cases are
  reproducible via the toggle at the top.
- An unread *count* cannot be derived without fetching, because seq counts
  events and not messages. Hence: fetch for DMs, a plain dot for channels.
2026-08-14 18:19:18 +02:00
b93ca7dcb6 ci: add Gitea workflow for Linux and Android release builds
Some checks failed
release / build (push) Failing after 2m6s
2026-08-14 12:04:34 +02:00
fb3bc6e93a docs: add README 2026-08-13 13:10:55 +02:00
3 changed files with 699 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
name: release
on:
push:
tags: ["v*"]
jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- uses: android-actions/setup-android@v3
- uses: nttld/setup-ndk@v1
id: ndk
with:
ndk-version: r26d
- name: System dependencies
run: |
sudo apt-get update
sudo apt-get install -y jq libgtk-3-dev libwebkit2gtk-4.1-dev \
libsoup-3.0-dev libayatana-appindicator3-dev librsvg2-dev
- run: npm ci
# Desktop: deb + rpm + AppImage (bundle.targets = "all")
- name: Build Linux desktop
run: npm run tauri build
- name: Build Android APK
env:
NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
run: |
npm run tauri android init
npm run tauri android build -- --apk
# ponytail: sign the finished APK with apksigner instead of patching
# gen/android/app/build.gradle.kts — keeps gen/ throwaway, no committed generated code.
- name: Sign APK
env:
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE }}
KS_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
run: |
echo "$KEYSTORE_B64" | base64 -d > /tmp/ks.jks
BT="$ANDROID_HOME/build-tools/$(ls "$ANDROID_HOME/build-tools" | sort -V | tail -1)"
IN=src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk
"$BT/zipalign" -p -f 4 "$IN" /tmp/aligned.apk
"$BT/apksigner" sign --ks /tmp/ks.jks --ks-pass "env:KS_PASS" \
--out "rustification-${GITHUB_REF_NAME}.apk" /tmp/aligned.apk
- name: Publish release
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -eu
ID=$(curl -sSf -X POST "$API/releases" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\"}" | jq -r .id)
for f in src-tauri/target/release/bundle/deb/*.deb \
src-tauri/target/release/bundle/rpm/*.rpm \
src-tauri/target/release/bundle/appimage/*.AppImage \
rustification-${GITHUB_REF_NAME}.apk; do
[ -f "$f" ] || continue
curl -sSf -X POST "$API/releases/$ID/assets?name=$(basename "$f")" \
-H "Authorization: token $TOKEN" -F "attachment=@$f"
done

View File

@@ -0,0 +1,493 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Prototyp — Frontend-Zustandsmodell nach dem WebSocket-Umbau</title>
<style>
:root {
--akzent: #b4491f;
--grund: #fbfaf8;
--papier: #ffffff;
--linie: #e3ded6;
--text: #23201d;
--leise: #7d7469;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 2.5rem 1.5rem 6rem;
background: var(--grund); color: var(--text);
font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
.huelle { max-width: 66rem; margin: 0 auto; }
h1 { font-size: 1.55rem; margin: 0 0 .4rem; letter-spacing: -.01em; }
h2 { font-size: .82rem; text-transform: uppercase; letter-spacing: .09em;
color: var(--leise); margin: 2.4rem 0 .9rem; font-weight: 600; }
p { margin: 0 0 1rem; }
.frage { background: var(--papier); border: 1px solid var(--linie);
border-left: 3px solid var(--akzent); padding: 1.1rem 1.3rem;
border-radius: 3px; margin-bottom: .5rem; }
.frage strong { display: block; margin-bottom: .35rem; }
.frage p:last-child { margin-bottom: 0; }
.klein { font-size: .87rem; color: var(--leise); }
.schalter { display: flex; gap: 1.6rem; flex-wrap: wrap; margin: 1rem 0 0; }
.schalter label { display: flex; gap: .5rem; align-items: flex-start;
font-size: .88rem; cursor: pointer; max-width: 30rem; }
.schalter input { margin-top: .35rem; }
.raster { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; }
@media (max-width: 62rem) { .raster { grid-template-columns: 1fr; } }
.tafel { background: var(--papier); border: 1px solid var(--linie);
border-radius: 3px; padding: 1.1rem 1.3rem; }
.server { border-bottom: 1px solid var(--linie); padding: .7rem 0; }
.server:last-child { border-bottom: 0; padding-bottom: 0; }
.server:first-child { padding-top: 0; }
.serverkopf { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; }
.name { font-weight: 600; }
.zustand { font-size: .78rem; padding: .1rem .5rem; border-radius: 2rem;
border: 1px solid var(--linie); color: var(--leise); white-space: nowrap; }
.zustand.verbunden { color: #1f6b3a; border-color: #bfe0cb; background: #f2faf5; }
.zustand.neuverbinden { color: #96601a; border-color: #ecd8b4; background: #fdf7ec; }
.zustand.getrennt { color: #8f2f2f; border-color: #eec8c8; background: #fdf3f3; }
.zustand.abgemeldet { color: var(--leise); background: #f3f1ed; }
.conv { display: flex; justify-content: space-between; gap: .8rem;
padding: .28rem 0 .28rem .9rem; font-size: .9rem; }
.conv.offen { border-left: 2px solid var(--akzent); padding-left: .75rem; font-weight: 600; }
.marke { font-size: .78rem; color: var(--akzent); white-space: nowrap; }
.msgs { margin: .35rem 0 .2rem 1.6rem; padding: 0; list-style: none;
font-size: .84rem; color: var(--leise); }
.msgs li { padding: .1rem 0; }
.msgs .pending { opacity: .5; font-style: italic; }
.msgs .deleted { text-decoration: line-through; opacity: .45; }
.msgs .seq { display: inline-block; min-width: 2.4rem; color: #b3aa9d; }
button { font: inherit; font-size: .87rem; padding: .42rem .85rem;
border: 1px solid var(--linie); background: var(--papier);
border-radius: 3px; cursor: pointer; color: var(--text); }
button:hover:not(:disabled) { border-color: var(--akzent); color: var(--akzent); }
button:disabled { opacity: .35; cursor: default; }
button.jetzt { border-color: var(--akzent); background: var(--akzent); color: #fff; }
.knopfreihe { display: flex; flex-wrap: wrap; gap: .5rem; }
.reiter { display: flex; flex-wrap: wrap; gap: .4rem; margin-bottom: 1rem; }
.reiter button.aktiv { border-color: var(--akzent); color: var(--akzent); font-weight: 600; }
ol.schritte { margin: 1rem 0 0; padding: 0; list-style: none; }
ol.schritte li { display: flex; gap: .7rem; align-items: baseline;
padding: .3rem 0; border-top: 1px dotted var(--linie); }
ol.schritte li:first-child { border-top: 0; }
ol.schritte .nr { color: #b3aa9d; font-size: .8rem; min-width: 1.2rem; }
ol.schritte .erledigt { color: var(--leise); text-decoration: line-through; }
.protokoll { margin-top: 1.4rem; font-size: .84rem; color: var(--leise);
max-height: 13rem; overflow-y: auto; }
.protokoll div { padding: .16rem 0; border-top: 1px dotted var(--linie); }
.protokoll div:first-child { border-top: 0; }
.protokoll .neu { color: var(--akzent); }
footer { margin-top: 3rem; font-size: .82rem; color: var(--leise);
border-top: 1px solid var(--linie); padding-top: 1rem; }
</style>
</head>
<body>
<div class="huelle">
<h1>Frontend-Zustandsmodell nach dem WebSocket-Umbau</h1>
<p class="klein">Wegwerf-Prototyp zu Ticket 10 der Map <em>websocket-realtime</em>. Nur zum Durchklicken — kein Produktionscode.</p>
<div class="frage">
<strong>Die Frage, die dieser Prototyp beantwortet</strong>
<p>Der Client hält Verbindungen zu <strong>mehreren unabhängigen Servern</strong>. Der Rust-Layer besitzt die Verbindungen, führt die Cursor und lädt selbst nach; das Frontend bekommt <strong>fertige Entitäten</strong> geliefert und hat keine Cursor-Logik. Trägt dieses Zustandsmodell die unangenehmen Fälle — Nachricht in einer nicht geöffneten Conversation, optimistisch gesendete Nachricht, die gleich nochmal vom Server kommt, Bearbeitung, Löschung, erzwungener Neuabgleich, und ein einzelner ausgefallener Server neben funktionierenden?</p>
<p class="klein">Die beiden Schalter unten sind die eigentlich offenen Entscheidungen. Stell sie um und fahr dieselben Durchläufe nochmal — der Unterschied ist der Punkt.</p>
<div class="schalter">
<label><input type="checkbox" id="s-immer">
<span><strong>Rust lädt auch für nicht geöffnete Conversations nach.</strong> Aus: es kommt nur ein Signal, der Inhalt wird erst beim Öffnen geholt.</span></label>
<label><input type="checkbox" id="s-clientid" checked>
<span><strong>Der Client vergibt die Nachrichten-ID.</strong> Aus: der Server vergibt sie, der Client muss die eigene Nachricht am Echo wiedererkennen.</span></label>
</div>
</div>
<div class="raster">
<div>
<h2>Aktueller Zustand</h2>
<div class="tafel" id="zustand"></div>
<h2>Was gerade passiert ist</h2>
<div class="tafel protokoll" id="protokoll"></div>
</div>
<div>
<h2>Freies Ausprobieren</h2>
<div class="tafel">
<div class="knopfreihe" id="freispiel"></div>
</div>
<h2>Geführte Durchläufe</h2>
<div class="tafel">
<div class="reiter" id="reiter"></div>
<p id="szenario-text" class="klein"></p>
<ol class="schritte" id="schritte"></ol>
</div>
</div>
</div>
<footer>
Prototyp — wird nicht ausgeliefert. Der Reducer im ersten <code>&lt;script&gt;</code>-Block ist der Teil, der in echten Code übernommen werden kann; die Seite drumherum nicht.
</footer>
</div>
<script>
/* ------------------------------------------------------------------ *
* REINER REDUCER — dieser Block ist der übernehmbare Teil.
* Kein DOM, keine Knöpfe, keine Seite. (Zustand, Aktion) => Zustand.
* ------------------------------------------------------------------ */
const A = "server-a", B = "server-b";
function anfang() {
return {
optionen: { rustLaedtImmer: false, clientVergibtId: true },
offen: { serverId: A, conversationId: "allgemein" },
naechsteSeq: { "server-a/allgemein": 3, "server-a/design": 8, "server-b/team": 5 },
server: {
[A]: {
name: "chat.example.org", verbindung: "verbunden",
conversations: {
allgemein: { name: "#allgemein", geladen: true, ungelesen: 0, nachrichten: [
{ id: "m1", seq: 1, autor: "Ida", text: "Moin.", zustand: "ok" },
{ id: "m2", seq: 2, autor: "Ruben", text: "Läuft der Server?", zustand: "ok" },
]},
design: { name: "#design", geladen: false, ungelesen: 0, nachrichten: [] },
},
},
[B]: {
name: "intern.firma.de", verbindung: "verbunden",
conversations: {
team: { name: "#team", geladen: false, ungelesen: 0, nachrichten: [] },
},
},
},
};
}
const istOffen = (s, srv, conv) =>
s.offen.serverId === srv && s.offen.conversationId === conv;
function sortiert(liste) {
// Anzeigereihenfolge ist ausschliesslich seq. Optimistische Nachrichten
// haben noch keine und hängen hinten an.
return [...liste].sort((x, y) => (x.seq ?? Infinity) - (y.seq ?? Infinity));
}
function reduce(zustand, aktion) {
const s = structuredClone(zustand);
const notiz = (t) => { s.letzteNotiz = t; };
switch (aktion.art) {
case "option": {
s.optionen[aktion.name] = aktion.wert;
notiz(`Option „${aktion.name}“ ist jetzt ${aktion.wert ? "an" : "aus"}.`);
return s;
}
case "conversationGeoeffnet": {
const { serverId, conversationId } = aktion;
s.offen = { serverId, conversationId };
const c = s.server[serverId].conversations[conversationId];
if (!c.geladen) {
// Beim Öffnen wird geholt, was noch fehlt.
c.geladen = true;
c.nachrichten = aktion.inhalt ?? c.nachrichten;
notiz(`${c.name} geöffnet — Inhalt wurde beim Öffnen nachgeladen.`);
} else {
notiz(`${c.name} geöffnet.`);
}
c.ungelesen = 0;
return s;
}
/* Rust hat ein Signal bekommen, nachgeladen und liefert Entitäten hoch.
Ob überhaupt nachgeladen wurde, entscheidet die Option. */
case "entitaetenEingetroffen": {
const { serverId, conversationId, entitaeten } = aktion;
const srv = s.server[serverId];
const c = srv.conversations[conversationId];
const sichtbar = istOffen(s, serverId, conversationId);
if (!sichtbar && !s.optionen.rustLaedtImmer) {
// Nur ein Signal: wir wissen DASS sich etwas geändert hat, nicht WAS.
c.ungelesen = "•";
notiz(`${srv.name} · ${c.name}: Signal ohne Inhalt — Markierung gesetzt, Anzahl unbekannt.`);
return s;
}
for (const e of entitaeten) {
const i = c.nachrichten.findIndex((m) => m.id === e.id);
if (i === -1) {
c.nachrichten.push({ ...e, zustand: e.zustand ?? "ok" });
} else {
// Ersetzen, nicht anhängen. seq der Nachricht bleibt, also
// bewegt sich eine bearbeitete Nachricht NICHT im Verlauf.
c.nachrichten[i] = { ...c.nachrichten[i], ...e };
}
}
c.geladen = true;
c.nachrichten = sortiert(c.nachrichten);
if (sichtbar) {
c.ungelesen = 0;
notiz(`${c.name}: ${entitaeten.length} Entität(en) übernommen — offen, also direkt sichtbar.`);
} else {
c.ungelesen = (typeof c.ungelesen === "number" ? c.ungelesen : 0) + entitaeten.length;
notiz(`${srv.name} · ${c.name}: nachgeladen im Hintergrund, Anzahl bekannt (${c.ungelesen}).`);
}
return s;
}
/* Optimistisches Senden. */
case "gesendet": {
const { serverId, conversationId, text } = aktion;
const c = s.server[serverId].conversations[conversationId];
const id = s.optionen.clientVergibtId ? aktion.clientId : null;
c.nachrichten.push({
id, clientId: aktion.clientId, seq: null,
autor: "ich", text, zustand: "pending",
});
notiz(`Gesendet: „${text}“ — erscheint sofort, noch ohne seq.`);
return s;
}
/* Die eigene Nachricht kommt über den Realtime-Weg zurück. */
case "eigeneNachrichtZurueck": {
const { serverId, conversationId, clientId, text } = aktion;
const c = s.server[serverId].conversations[conversationId];
const schluessel = `${serverId}/${conversationId}`;
const seq = s.naechsteSeq[schluessel]++;
// Zusammenführen: nur möglich, wenn es etwas gibt, woran man
// die eigene Nachricht wiedererkennt.
const i = s.optionen.clientVergibtId
? c.nachrichten.findIndex((m) => m.clientId === clientId)
: -1;
if (i === -1) {
c.nachrichten.push({
id: `srv-${seq}`, seq, autor: "ich", text, zustand: "ok",
});
notiz(s.optionen.clientVergibtId
? `Zurück vom Server — nichts zum Zusammenführen gefunden, angehängt.`
: `DOPPELT: Server vergab eine eigene ID, die optimistische Nachricht bleibt daneben stehen.`);
} else {
c.nachrichten[i] = { ...c.nachrichten[i], id: clientId, seq, zustand: "ok" };
notiz(`Zurück vom Server — über die Client-ID zusammengeführt, kein Duplikat.`);
}
c.nachrichten = sortiert(c.nachrichten);
return s;
}
case "bearbeitet": {
const { serverId, conversationId, id, text } = aktion;
const c = s.server[serverId].conversations[conversationId];
const m = c.nachrichten.find((x) => x.id === id);
if (m) { m.text = text; m.bearbeitet = true; }
notiz(`Bearbeitet — seq bleibt ${m ? m.seq : "?"}, die Nachricht springt also nicht.`);
return s;
}
case "geloescht": {
const { serverId, conversationId, id } = aktion;
const c = s.server[serverId].conversations[conversationId];
const m = c.nachrichten.find((x) => x.id === id);
if (m) { m.zustand = "deleted"; m.text = "(gelöscht)"; }
notiz(`Gelöscht — kommt als Grabstein mit, nicht als Auslassung.`);
return s;
}
/* Server kann kein Delta bilden: Zwischenspeicher wegwerfen. */
case "resync": {
const { serverId, conversationId, inhalt } = aktion;
const srv = s.server[serverId];
const c = srv.conversations[conversationId];
const sichtbar = istOffen(s, serverId, conversationId);
c.nachrichten = sichtbar ? inhalt : [];
c.geladen = sichtbar;
c.ungelesen = sichtbar ? 0 : "•";
notiz(sichtbar
? `${c.name}: Neuabgleich — Zwischenspeicher verworfen und frisch geladen.`
: `${srv.name} · ${c.name}: Neuabgleich — Zwischenspeicher verworfen, Laden beim Öffnen.`);
return s;
}
case "verbindungVerloren": {
s.server[aktion.serverId].verbindung = "neuverbinden";
notiz(`${s.server[aktion.serverId].name} getrennt — versucht neu zu verbinden. Andere Server unberührt.`);
return s;
}
case "verbindungZurueck": {
const srv = s.server[aktion.serverId];
srv.verbindung = "verbunden";
notiz(`${srv.name} wieder verbunden — Rust zieht das Manifest und gleicht ab.`);
return s;
}
case "verdraengt": {
s.server[aktion.serverId].verbindung = "abgemeldet";
notiz(`${s.server[aktion.serverId].name}: von neuerer Verbindung verdrängt (4002) — es wird NICHT neu verbunden.`);
return s;
}
case "zuruecksetzen":
return { ...anfang(), optionen: zustand.optionen, letzteNotiz: "Zurückgesetzt." };
default:
return s;
}
}
/* ------------------------------------------------------------------ *
* Ende des übernehmbaren Teils. Alles darunter ist Wegwerf-Seite.
* ------------------------------------------------------------------ */
</script>
<script>
let zustand = anfang();
let protokoll = [];
let aktivesSzenario = 0;
let schrittIndex = 0;
const el = (id) => document.getElementById(id);
function schicke(aktion) {
zustand = reduce(zustand, aktion);
if (zustand.letzteNotiz) protokoll.unshift(zustand.letzteNotiz);
protokoll = protokoll.slice(0, 40);
zeichne();
}
/* ---- Aktionen für das freie Ausprobieren ---- */
let zaehler = 0;
const neuerText = () => ["Kurze Frage.", "Bin gleich da.", "Sieht gut aus.", "Passt so.", "Melde mich später."][zaehler++ % 5];
const freispiel = [
{ titel: "#allgemein öffnen", tu: () => schicke({ art: "conversationGeoeffnet", serverId: A, conversationId: "allgemein" }) },
{ titel: "#design öffnen", tu: () => schicke({ art: "conversationGeoeffnet", serverId: A, conversationId: "design",
inhalt: [{ id: "d1", seq: 6, autor: "Ida", text: "Entwurf liegt bereit.", zustand: "ok" },
{ id: "d2", seq: 7, autor: "Nour", text: "Schaue ich mir an.", zustand: "ok" }] }) },
{ titel: "#team öffnen (Server B)", tu: () => schicke({ art: "conversationGeoeffnet", serverId: B, conversationId: "team",
inhalt: [{ id: "t1", seq: 4, autor: "Milan", text: "Standup um zehn.", zustand: "ok" }] }) },
{ titel: "Neue Nachricht in #allgemein", tu: () => schicke({ art: "entitaetenEingetroffen", serverId: A, conversationId: "allgemein",
entitaeten: [{ id: `x${++zaehler}`, seq: zustand.naechsteSeq["server-a/allgemein"]++, autor: "Ruben", text: neuerText() }] }) },
{ titel: "Neue Nachricht in #design", tu: () => schicke({ art: "entitaetenEingetroffen", serverId: A, conversationId: "design",
entitaeten: [{ id: `y${++zaehler}`, seq: zustand.naechsteSeq["server-a/design"]++, autor: "Nour", text: neuerText() }] }) },
{ titel: "Neue Nachricht in #team (Server B)", tu: () => schicke({ art: "entitaetenEingetroffen", serverId: B, conversationId: "team",
entitaeten: [{ id: `z${++zaehler}`, seq: zustand.naechsteSeq["server-b/team"]++, autor: "Milan", text: neuerText() }] }) },
{ titel: "Ich sende etwas", tu: () => { const cid = `c${++zaehler}`; letzteEigene = { cid, text: neuerText() };
schicke({ art: "gesendet", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, clientId: cid, text: letzteEigene.text }); } },
{ titel: "…und sie kommt zurück", tu: () => { if (!letzteEigene) return;
schicke({ art: "eigeneNachrichtZurueck", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, clientId: letzteEigene.cid, text: letzteEigene.text }); } },
{ titel: "Älteste Nachricht bearbeiten", tu: () => { const c = aktuelleConv(); const m = c.nachrichten.find(x => x.zustand === "ok");
if (m) schicke({ art: "bearbeitet", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, id: m.id, text: m.text + " (korrigiert)" }); } },
{ titel: "Älteste Nachricht löschen", tu: () => { const c = aktuelleConv(); const m = c.nachrichten.find(x => x.zustand === "ok");
if (m) schicke({ art: "geloescht", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId, id: m.id }); } },
{ titel: "Server verlangt Neuabgleich", tu: () => schicke({ art: "resync", serverId: zustand.offen.serverId, conversationId: zustand.offen.conversationId,
inhalt: [{ id: "r1", seq: 20, autor: "Ida", text: "Frisch geladen nach Neuabgleich.", zustand: "ok" }] }) },
{ titel: "Server A verliert Verbindung", tu: () => schicke({ art: "verbindungVerloren", serverId: A }) },
{ titel: "Server A ist zurück", tu: () => schicke({ art: "verbindungZurueck", serverId: A }) },
{ titel: "Server A: verdrängt (4002)", tu: () => schicke({ art: "verdraengt", serverId: A }) },
{ titel: "Alles zurücksetzen", tu: () => { protokoll = []; letzteEigene = null; schicke({ art: "zuruecksetzen" }); } },
];
let letzteEigene = null;
const aktuelleConv = () => zustand.server[zustand.offen.serverId].conversations[zustand.offen.conversationId];
/* ---- Geführte Durchläufe ---- */
const szenarien = [
{ name: "Nachricht kommt an",
text: "Der einfache Fall. Eine Nachricht trifft in der geöffneten Conversation ein. Achte darauf, dass das Frontend nur fertige Entitäten einsetzt — es rechnet nichts aus und fragt nichts nach.",
schritte: [freispiel[0], freispiel[3], freispiel[3]] },
{ name: "Nachricht anderswo",
text: "Die Nachricht kommt in einer Conversation an, die NICHT geöffnet ist. Fahr diesen Durchlauf zweimal — einmal mit dem Schalter „Rust lädt auch für nicht geöffnete Conversations nach“ aus, einmal an. Aus bekommst du nur einen Punkt, weil niemand die Anzahl kennt; an bekommst du eine Zahl, aber Requests für Inhalt, den vielleicht niemand ansieht.",
schritte: [freispiel[0], freispiel[4], freispiel[4], freispiel[1]] },
{ name: "Optimistisch senden",
text: "Die eigene Nachricht erscheint sofort und kommt gleich darauf über den Realtime-Weg zurück. Mit Client-ID wird zusammengeführt. Schalte „Der Client vergibt die Nachrichten-ID“ AUS und fahr den Durchlauf erneut — dann steht dieselbe Nachricht zweimal da.",
schritte: [freispiel[0], freispiel[6], freispiel[7]] },
{ name: "Bearbeiten und Löschen",
text: "Eine bereits sichtbare Nachricht wird geändert und dann gelöscht. Achte auf die Position: die Sortierung folgt der seq der Nachricht, nicht dem Änderungszeitpunkt — sie springt also nicht ans Ende. Und Löschen erzeugt einen Grabstein, keine Lücke.",
schritte: [freispiel[0], freispiel[8], freispiel[9]] },
{ name: "Neuabgleich",
text: "Der Server kann kein Delta bilden — Cursor zu alt, Limit überschritten, oder der Listener hatte einen Aussetzer. Der Zwischenspeicher dieser Conversation wird verworfen und frisch geladen. Das ist derselbe Pfad, den auch die Erstverbindung nimmt.",
schritte: [freispiel[0], freispiel[3], freispiel[10]] },
{ name: "Ein Server fällt aus",
text: "Server A verliert die Verbindung, während Server B weiterläuft — der Zustand ist pro Server, nicht global. Danach wird Server A verdrängt (Close-Code 4002): dieser Zustand ist endgültig, es wird bewusst NICHT neu verbunden, sonst verdrängen sich zwei Geräte im Kreis.",
schritte: [freispiel[0], freispiel[11], freispiel[5], freispiel[12], freispiel[13]] },
];
/* ---- Zeichnen ---- */
function zeichne() {
el("s-immer").checked = zustand.optionen.rustLaedtImmer;
el("s-clientid").checked = zustand.optionen.clientVergibtId;
el("zustand").innerHTML = Object.entries(zustand.server).map(([sid, srv]) => {
const zk = { verbunden: "verbunden", neuverbinden: "verbindet neu", getrennt: "getrennt", abgemeldet: "abgemeldet (4002)" }[srv.verbindung];
const convs = Object.entries(srv.conversations).map(([cid, c]) => {
const offen = istOffen(zustand, sid, cid);
const marke = c.ungelesen === "•" ? "• ungelesen"
: (typeof c.ungelesen === "number" && c.ungelesen > 0) ? `${c.ungelesen} neu` : "";
const liste = offen && c.nachrichten.length
? `<ul class="msgs">${sortiert(c.nachrichten).map(m =>
`<li class="${m.zustand === "pending" ? "pending" : m.zustand === "deleted" ? "deleted" : ""}">` +
`<span class="seq">${m.seq ?? "—"}</span>${m.autor}: ${m.text}${m.bearbeitet ? " ✎" : ""}</li>`).join("")}</ul>`
: (offen ? `<ul class="msgs"><li>— leer —</li></ul>` : "");
return `<div class="conv ${offen ? "offen" : ""}"><span>${c.name}${c.geladen ? "" : ' <span class="klein">(nicht geladen)</span>'}</span><span class="marke">${marke}</span></div>${liste}`;
}).join("");
return `<div class="server"><div class="serverkopf"><span class="name">${srv.name}</span>` +
`<span class="zustand ${srv.verbindung}">${zk}</span></div>${convs}</div>`;
}).join("");
el("protokoll").innerHTML = protokoll.length
? protokoll.map((t, i) => `<div class="${i === 0 ? "neu" : ""}">${t}</div>`).join("")
: `<div class="klein">Noch nichts passiert. Drück einen Knopf.</div>`;
el("freispiel").innerHTML = "";
freispiel.forEach((a, i) => {
const b = document.createElement("button");
b.textContent = a.titel;
b.onclick = a.tu;
el("freispiel").appendChild(b);
});
el("reiter").innerHTML = "";
szenarien.forEach((sz, i) => {
const b = document.createElement("button");
b.textContent = sz.name;
if (i === aktivesSzenario) b.className = "aktiv";
b.onclick = () => { aktivesSzenario = i; schrittIndex = 0; protokoll = [];
letzteEigene = null; schicke({ art: "zuruecksetzen" }); };
el("reiter").appendChild(b);
});
const sz = szenarien[aktivesSzenario];
el("szenario-text").textContent = sz.text;
el("schritte").innerHTML = "";
sz.schritte.forEach((schritt, i) => {
const li = document.createElement("li");
const nr = document.createElement("span");
nr.className = "nr"; nr.textContent = `${i + 1}.`;
const b = document.createElement("button");
b.textContent = schritt.titel;
b.disabled = i !== schrittIndex;
if (i === schrittIndex) b.className = "jetzt";
if (i < schrittIndex) b.classList.add("erledigt");
b.onclick = () => { schritt.tu(); schrittIndex = i + 1; zeichne(); };
li.append(nr, b);
el("schritte").appendChild(li);
});
}
el("s-immer").onchange = (e) => schicke({ art: "option", name: "rustLaedtImmer", wert: e.target.checked });
el("s-clientid").onchange = (e) => schicke({ art: "option", name: "clientVergibtId", wert: e.target.checked });
zeichne();
</script>
</body>
</html>

123
README.md Normal file
View File

@@ -0,0 +1,123 @@
# rustification-client
Desktop chat client for Rustification. Built with Tauri v2 (Rust backend) and React + TypeScript (frontend).
## Tech stack
| Layer | Technology |
|-------|-----------|
| Desktop shell | [Tauri](https://tauri.app) v2 |
| Frontend | React 18 + TypeScript, Vite |
| HTTP | reqwest (Rust) via Tauri commands |
| Persistence | tauri-plugin-store (JWT token, user ID) |
| Build (Linux) | nix-shell |
## Features
- **Login / Register** — authenticates against the rustification-server, stores JWT via tauri-plugin-store
- **Workspace list** — browse workspaces the current user belongs to
- **Chat page** — conversation list, message history, and real-time polling for new messages
- **Message input** — send messages to any conversation
- **Member panel** — view conversation members
- **Settings page** — user preferences
## Prerequisites
- [Rust](https://rustup.rs/) + Cargo
- [Node.js](https://nodejs.org/) 18+ and npm
- Tauri v2 system dependencies (see below)
- A running [rustification-server](../rustification-server)
### System dependencies (Linux)
The project ships a `shell.nix`. If you use Nix, enter the dev shell first:
```bash
nix-shell
```
This provides: `cargo`, `rustc`, `pkg-config`, `glib`, `gtk3`, `webkitgtk_4_1`, `libsoup_3`, `openssl`, `librsvg`.
Without Nix, install the equivalent packages via your system package manager. On Debian/Ubuntu:
```bash
sudo apt install libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev \
libayatana-appindicator3-dev librsvg2-dev
```
## Getting started
```bash
# Install frontend dependencies
npm install
# Start in development mode (hot-reload frontend + Tauri window)
# Must be run inside nix-shell on NixOS / Nix systems
npm run tauri dev
```
The client defaults to `http://localhost:3000/api/v1` as the server base URL (configurable at runtime via Settings).
## Build
```bash
# Must be run inside nix-shell on NixOS / Nix systems
npm run tauri build
```
Output bundles (`.deb`, `.AppImage`, etc.) are written to `src-tauri/target/release/bundle/`.
## Project structure
```
src/ # React frontend
├── main.tsx # Entry point
├── App.tsx # Router + AuthContext provider
├── theme.ts # MUI / global theme
├── styles.css
├── contexts/
│ └── AuthContext.tsx # Auth state (token, user ID, login/logout)
├── pages/
│ ├── LoginPage.tsx
│ ├── WorkspacePage.tsx
│ ├── ChatPage.tsx
│ └── SettingsPage.tsx
└── components/
├── ConversationList.tsx
├── MessageList.tsx
├── MessageInput.tsx
├── MemberPanel.tsx
└── Toast.tsx
src-tauri/ # Tauri Rust backend
├── Cargo.toml
└── src/
├── lib.rs # Tauri builder, plugin + command registration
├── main.rs
├── state.rs # AppState: reqwest client, token, base URL
├── models.rs # Shared response types
└── commands/
├── user.rs # login, register, refresh_session, logout
├── workspace.rs # list_workspaces
├── conversation.rs # list_conversations
└── message.rs # list_messages, send_message
```
## Tauri commands
The frontend calls these via `invoke()`:
| Command | Description |
|---------|-------------|
| `login` | Authenticates and stores JWT + user ID |
| `register` | Registers a new account |
| `refresh_session` | Refreshes the JWT using the stored refresh token |
| `logout` | Clears stored credentials |
| `list_workspaces` | Returns workspaces for the current user |
| `list_conversations` | Returns conversations for a workspace |
| `list_messages` | Returns messages for a conversation |
| `send_message` | Posts a new message |
## Server connection
The default base URL is `http://localhost:3000/api/v1`. To point the client at a different server, update the URL in Settings — it is persisted in the Tauri store across sessions.