12 Commits

Author SHA1 Message Date
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
dc7678d184 chore: add app icon 2026-08-12 18:11:43 +02:00
ee19de5f94 fix: reset scroll ref on conversation change
prevLengthRef.current was never zeroed when conversationId changed,
so the first batch of messages in a new (shorter) conversation would
not trigger scrollIntoView. Reset it in the polling useEffect cleanup.
2026-08-12 18:03:19 +02:00
455d304885 fix: address final review findings
- Finding 1: add AppState::authed_get/authed_post helpers that attach
  Bearer token when present; all 4 commands now use them
- Finding 2: MessageInput accepts onSent callback, calls it after
  successful send; ChatPage passes refreshKey bump as onSent
- Finding 3: MessageList useEffect uses cancellation flag so stale
  in-flight responses cannot overwrite a newly selected conversation
- Finding 4: scrollIntoView moved to separate effect keyed on
  messages.length so scroll only fires when new messages arrive
2026-08-12 17:58:02 +02:00
91260c8e30 feat: implement chat page with conversation list, message view, and polling 2026-08-12 17:51:00 +02:00
68c5024334 feat: implement workspace list page 2026-08-12 17:47:50 +02:00
b4e9d25b9f feat: implement login page 2026-08-12 17:44:21 +02:00
a432ac976b feat: add React app shell with routing 2026-08-12 17:27:40 +02:00
3109ac4c22 feat: add message Tauri commands 2026-08-12 17:21:44 +02:00
7c2e5ca9a9 feat: add workspace and conversation Tauri commands
Implement list_workspaces and list_conversations commands to enable
frontend access to workspace and conversation data via Tauri IPC.

- Add workspace.rs with list_workspaces command (GET /workspaces)
- Add conversation.rs with list_conversations command (GET /conversations?workspace_id=)
- Register both commands in lib.rs invoke_handler
- Update commands/mod.rs to expose new modules
2026-08-12 17:04:35 +02:00
d384465f2e feat: add login Tauri command 2026-08-12 16:59:55 +02:00
18 changed files with 637 additions and 1 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

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.

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -0,0 +1,17 @@
use crate::models::Conversation;
use crate::state::{AppState, BASE_URL};
#[tauri::command]
pub async fn list_conversations(
state: tauri::State<'_, AppState>,
workspace_id: String,
) -> Result<Vec<Conversation>, String> {
state
.authed_get(&format!("{}/conversations?workspace_id={}", BASE_URL, workspace_id))
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,47 @@
use crate::models::Message;
use crate::state::{AppState, BASE_URL};
#[tauri::command]
pub async fn list_messages(
state: tauri::State<'_, AppState>,
conversation_id: String,
) -> Result<Vec<Message>, String> {
state
.authed_get(&format!("{}/conversations/{}/messages", BASE_URL, conversation_id))
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn send_message(
state: tauri::State<'_, AppState>,
conversation_id: String,
content: String,
) -> Result<Message, String> {
let user_id = state
.user_id
.lock()
.unwrap()
.clone()
.ok_or_else(|| "Not logged in".to_string())?;
let body = serde_json::json!({
"conversation_id": conversation_id,
"user_id": user_id,
"content": content,
});
state
.authed_post(&format!("{}/messages", BASE_URL))
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,4 @@
pub mod conversation;
pub mod message;
pub mod user;
pub mod workspace;

View File

@@ -0,0 +1,42 @@
use crate::models::{AuthTokenResponse, User};
use crate::state::{AppState, BASE_URL};
#[tauri::command]
pub async fn login(state: tauri::State<'_, AppState>, email: String) -> Result<User, String> {
let users: Vec<User> = state
.client
.get(format!("{}/users", BASE_URL))
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
let user = users
.into_iter()
.find(|u| u.email == email)
.ok_or_else(|| "User not found".to_string())?;
let body = serde_json::json!({
"user_id": user.id,
"kind": "refresh_token",
"expires_at": "2027-01-01T00:00:00Z"
});
let token_resp: AuthTokenResponse = state
.client
.post(format!("{}/auth-tokens", BASE_URL))
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())?;
*state.token.lock().unwrap() = Some(token_resp.token);
*state.user_id.lock().unwrap() = Some(user.id.clone());
Ok(user)
}

View File

@@ -0,0 +1,14 @@
use crate::models::Workspace;
use crate::state::{AppState, BASE_URL};
#[tauri::command]
pub async fn list_workspaces(state: tauri::State<'_, AppState>) -> Result<Vec<Workspace>, String> {
state
.authed_get(&format!("{}/workspaces", BASE_URL))
.send()
.await
.map_err(|e| e.to_string())?
.json()
.await
.map_err(|e| e.to_string())
}

View File

@@ -1,11 +1,20 @@
mod commands;
mod models;
mod state;
use state::AppState;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(AppState::new())
.invoke_handler(tauri::generate_handler![
commands::user::login,
commands::workspace::list_workspaces,
commands::conversation::list_conversations,
commands::message::list_messages,
commands::message::send_message,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -16,4 +16,21 @@ impl AppState {
user_id: Mutex::new(None),
}
}
pub fn authed_get(&self, url: &str) -> reqwest::RequestBuilder {
let builder = self.client.get(url);
// ponytail: omit header when no token — server may not require auth yet
match self.token.lock().unwrap().clone() {
Some(t) => builder.bearer_auth(t),
None => builder,
}
}
pub fn authed_post(&self, url: &str) -> reqwest::RequestBuilder {
let builder = self.client.post(url);
match self.token.lock().unwrap().clone() {
Some(t) => builder.bearer_auth(t),
None => builder,
}
}
}

14
src/App.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { Routes, Route } from "react-router-dom";
import LoginPage from "./pages/LoginPage";
import WorkspacePage from "./pages/WorkspacePage";
import ChatPage from "./pages/ChatPage";
export default function App() {
return (
<Routes>
<Route path="/" element={<LoginPage />} />
<Route path="/workspaces" element={<WorkspacePage />} />
<Route path="/workspaces/:workspaceId" element={<ChatPage />} />
</Routes>
);
}

View File

@@ -0,0 +1,47 @@
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
interface Conversation {
id: string;
name: string | null;
kind: string;
}
interface Props {
workspaceId: string;
onSelect: (id: string) => void;
selectedId: string | null;
}
export default function ConversationList({ workspaceId, onSelect, selectedId }: Props) {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
invoke<Conversation[]>("list_conversations", { workspaceId })
.then(setConversations)
.catch((e) => setError(String(e)));
}, [workspaceId]);
return (
<div style={{ width: 220, borderRight: "1px solid #ccc", overflowY: "auto" }}>
<div style={{ padding: "12px 16px", fontWeight: "bold", borderBottom: "1px solid #ccc" }}>
Conversations
</div>
{error && <p style={{ color: "red", padding: 8 }}>{error}</p>}
{conversations.map((c) => (
<div
key={c.id}
onClick={() => onSelect(c.id)}
style={{
padding: "10px 16px",
cursor: "pointer",
background: selectedId === c.id ? "#e8f0fe" : "transparent",
}}
>
{c.name ?? `(${c.kind})`}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
interface Props {
conversationId: string;
onSent: () => void;
}
export default function MessageInput({ conversationId, onSent }: Props) {
const [content, setContent] = useState("");
const [error, setError] = useState<string | null>(null);
async function handleSend() {
if (!content.trim()) return;
try {
await invoke("send_message", { conversationId, content });
setContent("");
setError(null);
onSent();
} catch (e) {
setError(String(e));
}
}
return (
<div style={{ padding: 16, borderTop: "1px solid #ccc" }}>
{error && <p style={{ color: "red", margin: "0 0 8px" }}>{error}</p>}
<input
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Type a message and press Enter…"
style={{ width: "100%", padding: 10, boxSizing: "border-box", fontSize: 14 }}
/>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { useState, useEffect, useRef } from "react";
import { invoke } from "@tauri-apps/api/core";
interface Message {
id: string;
user_id: string;
content: string;
created_at: string;
}
interface Props {
conversationId: string;
refreshKey: number;
}
export default function MessageList({ conversationId, refreshKey }: Props) {
const [messages, setMessages] = useState<Message[]>([]);
const [error, setError] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const prevLengthRef = useRef(0);
// Fix 3: cancellation flag prevents stale responses from overwriting new conversation
useEffect(() => {
let cancelled = false;
const load = () =>
invoke<Message[]>("list_messages", { conversationId })
.then((msgs) => {
if (!cancelled) setMessages(msgs);
})
.catch((e) => { if (!cancelled) setError(String(e)); });
load();
// ponytail: polling every 3s — replace with WebSocket when server supports it
const id = setInterval(load, 3000);
return () => { cancelled = true; clearInterval(id); prevLengthRef.current = 0; };
// Fix 2: refreshKey in deps triggers immediate reload after send
}, [conversationId, refreshKey]);
// Fix 4: scroll only when message count increases, not on every poll
useEffect(() => {
if (messages.length > prevLengthRef.current) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
prevLengthRef.current = messages.length;
}
}, [messages.length]);
return (
<div style={{ flex: 1, overflowY: "auto", padding: 16 }}>
{error && <p style={{ color: "red" }}>{error}</p>}
{messages.map((m) => (
<div key={m.id} style={{ marginBottom: 12 }}>
<strong style={{ marginRight: 8 }}>{m.user_id}</strong>
<span style={{ color: "#888", fontSize: 12, marginRight: 8 }}>
{new Date(m.created_at).toLocaleTimeString()}
</span>
<div>{m.content}</div>
</div>
))}
<div ref={bottomRef} />
</div>
);
}

View File

@@ -1,8 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<div>Loading…</div>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

36
src/pages/ChatPage.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import ConversationList from "../components/ConversationList";
import MessageList from "../components/MessageList";
import MessageInput from "../components/MessageInput";
export default function ChatPage() {
const { workspaceId } = useParams<{ workspaceId: string }>();
const [conversationId, setConversationId] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
return (
<div style={{ display: "flex", height: "100vh" }}>
<ConversationList
workspaceId={workspaceId!}
onSelect={setConversationId}
selectedId={conversationId}
/>
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
{conversationId ? (
<>
<MessageList conversationId={conversationId} refreshKey={refreshKey} />
<MessageInput
conversationId={conversationId}
onSent={() => setRefreshKey((k) => k + 1)}
/>
</>
) : (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#888" }}>
Select a conversation
</div>
)}
</div>
</div>
);
}

41
src/pages/LoginPage.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useNavigate } from "react-router-dom";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
async function handleLogin() {
setError(null);
setLoading(true);
try {
await invoke("login", { email });
navigate("/workspaces");
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}
return (
<div style={{ maxWidth: 400, margin: "100px auto", padding: 24 }}>
<h1>Rustification</h1>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
placeholder="Email address"
style={{ display: "block", width: "100%", marginBottom: 12, padding: 8, boxSizing: "border-box" }}
/>
<button onClick={handleLogin} disabled={loading || !email}>
{loading ? "Logging in…" : "Login"}
</button>
{error && <p style={{ color: "red" }}>{error}</p>}
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useNavigate } from "react-router-dom";
interface Workspace {
id: string;
name: string;
slug: string;
}
export default function WorkspacePage() {
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
useEffect(() => {
invoke<Workspace[]>("list_workspaces")
.then(setWorkspaces)
.catch((e) => setError(String(e)));
}, []);
return (
<div style={{ maxWidth: 600, margin: "60px auto", padding: 24 }}>
<h1>Workspaces</h1>
{error && <p style={{ color: "red" }}>{error}</p>}
{workspaces.map((ws) => (
<div
key={ws.id}
onClick={() => navigate(`/workspaces/${ws.id}`)}
style={{ padding: 16, border: "1px solid #ccc", marginBottom: 8, cursor: "pointer", borderRadius: 4 }}
>
<strong>{ws.name}</strong>
<span style={{ marginLeft: 8, color: "#888" }}>/{ws.slug}</span>
</div>
))}
{workspaces.length === 0 && !error && <p>No workspaces found.</p>}
</div>
);
}