mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-05-12 04:59:27 +00:00
feat(chat-cli): wire up logos-delivery transport and switch to client API (#91)
Replace the direct use of `conversations::Context` with `client::ChatClient`, which is the intended public API for library consumers. Remove `MessageEnvelope` and the username-keyed session model. The envelope was never part of the wire protocol — sender identity was only tracked in the CLI's local state. Chats are now keyed by conversation ID; add `/nickname` as the user-facing replacement for named sessions. Add a logos-delivery (Waku) transport alongside the existing file transport. The active transport is selected at compile time: set `LOGOS_DELIVERY_LIB_DIR` to link liblogosdelivery, otherwise the file transport is used. Add logos-delivery as a Nix flake input and expose `.#logos-delivery` so the library can be built with `nix build` and referenced by `LOGOS_DELIVERY_LIB_DIR`. CI: rename `c-ffi-smoketest` to `smoketest`; add logos-delivery build step and a `--smoketest` invocation of chat-cli to verify startup.
This commit is contained in:
parent
df84fc87cf
commit
eaeffcd21f
31
.github/workflows/ci.yml
vendored
31
.github/workflows/ci.yml
vendored
@ -37,13 +37,26 @@ jobs:
|
|||||||
- run: rustup component add rustfmt
|
- run: rustup component add rustfmt
|
||||||
- run: cargo fmt --all -- --check
|
- run: cargo fmt --all -- --check
|
||||||
|
|
||||||
c-ffi-smoketest:
|
smoketest:
|
||||||
name: C FFI Smoketest
|
name: Smoketest
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- run: rustup update stable && rustup default stable
|
- run: rustup update stable && rustup default stable
|
||||||
|
- uses: cachix/install-nix-action@v31
|
||||||
|
with:
|
||||||
|
nix_version: 2.34.6
|
||||||
|
extra_nix_config: |
|
||||||
|
experimental-features = nix-command flakes
|
||||||
|
- uses: nix-community/cache-nix-action@v6
|
||||||
|
with:
|
||||||
|
primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.nix', 'flake.lock') }}
|
||||||
|
restore-prefixes-first-match: nix-${{ runner.os }}-
|
||||||
- name: Install valgrind
|
- name: Install valgrind
|
||||||
|
if: runner.os == 'Linux'
|
||||||
run: sudo apt-get install -y valgrind
|
run: sudo apt-get install -y valgrind
|
||||||
- name: Build C FFI example
|
- name: Build C FFI example
|
||||||
run: make
|
run: make
|
||||||
@ -52,8 +65,15 @@ jobs:
|
|||||||
run: ./c-client
|
run: ./c-client
|
||||||
working-directory: crates/client-ffi/examples/message-exchange
|
working-directory: crates/client-ffi/examples/message-exchange
|
||||||
- name: Run C FFI smoketest under valgrind
|
- name: Run C FFI smoketest under valgrind
|
||||||
|
if: runner.os == 'Linux'
|
||||||
run: make valgrind
|
run: make valgrind
|
||||||
working-directory: crates/client-ffi/examples/message-exchange
|
working-directory: crates/client-ffi/examples/message-exchange
|
||||||
|
- name: Build logos-delivery
|
||||||
|
run: nix build .#logos-delivery
|
||||||
|
- name: Build chat-cli (logos-delivery)
|
||||||
|
run: LOGOS_DELIVERY_LIB_DIR=./result/lib cargo build --release -p chat-cli
|
||||||
|
- name: Run chat-cli smoketest
|
||||||
|
run: ./target/release/chat-cli --name ci-test --smoketest
|
||||||
|
|
||||||
nix-build:
|
nix-build:
|
||||||
name: Nix Build
|
name: Nix Build
|
||||||
@ -65,6 +85,11 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: cachix/install-nix-action@v31
|
- uses: cachix/install-nix-action@v31
|
||||||
with:
|
with:
|
||||||
|
nix_version: 2.34.6
|
||||||
extra_nix_config: |
|
extra_nix_config: |
|
||||||
experimental-features = nix-command flakes
|
experimental-features = nix-command flakes
|
||||||
|
- uses: nix-community/cache-nix-action@v6
|
||||||
|
with:
|
||||||
|
primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.nix', 'flake.lock') }}
|
||||||
|
restore-prefixes-first-match: nix-${{ runner.os }}-
|
||||||
- run: nix build --print-build-logs
|
- run: nix build --print-build-logs
|
||||||
|
|||||||
725
Cargo.lock
generated
725
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
19
README.md
19
README.md
@ -1,2 +1,21 @@
|
|||||||
# libchat
|
# libchat
|
||||||
Supporting library for Logos-chat
|
Supporting library for Logos-chat
|
||||||
|
|
||||||
|
## Example app
|
||||||
|
|
||||||
|
[`bin/chat-cli`](bin/chat-cli/) is an end-to-end encrypted CLI chat app
|
||||||
|
built on this library. It uses [logos-delivery](https://github.com/logos-messaging/logos-delivery)
|
||||||
|
(Waku-based) as the transport so two users anywhere in the world can chat by
|
||||||
|
sharing an intro bundle.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Build logos-delivery with Nix
|
||||||
|
nix build .#logos-delivery
|
||||||
|
# Build chat-cli with Cargo
|
||||||
|
LOGOS_DELIVERY_LIB_DIR=./result/lib cargo build --release -p chat-cli
|
||||||
|
# Run binary
|
||||||
|
./target/release/chat-cli --name alice
|
||||||
|
```
|
||||||
|
|
||||||
|
See [`bin/chat-cli/README.md`](bin/chat-cli/README.md) for full build,
|
||||||
|
run, and test instructions.
|
||||||
|
|||||||
@ -8,11 +8,19 @@ name = "chat-cli"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
libchat = { path = "../../core/conversations" }
|
client = { path = "../../crates/client" }
|
||||||
|
|
||||||
ratatui = "0.29"
|
ratatui = "0.29"
|
||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
hex = "0.4"
|
|
||||||
arboard = "3"
|
arboard = "3"
|
||||||
|
base64 = "0.22"
|
||||||
|
thiserror = "2"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(logos_delivery)'] }
|
||||||
|
|||||||
@ -1,117 +1,109 @@
|
|||||||
# Chat CLI
|
# chat-cli
|
||||||
|
|
||||||
A terminal chat application based on libchat library.
|
A terminal chat application built on top of libchat. End-to-end encrypted messaging in your terminal.
|
||||||
|
|
||||||
## Features
|
## Building
|
||||||
|
|
||||||
- End-to-end encrypted messaging using libchat
|
### With logos-delivery transport (recommended)
|
||||||
- File-based transport for local simulation (no network required)
|
|
||||||
- Persistent storage (SQLite + JSON state)
|
|
||||||
- Multiple chat support with chat switching
|
|
||||||
|
|
||||||
## Usage
|
[logos-delivery](https://github.com/logos-messaging/logos-delivery) is exposed as a Nix package.
|
||||||
|
Build it once, then point `LOGOS_DELIVERY_LIB_DIR` at the result:
|
||||||
Run two instances with different usernames in separate terminals:
|
|
||||||
|
|
||||||
### Terminal 1 (Alice)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run -p chat-cli -- alice
|
nix build .#logos-delivery
|
||||||
|
LOGOS_DELIVERY_LIB_DIR=./result/lib cargo build --release -p chat-cli
|
||||||
```
|
```
|
||||||
|
|
||||||
### Terminal 2 (Bob)
|
The binary lands at `target/release/chat-cli`.
|
||||||
|
|
||||||
|
### File transport only (no Nix required)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run -p chat-cli -- bob
|
cargo build --release -p chat-cli
|
||||||
```
|
```
|
||||||
|
|
||||||
### Establishing a Connection
|
## Transports
|
||||||
|
|
||||||
1. In Alice's terminal, type `/intro` to generate an introduction bundle
|
| Transport | Description |
|
||||||
2. Copy the intro string
|
|-----------|-------------|
|
||||||
3. In Bob's terminal, type `/connect alice <intro>` (paste Alice's intro bundle)
|
| File (default) | Shared directory; no network needed — great for local testing |
|
||||||
4. Bob can now send messages to Alice
|
| logos-delivery | Embedded Waku node on the logos.dev network |
|
||||||
5. Alice will see Bob's initial "Hello!" message and can reply
|
|
||||||
|
|
||||||
### Commands
|
The transport is selected automatically at compile time: if `LOGOS_DELIVERY_LIB_DIR` is set when building, logos-delivery is used; otherwise the file transport is used.
|
||||||
|
|
||||||
|
## Quick start (file transport)
|
||||||
|
|
||||||
|
Run two instances in separate terminals, pointing at the same data directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1
|
||||||
|
cargo run -p chat-cli -- --name alice
|
||||||
|
|
||||||
|
# Terminal 2
|
||||||
|
cargo run -p chat-cli -- --name bob
|
||||||
|
```
|
||||||
|
|
||||||
|
### Establishing a connection
|
||||||
|
|
||||||
|
1. In Alice's terminal, type `/intro` — the bundle is copied to your clipboard automatically.
|
||||||
|
2. In Bob's terminal, type `/connect <paste bundle here>`.
|
||||||
|
3. Bob's "Hello!" message appears in Alice's terminal. Both can now chat.
|
||||||
|
|
||||||
|
## logos-delivery transport
|
||||||
|
|
||||||
|
After building with `LOGOS_DELIVERY_LIB_DIR` set, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/chat-cli --name alice
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional flags:
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| `--db <path>` | *(ephemeral)* | SQLite file for persistent identity across restarts |
|
||||||
|
| `--preset <name>` | `logos.dev` | Network preset (`logos.dev` or `twn`) |
|
||||||
|
| `--port <n>` | `60000` | TCP port for the embedded logos-delivery node |
|
||||||
|
| `--log-file <path>` | *(stderr, off)* | Write logs to a file instead of stderr |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `/help` | Show available commands |
|
| `/help` | Show available commands |
|
||||||
| `/intro` | Generate and display your introduction bundle |
|
| `/intro` | Generate your introduction bundle (copies to clipboard) |
|
||||||
| `/connect <user> <intro>` | Connect to a user using their introduction bundle |
|
| `/connect <bundle>` | Connect to a user using their introduction bundle |
|
||||||
| `/chats` | List all your established chats |
|
| `/chats` | List all established chats |
|
||||||
| `/switch <user>` | Switch to a different chat |
|
| `/switch <user>` | Switch active chat |
|
||||||
| `/delete <user>` | Delete a chat (removes session and crypto state) |
|
| `/delete <user>` | Delete a chat session |
|
||||||
| `/peers` | List transport-level peers (users with inbox directories) |
|
| `/status` | Show identity and connection info |
|
||||||
| `/status` | Show connection status and your address |
|
|
||||||
| `/clear` | Clear current chat's message history |
|
| `/clear` | Clear current chat's message history |
|
||||||
| `/quit` or `Esc` or `Ctrl+C` | Exit the application |
|
| `/quit` · `Esc` · `Ctrl+C` | Exit |
|
||||||
|
|
||||||
#### `/peers` vs `/chats`
|
## Storage (file transport)
|
||||||
|
|
||||||
- **`/peers`**: Shows users whose CLI has been started (have inbox directories). These are potential contacts you *could* message.
|
All data lives under `tmp/chat-cli-data/` by default (override with `--data`):
|
||||||
- **`/chats`**: Shows users you have an **encrypted session** with (via `/connect`). These are active conversations.
|
|
||||||
|
|
||||||
### Sending Messages
|
| Path | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| `<name>.db` | SQLite — identity keys, ratchet state, chat metadata (encrypted) |
|
||||||
|
| `<name>_state.json` | UI state — message history, active chat |
|
||||||
|
| `transport/<name>/` | Inbox directory watched for incoming messages |
|
||||||
|
|
||||||
Simply type your message and press Enter. Messages are automatically encrypted and delivered via file-based transport.
|
The SQLite database can be inspected with *DB Browser for SQLite*: password `chat-cli`, cipher `SQLCipher 4 defaults`.
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### File-Based Transport
|
|
||||||
|
|
||||||
Messages are passed between users via files in a shared directory:
|
|
||||||
|
|
||||||
1. Each user has an "inbox" directory at `tmp/chat-cli-data/transport/<username>/`
|
|
||||||
2. When Alice sends a message to Bob, it's written as a JSON file in Bob's inbox
|
|
||||||
3. Bob's client watches for new files and processes incoming messages
|
|
||||||
4. Files are deleted after processing
|
|
||||||
|
|
||||||
### Storage
|
|
||||||
|
|
||||||
Data is stored in the `tmp/chat-cli-data/` directory:
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `<username>.db` | SQLite database for identity keys, inbox keys, chat metadata, and Double Ratchet state |
|
|
||||||
| `<username>_state.json` | CLI state: username↔chat mappings, message history, active chat |
|
|
||||||
| `transport/<username>/` | Inbox directory for receiving messages |
|
|
||||||
|
|
||||||
The sqlite tables can be viewed with app `DB Browser for SQLite`, password is `123456`, config use `SQLCipher 4 defaults`.
|
|
||||||
|
|
||||||
## Example Session
|
|
||||||
|
|
||||||
```
|
|
||||||
# Terminal 1 (Alice)
|
|
||||||
$ cargo run -p chat-cli -- alice
|
|
||||||
|
|
||||||
/intro
|
|
||||||
# Output: logos_chatintro_abc123
|
|
||||||
|
|
||||||
# Terminal 2 (Bob)
|
|
||||||
$ cargo run -p chat-cli -- bob
|
|
||||||
|
|
||||||
/connect alice logos_chatintro_abc123
|
|
||||||
# Connected! Bob sends "Hello!" automatically
|
|
||||||
|
|
||||||
# Now type messages in either terminal to chat!
|
|
||||||
|
|
||||||
# To see your chats:
|
|
||||||
/chats
|
|
||||||
# Output: alice (active)
|
|
||||||
|
|
||||||
# To switch between chats (if you have multiple):
|
|
||||||
/switch alice
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
chat-cli/
|
bin/chat-cli/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── main.rs # Entry point
|
│ ├── main.rs entry point, CLI arg parsing, transport selection
|
||||||
│ ├── app.rs # Application state and logic
|
│ ├── app.rs application state and command handling
|
||||||
│ ├── transport.rs # File-based message transport
|
│ ├── ui.rs ratatui terminal UI
|
||||||
│ └── ui.rs # Ratatui terminal UI
|
│ ├── utils.rs shared helpers
|
||||||
|
│ ├── transport.rs module declarations
|
||||||
|
│ └── transport/
|
||||||
|
│ ├── file.rs file-based transport
|
||||||
|
│ └── logos_delivery.rs logos-delivery (Waku) transport + FFI
|
||||||
|
└── build.rs links liblogosdelivery when LOGOS_DELIVERY_LIB_DIR is set
|
||||||
```
|
```
|
||||||
|
|||||||
20
bin/chat-cli/build.rs
Normal file
20
bin/chat-cli/build.rs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
fn main() {
|
||||||
|
println!("cargo::rustc-check-cfg=cfg(logos_delivery)");
|
||||||
|
println!("cargo:rerun-if-env-changed=LOGOS_DELIVERY_LIB_DIR");
|
||||||
|
|
||||||
|
let Ok(lib_dir) = std::env::var("LOGOS_DELIVERY_LIB_DIR") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("cargo:rustc-cfg=logos_delivery");
|
||||||
|
println!("cargo:rustc-link-search=native={lib_dir}");
|
||||||
|
println!("cargo:rustc-link-lib=dylib=logosdelivery");
|
||||||
|
|
||||||
|
// Set rpath so the binary finds the shared library at runtime.
|
||||||
|
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||||
|
match target_os.as_str() {
|
||||||
|
"macos" => println!("cargo:rustc-link-arg=-Wl,-rpath,{lib_dir}"),
|
||||||
|
"linux" => println!("cargo:rustc-link-arg=-Wl,-rpath,{lib_dir}"),
|
||||||
|
other => panic!("unsupported OS for logos-delivery transport: {other}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,17 +1,15 @@
|
|||||||
//! Chat application logic.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::Result;
|
||||||
use arboard::Clipboard;
|
use arboard::Clipboard;
|
||||||
use libchat::{ChatStorage, Context as ChatManager, Introduction, StorageConfig};
|
use client::{ConversationIdOwned, DeliveryService};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{transport::FileTransport, utils::now};
|
use crate::utils::now;
|
||||||
|
|
||||||
/// A chat message for display.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct DisplayMessage {
|
pub struct DisplayMessage {
|
||||||
pub from_self: bool,
|
pub from_self: bool,
|
||||||
@ -19,87 +17,68 @@ pub struct DisplayMessage {
|
|||||||
pub timestamp: u64,
|
pub timestamp: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Metadata for a chat session (persisted).
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ChatSession {
|
pub struct ChatSession {
|
||||||
pub chat_id: String,
|
pub chat_id: String,
|
||||||
pub remote_user: String,
|
pub nickname: Option<String>,
|
||||||
pub messages: Vec<DisplayMessage>,
|
pub messages: Vec<DisplayMessage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// App state that gets persisted.
|
impl ChatSession {
|
||||||
|
/// Human-readable label: nickname if set, otherwise the first 8 chars of the chat ID.
|
||||||
|
pub fn display_name(&self) -> &str {
|
||||||
|
self.nickname
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_else(|| &self.chat_id[..8.min(self.chat_id.len())])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
/// Map from remote username to chat session.
|
/// Keyed by chat_id (conversation ID).
|
||||||
pub chats: HashMap<String, ChatSession>,
|
pub chats: HashMap<String, ChatSession>,
|
||||||
/// Currently active chat (remote username).
|
/// Holds the active chat_id.
|
||||||
pub active_chat: Option<String>,
|
pub active_chat: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The chat application state.
|
pub struct ChatApp<D: DeliveryService> {
|
||||||
pub struct ChatApp {
|
pub client: client::ChatClient<D>,
|
||||||
/// The logos-chat manager.
|
inbound: mpsc::Receiver<Vec<u8>>,
|
||||||
pub manager: ChatManager<ChatStorage>,
|
|
||||||
/// File-based transport for message passing.
|
|
||||||
pub transport: FileTransport,
|
|
||||||
/// Our introduction bundle (to share with others).
|
|
||||||
pub intro_bundle: Option<Vec<u8>>,
|
|
||||||
/// Persisted app state.
|
|
||||||
pub state: AppState,
|
pub state: AppState,
|
||||||
/// Global messages (shown when no active chat).
|
/// Ephemeral command output — not persisted, cleared on chat switch.
|
||||||
pub global_messages: Vec<DisplayMessage>,
|
command_output: Vec<DisplayMessage>,
|
||||||
/// Input buffer.
|
|
||||||
pub input: String,
|
pub input: String,
|
||||||
/// Status message.
|
|
||||||
pub status: String,
|
pub status: String,
|
||||||
/// Our user name.
|
|
||||||
pub user_name: String,
|
pub user_name: String,
|
||||||
/// Path to state file.
|
|
||||||
state_path: PathBuf,
|
state_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatApp {
|
impl<D: DeliveryService> ChatApp<D> {
|
||||||
/// Create a new chat application.
|
pub fn new(
|
||||||
pub fn new(user_name: &str, data_dir: &PathBuf) -> Result<Self> {
|
client: client::ChatClient<D>,
|
||||||
// Create database path
|
inbound: mpsc::Receiver<Vec<u8>>,
|
||||||
let db_path = data_dir.join(format!("{}.db", user_name));
|
user_name: &str,
|
||||||
std::fs::create_dir_all(data_dir)?;
|
data_dir: &Path,
|
||||||
|
) -> Result<Self> {
|
||||||
|
fs::create_dir_all(data_dir)?;
|
||||||
|
|
||||||
// Open or create the chat manager with file-based storage
|
let state_path = data_dir.join(format!("{user_name}_state.json"));
|
||||||
let manager = ChatManager::new_from_store(
|
|
||||||
user_name,
|
|
||||||
ChatStorage::new(StorageConfig::Encrypted {
|
|
||||||
path: db_path.to_string_lossy().to_string(),
|
|
||||||
key: "123456".to_string(),
|
|
||||||
})?,
|
|
||||||
)
|
|
||||||
.context("Failed to open ChatManager")?;
|
|
||||||
|
|
||||||
// Create file-based transport
|
|
||||||
let transport =
|
|
||||||
FileTransport::new(user_name, data_dir).context("Failed to create file transport")?;
|
|
||||||
|
|
||||||
// Load persisted state
|
|
||||||
let state_path = data_dir.join(format!("{}_state.json", user_name));
|
|
||||||
let state = Self::load_state(&state_path);
|
let state = Self::load_state(&state_path);
|
||||||
|
|
||||||
// Count existing chats
|
|
||||||
let chat_count = state.chats.len();
|
let chat_count = state.chats.len();
|
||||||
let status = if chat_count > 0 {
|
let status = if chat_count > 0 {
|
||||||
format!(
|
format!(
|
||||||
"Welcome back, {}! {} chat(s) loaded. Type /help for commands.",
|
"Welcome back, {user_name}! {chat_count} chat(s) loaded. Type /help for commands."
|
||||||
user_name, chat_count
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!("Welcome, {}! Type /help for commands.", user_name)
|
format!("Welcome, {user_name}! Type /help for commands.")
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
manager,
|
client,
|
||||||
transport,
|
inbound,
|
||||||
intro_bundle: None,
|
|
||||||
state,
|
state,
|
||||||
global_messages: Vec::new(),
|
command_output: Vec::new(),
|
||||||
input: String::new(),
|
input: String::new(),
|
||||||
status,
|
status,
|
||||||
user_name: user_name.to_string(),
|
user_name: user_name.to_string(),
|
||||||
@ -107,8 +86,7 @@ impl ChatApp {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load state from file.
|
fn load_state(path: &Path) -> AppState {
|
||||||
fn load_state(path: &PathBuf) -> AppState {
|
|
||||||
if path.exists()
|
if path.exists()
|
||||||
&& let Ok(contents) = fs::read_to_string(path)
|
&& let Ok(contents) = fs::read_to_string(path)
|
||||||
&& let Ok(state) = serde_json::from_str(&contents)
|
&& let Ok(state) = serde_json::from_str(&contents)
|
||||||
@ -118,14 +96,12 @@ impl ChatApp {
|
|||||||
AppState::default()
|
AppState::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save state to file.
|
|
||||||
fn save_state(&self) -> Result<()> {
|
fn save_state(&self) -> Result<()> {
|
||||||
let json = serde_json::to_string_pretty(&self.state)?;
|
let json = serde_json::to_string_pretty(&self.state)?;
|
||||||
fs::write(&self.state_path, json)?;
|
fs::write(&self.state_path, json)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current chat session (if any).
|
|
||||||
pub fn current_session(&self) -> Option<&ChatSession> {
|
pub fn current_session(&self) -> Option<&ChatSession> {
|
||||||
self.state
|
self.state
|
||||||
.active_chat
|
.active_chat
|
||||||
@ -133,135 +109,90 @@ impl ChatApp {
|
|||||||
.and_then(|name| self.state.chats.get(name))
|
.and_then(|name| self.state.chats.get(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current messages to display.
|
|
||||||
pub fn messages(&self) -> Vec<&DisplayMessage> {
|
pub fn messages(&self) -> Vec<&DisplayMessage> {
|
||||||
if let Some(session) = self.current_session() {
|
let chat = self
|
||||||
session.messages.iter().collect()
|
.current_session()
|
||||||
} else {
|
.map(|s| s.messages.as_slice())
|
||||||
// Show global messages when no active chat
|
.unwrap_or(&[]);
|
||||||
self.global_messages.iter().collect()
|
chat.iter().chain(self.command_output.iter()).collect()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create and display our introduction bundle.
|
fn set_active_chat(&mut self, chat_id: Option<String>) {
|
||||||
pub fn create_intro(&mut self) -> Result<String> {
|
self.state.active_chat = chat_id;
|
||||||
let intro = self.manager.create_intro_bundle()?;
|
self.command_output.clear();
|
||||||
let bundle_string = String::from_utf8_lossy(&intro).to_string();
|
|
||||||
self.intro_bundle = Some(intro);
|
|
||||||
self.status = "Introduction bundle created. Share it with others!".to_string();
|
|
||||||
Ok(bundle_string)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to another user using their introduction bundle.
|
/// Find a chat_id by nickname (exact) or chat_id prefix.
|
||||||
pub fn connect(&mut self, remote_user: &str, bundle_str: &str) -> Result<()> {
|
fn resolve_chat_id(&self, query: &str) -> Option<&str> {
|
||||||
// Check if we already have a chat with this user
|
// Exact nickname match first.
|
||||||
if self.state.chats.contains_key(remote_user) {
|
if let Some((id, _)) = self
|
||||||
return Err(anyhow::anyhow!(
|
.state
|
||||||
"Already have a chat with {}. Use /switch {} to switch to it.",
|
.chats
|
||||||
remote_user,
|
.iter()
|
||||||
remote_user
|
.find(|(_, s)| s.nickname.as_deref() == Some(query))
|
||||||
));
|
{
|
||||||
|
return Some(id.as_str());
|
||||||
}
|
}
|
||||||
|
// Fall back to chat_id prefix.
|
||||||
|
self.state
|
||||||
|
.chats
|
||||||
|
.keys()
|
||||||
|
.find(|id| id.starts_with(query))
|
||||||
|
.map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
let intro = Introduction::try_from(bundle_str.as_bytes())
|
pub fn process_incoming(&mut self) -> Result<()> {
|
||||||
.map_err(|e| anyhow::anyhow!("Invalid bundle: {:?}", e))?;
|
while let Ok(payload) = self.inbound.try_recv() {
|
||||||
|
match self.client.receive(&payload) {
|
||||||
|
Ok(Some(content)) => {
|
||||||
|
let chat_id = &content.conversation_id;
|
||||||
|
|
||||||
let (chat_id, envelopes) = self
|
if !self.state.chats.contains_key(chat_id) && content.is_new_convo {
|
||||||
.manager
|
let session = ChatSession {
|
||||||
.create_private_convo(&intro, "👋 Hello!".as_bytes())?;
|
chat_id: chat_id.clone(),
|
||||||
|
nickname: None,
|
||||||
|
messages: Vec::new(),
|
||||||
|
};
|
||||||
|
self.state.chats.insert(chat_id.clone(), session);
|
||||||
|
let label = chat_id[..8.min(chat_id.len())].to_string();
|
||||||
|
self.set_active_chat(Some(chat_id.clone()));
|
||||||
|
self.status = format!("New chat ({label})! Use /nickname to name it.");
|
||||||
|
}
|
||||||
|
|
||||||
// Send the envelopes via file transport
|
if !content.data.is_empty() {
|
||||||
for envelope in envelopes {
|
let text = String::from_utf8_lossy(&content.data).to_string();
|
||||||
self.transport.send(remote_user, envelope.data)?;
|
if let Some(session) = self.state.chats.get_mut(chat_id) {
|
||||||
|
session.messages.push(DisplayMessage {
|
||||||
|
from_self: false,
|
||||||
|
content: text,
|
||||||
|
timestamp: now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.save_state()?;
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => tracing::warn!("receive error: {e:?}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new session
|
|
||||||
let mut session = ChatSession {
|
|
||||||
chat_id: chat_id.clone().to_string(),
|
|
||||||
remote_user: remote_user.to_string(),
|
|
||||||
messages: Vec::new(),
|
|
||||||
};
|
|
||||||
session.messages.push(DisplayMessage {
|
|
||||||
from_self: true,
|
|
||||||
content: "👋 Hello!".to_string(),
|
|
||||||
timestamp: now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
self.state.chats.insert(remote_user.to_string(), session);
|
|
||||||
self.state.active_chat = Some(remote_user.to_string());
|
|
||||||
self.save_state()?;
|
|
||||||
|
|
||||||
self.status = format!("Connected to {}!", remote_user);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switch to a different chat.
|
|
||||||
pub fn switch_chat(&mut self, remote_user: &str) -> Result<()> {
|
|
||||||
if self.state.chats.contains_key(remote_user) {
|
|
||||||
self.state.active_chat = Some(remote_user.to_string());
|
|
||||||
self.save_state()?;
|
|
||||||
self.status = format!("Switched to chat with {}", remote_user);
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(anyhow::anyhow!(
|
|
||||||
"No chat with {}. Use /chats to list available chats.",
|
|
||||||
remote_user
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a chat session.
|
|
||||||
pub fn delete_chat(&mut self, remote_user: &str) -> Result<()> {
|
|
||||||
if let Some(_session) = self.state.chats.remove(remote_user) {
|
|
||||||
// TODO delete not implemented in libchat
|
|
||||||
// Also delete from the library's storage
|
|
||||||
// if let Err(e) = self.manager.delete_chat(&session.chat_id) {
|
|
||||||
// // Log but don't fail - the CLI state is already updated
|
|
||||||
// self.status = format!("Warning: failed to delete crypto state: {}", e);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// If we deleted the active chat, clear it
|
|
||||||
if self.state.active_chat.as_deref() == Some(remote_user) {
|
|
||||||
// Switch to another chat if available, otherwise clear
|
|
||||||
self.state.active_chat = self.state.chats.keys().next().cloned();
|
|
||||||
}
|
|
||||||
|
|
||||||
self.save_state()?;
|
|
||||||
self.status = format!("Deleted chat with {}", remote_user);
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(anyhow::anyhow!(
|
|
||||||
"No chat with {}. Use /chats to list available chats.",
|
|
||||||
remote_user
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a message in the current chat.
|
|
||||||
pub fn send_message(&mut self, content: &str) -> Result<()> {
|
pub fn send_message(&mut self, content: &str) -> Result<()> {
|
||||||
let active = self
|
let chat_id = self
|
||||||
.state
|
.state
|
||||||
.active_chat
|
.active_chat
|
||||||
.clone()
|
.clone()
|
||||||
.ok_or_else(|| anyhow::anyhow!("No active chat. Use /connect or /switch first."))?;
|
.ok_or_else(|| anyhow::anyhow!("No active chat. Use /connect or /switch first."))?;
|
||||||
|
|
||||||
let session = self
|
let convo_id: ConversationIdOwned = chat_id.as_str().into();
|
||||||
.state
|
|
||||||
.chats
|
|
||||||
.get(&active)
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Chat session not found"))?;
|
|
||||||
|
|
||||||
let chat_id = session.chat_id.clone();
|
self.client
|
||||||
let remote_user = session.remote_user.clone();
|
.send_message(&convo_id, content.as_bytes())
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
|
||||||
|
|
||||||
let envelopes = self.manager.send_content(&chat_id, content.as_bytes())?;
|
if let Some(session) = self.state.chats.get_mut(&chat_id) {
|
||||||
|
|
||||||
for envelope in envelopes {
|
|
||||||
self.transport.send(&remote_user, envelope.data)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update messages
|
|
||||||
if let Some(session) = self.state.chats.get_mut(&active) {
|
|
||||||
session.messages.push(DisplayMessage {
|
session.messages.push(DisplayMessage {
|
||||||
from_self: true,
|
from_self: true,
|
||||||
content: content.to_string(),
|
content: content.to_string(),
|
||||||
@ -273,77 +204,14 @@ impl ChatApp {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process incoming messages from transport.
|
|
||||||
pub fn process_incoming(&mut self) -> Result<()> {
|
|
||||||
while let Some(envelope) = self.transport.try_recv() {
|
|
||||||
self.handle_incoming_envelope(&envelope)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle an incoming envelope.
|
|
||||||
fn handle_incoming_envelope(
|
|
||||||
&mut self,
|
|
||||||
envelope: &crate::transport::MessageEnvelope,
|
|
||||||
) -> Result<()> {
|
|
||||||
match self.manager.handle_payload(&envelope.data) {
|
|
||||||
Ok(content) => {
|
|
||||||
let from_user = &envelope.from;
|
|
||||||
let content = content.ok_or(anyhow::anyhow!("Convo not exist"))?;
|
|
||||||
let chat_id = content.conversation_id.clone();
|
|
||||||
|
|
||||||
// Find or create session for this user
|
|
||||||
if !self.state.chats.contains_key(from_user) {
|
|
||||||
// New chat from someone
|
|
||||||
let session = ChatSession {
|
|
||||||
chat_id: chat_id.clone(),
|
|
||||||
remote_user: from_user.clone(),
|
|
||||||
messages: Vec::new(),
|
|
||||||
};
|
|
||||||
self.state.chats.insert(from_user.clone(), session);
|
|
||||||
self.state.active_chat = Some(from_user.clone());
|
|
||||||
self.status = format!("New chat from {}!", from_user);
|
|
||||||
}
|
|
||||||
|
|
||||||
let message = String::from_utf8_lossy(&content.data).to_string();
|
|
||||||
if !message.is_empty()
|
|
||||||
&& let Some(session) = self.state.chats.get_mut(from_user)
|
|
||||||
{
|
|
||||||
session.messages.push(DisplayMessage {
|
|
||||||
from_self: false,
|
|
||||||
content: message,
|
|
||||||
timestamp: envelope.timestamp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
self.save_state()?;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
self.status = format!("Error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a system message to the current chat (for display only).
|
|
||||||
fn add_system_message(&mut self, content: &str) {
|
fn add_system_message(&mut self, content: &str) {
|
||||||
let msg = DisplayMessage {
|
self.command_output.push(DisplayMessage {
|
||||||
from_self: true,
|
from_self: true,
|
||||||
content: content.to_string(),
|
content: content.to_string(),
|
||||||
timestamp: now(),
|
timestamp: now(),
|
||||||
};
|
});
|
||||||
|
|
||||||
if let Some(active) = &self.state.active_chat.clone()
|
|
||||||
&& let Some(session) = self.state.chats.get_mut(active)
|
|
||||||
{
|
|
||||||
session.messages.push(msg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// No active chat - add to global messages
|
|
||||||
self.global_messages.push(msg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a command (starts with /).
|
|
||||||
pub fn handle_command(&mut self, cmd: &str) -> Result<Option<String>> {
|
pub fn handle_command(&mut self, cmd: &str) -> Result<Option<String>> {
|
||||||
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
|
let parts: Vec<&str> = cmd.splitn(2, ' ').collect();
|
||||||
let command = parts[0];
|
let command = parts[0];
|
||||||
@ -353,91 +221,153 @@ impl ChatApp {
|
|||||||
"/help" => {
|
"/help" => {
|
||||||
self.add_system_message("── Commands ──");
|
self.add_system_message("── Commands ──");
|
||||||
self.add_system_message("/intro - Show your introduction bundle");
|
self.add_system_message("/intro - Show your introduction bundle");
|
||||||
self.add_system_message("/connect <user> <bundle> - Connect to a user");
|
self.add_system_message("/connect <bundle> - Connect using a bundle");
|
||||||
|
self.add_system_message("/nickname <name> - Name the active chat");
|
||||||
self.add_system_message("/chats - List all chats");
|
self.add_system_message("/chats - List all chats");
|
||||||
self.add_system_message("/switch <user> - Switch to chat with user");
|
self.add_system_message("/switch <name|id> - Switch active chat");
|
||||||
self.add_system_message("/delete <user> - Delete chat with user");
|
self.add_system_message("/delete <name|id> - Delete a chat");
|
||||||
self.add_system_message("/peers - List transport peers");
|
|
||||||
self.add_system_message("/status - Show connection status");
|
self.add_system_message("/status - Show connection status");
|
||||||
self.add_system_message("/clear - Clear current chat messages");
|
self.add_system_message("/clear - Clear current chat messages");
|
||||||
self.add_system_message("/quit or Esc or Ctrl+C - Exit");
|
self.add_system_message("/quit or Esc or Ctrl+C - Exit");
|
||||||
Ok(Some("Help displayed".to_string()))
|
Ok(Some("Help displayed".to_string()))
|
||||||
}
|
}
|
||||||
"/intro" => {
|
"/intro" => {
|
||||||
let bundle = self.create_intro()?;
|
let bundle_bytes = self
|
||||||
|
.client
|
||||||
|
.create_intro_bundle()
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
|
||||||
|
let bundle_str = String::from_utf8_lossy(&bundle_bytes).to_string();
|
||||||
self.add_system_message("── Your Introduction Bundle ──");
|
self.add_system_message("── Your Introduction Bundle ──");
|
||||||
self.add_system_message(&bundle);
|
self.add_system_message(&bundle_str);
|
||||||
let clipboard_msg = match Clipboard::new().and_then(|mut cb| cb.set_text(&bundle)) {
|
let clipboard_msg = match Clipboard::new()
|
||||||
Ok(()) => "Bundle copied to clipboard! Paste with Cmd+V in /connect.",
|
.and_then(|mut cb| cb.set_text(&bundle_str))
|
||||||
|
{
|
||||||
|
Ok(()) => "Bundle copied to clipboard! Share it, then /connect their bundle.",
|
||||||
Err(_) => "Share this bundle with others to connect!",
|
Err(_) => "Share this bundle with others to connect!",
|
||||||
};
|
};
|
||||||
self.add_system_message(clipboard_msg);
|
self.add_system_message(clipboard_msg);
|
||||||
Ok(Some("Bundle created and copied to clipboard".to_string()))
|
Ok(Some("Bundle created".to_string()))
|
||||||
}
|
}
|
||||||
"/connect" => {
|
"/connect" => {
|
||||||
let connect_parts: Vec<&str> = args.splitn(2, ' ').collect();
|
if args.is_empty() {
|
||||||
if connect_parts.len() < 2 {
|
return Ok(Some("Usage: /connect <bundle>".to_string()));
|
||||||
return Ok(Some("Usage: /connect <username> <bundle>".to_string()));
|
|
||||||
}
|
}
|
||||||
let remote_user = connect_parts[0];
|
let initial = format!("Hello from {}!", self.user_name);
|
||||||
let bundle = connect_parts[1];
|
let convo_id = self
|
||||||
self.connect(remote_user, bundle)?;
|
.client
|
||||||
Ok(Some(format!("Connected to {}", remote_user)))
|
.create_conversation(args.as_bytes(), initial.as_bytes())
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
|
||||||
|
|
||||||
|
let chat_id = convo_id.to_string();
|
||||||
|
let label = chat_id[..8.min(chat_id.len())].to_string();
|
||||||
|
let mut session = ChatSession {
|
||||||
|
chat_id: chat_id.clone(),
|
||||||
|
nickname: None,
|
||||||
|
messages: Vec::new(),
|
||||||
|
};
|
||||||
|
session.messages.push(DisplayMessage {
|
||||||
|
from_self: true,
|
||||||
|
content: initial,
|
||||||
|
timestamp: now(),
|
||||||
|
});
|
||||||
|
self.state.chats.insert(chat_id.clone(), session);
|
||||||
|
self.set_active_chat(Some(chat_id));
|
||||||
|
self.save_state()?;
|
||||||
|
self.status = format!("Connected ({label})! Use /nickname to name this chat.");
|
||||||
|
Ok(Some(format!("Connected ({label})")))
|
||||||
|
}
|
||||||
|
"/nickname" => {
|
||||||
|
if args.is_empty() {
|
||||||
|
return Ok(Some("Usage: /nickname <name>".to_string()));
|
||||||
|
}
|
||||||
|
let chat_id = self
|
||||||
|
.state
|
||||||
|
.active_chat
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No active chat."))?;
|
||||||
|
let session = self
|
||||||
|
.state
|
||||||
|
.chats
|
||||||
|
.get_mut(&chat_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Chat session not found."))?;
|
||||||
|
session.nickname = Some(args.to_string());
|
||||||
|
self.save_state()?;
|
||||||
|
self.status = format!("Chat named '{args}'.");
|
||||||
|
Ok(Some(format!("Nickname set to '{args}'")))
|
||||||
}
|
}
|
||||||
"/chats" => {
|
"/chats" => {
|
||||||
let chat_names: Vec<_> = self.state.chats.keys().cloned().collect();
|
let sessions: Vec<_> = self.state.chats.values().cloned().collect();
|
||||||
if chat_names.is_empty() {
|
if sessions.is_empty() {
|
||||||
Ok(Some("No chats yet. Use /connect to start one.".to_string()))
|
Ok(Some("No chats yet. Use /connect to start one.".to_string()))
|
||||||
} else {
|
} else {
|
||||||
self.add_system_message(&format!("── Your Chats ({}) ──", chat_names.len()));
|
self.add_system_message(&format!("── Your Chats ({}) ──", sessions.len()));
|
||||||
for name in &chat_names {
|
for s in &sessions {
|
||||||
let marker = if Some(name) == self.state.active_chat.as_ref() {
|
let marker = if self.state.active_chat.as_deref() == Some(&s.chat_id) {
|
||||||
" (active)"
|
" (active)"
|
||||||
} else {
|
} else {
|
||||||
""
|
""
|
||||||
};
|
};
|
||||||
self.add_system_message(&format!(" • {}{}", name, marker));
|
let label = format!(
|
||||||
|
" • {} ({}){marker}",
|
||||||
|
s.display_name(),
|
||||||
|
&s.chat_id[..8.min(s.chat_id.len())]
|
||||||
|
);
|
||||||
|
self.add_system_message(&label);
|
||||||
}
|
}
|
||||||
Ok(Some(format!("{} chat(s)", chat_names.len())))
|
Ok(Some(format!("{} chat(s)", sessions.len())))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"/switch" => {
|
"/switch" => {
|
||||||
if args.is_empty() {
|
if args.is_empty() {
|
||||||
return Ok(Some("Usage: /switch <username>".to_string()));
|
return Ok(Some("Usage: /switch <nickname|id-prefix>".to_string()));
|
||||||
}
|
}
|
||||||
self.switch_chat(args)?;
|
let chat_id = self
|
||||||
Ok(Some(format!("Switched to {}", args)))
|
.resolve_chat_id(args)
|
||||||
|
.map(str::to_string)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("No chat matching '{args}'."))?;
|
||||||
|
let label = self.state.chats[&chat_id].display_name().to_string();
|
||||||
|
self.set_active_chat(Some(chat_id));
|
||||||
|
self.save_state()?;
|
||||||
|
self.status = format!("Switched to '{label}'.");
|
||||||
|
Ok(Some(format!("Switched to '{label}'")))
|
||||||
}
|
}
|
||||||
"/delete" => {
|
"/delete" => {
|
||||||
if args.is_empty() {
|
if args.is_empty() {
|
||||||
return Ok(Some("Usage: /delete <username>".to_string()));
|
return Ok(Some("Usage: /delete <nickname|id-prefix>".to_string()));
|
||||||
}
|
}
|
||||||
self.delete_chat(args)?;
|
let chat_id = self
|
||||||
Ok(Some(format!("Deleted chat with {}", args)))
|
.resolve_chat_id(args)
|
||||||
}
|
.map(str::to_string)
|
||||||
"/peers" => {
|
.ok_or_else(|| anyhow::anyhow!("No chat matching '{args}'."))?;
|
||||||
let peers = self.transport.list_peers();
|
let label = self.state.chats[&chat_id].display_name().to_string();
|
||||||
if peers.is_empty() {
|
self.state.chats.remove(&chat_id);
|
||||||
Ok(Some(
|
if self.state.active_chat.as_deref() == Some(&chat_id) {
|
||||||
"No peers found. Start another chat-cli instance.".to_string(),
|
self.state.active_chat = self.state.chats.keys().next().cloned();
|
||||||
))
|
|
||||||
} else {
|
|
||||||
self.add_system_message(&format!("── Peers ({}) ──", peers.len()));
|
|
||||||
for peer in &peers {
|
|
||||||
self.add_system_message(&format!(" • {}", peer));
|
|
||||||
}
|
|
||||||
Ok(Some(format!("{} peer(s)", peers.len())))
|
|
||||||
}
|
}
|
||||||
|
self.save_state()?;
|
||||||
|
self.status = format!("Deleted '{label}'.");
|
||||||
|
Ok(Some(format!("Deleted '{label}'")))
|
||||||
}
|
}
|
||||||
"/status" => {
|
"/status" => {
|
||||||
let chats = self.state.chats.len();
|
let active_label = self
|
||||||
let active = self.state.active_chat.as_deref().unwrap_or("none");
|
.state
|
||||||
|
.active_chat
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|id| self.state.chats.get(id))
|
||||||
|
.map(|s| {
|
||||||
|
format!(
|
||||||
|
"{} ({})",
|
||||||
|
s.display_name(),
|
||||||
|
&s.chat_id[..8.min(s.chat_id.len())]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "none".to_string());
|
||||||
let status = format!(
|
let status = format!(
|
||||||
"User: {}\nAddress: {}\nChats: {}\nActive: {}",
|
"User: {}\nIdentity: {}\nChats: {}\nActive: {}",
|
||||||
self.user_name,
|
self.user_name,
|
||||||
hex::encode(self.manager.installation_key().as_bytes()),
|
self.client.installation_name(),
|
||||||
chats,
|
self.state.chats.len(),
|
||||||
active
|
active_label,
|
||||||
);
|
);
|
||||||
Ok(Some(status))
|
Ok(Some(status))
|
||||||
}
|
}
|
||||||
@ -452,8 +382,7 @@ impl ChatApp {
|
|||||||
}
|
}
|
||||||
"/quit" => Ok(None),
|
"/quit" => Ok(None),
|
||||||
_ => Ok(Some(format!(
|
_ => Ok(Some(format!(
|
||||||
"Unknown command: {}. Type /help for commands.",
|
"Unknown command: {command}. Type /help for commands."
|
||||||
command
|
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,93 +1,189 @@
|
|||||||
//! Chat CLI - A terminal chat application using logos-chat.
|
|
||||||
//!
|
|
||||||
//! This application demonstrates how to use the logos-chat library
|
|
||||||
//! with file-based transport for local communication.
|
|
||||||
//!
|
|
||||||
//! # Usage
|
|
||||||
//!
|
|
||||||
//! Run two instances with different usernames:
|
|
||||||
//!
|
|
||||||
//! ```bash
|
|
||||||
//! # Terminal 1
|
|
||||||
//! cargo run -p chat-cli -- alice
|
|
||||||
//!
|
|
||||||
//! # Terminal 2
|
|
||||||
//! cargo run -p chat-cli -- bob
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! Then in alice's terminal:
|
|
||||||
//! 1. Type `/intro` to get your introduction bundle
|
|
||||||
//! 2. Copy the bundle string
|
|
||||||
//!
|
|
||||||
//! In bob's terminal:
|
|
||||||
//! 1. Type `/connect alice <bundle>` (paste alice's bundle)
|
|
||||||
//!
|
|
||||||
//! Now bob can send messages to alice, and alice can reply.
|
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
mod transport;
|
mod transport;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
use std::{env, path::PathBuf};
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use clap::Parser;
|
||||||
|
use client::DeliveryService;
|
||||||
|
|
||||||
/// Get the data directory (in project folder).
|
use app::ChatApp;
|
||||||
fn get_data_dir() -> PathBuf {
|
|
||||||
env::current_dir()
|
#[derive(Parser, Debug)]
|
||||||
.unwrap_or_else(|_| PathBuf::from("."))
|
#[command(name = "chat-cli", about = "End-to-end encrypted terminal chat")]
|
||||||
.join("tmp/chat-cli-data")
|
struct Cli {
|
||||||
|
/// Your identity name.
|
||||||
|
#[arg(long, short)]
|
||||||
|
name: String,
|
||||||
|
|
||||||
|
// ── File-transport options ────────────────────────────────────────────────
|
||||||
|
/// Shared data directory for file transport (both peers must use the same path).
|
||||||
|
#[arg(long, default_value = "tmp/chat-cli-data")]
|
||||||
|
data: PathBuf,
|
||||||
|
|
||||||
|
// ── logos-delivery transport options ──────────────────────────────────────
|
||||||
|
/// Persistent SQLite database for logos-delivery transport (omit for ephemeral identity).
|
||||||
|
#[arg(long)]
|
||||||
|
db: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// logos-delivery network preset (`logos.dev` or `twn`).
|
||||||
|
#[arg(long, default_value = "logos.dev")]
|
||||||
|
preset: String,
|
||||||
|
|
||||||
|
/// TCP port for the embedded logos-delivery node.
|
||||||
|
#[arg(long, default_value_t = 60000)]
|
||||||
|
port: u16,
|
||||||
|
|
||||||
|
/// Write logs to a file instead of stderr (keeps TUI output clean).
|
||||||
|
#[arg(long)]
|
||||||
|
log_file: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Initialize and immediately exit without launching the TUI (for CI).
|
||||||
|
#[arg(long)]
|
||||||
|
smoketest: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
// Parse arguments
|
let cli = Cli::parse();
|
||||||
let args: Vec<String> = std::env::args().collect();
|
setup_logging(cli.log_file.as_deref())?;
|
||||||
if args.len() < 2 {
|
#[cfg(logos_delivery)]
|
||||||
eprintln!("Usage: {} <username>", args[0]);
|
return run_logos_delivery(cli);
|
||||||
eprintln!("\nExample:");
|
#[cfg(not(logos_delivery))]
|
||||||
eprintln!(" Terminal 1: {} alice", args[0]);
|
run_file(cli)
|
||||||
eprintln!(" Terminal 2: {} bob", args[0]);
|
}
|
||||||
std::process::exit(1);
|
|
||||||
|
#[cfg(not(logos_delivery))]
|
||||||
|
fn run_file(cli: Cli) -> Result<()> {
|
||||||
|
use transport::file::FileTransport;
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&cli.data).context("failed to create data directory")?;
|
||||||
|
|
||||||
|
println!("Starting chat as '{}'...", cli.name);
|
||||||
|
println!("Data dir: {}", cli.data.display());
|
||||||
|
|
||||||
|
let transport_dir = cli.data.join("transport");
|
||||||
|
let (transport, inbound) =
|
||||||
|
FileTransport::new(&transport_dir).context("failed to create file transport")?;
|
||||||
|
|
||||||
|
let db_path = cli.data.join(format!("{}.db", cli.name));
|
||||||
|
let client = client::ChatClient::open(
|
||||||
|
cli.name.clone(),
|
||||||
|
client::StorageConfig::Encrypted {
|
||||||
|
path: db_path.to_string_lossy().to_string(),
|
||||||
|
key: "chat-cli".to_string(),
|
||||||
|
},
|
||||||
|
transport,
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e:?}"))
|
||||||
|
.context("failed to open chat client")?;
|
||||||
|
|
||||||
|
let mut app = ChatApp::new(client, inbound, &cli.name, &cli.data)?;
|
||||||
|
|
||||||
|
if cli.smoketest {
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let user_name = &args[1];
|
let mut terminal = ui::init().context("failed to initialize terminal")?;
|
||||||
|
|
||||||
// Setup data directory in project folder
|
|
||||||
let data_dir = get_data_dir();
|
|
||||||
std::fs::create_dir_all(&data_dir).context("Failed to create data directory")?;
|
|
||||||
|
|
||||||
println!("Starting chat as '{}'...", user_name);
|
|
||||||
println!("Data dir: {:?}", data_dir);
|
|
||||||
|
|
||||||
// Create app
|
|
||||||
let mut app = app::ChatApp::new(user_name, &data_dir).context("Failed to create chat app")?;
|
|
||||||
|
|
||||||
// Initialize terminal UI
|
|
||||||
let mut terminal = ui::init().context("Failed to initialize terminal")?;
|
|
||||||
|
|
||||||
// Main loop
|
|
||||||
let result = run_app(&mut terminal, &mut app);
|
let result = run_app(&mut terminal, &mut app);
|
||||||
|
ui::restore().context("failed to restore terminal")?;
|
||||||
// Restore terminal
|
|
||||||
ui::restore().context("Failed to restore terminal")?;
|
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_app(terminal: &mut ui::Tui, app: &mut app::ChatApp) -> Result<()> {
|
#[cfg_attr(not(logos_delivery), allow(dead_code, unused_variables))]
|
||||||
|
fn run_logos_delivery(cli: Cli) -> Result<()> {
|
||||||
|
#[cfg(logos_delivery)]
|
||||||
|
{
|
||||||
|
use transport::logos_delivery::{Config, Service};
|
||||||
|
|
||||||
|
eprintln!("Starting logos-delivery node (preset={})...", cli.preset);
|
||||||
|
eprintln!("This may take a few seconds while connecting to the network.");
|
||||||
|
|
||||||
|
let logos_cfg = Config {
|
||||||
|
preset: cli.preset.clone(),
|
||||||
|
tcp_port: cli.port,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let (delivery, inbound) =
|
||||||
|
Service::start(logos_cfg).context("failed to start logos-delivery")?;
|
||||||
|
|
||||||
|
eprintln!("Node connected. Initializing chat client...");
|
||||||
|
|
||||||
|
let data_dir = cli
|
||||||
|
.db
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| p.parent())
|
||||||
|
.map(|p| p.to_path_buf())
|
||||||
|
.unwrap_or_else(|| cli.data.clone());
|
||||||
|
|
||||||
|
let client = match cli.db {
|
||||||
|
Some(ref path) => {
|
||||||
|
let db_str = path
|
||||||
|
.to_str()
|
||||||
|
.context("db path contains non-UTF-8 characters")?
|
||||||
|
.to_string();
|
||||||
|
client::ChatClient::open(
|
||||||
|
cli.name.clone(),
|
||||||
|
client::StorageConfig::Encrypted {
|
||||||
|
path: db_str,
|
||||||
|
key: "chat-cli".to_string(),
|
||||||
|
},
|
||||||
|
delivery,
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e:?}"))
|
||||||
|
.context("failed to open persistent client")?
|
||||||
|
}
|
||||||
|
None => client::ChatClient::new(cli.name.clone(), delivery),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut app = ChatApp::new(client, inbound, &cli.name, &data_dir)?;
|
||||||
|
|
||||||
|
if cli.smoketest {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut terminal = ui::init().context("failed to initialize terminal")?;
|
||||||
|
let result = run_app(&mut terminal, &mut app);
|
||||||
|
ui::restore().context("failed to restore terminal")?;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(logos_delivery))]
|
||||||
|
anyhow::bail!(
|
||||||
|
"logos-delivery transport is not available in this build.\n\
|
||||||
|
Build with LOGOS_DELIVERY_LIB_DIR set to enable it."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_app<D: DeliveryService>(terminal: &mut ui::Tui, app: &mut ChatApp<D>) -> Result<()> {
|
||||||
loop {
|
loop {
|
||||||
// Process incoming messages
|
|
||||||
app.process_incoming()?;
|
app.process_incoming()?;
|
||||||
|
|
||||||
// Draw UI
|
|
||||||
terminal.draw(|frame| ui::draw(frame, app))?;
|
terminal.draw(|frame| ui::draw(frame, app))?;
|
||||||
|
|
||||||
// Handle input
|
|
||||||
if !ui::handle_events(app)? {
|
if !ui::handle_events(app)? {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_logging(log_file: Option<&std::path::Path>) -> Result<()> {
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn"));
|
||||||
|
|
||||||
|
if let Some(path) = log_file {
|
||||||
|
let file = std::fs::File::create(path)
|
||||||
|
.with_context(|| format!("failed to create log file: {}", path.display()))?;
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_writer(file)
|
||||||
|
.init();
|
||||||
|
} else {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(EnvFilter::new("off"))
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,138 +1,4 @@
|
|||||||
//! File-based transport for local chat communication.
|
#[cfg(not(logos_delivery))]
|
||||||
//!
|
pub mod file;
|
||||||
//! Messages are passed between users via files in a shared directory.
|
#[cfg(logos_delivery)]
|
||||||
|
pub mod logos_delivery;
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::utils::now;
|
|
||||||
|
|
||||||
/// A message envelope for transport.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MessageEnvelope {
|
|
||||||
pub from: String,
|
|
||||||
pub data: Vec<u8>,
|
|
||||||
pub timestamp: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// File-based transport for local communication.
|
|
||||||
pub struct FileTransport {
|
|
||||||
/// Our user name.
|
|
||||||
user_name: String,
|
|
||||||
/// Base directory for transport files.
|
|
||||||
base_dir: PathBuf,
|
|
||||||
/// Our inbox directory.
|
|
||||||
inbox_dir: PathBuf,
|
|
||||||
/// Set of processed message files (to avoid reprocessing).
|
|
||||||
processed: HashSet<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FileTransport {
|
|
||||||
/// Create a new file transport.
|
|
||||||
pub fn new(user_name: &str, data_dir: &Path) -> Result<Self> {
|
|
||||||
let base_dir = data_dir.join("transport");
|
|
||||||
let inbox_dir = base_dir.join(user_name);
|
|
||||||
|
|
||||||
// Create our inbox directory
|
|
||||||
fs::create_dir_all(&inbox_dir).context("Failed to create inbox directory")?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
user_name: user_name.to_string(),
|
|
||||||
base_dir,
|
|
||||||
inbox_dir,
|
|
||||||
processed: HashSet::new(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a message to a specific user.
|
|
||||||
pub fn send(&self, to_user: &str, data: Vec<u8>) -> Result<()> {
|
|
||||||
let target_dir = self.base_dir.join(to_user);
|
|
||||||
|
|
||||||
// Create target inbox if it doesn't exist
|
|
||||||
fs::create_dir_all(&target_dir).context("Failed to create target inbox")?;
|
|
||||||
|
|
||||||
let envelope = MessageEnvelope {
|
|
||||||
from: self.user_name.clone(),
|
|
||||||
data,
|
|
||||||
timestamp: now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Write message to a unique file
|
|
||||||
let filename = format!("{}_{}.json", self.user_name, now());
|
|
||||||
let filepath = target_dir.join(&filename);
|
|
||||||
|
|
||||||
let json = serde_json::to_string_pretty(&envelope)?;
|
|
||||||
fs::write(&filepath, json).context("Failed to write message file")?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to receive an incoming message (non-blocking).
|
|
||||||
pub fn try_recv(&mut self) -> Option<MessageEnvelope> {
|
|
||||||
// List files in our inbox
|
|
||||||
let entries = match fs::read_dir(&self.inbox_dir) {
|
|
||||||
Ok(e) => e,
|
|
||||||
Err(_) => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
|
|
||||||
// Skip non-json files
|
|
||||||
if path.extension().map(|e| e != "json").unwrap_or(true) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let filename = path
|
|
||||||
.file_name()
|
|
||||||
.and_then(|n| n.to_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// Skip already processed files
|
|
||||||
if self.processed.contains(&filename) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to read and parse the message
|
|
||||||
if let Ok(contents) = fs::read_to_string(&path)
|
|
||||||
&& let Ok(envelope) = serde_json::from_str::<MessageEnvelope>(&contents)
|
|
||||||
{
|
|
||||||
// Mark as processed and delete
|
|
||||||
self.processed.insert(filename);
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
return Some(envelope);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List available peers (users with inbox directories).
|
|
||||||
pub fn list_peers(&self) -> Vec<String> {
|
|
||||||
let mut peers = Vec::new();
|
|
||||||
|
|
||||||
if let Ok(entries) = fs::read_dir(&self.base_dir) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
if entry.path().is_dir()
|
|
||||||
&& let Some(name) = entry.file_name().to_str()
|
|
||||||
&& name != self.user_name
|
|
||||||
{
|
|
||||||
peers.push(name.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
peers
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get our user name.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn user_name(&self) -> &str {
|
|
||||||
&self.user_name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
136
bin/chat-cli/src/transport/file.rs
Normal file
136
bin/chat-cli/src/transport/file.rs
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs::{self, File, OpenOptions};
|
||||||
|
use std::io::{self, BufReader, Read, Seek, SeekFrom, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use client::{AddressedEnvelope, DeliveryService};
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum FileTransportError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Io(#[from] io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FileTransport {
|
||||||
|
transport_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileTransport {
|
||||||
|
/// All instances pointing at the same `transport_dir` share one broadcast bus.
|
||||||
|
///
|
||||||
|
/// Messages are written to `{transport_dir}/{delivery_address}/{hours_since_epoch}.bin`
|
||||||
|
/// as length-prefixed frames (`[u32 BE length][payload bytes]`). The background
|
||||||
|
/// thread reads all files under `transport_dir` and forwards every frame to
|
||||||
|
/// the returned channel; `client.receive()` discards frames it cannot decrypt.
|
||||||
|
pub fn new(transport_dir: &Path) -> io::Result<(Self, mpsc::Receiver<Vec<u8>>)> {
|
||||||
|
fs::create_dir_all(transport_dir)?;
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::sync_channel(1024);
|
||||||
|
let dir = transport_dir.to_path_buf();
|
||||||
|
|
||||||
|
thread::Builder::new()
|
||||||
|
.name("file-transport".into())
|
||||||
|
.spawn(move || poll_reader(dir, tx))?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
Self {
|
||||||
|
transport_dir: transport_dir.to_path_buf(),
|
||||||
|
},
|
||||||
|
rx,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeliveryService for FileTransport {
|
||||||
|
type Error = FileTransportError;
|
||||||
|
|
||||||
|
fn publish(&mut self, envelope: AddressedEnvelope) -> Result<(), FileTransportError> {
|
||||||
|
let addr_dir = self.transport_dir.join(&envelope.delivery_address);
|
||||||
|
fs::create_dir_all(&addr_dir)?;
|
||||||
|
|
||||||
|
let filename = format!("{}.bin", current_hour());
|
||||||
|
let path = addr_dir.join(filename);
|
||||||
|
|
||||||
|
let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
|
||||||
|
let len = envelope.data.len() as u32;
|
||||||
|
file.write_all(&len.to_be_bytes())?;
|
||||||
|
file.write_all(&envelope.data)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hours since Unix epoch — used as the rolling filename.
|
||||||
|
fn current_hour() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
/ 3600
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_reader(transport_dir: PathBuf, tx: mpsc::SyncSender<Vec<u8>>) {
|
||||||
|
// Maps absolute file path → number of bytes already consumed.
|
||||||
|
let mut offsets: BTreeMap<PathBuf, u64> = BTreeMap::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let bin_files = collect_bin_files(&transport_dir);
|
||||||
|
|
||||||
|
for path in bin_files {
|
||||||
|
let offset = offsets.entry(path.clone()).or_insert(0);
|
||||||
|
|
||||||
|
let file = match File::open(&path) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let mut reader = BufReader::new(file);
|
||||||
|
if reader.seek(SeekFrom::Start(*offset)).is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut len_buf = [0u8; 4];
|
||||||
|
if reader.read_exact(&mut len_buf).is_err() {
|
||||||
|
break; // no complete header yet
|
||||||
|
}
|
||||||
|
let len = u32::from_be_bytes(len_buf) as usize;
|
||||||
|
let mut payload = vec![0u8; len];
|
||||||
|
if reader.read_exact(&mut payload).is_err() {
|
||||||
|
break; // partial frame — wait for writer to finish
|
||||||
|
}
|
||||||
|
let _ = tx.try_send(payload);
|
||||||
|
*offset += (4 + len) as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk `transport_dir/*/` and collect all `*.bin` files, sorted by path
|
||||||
|
/// (address subdir first, then filename = hour order).
|
||||||
|
fn collect_bin_files(transport_dir: &Path) -> Vec<PathBuf> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let Ok(addr_entries) = fs::read_dir(transport_dir) else {
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
for addr_entry in addr_entries.flatten() {
|
||||||
|
let addr_path = addr_entry.path();
|
||||||
|
if !addr_path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(file_entries) = fs::read_dir(&addr_path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for file_entry in file_entries.flatten() {
|
||||||
|
let p = file_entry.path();
|
||||||
|
if p.extension().is_some_and(|e| e == "bin") {
|
||||||
|
files.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files.sort();
|
||||||
|
files
|
||||||
|
}
|
||||||
303
bin/chat-cli/src/transport/logos_delivery.rs
Normal file
303
bin/chat-cli/src/transport/logos_delivery.rs
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
//! logos-delivery backed [`client::DeliveryService`] implementation.
|
||||||
|
//!
|
||||||
|
//! `LogosDeliveryService` wraps an embedded logos-delivery node running on a
|
||||||
|
//! dedicated `std::thread`. All interaction is via synchronous `std::sync::mpsc`
|
||||||
|
//! channels.
|
||||||
|
//!
|
||||||
|
//! ## Content topic mapping
|
||||||
|
//!
|
||||||
|
//! `AddressedEnvelope::delivery_address` maps to logos-delivery content topic
|
||||||
|
//! `/logos-chat/1/{delivery_address}/proto`.
|
||||||
|
|
||||||
|
pub(crate) mod sys;
|
||||||
|
pub(crate) mod wrapper;
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex, mpsc};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
use client::{AddressedEnvelope, DeliveryService};
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
use wrapper::LogosNodeCtx;
|
||||||
|
|
||||||
|
pub fn content_topic_for(delivery_address: &str) -> String {
|
||||||
|
format!("/logos-chat/1/{delivery_address}/proto")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Error ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum DeliveryError {
|
||||||
|
#[error("node startup failed: {0}")]
|
||||||
|
StartupFailed(String),
|
||||||
|
#[error("publish failed: {0}")]
|
||||||
|
PublishFailed(String),
|
||||||
|
#[error("send channel closed")]
|
||||||
|
ChannelClosed,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internals ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct OutboundCmd {
|
||||||
|
message_json: String,
|
||||||
|
reply: mpsc::SyncSender<Result<(), DeliveryError>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubscriberList = Arc<Mutex<Vec<mpsc::SyncSender<Vec<u8>>>>>;
|
||||||
|
|
||||||
|
// ── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub preset: String,
|
||||||
|
pub tcp_port: u16,
|
||||||
|
pub log_level: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
preset: "logos.dev".into(),
|
||||||
|
tcp_port: 60000,
|
||||||
|
log_level: "ERROR".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wire types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Outbound message sent to the logos-delivery node.
|
||||||
|
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
struct WakuMessage {
|
||||||
|
#[serde(rename = "contentTopic")]
|
||||||
|
content_topic: String,
|
||||||
|
/// Base64-encoded payload.
|
||||||
|
payload: String,
|
||||||
|
ephemeral: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top-level event envelope received from the logos-delivery node callback.
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct WakuEvent {
|
||||||
|
#[serde(rename = "eventType")]
|
||||||
|
event_type: String,
|
||||||
|
message: Option<ReceivedMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message payload from a `message_received` event.
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct ReceivedMessage {
|
||||||
|
#[serde(rename = "contentTopic")]
|
||||||
|
content_topic: String,
|
||||||
|
/// The node may deliver the payload as either a base64 string or a JSON
|
||||||
|
/// array of byte values.
|
||||||
|
payload: WakuPayload,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Untagged union that handles both payload representations.
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum WakuPayload {
|
||||||
|
Base64(String),
|
||||||
|
Bytes(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WakuPayload {
|
||||||
|
fn decode(self) -> Option<Vec<u8>> {
|
||||||
|
match self {
|
||||||
|
WakuPayload::Base64(s) => BASE64.decode(s).ok(),
|
||||||
|
WakuPayload::Bytes(b) => Some(b),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Service ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// logos-delivery backed delivery service. Cheap to clone — all clones share
|
||||||
|
/// the same background node.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Service {
|
||||||
|
outbound: mpsc::SyncSender<OutboundCmd>,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
subscribers: SubscriberList,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Service {
|
||||||
|
/// Start the embedded logos-delivery node. Returns the service and a
|
||||||
|
/// receiver for inbound raw payloads.
|
||||||
|
pub fn start(cfg: Config) -> Result<(Self, mpsc::Receiver<Vec<u8>>), DeliveryError> {
|
||||||
|
let (out_tx, out_rx) = mpsc::sync_channel::<OutboundCmd>(256);
|
||||||
|
let subscribers: SubscriberList = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let (ready_tx, ready_rx) = mpsc::channel::<Result<(), DeliveryError>>();
|
||||||
|
// Create the inbound channel before spawning so the receiver is
|
||||||
|
// registered inside the thread, before any event callback fires.
|
||||||
|
let (inbound_tx, inbound_rx) = mpsc::sync_channel::<Vec<u8>>(1024);
|
||||||
|
|
||||||
|
let subs_for_thread = subscribers.clone();
|
||||||
|
|
||||||
|
let handle = thread::Builder::new()
|
||||||
|
.name("logos-node".into())
|
||||||
|
.spawn(move || {
|
||||||
|
if let Err(panic) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
Self::node_thread(cfg, out_rx, subs_for_thread, inbound_tx, ready_tx);
|
||||||
|
})) {
|
||||||
|
let msg = panic
|
||||||
|
.downcast_ref::<&str>()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.or_else(|| panic.downcast_ref::<String>().cloned())
|
||||||
|
.unwrap_or_else(|| "unknown panic".into());
|
||||||
|
error!("logos-node thread panicked: {msg}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|e| DeliveryError::StartupFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
// On failure, the node thread drops LogosNodeCtx (stop+destroy against
|
||||||
|
// a half-initialized Nim node). Join it so the process doesn't begin
|
||||||
|
// teardown mid-destroy — that race SIGSEGVs inside the Nim async loop.
|
||||||
|
let ready = ready_rx.recv().unwrap_or_else(|_| {
|
||||||
|
Err(DeliveryError::StartupFailed(
|
||||||
|
"node thread exited before ready".into(),
|
||||||
|
))
|
||||||
|
});
|
||||||
|
if let Err(e) = ready {
|
||||||
|
let _ = handle.join();
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
Self {
|
||||||
|
outbound: out_tx,
|
||||||
|
subscribers,
|
||||||
|
},
|
||||||
|
inbound_rx,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_thread(
|
||||||
|
cfg: Config,
|
||||||
|
out_rx: mpsc::Receiver<OutboundCmd>,
|
||||||
|
subscribers: SubscriberList,
|
||||||
|
inbound_tx: mpsc::SyncSender<Vec<u8>>,
|
||||||
|
ready_tx: mpsc::Sender<Result<(), DeliveryError>>,
|
||||||
|
) {
|
||||||
|
// discv5UdpPort defaults to 9000 in libwaku, so a second instance with
|
||||||
|
// a distinct --port still collides on UDP. Bind it to tcp_port so a
|
||||||
|
// single --port knob keeps both ports distinct across instances.
|
||||||
|
let config_json = serde_json::json!({
|
||||||
|
"logLevel": cfg.log_level,
|
||||||
|
"mode": "Core",
|
||||||
|
"preset": cfg.preset,
|
||||||
|
"tcpPort": cfg.tcp_port,
|
||||||
|
"discv5UdpPort": cfg.tcp_port,
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let mut node = match LogosNodeCtx::new(&config_json) {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = ready_tx.send(Err(DeliveryError::StartupFailed(e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register the inbound sender before installing the event callback so
|
||||||
|
// there is no window where the callback is live but the channel is not
|
||||||
|
// yet in the subscriber list.
|
||||||
|
subscribers.lock().unwrap().push(inbound_tx);
|
||||||
|
|
||||||
|
let subs_for_cb = subscribers.clone();
|
||||||
|
let event_closure = move |_ret: i32, data: &str| {
|
||||||
|
if let Some(payload) = Self::parse_message_received(data) {
|
||||||
|
let mut guard = match subs_for_cb.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(e) => {
|
||||||
|
error!("subscriber mutex poisoned: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
guard.retain(|tx| match tx.try_send(payload.clone()) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(mpsc::TrySendError::Full(_)) => true,
|
||||||
|
Err(mpsc::TrySendError::Disconnected(_)) => false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
node.set_event_callback(event_closure);
|
||||||
|
|
||||||
|
if let Err(e) = node.start() {
|
||||||
|
let _ = ready_tx.send(Err(DeliveryError::StartupFailed(e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
info!("logos-delivery node started (preset={})", cfg.preset);
|
||||||
|
|
||||||
|
// FIXME: This unconditional sleep is a stand-in for proper
|
||||||
|
// peer-connectivity detection. The right approach is to listen for a
|
||||||
|
// `peer_connected` (or equivalent status-change) event from the node
|
||||||
|
// callback and only proceed once at least one peer is reachable,
|
||||||
|
// falling back to a configurable timeout. logos-delivery would need to
|
||||||
|
// surface such an event via its callback mechanism for this to work.
|
||||||
|
thread::sleep(Duration::from_secs(3));
|
||||||
|
|
||||||
|
let default_topic = content_topic_for("delivery_address");
|
||||||
|
if let Err(e) = node.subscribe(&default_topic) {
|
||||||
|
warn!("subscribe to {default_topic}: {e}");
|
||||||
|
} else {
|
||||||
|
info!("subscribed to {default_topic}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = ready_tx.send(Ok(()));
|
||||||
|
|
||||||
|
while let Ok(cmd) = out_rx.recv() {
|
||||||
|
let result = node
|
||||||
|
.send(&cmd.message_json)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(DeliveryError::PublishFailed);
|
||||||
|
let _ = cmd.reply.try_send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("logos-node outbound loop finished");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_message_received(data: &str) -> Option<Vec<u8>> {
|
||||||
|
let event: WakuEvent = serde_json::from_str(data).ok()?;
|
||||||
|
|
||||||
|
if event.event_type != "message_received" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = event.message?;
|
||||||
|
|
||||||
|
if !msg.content_topic.starts_with("/logos-chat/1/") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.payload.decode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeliveryService for Service {
|
||||||
|
type Error = DeliveryError;
|
||||||
|
|
||||||
|
fn publish(&mut self, envelope: AddressedEnvelope) -> Result<(), DeliveryError> {
|
||||||
|
let msg = WakuMessage {
|
||||||
|
content_topic: content_topic_for(&envelope.delivery_address),
|
||||||
|
payload: BASE64.encode(&envelope.data),
|
||||||
|
ephemeral: false,
|
||||||
|
};
|
||||||
|
let message_json =
|
||||||
|
serde_json::to_string(&msg).map_err(|e| DeliveryError::PublishFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
let (reply_tx, reply_rx) = mpsc::sync_channel(1);
|
||||||
|
self.outbound
|
||||||
|
.send(OutboundCmd {
|
||||||
|
message_json,
|
||||||
|
reply: reply_tx,
|
||||||
|
})
|
||||||
|
.map_err(|_| DeliveryError::ChannelClosed)?;
|
||||||
|
|
||||||
|
reply_rx.recv().map_err(|_| DeliveryError::ChannelClosed)?
|
||||||
|
}
|
||||||
|
}
|
||||||
102
bin/chat-cli/src/transport/logos_delivery/sys.rs
Normal file
102
bin/chat-cli/src/transport/logos_delivery/sys.rs
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
//! Raw FFI declarations matching liblogosdelivery.h (trampoline pattern).
|
||||||
|
//!
|
||||||
|
//! No `#[link]` attribute — build.rs handles linking to liblogosdelivery.
|
||||||
|
#![allow(unused)]
|
||||||
|
|
||||||
|
use std::os::raw::{c_char, c_int, c_void};
|
||||||
|
use std::slice;
|
||||||
|
|
||||||
|
pub const RET_OK: i32 = 0;
|
||||||
|
|
||||||
|
pub type FFICallBack = unsafe extern "C" fn(c_int, *const c_char, usize, *const c_void);
|
||||||
|
|
||||||
|
unsafe extern "C" {
|
||||||
|
pub fn logosdelivery_create_node(
|
||||||
|
config_json: *const c_char,
|
||||||
|
cb: FFICallBack,
|
||||||
|
user_data: *const c_void,
|
||||||
|
) -> *mut c_void;
|
||||||
|
|
||||||
|
pub fn logosdelivery_start_node(
|
||||||
|
ctx: *mut c_void,
|
||||||
|
cb: FFICallBack,
|
||||||
|
user_data: *const c_void,
|
||||||
|
) -> c_int;
|
||||||
|
|
||||||
|
pub fn logosdelivery_stop_node(
|
||||||
|
ctx: *mut c_void,
|
||||||
|
cb: FFICallBack,
|
||||||
|
user_data: *const c_void,
|
||||||
|
) -> c_int;
|
||||||
|
|
||||||
|
pub fn logosdelivery_destroy(
|
||||||
|
ctx: *mut c_void,
|
||||||
|
cb: FFICallBack,
|
||||||
|
user_data: *const c_void,
|
||||||
|
) -> c_int;
|
||||||
|
|
||||||
|
pub fn logosdelivery_subscribe(
|
||||||
|
ctx: *mut c_void,
|
||||||
|
cb: FFICallBack,
|
||||||
|
user_data: *const c_void,
|
||||||
|
content_topic: *const c_char,
|
||||||
|
) -> c_int;
|
||||||
|
|
||||||
|
pub fn logosdelivery_unsubscribe(
|
||||||
|
ctx: *mut c_void,
|
||||||
|
cb: FFICallBack,
|
||||||
|
user_data: *const c_void,
|
||||||
|
content_topic: *const c_char,
|
||||||
|
) -> c_int;
|
||||||
|
|
||||||
|
/// `message_json`: `{"contentTopic": "...", "payload": "<base64>", "ephemeral": false}`
|
||||||
|
pub fn logosdelivery_send(
|
||||||
|
ctx: *mut c_void,
|
||||||
|
cb: FFICallBack,
|
||||||
|
user_data: *const c_void,
|
||||||
|
message_json: *const c_char,
|
||||||
|
) -> c_int;
|
||||||
|
|
||||||
|
pub fn logosdelivery_set_event_callback(
|
||||||
|
ctx: *mut c_void,
|
||||||
|
cb: FFICallBack,
|
||||||
|
user_data: *const c_void,
|
||||||
|
);
|
||||||
|
|
||||||
|
pub fn logosdelivery_get_node_info(
|
||||||
|
ctx: *mut c_void,
|
||||||
|
cb: FFICallBack,
|
||||||
|
user_data: *const c_void,
|
||||||
|
node_info_id: *const c_char,
|
||||||
|
) -> c_int;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trampoline ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub unsafe extern "C" fn trampoline<C>(
|
||||||
|
return_val: c_int,
|
||||||
|
buffer: *const c_char,
|
||||||
|
buffer_len: usize,
|
||||||
|
data: *const c_void,
|
||||||
|
) where
|
||||||
|
C: FnMut(i32, &str),
|
||||||
|
{
|
||||||
|
if data.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let closure = unsafe { &mut *(data as *mut C) };
|
||||||
|
if buffer.is_null() || buffer_len == 0 {
|
||||||
|
closure(return_val, "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let bytes = unsafe { slice::from_raw_parts(buffer as *const u8, buffer_len) };
|
||||||
|
let s = String::from_utf8_lossy(bytes);
|
||||||
|
closure(return_val, &s);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_trampoline<C>(_: &C) -> FFICallBack
|
||||||
|
where
|
||||||
|
C: FnMut(i32, &str),
|
||||||
|
{
|
||||||
|
trampoline::<C>
|
||||||
|
}
|
||||||
227
bin/chat-cli/src/transport/logos_delivery/wrapper.rs
Normal file
227
bin/chat-cli/src/transport/logos_delivery/wrapper.rs
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
//! Safe synchronous wrapper around the raw liblogosdelivery FFI.
|
||||||
|
//!
|
||||||
|
//! # Why Box::into_raw for one-shot callbacks?
|
||||||
|
//!
|
||||||
|
//! `sendRequestToFFIThread` (nim-ffi) signals the caller as soon as the FFI
|
||||||
|
//! thread *receives* the request, before it processes it. The actual result
|
||||||
|
//! callback fires later, from the Nim async event loop, after the Rust call
|
||||||
|
//! frame has returned and its stack variables are gone. Passing `&mut closure`
|
||||||
|
//! as `user_data` therefore produces a dangling pointer by the time the
|
||||||
|
//! callback fires — a use-after-free that manifests as a SIGSEGV when the
|
||||||
|
//! operation fails and the callback tries to write an error into captured
|
||||||
|
//! stack memory.
|
||||||
|
//!
|
||||||
|
//! Fix: heap-allocate each one-shot closure with `Box::into_raw`, synchronise
|
||||||
|
//! via an `mpsc` channel (blocking until the callback fires), then drop the
|
||||||
|
//! box. The pointer is valid for the entire async lifetime of the request.
|
||||||
|
//!
|
||||||
|
//! # Why store the event callback inside LogosNodeCtx?
|
||||||
|
//!
|
||||||
|
//! Rust drops locals in reverse declaration order. If the event-callback box
|
||||||
|
//! were held by the caller (outside the node), it would be freed before the
|
||||||
|
//! node's Drop runs stop+destroy. During stop/destroy the Nim async event
|
||||||
|
//! loop can still fire the event callback, which would access freed memory.
|
||||||
|
//!
|
||||||
|
//! By storing the box as `_event_cb` inside `LogosNodeCtx`, Rust's field-drop
|
||||||
|
//! order guarantees it is freed *after* Drop::drop returns (i.e. after
|
||||||
|
//! stop+destroy complete), so the pointer is always valid when Nim calls it.
|
||||||
|
|
||||||
|
use std::ffi::CString;
|
||||||
|
use std::os::raw::c_void;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
use super::sys::{self as ffi, RET_OK, get_trampoline};
|
||||||
|
|
||||||
|
/// Opaque handle to a logos-delivery node context.
|
||||||
|
pub struct LogosNodeCtx {
|
||||||
|
ctx: *mut c_void,
|
||||||
|
/// Keeps the event-callback closure alive for the lifetime of the node.
|
||||||
|
_event_cb: Option<Box<dyn std::any::Any + Send>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The logos-delivery ctx pointer is thread-safe (serialized calls inside C/Nim).
|
||||||
|
unsafe impl Send for LogosNodeCtx {}
|
||||||
|
unsafe impl Sync for LogosNodeCtx {}
|
||||||
|
|
||||||
|
impl LogosNodeCtx {
|
||||||
|
pub fn new(config_json: &str) -> Result<Self, String> {
|
||||||
|
let config_cstr = CString::new(config_json).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
let closure = move |ret: i32, data: &str| {
|
||||||
|
let _ = tx.send(if ret == RET_OK {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(data.to_string())
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let raw = Box::into_raw(Box::new(closure));
|
||||||
|
let cb = get_trampoline(unsafe { &*raw });
|
||||||
|
|
||||||
|
let ctx = unsafe {
|
||||||
|
ffi::logosdelivery_create_node(config_cstr.as_ptr(), cb, raw as *const c_void)
|
||||||
|
};
|
||||||
|
|
||||||
|
// create_node may call the callback synchronously (try_recv) or
|
||||||
|
// asynchronously (recv). Handle both.
|
||||||
|
let callback_result: Result<(), String> = if ctx.is_null() {
|
||||||
|
rx.try_recv()
|
||||||
|
.unwrap_or(Err("logosdelivery_create_node returned null".into()))
|
||||||
|
} else {
|
||||||
|
rx.recv()
|
||||||
|
.unwrap_or(Err("callback channel disconnected".into()))
|
||||||
|
};
|
||||||
|
drop(unsafe { Box::from_raw(raw) });
|
||||||
|
|
||||||
|
callback_result.map(|_| Self {
|
||||||
|
ctx,
|
||||||
|
_event_cb: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(&self) -> Result<(), String> {
|
||||||
|
let (tx, rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
let closure = move |ret: i32, data: &str| {
|
||||||
|
let _ = tx.send(if ret == RET_OK {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(data.to_string())
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let raw = Box::into_raw(Box::new(closure));
|
||||||
|
let cb = get_trampoline(unsafe { &*raw });
|
||||||
|
|
||||||
|
let ret = unsafe { ffi::logosdelivery_start_node(self.ctx, cb, raw as *const c_void) };
|
||||||
|
|
||||||
|
if ret != RET_OK {
|
||||||
|
drop(unsafe { Box::from_raw(raw) });
|
||||||
|
return Err(format!("logosdelivery_start_node returned {ret}"));
|
||||||
|
}
|
||||||
|
let result = rx
|
||||||
|
.recv()
|
||||||
|
.unwrap_or(Err("callback channel disconnected".into()));
|
||||||
|
drop(unsafe { Box::from_raw(raw) });
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscribe(&self, content_topic: &str) -> Result<(), String> {
|
||||||
|
let topic_cstr = CString::new(content_topic).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
let closure = move |ret: i32, data: &str| {
|
||||||
|
let _ = tx.send(if ret == RET_OK {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(data.to_string())
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let raw = Box::into_raw(Box::new(closure));
|
||||||
|
let cb = get_trampoline(unsafe { &*raw });
|
||||||
|
|
||||||
|
let ret = unsafe {
|
||||||
|
ffi::logosdelivery_subscribe(self.ctx, cb, raw as *const c_void, topic_cstr.as_ptr())
|
||||||
|
};
|
||||||
|
|
||||||
|
if ret != RET_OK {
|
||||||
|
drop(unsafe { Box::from_raw(raw) });
|
||||||
|
return Err(format!("logosdelivery_subscribe returned {ret}"));
|
||||||
|
}
|
||||||
|
let result = rx
|
||||||
|
.recv()
|
||||||
|
.unwrap_or(Err("callback channel disconnected".into()));
|
||||||
|
drop(unsafe { Box::from_raw(raw) });
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the request ID on success.
|
||||||
|
pub fn send(&self, message_json: &str) -> Result<String, String> {
|
||||||
|
let msg_cstr = CString::new(message_json).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::sync_channel::<Result<String, String>>(1);
|
||||||
|
let closure = move |ret: i32, data: &str| {
|
||||||
|
let _ = tx.send(if ret == RET_OK {
|
||||||
|
Ok(data.to_string())
|
||||||
|
} else {
|
||||||
|
Err(data.to_string())
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let raw = Box::into_raw(Box::new(closure));
|
||||||
|
let cb = get_trampoline(unsafe { &*raw });
|
||||||
|
|
||||||
|
let ret = unsafe {
|
||||||
|
ffi::logosdelivery_send(self.ctx, cb, raw as *const c_void, msg_cstr.as_ptr())
|
||||||
|
};
|
||||||
|
|
||||||
|
if ret != RET_OK {
|
||||||
|
drop(unsafe { Box::from_raw(raw) });
|
||||||
|
return Err(format!("logosdelivery_send returned {ret}"));
|
||||||
|
}
|
||||||
|
let result = rx
|
||||||
|
.recv()
|
||||||
|
.unwrap_or(Err("callback channel disconnected".into()));
|
||||||
|
drop(unsafe { Box::from_raw(raw) });
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores the event callback inside the node so it is dropped *after*
|
||||||
|
/// stop+destroy in Drop, keeping the pointer valid for the node's lifetime.
|
||||||
|
pub fn set_event_callback<C>(&mut self, closure: C)
|
||||||
|
where
|
||||||
|
C: FnMut(i32, &str) + Send + 'static,
|
||||||
|
{
|
||||||
|
let mut boxed = Box::new(closure);
|
||||||
|
let cb = get_trampoline(&*boxed);
|
||||||
|
let user_data = &mut *boxed as *mut C as *const c_void;
|
||||||
|
unsafe {
|
||||||
|
ffi::logosdelivery_set_event_callback(self.ctx, cb, user_data);
|
||||||
|
}
|
||||||
|
// Move the box into self; the heap address (user_data) is unaffected.
|
||||||
|
self._event_cb = Some(boxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self) -> Result<(), String> {
|
||||||
|
let (tx, rx) = mpsc::sync_channel::<Result<(), String>>(1);
|
||||||
|
let closure = move |ret: i32, data: &str| {
|
||||||
|
let _ = tx.send(if ret == RET_OK {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(data.to_string())
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let raw = Box::into_raw(Box::new(closure));
|
||||||
|
let cb = get_trampoline(unsafe { &*raw });
|
||||||
|
|
||||||
|
let ret = unsafe { ffi::logosdelivery_stop_node(self.ctx, cb, raw as *const c_void) };
|
||||||
|
|
||||||
|
if ret != RET_OK {
|
||||||
|
drop(unsafe { Box::from_raw(raw) });
|
||||||
|
return Err(format!("logosdelivery_stop_node returned {ret}"));
|
||||||
|
}
|
||||||
|
let result = rx
|
||||||
|
.recv()
|
||||||
|
.unwrap_or(Err("callback channel disconnected".into()));
|
||||||
|
drop(unsafe { Box::from_raw(raw) });
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for LogosNodeCtx {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// stop+destroy must complete before _event_cb is freed.
|
||||||
|
// Rust drops fields after Drop::drop returns, so _event_cb outlives
|
||||||
|
// everything below — the event callback pointer stays valid throughout.
|
||||||
|
if let Err(e) = self.stop() {
|
||||||
|
tracing::warn!("logosdelivery_stop_node failed during drop: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::sync_channel::<()>(1);
|
||||||
|
let closure = move |_: i32, _: &str| {
|
||||||
|
let _ = tx.send(());
|
||||||
|
};
|
||||||
|
let raw = Box::into_raw(Box::new(closure));
|
||||||
|
let cb = get_trampoline(unsafe { &*raw });
|
||||||
|
unsafe { ffi::logosdelivery_destroy(self.ctx, cb, raw as *const c_void) };
|
||||||
|
let _ = rx.recv();
|
||||||
|
drop(unsafe { Box::from_raw(raw) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,6 +16,8 @@ use ratatui::{
|
|||||||
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
|
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use client::DeliveryService;
|
||||||
|
|
||||||
use crate::app::ChatApp;
|
use crate::app::ChatApp;
|
||||||
|
|
||||||
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
|
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
|
||||||
@ -36,7 +38,7 @@ pub fn restore() -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Draw the UI.
|
/// Draw the UI.
|
||||||
pub fn draw(frame: &mut Frame, app: &ChatApp) {
|
pub fn draw<D: DeliveryService>(frame: &mut Frame, app: &ChatApp<D>) {
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([
|
||||||
@ -53,13 +55,16 @@ pub fn draw(frame: &mut Frame, app: &ChatApp) {
|
|||||||
draw_status(frame, app, chunks[3]);
|
draw_status(frame, app, chunks[3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_header(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
fn draw_header<D: DeliveryService>(frame: &mut Frame, app: &ChatApp<D>, area: Rect) {
|
||||||
let title = match app.current_session() {
|
let title = match app.current_session() {
|
||||||
Some(session) => format!(" 💬 Chat: {} ↔ {} ", app.user_name, session.remote_user),
|
Some(session) => {
|
||||||
None => format!(
|
let id = &session.chat_id[..8.min(session.chat_id.len())];
|
||||||
" 💬 {} (no active chat - use /connect or /chats) ",
|
match &session.nickname {
|
||||||
app.user_name
|
Some(name) => format!(" 💬 Chat: {} ↔ {name} ({id}) ", app.user_name),
|
||||||
),
|
None => format!(" 💬 Chat: {} ↔ ({id}) ", app.user_name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => format!(" 💬 {} — no active chat ", app.user_name),
|
||||||
};
|
};
|
||||||
|
|
||||||
let header = Paragraph::new(title)
|
let header = Paragraph::new(title)
|
||||||
@ -73,10 +78,10 @@ fn draw_header(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
|||||||
frame.render_widget(header, area);
|
frame.render_widget(header, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_messages(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
fn draw_messages<D: DeliveryService>(frame: &mut Frame, app: &ChatApp<D>, area: Rect) {
|
||||||
let remote_name = app
|
let remote_name = app
|
||||||
.current_session()
|
.current_session()
|
||||||
.map(|s| s.remote_user.as_str())
|
.map(|s| s.display_name())
|
||||||
.unwrap_or("Them");
|
.unwrap_or("Them");
|
||||||
|
|
||||||
// Inner width: area minus borders (2) for wrapping long content.
|
// Inner width: area minus borders (2) for wrapping long content.
|
||||||
@ -108,7 +113,7 @@ fn draw_messages(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
|||||||
let first_line_width = inner_width.saturating_sub(prefix_len).max(1);
|
let first_line_width = inner_width.saturating_sub(prefix_len).max(1);
|
||||||
|
|
||||||
// First line includes the prefix.
|
// First line includes the prefix.
|
||||||
let (first_chunk, rest) = if content.len() <= first_line_width {
|
let (first_chunk, rest): (&str, &str) = if content.len() <= first_line_width {
|
||||||
(content.as_str(), "")
|
(content.as_str(), "")
|
||||||
} else {
|
} else {
|
||||||
content.split_at(first_line_width)
|
content.split_at(first_line_width)
|
||||||
@ -121,7 +126,7 @@ fn draw_messages(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
|||||||
|
|
||||||
// Continuation lines are indented to align with content.
|
// Continuation lines are indented to align with content.
|
||||||
let indent = " ".repeat(prefix_len);
|
let indent = " ".repeat(prefix_len);
|
||||||
let mut remaining = rest;
|
let mut remaining: &str = rest;
|
||||||
while !remaining.is_empty() {
|
while !remaining.is_empty() {
|
||||||
let chunk_width = inner_width.saturating_sub(prefix_len).max(1);
|
let chunk_width = inner_width.saturating_sub(prefix_len).max(1);
|
||||||
let (chunk, tail) = if remaining.len() <= chunk_width {
|
let (chunk, tail) = if remaining.len() <= chunk_width {
|
||||||
@ -141,17 +146,25 @@ fn draw_messages(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let title = match app.current_session() {
|
let title = match app.current_session() {
|
||||||
Some(session) => format!(" Messages with {} ", session.remote_user),
|
Some(s) => match &s.nickname {
|
||||||
None => " Messages ".to_string(),
|
Some(name) => format!(" Messages with {name} "),
|
||||||
|
None => format!(" Messages ({}) ", &s.chat_id[..8.min(s.chat_id.len())]),
|
||||||
|
},
|
||||||
|
None => " Command output ".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let item_count = messages.len();
|
||||||
let messages_widget =
|
let messages_widget =
|
||||||
List::new(messages).block(Block::default().title(title).borders(Borders::ALL));
|
List::new(messages).block(Block::default().title(title).borders(Borders::ALL));
|
||||||
|
|
||||||
frame.render_widget(messages_widget, area);
|
// Scroll so the last line is always visible (area height minus two borders).
|
||||||
|
let visible = area.height.saturating_sub(2) as usize;
|
||||||
|
let offset = item_count.saturating_sub(visible);
|
||||||
|
let mut list_state = ratatui::widgets::ListState::default().with_offset(offset);
|
||||||
|
frame.render_stateful_widget(messages_widget, area, &mut list_state);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_input(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
fn draw_input<D: DeliveryService>(frame: &mut Frame, app: &ChatApp<D>, area: Rect) {
|
||||||
// Inner width: area minus borders (2).
|
// Inner width: area minus borders (2).
|
||||||
let inner_width = area.width.saturating_sub(2) as usize;
|
let inner_width = area.width.saturating_sub(2) as usize;
|
||||||
let input_len = app.input.len();
|
let input_len = app.input.len();
|
||||||
@ -165,13 +178,11 @@ fn draw_input(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
|||||||
|
|
||||||
let visible_input = &app.input[scroll_offset..];
|
let visible_input = &app.input[scroll_offset..];
|
||||||
|
|
||||||
let input = Paragraph::new(visible_input)
|
let input = Paragraph::new(visible_input).style(Style::default()).block(
|
||||||
.style(Style::default().fg(Color::White))
|
Block::default()
|
||||||
.block(
|
.title(" Input (Enter to send) ")
|
||||||
Block::default()
|
.borders(Borders::ALL),
|
||||||
.title(" Input (Enter to send) ")
|
);
|
||||||
.borders(Borders::ALL),
|
|
||||||
);
|
|
||||||
|
|
||||||
frame.render_widget(input, area);
|
frame.render_widget(input, area);
|
||||||
|
|
||||||
@ -180,7 +191,7 @@ fn draw_input(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
|||||||
frame.set_cursor_position((cursor_x, area.y + 1));
|
frame.set_cursor_position((cursor_x, area.y + 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_status(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
fn draw_status<D: DeliveryService>(frame: &mut Frame, app: &ChatApp<D>, area: Rect) {
|
||||||
let status = Paragraph::new(app.status.as_str())
|
let status = Paragraph::new(app.status.as_str())
|
||||||
.style(Style::default().fg(Color::Gray))
|
.style(Style::default().fg(Color::Gray))
|
||||||
.block(Block::default().title(" Status ").borders(Borders::ALL))
|
.block(Block::default().title(" Status ").borders(Borders::ALL))
|
||||||
@ -190,7 +201,7 @@ fn draw_status(frame: &mut Frame, app: &ChatApp, area: Rect) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handle keyboard events.
|
/// Handle keyboard events.
|
||||||
pub fn handle_events(app: &mut ChatApp) -> io::Result<bool> {
|
pub fn handle_events<D: DeliveryService>(app: &mut ChatApp<D>) -> io::Result<bool> {
|
||||||
// Poll for events with a short timeout to allow checking incoming messages
|
// Poll for events with a short timeout to allow checking incoming messages
|
||||||
if event::poll(std::time::Duration::from_millis(100))?
|
if event::poll(std::time::Duration::from_millis(100))?
|
||||||
&& let Event::Key(key) = event::read()?
|
&& let Event::Key(key) = event::read()?
|
||||||
|
|||||||
107
flake.lock
generated
107
flake.lock
generated
@ -1,6 +1,42 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"logos-delivery": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"rust-overlay": "rust-overlay",
|
||||||
|
"zerokit": "zerokit"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1777287099,
|
||||||
|
"narHash": "sha256-H2gpbDUg6Wy+uIY9wL0t9ICUPN82B/vCnXZ2mo3Wa/E=",
|
||||||
|
"owner": "logos-messaging",
|
||||||
|
"repo": "logos-delivery",
|
||||||
|
"rev": "5034086fefe2f32bf95319cdd39aa62fc622e4bc",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "logos-messaging",
|
||||||
|
"repo": "logos-delivery",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1770464364,
|
||||||
|
"narHash": "sha256-z5NJPSBwsLf/OfD8WTmh79tlSU8XgIbwmk6qB1/TFzY=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "23d72dabcb3b12469f57b37170fcbc1789bd7457",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "23d72dabcb3b12469f57b37170fcbc1789bd7457",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1775710090,
|
"lastModified": 1775710090,
|
||||||
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
|
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
|
||||||
@ -18,11 +54,55 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": "nixpkgs",
|
"logos-delivery": "logos-delivery",
|
||||||
"rust-overlay": "rust-overlay"
|
"nixpkgs": "nixpkgs_2",
|
||||||
|
"rust-overlay": "rust-overlay_3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rust-overlay": {
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"logos-delivery",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1775099554,
|
||||||
|
"narHash": "sha256-3xBsGnGDLOFtnPZ1D3j2LU19wpAlYefRKTlkv648rU0=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "8d6387ed6d8e6e6672fd3ed4b61b59d44b124d99",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay_2": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"logos-delivery",
|
||||||
|
"zerokit",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1771211437,
|
||||||
|
"narHash": "sha256-lcNK438i4DGtyA+bPXXyVLHVmJjYpVKmpux9WASa3ro=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "c62195b3d6e1bb11e0c2fb2a494117d3b55d410f",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay_3": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
@ -41,6 +121,29 @@
|
|||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"zerokit": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"logos-delivery",
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"rust-overlay": "rust-overlay_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1771279884,
|
||||||
|
"narHash": "sha256-tzkQPwSl4vPTUo1ixHh6NCENjsBDroMKTjifg2q8QX8=",
|
||||||
|
"owner": "vacp2p",
|
||||||
|
"repo": "zerokit",
|
||||||
|
"rev": "53b18098e6d5d046e3eb1ac338a8f4f651432477",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "vacp2p",
|
||||||
|
"repo": "zerokit",
|
||||||
|
"rev": "53b18098e6d5d046e3eb1ac338a8f4f651432477",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
12
flake.nix
12
flake.nix
@ -7,12 +7,14 @@
|
|||||||
url = "github:oxalica/rust-overlay";
|
url = "github:oxalica/rust-overlay";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
logos-delivery.url = "github:logos-messaging/logos-delivery";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, rust-overlay }:
|
outputs = { self, nixpkgs, rust-overlay, logos-delivery }:
|
||||||
let
|
let
|
||||||
systems = [ "aarch64-darwin" "x86_64-darwin" "aarch64-linux" "x86_64-linux" ];
|
systems = [ "aarch64-darwin" "x86_64-darwin" "aarch64-linux" "x86_64-linux" ];
|
||||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f {
|
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f {
|
||||||
|
inherit system;
|
||||||
pkgs = import nixpkgs {
|
pkgs = import nixpkgs {
|
||||||
inherit system;
|
inherit system;
|
||||||
overlays = [ rust-overlay.overlays.default ];
|
overlays = [ rust-overlay.overlays.default ];
|
||||||
@ -20,15 +22,21 @@
|
|||||||
});
|
});
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
packages = forAllSystems ({ pkgs }:
|
packages = forAllSystems ({ pkgs, system }:
|
||||||
let
|
let
|
||||||
rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust_toolchain.toml;
|
rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust_toolchain.toml;
|
||||||
rustPlatform = pkgs.makeRustPlatform {
|
rustPlatform = pkgs.makeRustPlatform {
|
||||||
cargo = rustToolchain;
|
cargo = rustToolchain;
|
||||||
rustc = rustToolchain;
|
rustc = rustToolchain;
|
||||||
};
|
};
|
||||||
|
logos-delivery-lib = logos-delivery.packages.${system}.liblogosdelivery.override {
|
||||||
|
enablePostgres = false;
|
||||||
|
enableNimDebugDlOpen = false;
|
||||||
|
chroniclesLogLevel = "FATAL";
|
||||||
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
logos-delivery = logos-delivery-lib;
|
||||||
default = rustPlatform.buildRustPackage {
|
default = rustPlatform.buildRustPackage {
|
||||||
pname = "libchat";
|
pname = "libchat";
|
||||||
version = (builtins.fromTOML (builtins.readFile ./crates/client-ffi/Cargo.toml)).package.version;
|
version = (builtins.fromTOML (builtins.readFile ./crates/client-ffi/Cargo.toml)).package.version;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user