mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 23:34:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b45bb5661 | ||
|
|
6235565480 | ||
|
|
ecb20c4c52 | ||
|
|
a85d16fff8 | ||
|
|
6db93ecfea | ||
|
|
67587dafc2 | ||
|
|
1bbe099835 | ||
|
|
769aa5b468 | ||
|
|
6660944588 | ||
|
|
4b41438af0 | ||
|
|
a4fe432992 | ||
|
|
9279e8be06 | ||
|
|
7f9fcfe3cc | ||
|
|
62c5ab27c5 | ||
|
|
240892495b | ||
|
|
6e7cbf39f0 | ||
|
|
83d257591e | ||
|
|
e42f1a0965 | ||
|
|
fe6dfd3d4d | ||
|
|
d6282885e3 | ||
|
|
bd8b88add2 | ||
|
|
ff9a9e3dd0 | ||
|
|
aa6599a4e0 | ||
|
|
3091a146eb | ||
|
|
000764fdb4 | ||
|
|
56fccf5cfb | ||
|
|
b98673f8c5 | ||
|
|
335cfcc28a | ||
|
|
6aef6890cf | ||
|
|
5b8df9d505 | ||
|
|
6dbc29818c | ||
|
|
6758aceb5d | ||
|
|
06aba16303 | ||
|
|
89b028cf06 | ||
|
|
eb4aac9861 | ||
|
|
38d63d844d | ||
|
|
3f2211f42a | ||
|
|
0606394af2 | ||
|
|
ee18743721 | ||
|
|
0aaa371488 | ||
|
|
8689dc2a01 | ||
|
|
5d61266fd7 | ||
|
|
ae342381e0 | ||
|
|
ba536c6f9c | ||
|
|
92b89fa8a0 | ||
|
|
9029d63e54 | ||
|
|
023e62dea6 | ||
|
|
36e1ea0dfa | ||
|
|
be18cf88df | ||
|
|
595475c436 | ||
|
|
1b6c27247f |
+6
-1
@@ -6,7 +6,12 @@
|
||||
Server__Name=My EchoHub Server
|
||||
Server__Description=A self-hosted EchoHub chat server
|
||||
Server__PublicServer=false
|
||||
# Server__PublicHost=echohub.example.com
|
||||
# Hostnames advertised to the EchoHubSpace directory. Index per entry.
|
||||
# Server__PublicHosts__0=echohub.example.com
|
||||
# Server__PublicHosts__1=alias.example.com
|
||||
# Topic tags surfaced in the EchoHubSpace browser. Index per entry.
|
||||
# Server__Tags__0=community
|
||||
# Server__Tags__1=gaming
|
||||
# Server__Admins__0=adminUsername
|
||||
|
||||
# ── JWT ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Release Checklist
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release-checklist:
|
||||
name: Release Checklist
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "::error file=src/Directory.Build.props::Could not read version from Directory.Build.props"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Version: $VERSION"
|
||||
|
||||
- name: Check version was bumped from master
|
||||
run: |
|
||||
BRANCH_VERSION="${{ steps.version.outputs.version }}"
|
||||
git fetch origin master --depth=1
|
||||
MASTER_VERSION=$(git show origin/master:src/Directory.Build.props | grep -oP '(?<=<Version>)[^<]+')
|
||||
echo "Branch: $BRANCH_VERSION | Master: $MASTER_VERSION"
|
||||
if [ "$BRANCH_VERSION" = "$MASTER_VERSION" ]; then
|
||||
echo "::error file=src/Directory.Build.props::Version $BRANCH_VERSION was not bumped from master. Update <Version> in src/Directory.Build.props."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check changelog file exists
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
FILE="docs/changelog/v${VERSION}.md"
|
||||
if [ ! -f "$FILE" ]; then
|
||||
echo "::error::Missing changelog file: $FILE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found: $FILE"
|
||||
|
||||
- name: Check changelog TOC
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
if ! grep -q "v${VERSION}.md" docs/changelog/toc.yml; then
|
||||
echo "::error file=docs/changelog/toc.yml::v${VERSION} not found in changelog TOC. Add it to docs/changelog/toc.yml."
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "v${VERSION}" docs/changelog/index.md; then
|
||||
echo "::error file=docs/changelog/index.md::v${VERSION} not found in changelog index. Add it to docs/changelog/index.md."
|
||||
exit 1
|
||||
fi
|
||||
echo "toc.yml and index.md: OK"
|
||||
@@ -12,6 +12,11 @@ jobs:
|
||||
release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_release: ${{ steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' }}
|
||||
release_exists: ${{ steps.check_release.outputs.exists }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -30,7 +35,6 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Read version
|
||||
if: steps.changes.outputs.src_changed == 'true'
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
|
||||
@@ -38,7 +42,6 @@ jobs:
|
||||
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check if release exists
|
||||
if: steps.changes.outputs.src_changed == 'true'
|
||||
id: check_release
|
||||
run: |
|
||||
if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then
|
||||
@@ -53,7 +56,7 @@ jobs:
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Publish Server win-x64
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
@@ -77,23 +80,23 @@ jobs:
|
||||
|
||||
- name: Publish Client win-x64
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -o publish/client-win-x64
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish/client-win-x64
|
||||
|
||||
- name: Publish Client linux-x64
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-x64 --self-contained true -o publish/client-linux-x64
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish/client-linux-x64
|
||||
|
||||
- name: Publish Client osx-x64
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-x64 --self-contained true -o publish/client-osx-x64
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish/client-osx-x64
|
||||
|
||||
- name: Publish Client osx-arm64
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-arm64 --self-contained true -o publish/client-osx-arm64
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-arm64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish/client-osx-arm64
|
||||
|
||||
- name: Publish Client linux-arm64
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-arm64 --self-contained true -o publish/client-linux-arm64
|
||||
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-arm64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o publish/client-linux-arm64
|
||||
|
||||
- name: Zip artifacts
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
@@ -158,21 +161,48 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Publish Chocolatey package
|
||||
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||
choco:
|
||||
name: Publish to Chocolatey
|
||||
needs: release
|
||||
if: needs.release.outputs.release_exists == 'true' || needs.release.outputs.should_release == 'true'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check if version already published
|
||||
id: choco_check
|
||||
shell: pwsh
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
CHECKSUM=$(sha256sum EchoHub-Client-win-x64.zip | awk '{print $1}')
|
||||
$version = "${{ needs.release.outputs.version }}"
|
||||
$result = choco search echohub --version $version --exact --source https://community.chocolatey.org/api/v2/ 2>&1
|
||||
if ($result -match "echohub $version") {
|
||||
echo "exists=true" >> $env:GITHUB_OUTPUT
|
||||
Write-Host "Chocolatey package echohub $version already published — skipping."
|
||||
} else {
|
||||
echo "exists=false" >> $env:GITHUB_OUTPUT
|
||||
}
|
||||
|
||||
- name: Download release asset
|
||||
if: steps.choco_check.outputs.exists == 'false'
|
||||
run: gh release download "v${{ needs.release.outputs.version }}" --pattern "EchoHub-Client-win-x64.zip"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Pack and push
|
||||
if: steps.choco_check.outputs.exists == 'false'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ needs.release.outputs.version }}"
|
||||
$checksum = (Get-FileHash EchoHub-Client-win-x64.zip -Algorithm SHA256).Hash.ToLower()
|
||||
|
||||
# Stamp version and checksum into package templates
|
||||
sed -i "s/__VERSION__/$VERSION/g" packaging/choco/echohub.nuspec
|
||||
sed -i "s/__VERSION__/$VERSION/g" packaging/choco/tools/chocolateyInstall.ps1
|
||||
sed -i "s/__CHECKSUM64__/$CHECKSUM/g" packaging/choco/tools/chocolateyInstall.ps1
|
||||
(Get-Content packaging/choco/echohub.nuspec) -replace '__VERSION__', $version | Set-Content packaging/choco/echohub.nuspec
|
||||
(Get-Content packaging/choco/tools/chocolateyInstall.ps1) -replace '__VERSION__', $version | Set-Content packaging/choco/tools/chocolateyInstall.ps1
|
||||
(Get-Content packaging/choco/tools/chocolateyInstall.ps1) -replace '__CHECKSUM64__', $checksum | Set-Content packaging/choco/tools/chocolateyInstall.ps1
|
||||
|
||||
# Build and push the package
|
||||
mkdir -p /tmp/choco-out
|
||||
cd packaging/choco
|
||||
choco pack echohub.nuspec --output-directory /tmp/choco-out
|
||||
choco push /tmp/choco-out/echohub.*.nupkg --source https://push.chocolatey.org/ --api-key "$CHOCO_API_KEY"
|
||||
choco pack echohub.nuspec --output-directory $env:TEMP
|
||||
$pkg = Get-ChildItem "$env:TEMP\echohub.*.nupkg" | Select-Object -First 1
|
||||
choco push $pkg.FullName --source https://push.chocolatey.org/ --api-key $env:CHOCO_API_KEY
|
||||
env:
|
||||
CHOCO_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }}
|
||||
|
||||
@@ -27,12 +27,14 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/HueByte/EchoHub/actions/workflows/ci.yml"><img alt="Build" src="https://img.shields.io/github/actions/workflow/status/HueByte/EchoHub/ci.yml?branch=master&style=flat-square&logo=github&label=Build" /></a>
|
||||
<a href="https://github.com/HueByte/EchoHub/releases/latest"><img alt="Release" src="https://img.shields.io/github/v/release/HueByte/EchoHub?style=flat-square&logo=github&label=Release" /></a>
|
||||
<a href="https://community.chocolatey.org/packages/echohub"><img alt="Chocolatey" src="https://img.shields.io/chocolatey/v/echohub?style=flat-square&logo=chocolatey&label=Chocolatey" /></a>
|
||||
<a href="https://github.com/HueByte/EchoHub/pkgs/container/echohub-server"><img alt="Docker" src="https://img.shields.io/badge/Docker-GHCR-2496ED?style=flat-square&logo=docker&logoColor=white" /></a>
|
||||
<img alt=".NET 10" src="https://img.shields.io/badge/.NET-10-512BD4?style=flat-square&logo=dotnet&logoColor=white" />
|
||||
<img alt="SignalR" src="https://img.shields.io/badge/SignalR-Real--time-0078D4?style=flat-square" />
|
||||
<img alt="SQLite" src="https://img.shields.io/badge/SQLite-EF%20Core-003B57?style=flat-square&logo=sqlite&logoColor=white" />
|
||||
<img alt="License" src="https://img.shields.io/badge/License-MIT-green?style=flat-square" />
|
||||
<img alt="Terminal.Gui" src="https://img.shields.io/badge/TUI-Terminal.Gui%20v2-yellow?style=flat-square" />
|
||||
<img alt="Electron" src="https://img.shields.io/badge/Electron-None-red?style=flat-square" />
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/github/license/HueByte/EchoHub?style=flat-square" /></a>
|
||||
<img alt="Repo size" src="https://img.shields.io/github/repo-size/HueByte/EchoHub?style=flat-square&label=Size" />
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -76,10 +78,12 @@ graph TD
|
||||
### Server
|
||||
|
||||
- **Self-hostable** — your server, your rules, your data
|
||||
- **Docker ready** — `docker compose up -d` and you're done
|
||||
- **Real-time messaging** via SignalR WebSockets
|
||||
- **IRC gateway** — native IRC clients connect alongside TUI users, full cross-protocol messaging
|
||||
- **JWT auth** with short-lived access tokens and 30-day refresh tokens
|
||||
- **Channels** — create, set topics, delete (no 47-step permission wizard required)
|
||||
- **Moderation** — ban, mute (timed or permanent), kick, role assignment
|
||||
- **File & image uploads** with actual validation (magic bytes, not just trusting the extension)
|
||||
- **Image-to-ASCII** — because images in a terminal is objectively cool
|
||||
- **Presence tracking** — online/away/DND/invisible with custom status messages
|
||||
@@ -94,34 +98,55 @@ graph TD
|
||||
- **13 built-in themes** — including `hacker` for when you want to feel like you're in a movie
|
||||
- **Slash commands** — `/join`, `/send`, `/status`, `/theme`, etc.
|
||||
- **Colored nicknames** — pick your hex color, express yourself
|
||||
- **Clickable everything** — usernames, @mentions, #channels — just press Enter
|
||||
- **File/image sharing** — local files or URLs
|
||||
- **Multi-server** — save and switch between servers
|
||||
- **Auto-reconnect** — drops happen, it rejoins your channels automatically
|
||||
- **Auto-updater** — updates in-place with automatic rollback if something goes wrong
|
||||
- **Message history** on join — you won't miss context
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Download
|
||||
### Install the Client
|
||||
|
||||
Grab a self-contained binary from [Releases](../../releases) — no runtime needed, just run it.
|
||||
**Windows (Chocolatey):**
|
||||
|
||||
### Prerequisites (for development)
|
||||
```bash
|
||||
choco install echohub
|
||||
```
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download)
|
||||
**Linux / macOS:**
|
||||
|
||||
### Run the Server
|
||||
```bash
|
||||
curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
**Manual download:** grab a self-contained binary from [Releases](../../releases) — no runtime needed, just run it.
|
||||
|
||||
### Host a Server
|
||||
|
||||
**Docker (recommended):**
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Pre-built images on [GHCR](https://github.com/HueByte/EchoHub/pkgs/container/echohub-server) — `linux/amd64` and `linux/arm64`.
|
||||
|
||||
**From source:**
|
||||
|
||||
```bash
|
||||
dotnet run --project src/EchoHub.Server
|
||||
```
|
||||
|
||||
First run does everything for you:
|
||||
Requires [.NET 10 SDK](https://dotnet.microsoft.com/download). First run does everything for you:
|
||||
|
||||
1. Creates `appsettings.json` from the example config
|
||||
2. Generates a secure JWT secret
|
||||
3. Creates the database with a `#general` channel
|
||||
|
||||
### Run the Client
|
||||
### Run the Client (from source)
|
||||
|
||||
```bash
|
||||
dotnet run --project src/EchoHub.Client
|
||||
@@ -165,7 +190,7 @@ irssi -c your-server.com -p 6667 -w <password> -n <username>
|
||||
/connect echohub
|
||||
```
|
||||
|
||||
IRC users must have an existing EchoHub account (no registration via IRC). Auth works via `PASS`/`NICK`/`USER` or SASL PLAIN.
|
||||
Auth works via `PASS`/`NICK`/`USER` or SASL PLAIN. New usernames are auto-registered on first connect — no separate signup needed.
|
||||
|
||||
### What Works
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Configuration
|
||||
|
||||
EchoHub Server generates an `appsettings.json` with sensible defaults on first run (including a random JWT secret), so you can launch and start chatting immediately. Tweak things later when you feel like it.
|
||||
|
||||
> [!NOTE]
|
||||
> Under the hood, EchoHub Server is built on ASP.NET Core, so it inherits the standard .NET configuration system. If you're familiar with that, everything works exactly as you'd expect. If not — no worries, this page covers everything you need.
|
||||
|
||||
## How It Works
|
||||
|
||||
EchoHub Server loads settings from multiple sources. Each source **overrides** the previous one, so you can layer defaults with environment-specific values:
|
||||
|
||||
```text
|
||||
1. appsettings.json (base defaults)
|
||||
2. appsettings.{Environment}.json (e.g. appsettings.Production.json)
|
||||
3. Environment variables (great for Docker / CI)
|
||||
4. Command-line arguments (highest priority)
|
||||
```
|
||||
|
||||
The last one wins. If `appsettings.json` says `"Irc:Port": 6667` but you pass `--Irc:Port=7000` on the command line, port 7000 is what you get.
|
||||
|
||||
In practice this means you can leave `appsettings.json` alone and override just the settings you care about using environment variables or CLI flags — no need to edit JSON files if that's not your thing.
|
||||
|
||||
### Environment Variable Mapping
|
||||
|
||||
Environment variables use **double underscores** (`__`) in place of the JSON nesting. The rule is simple — replace every `:` (or each level of JSON nesting) with `__`:
|
||||
|
||||
| appsettings.json path | Environment variable |
|
||||
| --- | --- |
|
||||
| `Server:Name` | `Server__Name` |
|
||||
| `Irc:Enabled` | `Irc__Enabled` |
|
||||
| `Jwt:Secret` | `Jwt__Secret` |
|
||||
| `Serilog:MinimumLevel:Default` | `Serilog__MinimumLevel__Default` |
|
||||
| `ConnectionStrings:DefaultConnection` | `ConnectionStrings__DefaultConnection` |
|
||||
|
||||
Arrays use numeric indices: `Server:Admins:0` becomes `Server__Admins__0`, `Server:Admins:1` becomes `Server__Admins__1`, and so on.
|
||||
|
||||
This is why the Docker `.env` file uses `Server__Name=My Server` instead of JSON — Docker passes these as environment variables, and the server picks them up automatically.
|
||||
|
||||
### Examples
|
||||
|
||||
All three of these achieve the same thing — use whichever fits your setup.
|
||||
|
||||
**appsettings.json** (direct editing):
|
||||
|
||||
```json
|
||||
{
|
||||
"Server": {
|
||||
"Name": "My EchoHub Server",
|
||||
"PublicServer": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Environment variables** (Docker, systemd, shell export):
|
||||
|
||||
```bash
|
||||
export Server__Name="My EchoHub Server"
|
||||
export Server__PublicServer=true
|
||||
```
|
||||
|
||||
**Command-line arguments** (quick overrides, highest priority):
|
||||
|
||||
```bash
|
||||
./EchoHub.Server --Server:Name="My EchoHub Server" --Irc:Enabled=true
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
The full `appsettings.json` is auto-generated on first run from the [example config](https://github.com/HueByte/EchoHub/blob/master/src/EchoHub.Server/appsettings.example.json). Here's every option:
|
||||
|
||||
### General
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `Urls` | `http://0.0.0.0:5000` | Listen address and port |
|
||||
| `AllowedHosts` | `*` | Allowed host headers (leave `*` unless you need host filtering) |
|
||||
|
||||
### Database
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `ConnectionStrings:DefaultConnection` | *(empty)* | SQLite connection string. Empty = `echohub.db` in the app directory |
|
||||
|
||||
### Authentication
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `Jwt:Secret` | *(auto-generated)* | Signing key (min 32 chars). Auto-generated on first run |
|
||||
| `Jwt:Issuer` | `EchoHub.Server` | JWT issuer claim |
|
||||
| `Jwt:Audience` | `EchoHub.Client` | JWT audience claim |
|
||||
|
||||
Access tokens expire after 15 minutes, refresh tokens after 30 days with rotation on each use.
|
||||
|
||||
### Server Identity
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `Server:Name` | `My EchoHub Server` | Display name shown to clients |
|
||||
| `Server:Description` | `A self-hosted EchoHub chat server` | Server description |
|
||||
| `Server:PublicServer` | `false` | Register on the [public directory](https://echohub.voidcube.cloud/servers) |
|
||||
| `Server:PublicHost` | *(empty)* | Public hostname for the directory listing (e.g. `chat.example.com:5000`) |
|
||||
| `Server:Admins` | `[]` | Array of admin usernames (e.g. `["alice", "bob"]`) |
|
||||
|
||||
### Encryption
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `Encryption:Key` | *(auto-generated)* | AES key for message encryption in transit |
|
||||
| `Encryption:EncryptDatabase` | `false` | Also encrypt message content at rest in SQLite |
|
||||
|
||||
### Storage
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `Storage:CleanupIntervalHours` | `1` | How often the cleanup job runs (hours) |
|
||||
| `Storage:RetentionDays` | `30` | Days to keep uploaded files before cleanup |
|
||||
|
||||
### IRC Gateway
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `Irc:Enabled` | `false` | Enable the IRC protocol gateway |
|
||||
| `Irc:Port` | `6667` | IRC plain-text listen port |
|
||||
| `Irc:TlsEnabled` | `false` | Enable TLS termination for IRC |
|
||||
| `Irc:TlsPort` | `6697` | IRC TLS listen port |
|
||||
| `Irc:TlsCertPath` | *(empty)* | Path to a PKCS#12 (`.pfx`) certificate |
|
||||
| `Irc:TlsCertPassword` | *(empty)* | Password for the certificate file |
|
||||
| `Irc:ServerName` | `echohub` | IRC server name in protocol messages |
|
||||
| `Irc:Motd` | `Welcome to EchoHub IRC Gateway!` | Message of the day |
|
||||
|
||||
### Logging
|
||||
|
||||
EchoHub uses [Serilog](https://serilog.net/) for structured logging — console output + daily rolling files with 14-day retention by default.
|
||||
|
||||
| Key | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `Serilog:MinimumLevel:Default` | `Information` | Global log level (`Debug`, `Information`, `Warning`, `Error`) |
|
||||
| `Serilog:MinimumLevel:Override:Microsoft` | `Warning` | Suppress noisy framework logs |
|
||||
| `Serilog:MinimumLevel:Override:Microsoft.AspNetCore` | `Warning` | Suppress request pipeline logs |
|
||||
| `Serilog:MinimumLevel:Override:Microsoft.EntityFrameworkCore` | `Warning` | Suppress database query logs |
|
||||
|
||||
Log files are written to `logs/echohub-server-YYYY-MM-DD.log`. To change the path or retention, edit the `Serilog:WriteTo` section in `appsettings.json`.
|
||||
|
||||
Want more verbose output for debugging? Set the minimum level to `Debug`:
|
||||
|
||||
```bash
|
||||
# via environment variable
|
||||
export Serilog__MinimumLevel__Default=Debug
|
||||
|
||||
# or command line
|
||||
./EchoHub.Server --Serilog:MinimumLevel:Default=Debug
|
||||
```
|
||||
+5
-14
@@ -24,26 +24,17 @@ services:
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings are configured through the `.env` file. These are ASP.NET Core environment variables that override `appsettings.json`.
|
||||
All settings are configured through the `.env` file. These are environment variables that override `appsettings.json` — the `__` (double underscore) maps to JSON nesting levels. For example, `Server__Name` overrides the `Server:Name` key in `appsettings.json`.
|
||||
|
||||
See the [Configuration](configuration.md) guide for the full reference of all available settings and how the override hierarchy works.
|
||||
|
||||
Common Docker-relevant variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `Server__Name` | My EchoHub Server | Display name for your server |
|
||||
| `Server__Description` | A self-hosted EchoHub chat server | Server description |
|
||||
| `Server__PublicServer` | `false` | List on the [public directory](https://echohub.voidcube.cloud/servers) |
|
||||
| `Server__PublicHost` | *(empty)* | Public address for the directory listing |
|
||||
| `Server__Admins__0` | *(empty)* | Admin username (use `__1`, `__2` for more) |
|
||||
| `Jwt__Secret` | *(auto-generated)* | JWT signing key. Auto-generated on first run |
|
||||
| `Encryption__Key` | *(auto-generated)* | AES encryption key. Auto-generated on first run |
|
||||
| `Encryption__EncryptDatabase` | `false` | Encrypt message content in the database |
|
||||
| `Storage__CleanupIntervalHours` | `1` | How often to clean expired uploads |
|
||||
| `Storage__RetentionDays` | `30` | Days to keep uploaded files |
|
||||
| `Irc__Enabled` | `false` | Enable the IRC gateway |
|
||||
| `Irc__Port` | `6667` | IRC plain-text port |
|
||||
| `Irc__TlsEnabled` | `false` | Enable IRC over TLS |
|
||||
| `Irc__TlsPort` | `6697` | IRC TLS port |
|
||||
| `Irc__ServerName` | `echohub` | IRC server name shown to clients |
|
||||
| `Irc__Motd` | Welcome to EchoHub IRC Gateway! | Message of the day |
|
||||
| `Serilog__MinimumLevel__Default` | `Information` | Log level (`Debug`, `Warning`, etc.) |
|
||||
|
||||
## Persistent Data
|
||||
|
||||
@@ -17,7 +17,7 @@ curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/inst
|
||||
To install a specific version or to a custom directory:
|
||||
|
||||
```bash
|
||||
curl -sSfL .../install.sh | sh -s -- --version 0.2.8
|
||||
curl -sSfL .../install.sh | sh -s -- --version 0.2.11
|
||||
curl -sSfL .../install.sh | sh -s -- --install-dir /opt/echohub
|
||||
```
|
||||
|
||||
@@ -84,9 +84,9 @@ See the [Architecture](architecture.md) page for details on how the IRC gateway
|
||||
|
||||
## Configuration
|
||||
|
||||
Server configuration is in `appsettings.json` (auto-generated on first run). See the [example config](https://github.com/HueByte/EchoHub/blob/master/src/EchoHub.Server/appsettings.example.json) for all available options.
|
||||
Server configuration is in `appsettings.json` (auto-generated on first run). You can also use environment variables or command-line arguments to override settings.
|
||||
|
||||
To list your server on the [public directory](https://echohub.voidcube.cloud/servers), set `Server:PublicServer` to `true` and `Server:PublicHost` to your server's public address.
|
||||
See the [Configuration](configuration.md) guide for the full reference and how it all works.
|
||||
|
||||
## Build from Source
|
||||
|
||||
|
||||
+16
-10
@@ -1,10 +1,16 @@
|
||||
- name: Getting Started
|
||||
href: getting-started.md
|
||||
- name: Docker
|
||||
href: docker.md
|
||||
- name: Architecture
|
||||
href: architecture.md
|
||||
- name: Encryption
|
||||
href: encryption.md
|
||||
- name: Notification Sounds
|
||||
href: notification-sounds.md
|
||||
- name: Guides
|
||||
items:
|
||||
- name: Getting Started
|
||||
href: getting-started.md
|
||||
- name: Docker
|
||||
href: docker.md
|
||||
- name: Architecture
|
||||
href: architecture.md
|
||||
- name: Configuration
|
||||
href: configuration.md
|
||||
- name: Encryption
|
||||
href: encryption.md
|
||||
- name: Notification Sounds
|
||||
href: notification-sounds.md
|
||||
- name: Flows
|
||||
href: ../flows/toc.yml
|
||||
|
||||
@@ -4,6 +4,9 @@ Release history for EchoHub.
|
||||
|
||||
## Releases
|
||||
|
||||
- [v0.2.11](v0.2.11.md) - EchoHubSpace Auth, Live Directory Updates & Server Browser Metadata
|
||||
- [v0.2.10](v0.2.10.md) - Command Palette, Infinite History Scroll & Auto-Updater Fixes
|
||||
- [v0.2.9](v0.2.9.md) - Install Script & Chocolatey Fixes
|
||||
- [v0.2.8](v0.2.8.md) - Docker Support, IRC Account Creation & BOM Fix
|
||||
- [v0.2.7](v0.2.7.md) - User List Fix & Terminal.Gui NuGet Migration
|
||||
- [v0.2.6](v0.2.6.md) - Major Refactoring & Code Organization
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
- name: Overview
|
||||
href: index.md
|
||||
- name: v0.2.11
|
||||
href: v0.2.11.md
|
||||
- name: v0.2.10
|
||||
href: v0.2.10.md
|
||||
- name: v0.2.9
|
||||
href: v0.2.9.md
|
||||
- name: v0.2.8
|
||||
href: v0.2.8.md
|
||||
- name: v0.2.7
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# v0.2.10
|
||||
|
||||
Follow-up patch release for v0.2.9 addressing auto-updater regressions, adding a command palette, infinite-scroll message history, and input polish.
|
||||
|
||||
## New Features
|
||||
|
||||
- Command palette — press Ctrl+K from the message input (or anywhere in the main window) to open a searchable dialog for navigating channels and triggering app actions (connect, disconnect, logout, profile, status, create/delete channel, saved servers, toggle users panel, check for updates, quit). Fuzzy matches against both the label and the underlying key so typing `ch` surfaces channel actions alongside `#channel` entries
|
||||
- Scroll-to-load message history — scrolling to the top of a channel now fetches the next batch of older messages in the background (previously only the most recent 100 messages were available). Duplicate messages are filtered by ID, a per-channel guard prevents concurrent fetches, and the scroll position is preserved after the prepend so your reading position doesn't jump
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Fix update progress dialog freezing / not repainting — progress callbacks now run on the UI thread so the download and extraction percentage actually updates while an update is in progress
|
||||
- Fix pre-update backup failing when a Serilog-held log file is locked — `UpdateBackupService` now enumerates files manually, skips the `logs/` directory and `.log` files, and logs-and-continues on `IOException`/`UnauthorizedAccessException` instead of aborting the whole backup
|
||||
- Simplify update progress dispatch — remove redundant `Application.Invoke` wrappers around progress updates that are already called from the UI thread (introduced while fixing the freeze above)
|
||||
- Fix cursor position being reset to the start of the line when auto-completing commands in the CLI app — insertion point is now moved to the end of the completed text
|
||||
- Fix notification sounds crashing or being silently dropped when several arrive in quick succession — playback is now serialized through a semaphore that's held for the duration of each sound (using `PlaybackFinished` with a 10s safety timeout) and always released in `finally`, so back-to-back notifications queue up and play in order instead of racing the underlying audio player (fixes #20)
|
||||
- Fix client crashing on startup with `No Serilog:Using configuration section is defined` under single-file publish — pass `ConfigurationReaderOptions` with the `Serilog.Sinks.File` assembly explicitly so Serilog can resolve sinks without scanning the filesystem for `.dll`s (which don't exist in a bundled exe)
|
||||
|
||||
## Refactoring
|
||||
|
||||
- Move search-dialog dispatch out of `MainWindow` into `AppOrchestrator` — `MainWindow` now just raises `OnSearchRequested`, keeping the view dumb and letting the orchestrator own navigation/action routing
|
||||
- `ChatHub.GetChannelHistory` and `IChatService.GetChannelHistoryAsync` gain an additional `offset` parameter for paginated history loading (defaults to `0` — existing callers are unaffected)
|
||||
- `ValidationConstants.MaxHistoryCount` raised from `100` to `200` so power users and paginated fetches can request larger batches; `DefaultHistoryCount` stays at `100`
|
||||
@@ -0,0 +1,23 @@
|
||||
# v0.2.11
|
||||
|
||||
EchoHubSpace directory protocol overhaul: authenticated server registration with persistent claim tokens, near-real-time user-count updates, and richer server metadata (tags, multi-host, version). Coordinated cutover with the EchoHubSpace directory deploy.
|
||||
|
||||
## New Features
|
||||
|
||||
- EchoHubSpace claim-token authentication — the directory issues a per-server claim token on first registration, persisted atomically alongside the SQLite database (chmod 0600 on Unix). Subsequent reconnects authenticate with the token instead of relying on raw hostname-squatting protection. Token survives both client and directory restarts; lost tokens require an admin-side `DELETE /api/servers/{id}` on the directory to recover
|
||||
- Server tags — public servers can advertise topic tags via the new `Server:Tags` config array, surfacing as filter facets in the EchoHubSpace browser
|
||||
- Multi-host advertisement — a single server can register multiple hostnames (e.g. apex domain, IPv6, alias domains) by listing them in `Server:PublicHosts`. All hosts route to the same directory row
|
||||
- Server version sent to directory — the EchoHubSpace browser shows what version each public server is running, pulled from the server's assembly informational version
|
||||
- Operator-facing `GET /api/server/directory` endpoint (Admin role required) — returns `ServerId`, `IsRegistered`, `LastRegisteredAt`, `LastError`, and any `ConflictingHosts` for support tickets. Never exposes the claim token itself, only a `HasClaimToken` boolean
|
||||
|
||||
## Refactoring
|
||||
|
||||
- Replace 30s polling with event-driven directory updates — `PresenceTracker` now raises `UserCountChanged` only when the distinct user count actually changes (multi-tab/multi-connection users no longer trigger). `ServerDirectoryService` consumes via a single-slot `Channel<int>` (latest-wins coalesces bursts) with a 1-second min-interval throttle. Directory reflects user-count changes within ~1s instead of up to 30s stale
|
||||
- Wrap directory hub responses in a `Response<T>` envelope with `IsSuccess`/`Data`/`Errors`/`Version` shape — protocol version is pinned client-side (currently `1.0`); mismatches trigger a permanent-failure stop with operator-facing log
|
||||
- Stop attempting re-registration after permanent failures (`HostAlreadyClaimed`, `InvalidToken`, `HostConflict`, `InvalidInput`) — the directory no longer terminates the connection on these errors, so the client suppresses re-register on `Reconnected` to avoid tight retry loops. Operator must restart the server after fixing config
|
||||
|
||||
## Configuration
|
||||
|
||||
- **Breaking**: `Server:PublicHost` (string) renamed to `Server:PublicHosts` (string array). Public servers must update `appsettings.json` — single-host deployments use a one-element array
|
||||
- New `Server:Tags` (string array) — defaults to empty
|
||||
- New optional `Server:DirectoryClaimPath` — overrides the path of the persisted claim file. Defaults to a `directory-claim.json` next to the SQLite database. Treat the file as a secret; back it up alongside the database
|
||||
@@ -0,0 +1,23 @@
|
||||
# v0.2.9
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Fix Linux/macOS client install — enable single-file publish so the install script copies one self-contained binary instead of just the native host (which failed with "does not exist: EchoHub.Client.dll")
|
||||
- Fix Chocolatey install path on Windows — `chocolateyInstall.ps1` was joining the install directory and executable name into a single segment, producing an invalid target path
|
||||
- Fix Chocolatey package metadata — corrected GitHub repository URLs and documentation URLs in `echohub.nuspec` that pointed at the wrong location
|
||||
- Fix double-click on "Public" checkbox in the Create Channel dialog accidentally submitting the dialog — checkbox toggle commands no longer bubble up to the dialog's default button
|
||||
|
||||
## Documentation
|
||||
|
||||
- Add a dedicated configuration guide (`docs/articles/configuration.md`) covering server settings, client settings, and environment overrides
|
||||
- Refresh README badges and reorganize the articles table of contents for better discoverability
|
||||
- Polish Docker, getting-started, and flow docs to match the current configuration surface
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Bump `Terminal.Gui` to `2.0.0-develop.5043` (from `5039`)
|
||||
|
||||
## CI
|
||||
|
||||
- Release workflow now publishes a single-file self-contained client binary for Linux and macOS so the install script works out-of-the-box
|
||||
- Chocolatey publishing step now triggers only when the package source actually changes and performs a proper version check against the feed before pushing
|
||||
+1
-1
@@ -85,7 +85,7 @@
|
||||
"_appTitle": "EchoHub Documentation",
|
||||
"_appLogoPath": "images/hue_icon.svg",
|
||||
"_appFaviconPath": "images/hue_icon.svg",
|
||||
"_appFooter": "<div class='footer-custom'><div class='footer-inner'><span class='footer-brand'>EchoHub</span><span class='footer-sep'>·</span><a href='https://github.com/HueByte/EchoHub'>GitHub</a><span class='footer-sep'>·</span><a href='https://echohub.voidcube.cloud'>Website</a><span class='footer-sep'>·</span><span class='footer-credit'>Built with <a href='https://dotnet.github.io/docfx'>DocFX</a></span></div></div>",
|
||||
"_appFooter": "<div class='footer-custom'><div class='footer-inner'><span class='footer-brand'>EchoHub</span><span class='footer-sep'>·</span><a href='https://github.com/HueByte/EchoHub'>GitHub</a><span class='footer-sep'>·</span><a href='https://echohub.voidcube.cloud'>Website</a></div></div>",
|
||||
"_enableSearch": true,
|
||||
"_disableContribution": false,
|
||||
"_gitContribute": {
|
||||
|
||||
@@ -37,19 +37,6 @@ sequenceDiagram
|
||||
CM->>CM: Continue to connection setup (see Connection Flow)
|
||||
```
|
||||
|
||||
**Code references:**
|
||||
|
||||
| Step | File | Location |
|
||||
|------|------|----------|
|
||||
| Dialog UI | `src/EchoHub.Client/UI/Dialogs/ConnectDialog.cs` | Lines 251-268 (register handler) |
|
||||
| Orchestrator entry | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 550-592 (`HandleConnect`) |
|
||||
| ConnectionManager auth | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 74-76 (register branch) |
|
||||
| ApiClient register | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 32-43 (`RegisterAsync`) |
|
||||
| AuthController register | `src/EchoHub.Server/Controllers/AuthController.cs` | Lines 28-49 |
|
||||
| UserService register | `src/EchoHub.Server/Services/UserService.cs` | Lines 20-59 (`RegisterUserAsync`) |
|
||||
| JWT generation | `src/EchoHub.Server/Auth/JwtTokenService.cs` | Lines 30-53 (access), 80-86 (refresh) |
|
||||
| Token persistence | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 1039-1053 (`SaveServerToConfig`) |
|
||||
|
||||
---
|
||||
|
||||
## User Login
|
||||
@@ -85,18 +72,6 @@ sequenceDiagram
|
||||
API->>API: SetTokens()
|
||||
```
|
||||
|
||||
**Code references:**
|
||||
|
||||
| Step | File | Location |
|
||||
|------|------|----------|
|
||||
| Login button handler | `src/EchoHub.Client/UI/Dialogs/ConnectDialog.cs` | Lines 214-249 |
|
||||
| Saved token branch | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 69-71 |
|
||||
| Password branch | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 78-80 |
|
||||
| ApiClient login | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 45-56 (`LoginAsync`) |
|
||||
| AuthController login | `src/EchoHub.Server/Controllers/AuthController.cs` | Lines 51-72 |
|
||||
| AuthController refresh | `src/EchoHub.Server/Controllers/AuthController.cs` | Lines 74-108 |
|
||||
| UserService authenticate | `src/EchoHub.Server/Services/UserService.cs` | Lines 61-83 |
|
||||
|
||||
---
|
||||
|
||||
## Token Refresh
|
||||
@@ -128,15 +103,3 @@ sequenceDiagram
|
||||
API-->>Config: Persist new refresh token (if Remember Me)
|
||||
API->>SR: Retry original request with new token
|
||||
```
|
||||
|
||||
**Code references:**
|
||||
|
||||
| Step | File | Location |
|
||||
|------|------|----------|
|
||||
| Proactive check | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 110-129 (`GetValidTokenAsync`) |
|
||||
| Reactive 401 retry (GET) | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 338-358 (`AuthenticatedGetAsync`) |
|
||||
| Reactive 401 retry (POST/PUT/DELETE) | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 364-384 (`AuthenticatedRequestAsync`) |
|
||||
| Refresh HTTP call | `src/EchoHub.Client/Services/ApiClient.cs` | Lines 58-71 (`RefreshTokenAsync`) |
|
||||
| SignalR token provider | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Line 37 (`AccessTokenProvider`) |
|
||||
| Server-side rotation | `src/EchoHub.Server/Controllers/AuthController.cs` | Lines 74-108 |
|
||||
| Token persistence callback | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 253-264 |
|
||||
|
||||
@@ -33,14 +33,6 @@ sequenceDiagram
|
||||
CC-->>Client: 201 Created (ChannelDto)
|
||||
```
|
||||
|
||||
**Code references:**
|
||||
|
||||
| Step | File | Location |
|
||||
|------|------|----------|
|
||||
| Controller endpoint | `src/EchoHub.Server/Controllers/ChannelsController.cs` | Lines 60-76 |
|
||||
| Channel service create | `src/EchoHub.Server/Services/ChannelService.cs` | Lines 50-90 |
|
||||
| Broadcast updated | `src/EchoHub.Server/Services/ChatService.cs` | Lines 308-309 |
|
||||
|
||||
---
|
||||
|
||||
## Channel Deletion
|
||||
@@ -65,13 +57,6 @@ sequenceDiagram
|
||||
CC-->>Client: 204 No Content
|
||||
```
|
||||
|
||||
**Code references:**
|
||||
|
||||
| Step | File | Location |
|
||||
|------|------|----------|
|
||||
| Controller endpoint | `src/EchoHub.Server/Controllers/ChannelsController.cs` | Lines 94-106 |
|
||||
| Channel service delete | `src/EchoHub.Server/Services/ChannelService.cs` | Lines 119-144 |
|
||||
|
||||
---
|
||||
|
||||
## Joining a Channel
|
||||
@@ -114,17 +99,6 @@ sequenceDiagram
|
||||
Entry-->>Client: History messages
|
||||
```
|
||||
|
||||
**Code references:**
|
||||
|
||||
| Step | File | Location |
|
||||
|------|------|----------|
|
||||
| SignalR hub join | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 59-81 |
|
||||
| IRC join | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 361-414 |
|
||||
| ChatService join | `src/EchoHub.Server/Services/ChatService.cs` | Lines 96-135 |
|
||||
| Presence join | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 58-70 |
|
||||
| SignalR broadcast | `src/EchoHub.Server/Services/SignalRBroadcaster.cs` | Lines 26-32 |
|
||||
| IRC broadcast | `src/EchoHub.Server.Irc/IrcBroadcaster.cs` | Lines 34-41 |
|
||||
|
||||
---
|
||||
|
||||
## Leaving a Channel
|
||||
@@ -148,12 +122,3 @@ sequenceDiagram
|
||||
CS->>IRCB: SendUserLeftAsync(channel, username)
|
||||
end
|
||||
```
|
||||
|
||||
**Code references:**
|
||||
|
||||
| Step | File | Location |
|
||||
|------|------|----------|
|
||||
| SignalR hub leave | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 83-96 |
|
||||
| IRC part | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 416-435 |
|
||||
| ChatService leave | `src/EchoHub.Server/Services/ChatService.cs` | Lines 137-143 |
|
||||
| Presence leave | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 72-81 |
|
||||
|
||||
@@ -34,19 +34,6 @@ sequenceDiagram
|
||||
EHC-->>CM: Decrypted history
|
||||
```
|
||||
|
||||
**Code references:**
|
||||
|
||||
| Step | File | Location |
|
||||
|------|------|----------|
|
||||
| Connection orchestration | `src/EchoHub.Client/Services/ConnectionManager.cs` | Lines 58-140 (`ConnectAsync`) |
|
||||
| EchoHubConnection setup | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Lines 29-62 (constructor) |
|
||||
| Handler registration | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Lines 64-122 (`RegisterHandlers`) |
|
||||
| Hub OnConnected | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 31-43 |
|
||||
| ChatService connected | `src/EchoHub.Server/Services/ChatService.cs` | Lines 41-57 |
|
||||
| PresenceTracker connect | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 13-29 |
|
||||
| Join channel (hub) | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 59-81 |
|
||||
| Join channel (service) | `src/EchoHub.Server/Services/ChatService.cs` | Lines 96-135 |
|
||||
|
||||
---
|
||||
|
||||
## IRC Client Connection
|
||||
@@ -96,18 +83,6 @@ sequenceDiagram
|
||||
Note over IRC,CH: Client is now ready for JOIN/PART/PRIVMSG
|
||||
```
|
||||
|
||||
**Code references:**
|
||||
|
||||
| Step | File | Location |
|
||||
|------|------|----------|
|
||||
| TCP listener | `src/EchoHub.Server.Irc/IrcGatewayService.cs` | Lines 45-90 (`ExecuteAsync`) |
|
||||
| Client handler | `src/EchoHub.Server.Irc/IrcGatewayService.cs` | Lines 92-154 (`HandleClientAsync`) |
|
||||
| Command read loop | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 40-98 (`RunAsync`) |
|
||||
| SASL auth | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 136-207 (`HandleAuthenticateAsync`) |
|
||||
| PASS/NICK/USER | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 209-267 |
|
||||
| Registration completion | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 268-315 (`TryCompleteRegistrationAsync`) |
|
||||
| Cleanup on disconnect | `src/EchoHub.Server.Irc/IrcGatewayService.cs` | Lines 136-153 |
|
||||
|
||||
---
|
||||
|
||||
## User Disconnect & Presence
|
||||
@@ -139,12 +114,3 @@ sequenceDiagram
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Code references:**
|
||||
|
||||
| Step | File | Location |
|
||||
|------|------|----------|
|
||||
| SignalR disconnect | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 45-57 |
|
||||
| IRC cleanup | `src/EchoHub.Server.Irc/IrcGatewayService.cs` | Lines 136-153 |
|
||||
| ChatService disconnect | `src/EchoHub.Server/Services/ChatService.cs` | Lines 59-94 |
|
||||
| Presence disconnect | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 31-53 |
|
||||
|
||||
@@ -28,15 +28,6 @@ sequenceDiagram
|
||||
CS->>CS: BroadcastToAllAsync → fan out to clients
|
||||
```
|
||||
|
||||
**Code references:**
|
||||
|
||||
| Step | File | Location |
|
||||
|------|------|----------|
|
||||
| Upload endpoint | `src/EchoHub.Server/Controllers/ChannelsController.cs` | Lines 108-200 |
|
||||
| File validation | `src/EchoHub.Server/Services/FileValidationHelper.cs` | Lines 15-82 |
|
||||
| File storage | `src/EchoHub.Server/Services/FileStorageService.cs` | Lines 1-47 |
|
||||
| File download | `src/EchoHub.Server/Controllers/FilesController.cs` | Lines 22-53 |
|
||||
|
||||
---
|
||||
|
||||
## Link Embed Resolution
|
||||
@@ -67,17 +58,6 @@ sequenceDiagram
|
||||
Note over CS: Attached to MessageDto before broadcast
|
||||
```
|
||||
|
||||
**Code references:**
|
||||
|
||||
| Step | File | Location |
|
||||
|------|------|----------|
|
||||
| Entry point | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 28-51 (`TryGetEmbedsAsync`) |
|
||||
| URL extraction | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 145-160 |
|
||||
| Private IP blocking | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 162-181 |
|
||||
| OG tag parsing | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 187-210 |
|
||||
| Theme color parsing | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 117-143 |
|
||||
| ChatService integration | `src/EchoHub.Server/Services/ChatService.cs` | Lines 194-201 |
|
||||
|
||||
---
|
||||
|
||||
## Server Directory Registration
|
||||
@@ -107,12 +87,3 @@ sequenceDiagram
|
||||
|
||||
Note over SDS,Dir: Exponential backoff on disconnect (2s → 30s max)
|
||||
```
|
||||
|
||||
**Code references:**
|
||||
|
||||
| Step | File | Location |
|
||||
|------|------|----------|
|
||||
| Service lifecycle | `src/EchoHub.Server/Services/ServerDirectoryService.cs` | Lines 29-122 |
|
||||
| Registration | `src/EchoHub.Server/Services/ServerDirectoryService.cs` | Lines 195-212 |
|
||||
| User count polling | `src/EchoHub.Server/Services/ServerDirectoryService.cs` | Lines 154-187 |
|
||||
| Reconnection backoff | `src/EchoHub.Server/Services/ServerDirectoryService.cs` | Lines 77-82, 191 |
|
||||
|
||||
@@ -45,22 +45,6 @@ sequenceDiagram
|
||||
end
|
||||
```
|
||||
|
||||
**Code references:**
|
||||
|
||||
| Step | File | Location |
|
||||
|------|------|----------|
|
||||
| Input handler | `src/EchoHub.Client/UI/MainWindow.cs` | Lines 428-449 (`OnInputKeyDown`) |
|
||||
| Orchestrator dispatch | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 631-661 (`HandleMessageSubmitted`) |
|
||||
| Client encrypt + send | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Lines 148-153 (`SendMessageAsync`) |
|
||||
| Hub receive | `src/EchoHub.Server/Hubs/ChatHub.cs` | Lines 98-111 (`SendMessage`) |
|
||||
| ChatService process | `src/EchoHub.Server/Services/ChatService.cs` | Lines 145-241 (`SendMessageAsync`) |
|
||||
| Mute check | `src/EchoHub.Server/Services/ChatService.cs` | Lines 177-190 |
|
||||
| Link embeds | `src/EchoHub.Server/Services/LinkEmbedService.cs` | Lines 28-51 (`TryGetEmbedsAsync`) |
|
||||
| DB insert | `src/EchoHub.Server/Services/ChatService.cs` | Lines 208-221 |
|
||||
| Broadcast fan-out | `src/EchoHub.Server/Services/ChatService.cs` | Lines 311-324 (`BroadcastToAllAsync`) |
|
||||
| SignalR broadcast | `src/EchoHub.Server/Services/SignalRBroadcaster.cs` | Lines 23-24 |
|
||||
| IRC broadcast | `src/EchoHub.Server.Irc/IrcBroadcaster.cs` | Lines 17-32 |
|
||||
|
||||
---
|
||||
|
||||
## Sending a Message (IRC)
|
||||
@@ -94,15 +78,6 @@ sequenceDiagram
|
||||
end
|
||||
```
|
||||
|
||||
**Code references:**
|
||||
|
||||
| Step | File | Location |
|
||||
|------|------|----------|
|
||||
| PRIVMSG handler | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Lines 437-469 |
|
||||
| Channel name conversion | `src/EchoHub.Server.Irc/IrcCommandHandler.cs` | Line 680 (`IrcToEchoHubChannel`) |
|
||||
| ChatService (shared path) | `src/EchoHub.Server/Services/ChatService.cs` | Lines 145-241 |
|
||||
| IRC echo suppression | `src/EchoHub.Server.Irc/IrcBroadcaster.cs` | Lines 25-26 |
|
||||
|
||||
---
|
||||
|
||||
## Receiving a Message (TUI Client)
|
||||
@@ -129,14 +104,6 @@ sequenceDiagram
|
||||
end
|
||||
```
|
||||
|
||||
**Code references:**
|
||||
|
||||
| Step | File | Location |
|
||||
|------|------|----------|
|
||||
| SignalR handler | `src/EchoHub.Client/Services/EchoHubConnection.cs` | Lines 64-71 |
|
||||
| Orchestrator receive | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 372-383 |
|
||||
| @mention detection | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 378-382 |
|
||||
|
||||
---
|
||||
|
||||
## Command Execution
|
||||
@@ -199,12 +166,3 @@ sequenceDiagram
|
||||
| `/users` | Local | Show userlist |
|
||||
| `/help` | Local | Show help text |
|
||||
| `/quit` | Local | Exit application |
|
||||
|
||||
**Code references:**
|
||||
|
||||
| Step | File | Location |
|
||||
|------|------|----------|
|
||||
| Command detection | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 639-655 |
|
||||
| Command dispatch | `src/EchoHub.Client/Commands/CommandHandler.cs` | Lines 34-69 (`HandleAsync`) |
|
||||
| Command handlers wired | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 97-117 |
|
||||
| Individual handlers | `src/EchoHub.Client/AppOrchestrator.cs` | Lines 122-350 |
|
||||
|
||||
@@ -36,15 +36,3 @@ sequenceDiagram
|
||||
CS->>IRCB: ForceDisconnectUserAsync(connectionIds, reason)
|
||||
CS->>DB: Set Status=Invisible, LastSeenAt=now
|
||||
```
|
||||
|
||||
**Code references:**
|
||||
|
||||
| Step | File | Location |
|
||||
|------|------|----------|
|
||||
| Kick endpoint | `src/EchoHub.Server/Controllers/ModerationController.cs` | Lines 62-87 |
|
||||
| Ban endpoint | `src/EchoHub.Server/Controllers/ModerationController.cs` | Lines 89-112 |
|
||||
| Mute endpoint | `src/EchoHub.Server/Controllers/ModerationController.cs` | Lines 130-151 |
|
||||
| Force disconnect | `src/EchoHub.Server/Controllers/ModerationController.cs` | Lines 232-256 |
|
||||
| Mute expiration | `src/EchoHub.Server/Services/MuteExpirationService.cs` | Lines 22-62 |
|
||||
| Mute enforcement | `src/EchoHub.Server/Services/ChatService.cs` | Lines 177-190 |
|
||||
| Presence force remove | `src/EchoHub.Server/Services/PresenceTracker.cs` | Lines 161-178 |
|
||||
|
||||
+1
-13
@@ -49,6 +49,7 @@ header.top-navbar {
|
||||
.navbar .navbar-brand svg {
|
||||
height: 28px;
|
||||
width: auto;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.navbar .nav-link {
|
||||
@@ -320,19 +321,6 @@ footer {
|
||||
color: #E6C06E;
|
||||
}
|
||||
|
||||
.footer-credit {
|
||||
color: #484f58;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.footer-credit a {
|
||||
color: #6e7681;
|
||||
}
|
||||
|
||||
.footer-credit a:hover {
|
||||
color: #E6C06E;
|
||||
}
|
||||
|
||||
/* --- Scrollbar --- */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
- name: Articles
|
||||
href: articles/
|
||||
homepage: articles/getting-started.md
|
||||
- name: Flows
|
||||
href: flows/
|
||||
homepage: flows/flows.md
|
||||
- name: Changelog
|
||||
href: changelog/
|
||||
homepage: changelog/index.md
|
||||
|
||||
+3
-2
@@ -15,10 +15,11 @@
|
||||
- [x] unmute timer does not seem to work
|
||||
- [ ] add IRC network like support
|
||||
- that means basically multiple servers linked, so users can chat cross-server in this network
|
||||
- [ ] when users clicks public -> private -> public checkbox in the channel creation, it ends up creating the channel on 3rd check switch
|
||||
- [x] when users clicks public -> private -> public checkbox in the channel creation, it ends up creating the channel on 3rd check switch
|
||||
- [ ] add keyboard only controls | at least for most important parts and the rest might be accessible with: (down)
|
||||
- [ ] add search bar / search modal – that will allow users to instantly navigate to room / focus on app element & etc
|
||||
- [x] add search bar / search modal – that will allow users to instantly navigate to room / focus on app element & etc
|
||||
- [ ] Actually smart data management – cache messages, lazy load messages on scroll (currently hardcoded 100msgs fetched + new ones)
|
||||
- [x] Another thing would be stateful userlist – basically fetch once and listen for userlist updates
|
||||
- [x] Send to EchohubSpace only state changes, currently we send user count periodically, instead of updating it on update
|
||||
- [x] space between mod|admin "icon" and username
|
||||
- [ ] Embeds still incorrectly display colors
|
||||
|
||||
@@ -7,17 +7,17 @@
|
||||
<authors>HueByte</authors>
|
||||
<owners>HueByte</owners>
|
||||
<requireLicenseAcceptance>false</requireLicenseAcceptance>
|
||||
<licenseUrl>https://github.com/HueByteByte/EchoHub/blob/master/LICENSE</licenseUrl>
|
||||
<projectUrl>https://github.com/HueByteByte/EchoHub</projectUrl>
|
||||
<projectSourceUrl>https://github.com/HueByteByte/EchoHub</projectSourceUrl>
|
||||
<docsUrl>https://HueBytebyte.github.io/EchoHub</docsUrl>
|
||||
<bugTrackerUrl>https://github.com/HueByteByte/EchoHub/issues</bugTrackerUrl>
|
||||
<packageSourceUrl>https://github.com/HueByteByte/EchoHub/tree/master/packaging/choco</packageSourceUrl>
|
||||
<iconUrl>https://raw.githubusercontent.com/HueByteByte/EchoHub/master/assets/HueByte_icon.png</iconUrl>
|
||||
<licenseUrl>https://github.com/HueByte/EchoHub/blob/master/LICENSE</licenseUrl>
|
||||
<projectUrl>https://github.com/HueByte/EchoHub</projectUrl>
|
||||
<projectSourceUrl>https://github.com/HueByte/EchoHub</projectSourceUrl>
|
||||
<docsUrl>https://huebyte.github.io/EchoHub</docsUrl>
|
||||
<bugTrackerUrl>https://github.com/HueByte/EchoHub/issues</bugTrackerUrl>
|
||||
<packageSourceUrl>https://github.com/HueByte/EchoHub/tree/master/packaging/choco</packageSourceUrl>
|
||||
<iconUrl>https://raw.githubusercontent.com/HueByte/EchoHub/refs/heads/master/assets/hue_icon.png</iconUrl>
|
||||
<description>EchoHub is a decentralized IRC-style chat application with a terminal user interface (TUI). Connect to any EchoHub server, join channels, and chat — all from your terminal. Features include SignalR real-time messaging, file sharing, custom themes, and IRC gateway compatibility.</description>
|
||||
<summary>Decentralized terminal chat client with IRC gateway support</summary>
|
||||
<tags>chat irc decentralized tui terminal signalr echohub</tags>
|
||||
<releaseNotes>https://HueBytebyte.github.io/EchoHub/changelog/v__VERSION__.html</releaseNotes>
|
||||
<releaseNotes>https://huebyte.github.io/EchoHub/changelog/v__VERSION__.html</releaseNotes>
|
||||
<copyright>Copyright (c) 2026 HueByte</copyright>
|
||||
</metadata>
|
||||
<files>
|
||||
|
||||
@@ -14,5 +14,6 @@ $packageArgs = @{
|
||||
Install-ChocolateyZipPackage @packageArgs
|
||||
|
||||
# Create a shim so 'echohub' is available on PATH
|
||||
$exePath = Join-Path $toolsDir 'client-win-x64' 'EchoHub.Client.exe'
|
||||
$exeDir = Join-Path $toolsDir 'client-win-x64'
|
||||
$exePath = Join-Path $exeDir 'EchoHub.Client.exe'
|
||||
Install-BinFile -Name 'echohub' -Path $exePath
|
||||
|
||||
+11
-4
@@ -30,7 +30,7 @@ while [ $# -gt 0 ]; do
|
||||
sed -n '2,8p' "$0" 2>/dev/null || true
|
||||
echo ""
|
||||
echo " curl -sSfL https://raw.githubusercontent.com/$REPO/master/scripts/install.sh | sh"
|
||||
echo " curl ... | sh -s -- --version 0.2.8"
|
||||
echo " curl ... | sh -s -- --version 0.2.11"
|
||||
echo " curl ... | sh -s -- --install-dir /opt/echohub"
|
||||
exit 0
|
||||
;;
|
||||
@@ -199,10 +199,17 @@ main() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install the binary
|
||||
# Install full directory to ~/.local/share/echohub and symlink the binary.
|
||||
# Even single-file publishes may have content files (appsettings, assets)
|
||||
# that the app expects next to the binary via AppContext.BaseDirectory.
|
||||
app_dir="$HOME/.local/share/echohub"
|
||||
rm -rf "$app_dir"
|
||||
mkdir -p "$app_dir"
|
||||
cp -r "$src_dir"/. "$app_dir/"
|
||||
chmod +x "$app_dir/EchoHub.Client"
|
||||
|
||||
mkdir -p "$install_dir"
|
||||
cp "$src_dir/EchoHub.Client" "$install_dir/$BINARY_NAME"
|
||||
chmod +x "$install_dir/$BINARY_NAME"
|
||||
ln -sf "$app_dir/EchoHub.Client" "$install_dir/$BINARY_NAME"
|
||||
|
||||
echo ""
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>0.2.8</Version>
|
||||
<Version>0.2.11</Version>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -30,6 +30,7 @@ public sealed class AppOrchestrator : IDisposable
|
||||
private readonly ConnectionManager _conn = new();
|
||||
private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Lock _channelUsersLock = new();
|
||||
private readonly HashSet<string> _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private ClientConfig _config;
|
||||
private readonly UserSession _session = new();
|
||||
@@ -91,6 +92,8 @@ public sealed class AppOrchestrator : IDisposable
|
||||
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
||||
_mainWindow.OnUserProfileRequested += HandleViewProfile;
|
||||
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
|
||||
_mainWindow.OnSearchRequested += HandleSearchRequested;
|
||||
_mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested;
|
||||
}
|
||||
|
||||
// ── Command Handler Wiring ─────────────────────────────────────────────
|
||||
@@ -720,6 +723,31 @@ public sealed class AppOrchestrator : IDisposable
|
||||
}, "Failed to join channel");
|
||||
}
|
||||
|
||||
private void HandleLoadMoreRequested()
|
||||
{
|
||||
if (!_conn.IsConnected) return;
|
||||
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
if (string.IsNullOrEmpty(channel)) return;
|
||||
|
||||
if (!_channelsLoadingMore.Add(channel)) return;
|
||||
|
||||
var offset = _messageManager.GetMessages(channel)?.Count ?? 0;
|
||||
|
||||
RunAsync(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var history = await _conn.GetHistoryAsync(channel, HubConstants.DefaultHistoryCount, offset);
|
||||
InvokeUI(() => _messageManager.PrependHistory(channel, history));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_channelsLoadingMore.Remove(channel);
|
||||
}
|
||||
}, "Failed to load more messages");
|
||||
}
|
||||
|
||||
private void HandleChannelJoinFromMessage(string channelName)
|
||||
{
|
||||
if (!_conn.IsConnected) return;
|
||||
@@ -733,6 +761,38 @@ public sealed class AppOrchestrator : IDisposable
|
||||
HandleChannelSelected(channelName);
|
||||
}
|
||||
|
||||
|
||||
private void HandleSearchRequested()
|
||||
{
|
||||
var result = SearchDialog.Show(_app, _mainWindow.GetChannelNames());
|
||||
if (result is null) return;
|
||||
|
||||
switch (result.Type)
|
||||
{
|
||||
case SearchResultType.Channel:
|
||||
_mainWindow.SwitchToChannel(result.Key);
|
||||
HandleChannelSelected(result.Key);
|
||||
break;
|
||||
|
||||
case SearchResultType.Action:
|
||||
switch (result.Key)
|
||||
{
|
||||
case "connect": HandleConnect(); break;
|
||||
case "disconnect": HandleDisconnect(); break;
|
||||
case "logout": HandleLogout(); break;
|
||||
case "profile": HandleProfileRequested(); break;
|
||||
case "status": HandleStatusRequested(); break;
|
||||
case "create-channel": HandleCreateChannelRequested(); break;
|
||||
case "delete-channel": HandleDeleteChannelRequested(); break;
|
||||
case "servers": HandleSavedServersRequested(); break;
|
||||
case "toggle-users": _mainWindow.ToggleUsersPanel(); break;
|
||||
case "updates": HandleCheckForUpdatesRequested(); break;
|
||||
case "quit": _app.RequestStop(); break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleProfileRequested()
|
||||
{
|
||||
HandleViewProfile(null);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5039" />
|
||||
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5043" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -4,6 +4,7 @@ using EchoHub.Client.Services;
|
||||
using EchoHub.Client.Themes;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Serilog;
|
||||
using Serilog.Settings.Configuration;
|
||||
using Terminal.Gui.App;
|
||||
|
||||
// == CLI rollback: works without TUI, before anything else ================
|
||||
@@ -71,8 +72,11 @@ var configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
|
||||
.Build();
|
||||
|
||||
// Explicit sink-assembly reference is required under PublishSingleFile — the default
|
||||
// AssemblyFinder scans for Serilog.Sinks.*.dll on disk, which don't exist in a bundled exe.
|
||||
var serilogOptions = new ConfigurationReaderOptions(typeof(FileLoggerConfigurationExtensions).Assembly);
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(configuration)
|
||||
.ReadFrom.Configuration(configuration, serilogOptions)
|
||||
.CreateLogger();
|
||||
|
||||
Log.Information("EchoHub client starting");
|
||||
|
||||
@@ -196,8 +196,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
||||
_connection?.SendMessageAsync(channel, content)
|
||||
?? throw new InvalidOperationException("Not connected");
|
||||
|
||||
public Task<List<MessageDto>> GetHistoryAsync(string channel) =>
|
||||
_connection?.GetHistoryAsync(channel)
|
||||
public Task<List<MessageDto>> GetHistoryAsync(string channel, int count = HubConstants.DefaultHistoryCount, int offset = 0) =>
|
||||
_connection?.GetHistoryAsync(channel, count, offset)
|
||||
?? throw new InvalidOperationException("Not connected");
|
||||
|
||||
public Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channel) =>
|
||||
|
||||
@@ -154,9 +154,9 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
||||
await _connection.InvokeAsync("SendMessage", channelName, encrypted);
|
||||
}
|
||||
|
||||
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount)
|
||||
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
|
||||
{
|
||||
var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count);
|
||||
var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count, offset);
|
||||
return DecryptMessages(messages);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ namespace EchoHub.Client.Services;
|
||||
|
||||
public class NotificationSoundService
|
||||
{
|
||||
// Safety net: if PlaybackFinished never fires we don't want to block future notifications forever.
|
||||
private static readonly TimeSpan PlaybackTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly Player _player = new();
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
private readonly NotificationConfig _config;
|
||||
private string? _resolvedSoundPath;
|
||||
|
||||
@@ -41,18 +45,31 @@ public class NotificationSoundService
|
||||
|
||||
private async Task PlayInternal()
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
|
||||
// _player.Play returns as soon as playback starts, so we wait on PlaybackFinished
|
||||
// to hold the lock for the duration of the sound. A one-shot handler + timeout
|
||||
// keeps the finally release robust: never-fires → timeout; fires twice → ignored
|
||||
// (TrySetResult); handler throws → caller's catch still runs finally.
|
||||
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
void OnFinished(object? s, EventArgs e) => completion.TrySetResult();
|
||||
_player.PlaybackFinished += OnFinished;
|
||||
|
||||
try
|
||||
{
|
||||
if (_player.Playing)
|
||||
await _player.Stop();
|
||||
|
||||
await _player.SetVolume(_config.Volume);
|
||||
await _player.Play(_resolvedSoundPath!);
|
||||
await Task.WhenAny(completion.Task, Task.Delay(PlaybackTimeout));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to play notification sound");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_player.PlaybackFinished -= OnFinished;
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void ResolveSoundPath()
|
||||
|
||||
@@ -40,7 +40,37 @@ public static class UpdateBackupService
|
||||
|
||||
Log.Information("Creating pre-update backup of {AppDir} (v{Version})", appDir, version);
|
||||
|
||||
ZipFile.CreateFromDirectory(appDir, BackupZipPath, CompressionLevel.Fastest, includeBaseDirectory: false);
|
||||
using (var archive = ZipFile.Open(BackupZipPath, ZipArchiveMode.Create))
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(appDir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(appDir, file);
|
||||
|
||||
// Skip log files to prevent locking errors with Serilog while zipping
|
||||
if (relativePath.StartsWith("logs" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||
relativePath.StartsWith("logs" + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||
relativePath.EndsWith(".log", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize path separators for the zip archive format
|
||||
var entryName = relativePath.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/');
|
||||
|
||||
try
|
||||
{
|
||||
archive.CreateEntryFromFile(file, entryName, CompressionLevel.Fastest);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
Log.Warning(ex, "Skipped locked file {FileName} during backup calculation", relativePath);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
Log.Warning(ex, "Skipped inaccessible file {FileName} during backup calculation", relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var info = new BackupInfo(version, appDir, DateTimeOffset.UtcNow);
|
||||
var json = JsonSerializer.Serialize(info, BackupJsonContext.Default.BackupInfo);
|
||||
|
||||
@@ -71,7 +71,7 @@ public sealed class UpdateChecker : IDisposable
|
||||
// Create backup before the update starts
|
||||
try
|
||||
{
|
||||
_app.Invoke(() => _progressDialog?.UpdateProgress(0f, "Creating backup..."));
|
||||
_progressDialog?.UpdateProgress(0f, "Creating backup...");
|
||||
UpdateBackupService.CreateBackup();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -79,27 +79,21 @@ public sealed class UpdateChecker : IDisposable
|
||||
Log.Error(ex, "Failed to create pre-update backup");
|
||||
|
||||
var proceed = false;
|
||||
_app.Invoke(() =>
|
||||
{
|
||||
proceed = MessageBox.Query(
|
||||
_app,
|
||||
"Backup Warning",
|
||||
$"Could not create backup: {ex.Message}\n\nContinue update without backup?",
|
||||
"Continue", "Cancel") == 0;
|
||||
});
|
||||
proceed = MessageBox.Query(
|
||||
_app,
|
||||
"Backup Warning",
|
||||
$"Could not create backup: {ex.Message}\n\nContinue update without backup?",
|
||||
"Continue", "Cancel") == 0;
|
||||
|
||||
if (!proceed)
|
||||
{
|
||||
_app.Invoke(() =>
|
||||
{
|
||||
_progressDialog?.Close();
|
||||
_progressDialog = null;
|
||||
});
|
||||
_progressDialog?.Close();
|
||||
_progressDialog = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_app.Invoke(() => _progressDialog?.UpdateProgress(0f, "Downloading update..."));
|
||||
_progressDialog?.UpdateProgress(0f, "Downloading update...");
|
||||
await _updater.UpdateAsync();
|
||||
});
|
||||
|
||||
|
||||
@@ -186,6 +186,39 @@ public sealed class ChatMessageManager
|
||||
MessagesChanged?.Invoke(channelName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prepend older messages at the front of a channel's buffer, skipping any that are already present.
|
||||
/// Fires <see cref="HistoryPrepended"/> when new lines are actually inserted.
|
||||
/// </summary>
|
||||
public void PrependHistory(string channelName, List<MessageDto> olderMessages)
|
||||
{
|
||||
if (!_channelMessages.TryGetValue(channelName, out var existing))
|
||||
return;
|
||||
|
||||
var existingIds = existing
|
||||
.Where(l => l.MessageId.HasValue)
|
||||
.Select(l => l.MessageId!.Value)
|
||||
.ToHashSet();
|
||||
|
||||
var newLines = olderMessages
|
||||
.Where(m => !existingIds.Contains(m.Id))
|
||||
.SelectMany(FormatMessage)
|
||||
.ToList();
|
||||
|
||||
if (newLines.Count == 0)
|
||||
return;
|
||||
|
||||
existing.InsertRange(0, newLines);
|
||||
|
||||
if (channelName == _currentChannel)
|
||||
HistoryPrepended?.Invoke(channelName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fired after older messages are prepended to a channel's buffer. Parameter is the channel name.
|
||||
/// </summary>
|
||||
public event Action<string>? HistoryPrepended;
|
||||
|
||||
/// <summary>
|
||||
/// Reset all message state (used on disconnect).
|
||||
/// </summary>
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed class CreateChannelDialog
|
||||
{
|
||||
CreateChannelResult? result = null;
|
||||
|
||||
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 14 };
|
||||
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 14, CommandsToBubbleUp = [] };
|
||||
|
||||
var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
|
||||
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) };
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using EchoHub.Client.UI.ListSources;
|
||||
|
||||
using System.Collections;
|
||||
using System.Collections.Specialized;
|
||||
using System.Diagnostics;
|
||||
|
||||
using Terminal.Gui.App;
|
||||
using Terminal.Gui.Drawing;
|
||||
using Terminal.Gui.Input;
|
||||
using Terminal.Gui.Text;
|
||||
using Terminal.Gui.ViewBase;
|
||||
using Terminal.Gui.Views;
|
||||
|
||||
namespace EchoHub.Client.UI.Dialogs;
|
||||
|
||||
public enum SearchResultType
|
||||
{
|
||||
Channel,
|
||||
Action
|
||||
}
|
||||
|
||||
public record SearchResult(SearchResultType Type, string Key, string Label);
|
||||
|
||||
/// <summary>
|
||||
/// Command-palette style search dialog (Ctrl+K) for navigating channels and triggering app actions.
|
||||
/// </summary>
|
||||
public static class SearchDialog
|
||||
{
|
||||
private static readonly IReadOnlyList<SearchResult> DefaultActions = [
|
||||
new(SearchResultType.Action, "connect", "Connect to Server"),
|
||||
new(SearchResultType.Action, "disconnect", "Disconnect"),
|
||||
new(SearchResultType.Action, "logout", "Logout"),
|
||||
new(SearchResultType.Action, "profile", "My Profile"),
|
||||
new(SearchResultType.Action, "status", "Set Status"),
|
||||
new(SearchResultType.Action, "create-channel", "Create Channel"),
|
||||
new(SearchResultType.Action, "delete-channel", "Delete Channel"),
|
||||
new(SearchResultType.Action, "servers", "Saved Servers"),
|
||||
new(SearchResultType.Action, "toggle-users", "Toggle Users Panel"),
|
||||
new(SearchResultType.Action, "updates", "Check for Updates"),
|
||||
new(SearchResultType.Action, "quit", "Quit"),
|
||||
];
|
||||
|
||||
public static SearchResult? Show(IApplication app, IReadOnlyList<string> channels)
|
||||
{
|
||||
SearchResult? result = null;
|
||||
var source = new SearchListSource(BuildAllItems(channels));
|
||||
|
||||
var dialog = new Dialog
|
||||
{
|
||||
Title = "Search",
|
||||
Width = 59,
|
||||
Height = 22,
|
||||
};
|
||||
|
||||
var hintLabel = new Label
|
||||
{
|
||||
Text = "Channels and actions \u2502 \u2193 to navigate \u2502 Enter to select",
|
||||
X = 1,
|
||||
Y = 1,
|
||||
};
|
||||
|
||||
var searchField = new TextField
|
||||
{
|
||||
X = 1,
|
||||
Y = 2,
|
||||
Title = "Search",
|
||||
Width = Dim.Fill(2),
|
||||
};
|
||||
|
||||
var resultList = new ListView
|
||||
{
|
||||
X = 1,
|
||||
Y = 4,
|
||||
Width = Dim.Fill(2),
|
||||
Height = Dim.Fill(3),
|
||||
Source = source
|
||||
};
|
||||
|
||||
var cancelButton = new Button
|
||||
{
|
||||
Text = "Cancel",
|
||||
X = Pos.Center(),
|
||||
Y = Pos.AnchorEnd(1),
|
||||
};
|
||||
|
||||
if (source.Count > 0)
|
||||
resultList.SelectedItem = 0;
|
||||
|
||||
searchField.KeyDown += (s, e) =>
|
||||
{
|
||||
if (e.KeyCode == Key.K.WithCtrl)
|
||||
{
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
}
|
||||
};
|
||||
|
||||
searchField.TextChanged += (s, e) =>
|
||||
{
|
||||
source.Filter(searchField.Text ?? string.Empty);
|
||||
resultList.Source = source;
|
||||
if (source.Count > 0)
|
||||
resultList.SelectedItem = 0;
|
||||
};
|
||||
|
||||
searchField.Accepting += (s, e) => TryConfirm(e);
|
||||
|
||||
resultList.Accepting += (s, e) => TryConfirm(e);
|
||||
|
||||
resultList.KeystrokeNavigator.SearchStringChanged += (s, e) =>
|
||||
{
|
||||
app.Invoke(() =>
|
||||
{
|
||||
searchField.SetFocus();
|
||||
});
|
||||
};
|
||||
|
||||
cancelButton.Accepting += (s, e) =>
|
||||
{
|
||||
result = null;
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
|
||||
dialog.KeyDown += (s, e) =>
|
||||
{
|
||||
if (e.KeyCode == Key.K.WithCtrl)
|
||||
{
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
}
|
||||
};
|
||||
|
||||
dialog.Add(hintLabel, searchField, resultList, cancelButton);
|
||||
searchField.SetFocus();
|
||||
app.Run(dialog);
|
||||
|
||||
return result;
|
||||
|
||||
void TryConfirm(CommandEventArgs e)
|
||||
{
|
||||
var idx = resultList.SelectedItem ?? 0;
|
||||
if (source.Count > 0 && idx >= 0 && idx < source.Count)
|
||||
{
|
||||
result = source.GetItem(idx);
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<SearchResult> BuildAllItems(IReadOnlyList<string> channels)
|
||||
{
|
||||
var items = new List<SearchResult>();
|
||||
foreach (var ch in channels)
|
||||
items.Add(new SearchResult(SearchResultType.Channel, ch, $"#{ch}"));
|
||||
items.AddRange(DefaultActions);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using EchoHub.Client.UI.Chat;
|
||||
using EchoHub.Client.UI.Dialogs;
|
||||
|
||||
using System.Collections;
|
||||
using System.Collections.Specialized;
|
||||
|
||||
using Terminal.Gui.Drawing;
|
||||
using Terminal.Gui.Text;
|
||||
using Terminal.Gui.Views;
|
||||
|
||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||
|
||||
namespace EchoHub.Client.UI.ListSources;
|
||||
|
||||
/// <summary>
|
||||
/// List data source for the search dialog with filtering and colored rendering.
|
||||
/// </summary>
|
||||
public class SearchListSource(List<SearchResult> items) : IListDataSource
|
||||
{
|
||||
private readonly List<SearchResult> _allItems = items;
|
||||
private List<SearchResult> _filtered = [.. items];
|
||||
|
||||
private static readonly Attribute ChannelAttribute = new(Color.BrightCyan, Color.None);
|
||||
private static readonly Attribute ActionAttribute = new(Color.White, Color.None);
|
||||
|
||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||
public int Count => _filtered.Count;
|
||||
public int MaxItemLength => _filtered.Count > 0 ? _filtered.Max(i => i.Label.GetColumns()) : 0;
|
||||
public bool SuspendCollectionChangedEvent { get; set; }
|
||||
|
||||
public void Filter(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
_filtered = [.. _allItems];
|
||||
}
|
||||
else
|
||||
{
|
||||
_filtered = [.. _allItems.Where(i =>
|
||||
i.Label.Contains(query, StringComparison.OrdinalIgnoreCase)
|
||||
|| i.Key.Contains(query, StringComparison.OrdinalIgnoreCase))];
|
||||
}
|
||||
|
||||
if (!SuspendCollectionChangedEvent)
|
||||
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||
}
|
||||
|
||||
public SearchResult? GetItem(int index) => index >= 0 && index < _filtered.Count ? _filtered[index] : null;
|
||||
|
||||
public bool IsMarked(int item) => false;
|
||||
public void SetMark(int item, bool value) { }
|
||||
public IList ToList() => _filtered.Select(i => (object)i.Label).ToList();
|
||||
|
||||
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
|
||||
{
|
||||
listView.Move(Math.Max(col - viewportX, 0), row);
|
||||
|
||||
var entry = _filtered[item];
|
||||
var fillAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
|
||||
|
||||
Attribute itemAttr;
|
||||
if (selected)
|
||||
{
|
||||
itemAttr = fillAttr;
|
||||
}
|
||||
else
|
||||
{
|
||||
var raw = entry.Type switch
|
||||
{
|
||||
SearchResultType.Channel => ChannelAttribute,
|
||||
SearchResultType.Action => ActionAttribute,
|
||||
_ => fillAttr
|
||||
};
|
||||
itemAttr = raw.Background == Color.None ? raw with { Background = fillAttr.Background } : raw;
|
||||
}
|
||||
|
||||
listView.SetAttribute(itemAttr);
|
||||
|
||||
var drawn = RenderHelpers.WriteText(listView, entry.Label, 0, width);
|
||||
|
||||
listView.SetAttribute(fillAttr);
|
||||
for (var i = drawn; i < width; i++)
|
||||
listView.AddStr(" ");
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using EchoHub.Client.Services;
|
||||
using EchoHub.Client.Themes;
|
||||
@@ -48,6 +49,7 @@ public sealed partial class MainWindow : Runnable
|
||||
private static readonly Key NewlineKey = Key.N.WithCtrl;
|
||||
private static readonly Key AltQKey = Key.Q.WithAlt;
|
||||
private static readonly Key TabKey = Key.Tab;
|
||||
private static readonly Key CtrlKKey = Key.K.WithCtrl;
|
||||
|
||||
// Available slash commands for Tab autocomplete
|
||||
private static readonly string[] SlashCommands =
|
||||
@@ -116,6 +118,11 @@ public sealed partial class MainWindow : Runnable
|
||||
/// </summary>
|
||||
public event Action? OnSavedServersRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user scrolls to the top of the message list and older messages should be loaded.
|
||||
/// </summary>
|
||||
public event Action? OnLoadMoreRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to create a new channel.
|
||||
/// </summary>
|
||||
@@ -151,11 +158,17 @@ public sealed partial class MainWindow : Runnable
|
||||
/// </summary>
|
||||
public event Action<string>? OnChannelJoinRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to open the search dialog (via menu or Ctrl+K).
|
||||
/// </summary>
|
||||
public event Action? OnSearchRequested;
|
||||
|
||||
public MainWindow(IApplication app, ChatMessageManager messageManager)
|
||||
{
|
||||
_app = app;
|
||||
_messageManager = messageManager;
|
||||
_messageManager.MessagesChanged += OnMessagesChanged;
|
||||
_messageManager.HistoryPrepended += OnHistoryPrepended;
|
||||
Arrangement = ViewArrangement.Fixed;
|
||||
|
||||
// Menu bar at the top
|
||||
@@ -216,13 +229,16 @@ public sealed partial class MainWindow : Runnable
|
||||
};
|
||||
_messageList.Source = new ChatListSource();
|
||||
_messageList.Accepting += OnMessageListAccepting;
|
||||
_messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled;
|
||||
_messageList.VerticalScrollBar.Visible = true;
|
||||
|
||||
_chatFrame.Add(_messageList);
|
||||
Add(_chatFrame);
|
||||
|
||||
// Bottom input area
|
||||
_inputFrame = new FrameView
|
||||
{
|
||||
Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete",
|
||||
Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete \u2502 Ctrl+K=search",
|
||||
X = 22,
|
||||
Y = Pos.Bottom(_chatFrame),
|
||||
Width = Dim.Fill(UsersPanelWidth),
|
||||
@@ -472,6 +488,12 @@ public sealed partial class MainWindow : Runnable
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs<int> e)
|
||||
{
|
||||
if (_messageList.VerticalScrollBar.Value == 0)
|
||||
OnLoadMoreRequested?.Invoke();
|
||||
}
|
||||
|
||||
private void OnUsersListAccepting(object? sender, CommandEventArgs e)
|
||||
{
|
||||
var index = _usersList.SelectedItem;
|
||||
@@ -513,6 +535,11 @@ public sealed partial class MainWindow : Runnable
|
||||
_app.RequestStop();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == CtrlKKey.KeyCode)
|
||||
{
|
||||
ShowSearchDialog();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private bool _suppressEmojiReplace;
|
||||
@@ -572,6 +599,9 @@ public sealed partial class MainWindow : Runnable
|
||||
if (prefix.Length > text.Length)
|
||||
_inputField.Text = prefix;
|
||||
}
|
||||
|
||||
// Move cursor to end after autocomplete
|
||||
_inputField.InsertionPoint = new System.Drawing.Point(_inputField.Text?.Length ?? 0, 0);
|
||||
}
|
||||
|
||||
private void OnChatViewportChanged()
|
||||
@@ -597,6 +627,16 @@ public sealed partial class MainWindow : Runnable
|
||||
ToggleUsersPanel();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.KeyCode == CtrlKKey.KeyCode)
|
||||
{
|
||||
ShowSearchDialog();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowSearchDialog()
|
||||
{
|
||||
OnSearchRequested?.Invoke();
|
||||
}
|
||||
|
||||
private void OnMessagesChanged(string channelName)
|
||||
@@ -607,6 +647,26 @@ public sealed partial class MainWindow : Runnable
|
||||
RefreshChannelList();
|
||||
}
|
||||
|
||||
private void OnHistoryPrepended(string channelName)
|
||||
{
|
||||
if (channelName != _messageManager.CurrentChannel)
|
||||
return;
|
||||
|
||||
var messages = _messageManager.GetMessages(channelName);
|
||||
if (messages is null)
|
||||
return;
|
||||
|
||||
var oldCount = (_messageList.Source as ChatListSource)?.Count ?? 0;
|
||||
|
||||
RefreshMessages();
|
||||
|
||||
// Scroll to the item that was at the top before the prepend so the user
|
||||
// stays at their previous reading position rather than jumping to the top.
|
||||
var prependedCount = (_messageList.Source as ChatListSource)?.Count - oldCount;
|
||||
if (prependedCount > 0)
|
||||
_messageList.SelectedItem = prependedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the list of available channels, storing topics, and refresh the channel list view.
|
||||
/// </summary>
|
||||
|
||||
@@ -13,7 +13,7 @@ public static partial class ValidationConstants
|
||||
public const int MaxBioLength = 500;
|
||||
public const int MaxStatusMessageLength = 100;
|
||||
public const int MaxChannelTopicLength = 500;
|
||||
public const int MaxHistoryCount = 100;
|
||||
public const int MaxHistoryCount = 200;
|
||||
|
||||
[GeneratedRegex(UsernamePattern)]
|
||||
public static partial Regex UsernameRegex();
|
||||
|
||||
@@ -15,7 +15,7 @@ public interface IChatService
|
||||
|
||||
// Messaging
|
||||
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content);
|
||||
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count);
|
||||
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0);
|
||||
|
||||
// Presence
|
||||
Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using System.Security.Claims;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Server.Data;
|
||||
using EchoHub.Server.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
@@ -13,11 +16,13 @@ public class ServerController : ControllerBase
|
||||
{
|
||||
private readonly EchoHubDbContext _db;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly DirectoryClaimStore _claimStore;
|
||||
|
||||
public ServerController(EchoHubDbContext db, IConfiguration config)
|
||||
public ServerController(EchoHubDbContext db, IConfiguration config, DirectoryClaimStore claimStore)
|
||||
{
|
||||
_db = db;
|
||||
_config = config;
|
||||
_claimStore = claimStore;
|
||||
}
|
||||
|
||||
[HttpGet("info")]
|
||||
@@ -47,4 +52,46 @@ public class ServerController : ControllerBase
|
||||
|
||||
return Ok(new EncryptionKeyResponse(key));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Operator-facing view of the EchoHubSpace directory registration: ServerId for admin
|
||||
/// support tickets, current registration state, and the last error/conflict if any.
|
||||
/// Never exposes the claim token itself.
|
||||
/// </summary>
|
||||
[HttpGet("directory")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetDirectoryStatus()
|
||||
{
|
||||
var (_, error) = await GetCallerAsync(ServerRole.Admin);
|
||||
if (error is not null) return error;
|
||||
|
||||
var status = _claimStore.Status;
|
||||
var response = new
|
||||
{
|
||||
ServerId = _claimStore.ServerId,
|
||||
HasClaimToken = _claimStore.ClaimToken is not null,
|
||||
status.IsRegistered,
|
||||
status.LastRegisteredAt,
|
||||
status.LastError,
|
||||
status.ConflictingHosts,
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
private async Task<(User? Caller, IActionResult? Error)> GetCallerAsync(ServerRole minimumRole)
|
||||
{
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userIdClaim is null)
|
||||
return (null, Unauthorized(new ErrorResponse("Authentication required.")));
|
||||
|
||||
var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim));
|
||||
if (caller is null)
|
||||
return (null, Unauthorized(new ErrorResponse("User not found.")));
|
||||
|
||||
if (caller.Role < minimumRole)
|
||||
return (null, StatusCode(403, new ErrorResponse($"Requires {minimumRole} role or higher.")));
|
||||
|
||||
return (caller, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ RUN dotnet restore EchoHub.Server/EchoHub.Server.csproj
|
||||
COPY EchoHub.Core/ EchoHub.Core/
|
||||
COPY EchoHub.Server.Irc/ EchoHub.Server.Irc/
|
||||
COPY EchoHub.Server/ EchoHub.Server/
|
||||
# Icon referenced by Server.csproj ApplicationIcon
|
||||
COPY EchoHub.Client/Assets/hue_icon.ico EchoHub.Client/Assets/hue_icon.ico
|
||||
RUN dotnet publish EchoHub.Server/EchoHub.Server.csproj -c Release -o /app/publish --no-restore
|
||||
|
||||
# Runtime
|
||||
|
||||
@@ -106,11 +106,11 @@ public class ChatHub : Hub<IEchoHubClient>
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount)
|
||||
public async Task<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _chatService.GetChannelHistoryAsync(channelName, count);
|
||||
return await _chatService.GetChannelHistoryAsync(channelName, count, offset);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -107,6 +107,7 @@ while (true)
|
||||
builder.Services.AddSingleton<ImageToAsciiService>();
|
||||
builder.Services.AddSingleton<FileStorageService>();
|
||||
builder.Services.AddSingleton<LinkEmbedService>();
|
||||
builder.Services.AddSingleton<DirectoryClaimStore>();
|
||||
builder.Services.AddHostedService<ServerDirectoryService>();
|
||||
builder.Services.AddHostedService<FileCleanupService>();
|
||||
builder.Services.AddHostedService<MuteExpirationService>();
|
||||
|
||||
@@ -245,15 +245,16 @@ public class ChatService : IChatService
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count)
|
||||
public async Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0)
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
|
||||
offset = Math.Max(offset, 0);
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||
|
||||
return await GetChannelHistoryInternalAsync(db, channelName, count);
|
||||
return await GetChannelHistoryInternalAsync(db, channelName, count, offset);
|
||||
}
|
||||
|
||||
public async Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
|
||||
@@ -366,7 +367,7 @@ public class ChatService : IChatService
|
||||
return string.Join('\n', result);
|
||||
}
|
||||
|
||||
private async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)
|
||||
private async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count, int offset = 0)
|
||||
{
|
||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
if (channel is null)
|
||||
@@ -375,6 +376,7 @@ public class ChatService : IChatService
|
||||
var raw = await db.Messages
|
||||
.Where(m => m.ChannelId == channel.Id)
|
||||
.OrderByDescending(m => m.SentAt)
|
||||
.Skip(offset)
|
||||
.Take(count)
|
||||
.Join(db.Users,
|
||||
m => m.SenderUserId,
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace EchoHub.Server.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists and exposes the EchoHubSpace directory claim — the opaque token issued on first
|
||||
/// registration and the row's stable <c>ServerId</c>. Also surfaces ephemeral registration
|
||||
/// status (success/failure code, conflicting hosts) for operator-facing endpoints.
|
||||
///
|
||||
/// Persistence uses atomic write (tmp + rename). Treat the file contents as a secret.
|
||||
/// </summary>
|
||||
public sealed class DirectoryClaimStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private readonly string _filePath;
|
||||
private readonly ILogger<DirectoryClaimStore> _logger;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
|
||||
private PersistedClaim _persisted = new(null, null);
|
||||
private RegistrationStatus _status = new(false, null, null, null, null);
|
||||
|
||||
public DirectoryClaimStore(IConfiguration configuration, ILogger<DirectoryClaimStore> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_filePath = ResolveFilePath(configuration);
|
||||
Load();
|
||||
}
|
||||
|
||||
public string FilePath => _filePath;
|
||||
|
||||
public string? ClaimToken => Volatile.Read(ref _persisted).ClaimToken;
|
||||
public Guid? ServerId => Volatile.Read(ref _persisted).ServerId;
|
||||
|
||||
public RegistrationStatus Status => Volatile.Read(ref _status);
|
||||
|
||||
/// <summary>
|
||||
/// Persist a freshly-issued claim token alongside the server's stable ServerId.
|
||||
/// Called exactly once per row's lifetime — on first claim. Atomic on-disk swap.
|
||||
/// </summary>
|
||||
public async Task SaveClaimAsync(string claimToken, Guid serverId, CancellationToken ct = default)
|
||||
{
|
||||
await _writeLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var next = new PersistedClaim(claimToken, serverId);
|
||||
await WriteAtomicAsync(next, ct);
|
||||
Volatile.Write(ref _persisted, next);
|
||||
_logger.LogInformation("Persisted directory claim token for ServerId {ServerId} at {Path}", serverId, _filePath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update only the ServerId — used when re-registering with an existing token (Success path,
|
||||
/// hub returns ServerId again but no fresh token). No-op if the value is unchanged.
|
||||
/// </summary>
|
||||
public async Task UpdateServerIdAsync(Guid serverId, CancellationToken ct = default)
|
||||
{
|
||||
var current = Volatile.Read(ref _persisted);
|
||||
if (current.ServerId == serverId)
|
||||
return;
|
||||
|
||||
await _writeLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var next = current with { ServerId = serverId };
|
||||
await WriteAtomicAsync(next, ct);
|
||||
Volatile.Write(ref _persisted, next);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetSuccess(Guid serverId)
|
||||
{
|
||||
Volatile.Write(ref _status, new RegistrationStatus(
|
||||
IsRegistered: true,
|
||||
ServerId: serverId,
|
||||
LastRegisteredAt: DateTimeOffset.UtcNow,
|
||||
LastError: null,
|
||||
ConflictingHosts: null));
|
||||
}
|
||||
|
||||
public void SetFailure(string errorCode, string[]? conflictingHosts)
|
||||
{
|
||||
var current = Volatile.Read(ref _status);
|
||||
Volatile.Write(ref _status, current with
|
||||
{
|
||||
IsRegistered = false,
|
||||
LastError = errorCode,
|
||||
ConflictingHosts = conflictingHosts,
|
||||
});
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
if (!File.Exists(_filePath))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(_filePath);
|
||||
var loaded = JsonSerializer.Deserialize<PersistedClaim>(stream, JsonOptions);
|
||||
if (loaded is not null)
|
||||
{
|
||||
_persisted = loaded;
|
||||
_logger.LogInformation("Loaded directory claim from {Path} (ServerId {ServerId})", _filePath, loaded.ServerId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Don't crash startup over a corrupt state file — log and proceed as if no claim exists.
|
||||
// Operator will see HostAlreadyClaimed on next register and can intervene.
|
||||
_logger.LogError(ex, "Failed to read directory claim file at {Path} — treating as unclaimed", _filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteAtomicAsync(PersistedClaim claim, CancellationToken ct)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_filePath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var tmpPath = _filePath + ".tmp";
|
||||
|
||||
await using (var stream = new FileStream(
|
||||
tmpPath,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
bufferSize: 4096,
|
||||
useAsync: true))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, claim, JsonOptions, ct);
|
||||
await stream.FlushAsync(ct);
|
||||
}
|
||||
|
||||
// 0600 on Unix — the file holds a secret. No-op on Windows.
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(tmpPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to set restrictive permissions on {Path}", tmpPath);
|
||||
}
|
||||
}
|
||||
|
||||
File.Move(tmpPath, _filePath, overwrite: true);
|
||||
}
|
||||
|
||||
private static string ResolveFilePath(IConfiguration configuration)
|
||||
{
|
||||
var configured = configuration["Server:DirectoryClaimPath"];
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
return configured;
|
||||
|
||||
// Co-locate with the SQLite database so a single data-directory backup captures both.
|
||||
var connectionString = configuration.GetConnectionString("DefaultConnection");
|
||||
if (!string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
try
|
||||
{
|
||||
var builder = new SqliteConnectionStringBuilder(connectionString);
|
||||
if (!string.IsNullOrWhiteSpace(builder.DataSource))
|
||||
{
|
||||
var dir = Path.GetDirectoryName(Path.GetFullPath(builder.DataSource));
|
||||
if (!string.IsNullOrWhiteSpace(dir))
|
||||
return Path.Combine(dir, "directory-claim.json");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
return Path.Combine(AppContext.BaseDirectory, "directory-claim.json");
|
||||
}
|
||||
|
||||
private sealed record PersistedClaim(string? ClaimToken, Guid? ServerId);
|
||||
}
|
||||
|
||||
public sealed record RegistrationStatus(
|
||||
bool IsRegistered,
|
||||
Guid? ServerId,
|
||||
DateTimeOffset? LastRegisteredAt,
|
||||
string? LastError,
|
||||
string[]? ConflictingHosts);
|
||||
@@ -10,10 +10,18 @@ public class PresenceTracker
|
||||
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the distinct online user count changes (multi-connection users only fire once).
|
||||
/// </summary>
|
||||
public event Action<int>? UserCountChanged;
|
||||
|
||||
public void UserConnected(string connectionId, Guid userId, string username)
|
||||
{
|
||||
_connections[connectionId] = (userId, username);
|
||||
|
||||
bool userIsNew;
|
||||
int newCount;
|
||||
|
||||
// Lock is required: ConcurrentDictionary only protects its own slots, not the HashSet values inside.
|
||||
// It also makes the TryGetValue → add sequence atomic to prevent race conditions.
|
||||
lock (_lock)
|
||||
@@ -22,10 +30,19 @@ public class PresenceTracker
|
||||
{
|
||||
connections = new HashSet<string>();
|
||||
_userConnections[username] = connections;
|
||||
userIsNew = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
userIsNew = false;
|
||||
}
|
||||
|
||||
connections.Add(connectionId);
|
||||
newCount = _userConnections.Count;
|
||||
}
|
||||
|
||||
if (userIsNew)
|
||||
UserCountChanged?.Invoke(newCount);
|
||||
}
|
||||
|
||||
public string? UserDisconnected(string connectionId)
|
||||
@@ -34,6 +51,8 @@ public class PresenceTracker
|
||||
return null;
|
||||
|
||||
var username = userInfo.username;
|
||||
bool userRemoved = false;
|
||||
int newCount;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
@@ -45,10 +64,16 @@ public class PresenceTracker
|
||||
{
|
||||
_userConnections.TryRemove(username, out _);
|
||||
_userChannels.TryRemove(username, out _);
|
||||
userRemoved = true;
|
||||
}
|
||||
}
|
||||
|
||||
newCount = _userConnections.Count;
|
||||
}
|
||||
|
||||
if (userRemoved)
|
||||
UserCountChanged?.Invoke(newCount);
|
||||
|
||||
return username;
|
||||
}
|
||||
|
||||
@@ -160,20 +185,29 @@ public class PresenceTracker
|
||||
/// </summary>
|
||||
public (List<string> ConnectionIds, List<string> Channels) ForceRemoveUser(string username)
|
||||
{
|
||||
bool userRemoved;
|
||||
int newCount;
|
||||
List<string> channels;
|
||||
List<string> connectionIds;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var channels = _userChannels.TryRemove(username, out var ch)
|
||||
channels = _userChannels.TryRemove(username, out var ch)
|
||||
? ch.ToList()
|
||||
: [];
|
||||
|
||||
var connectionIds = _userConnections.TryRemove(username, out var conns)
|
||||
? conns.ToList()
|
||||
: [];
|
||||
userRemoved = _userConnections.TryRemove(username, out var conns);
|
||||
connectionIds = userRemoved ? conns!.ToList() : [];
|
||||
|
||||
foreach (var connId in connectionIds)
|
||||
_connections.TryRemove(connId, out _);
|
||||
|
||||
return (connectionIds, channels);
|
||||
newCount = _userConnections.Count;
|
||||
}
|
||||
|
||||
if (userRemoved)
|
||||
UserCountChanged?.Invoke(newCount);
|
||||
|
||||
return (connectionIds, channels);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
namespace EchoHub.Server.Services;
|
||||
@@ -5,24 +8,36 @@ namespace EchoHub.Server.Services;
|
||||
public sealed class ServerDirectoryService : BackgroundService
|
||||
{
|
||||
private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers";
|
||||
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan ReconnectBaseDelay = TimeSpan.FromSeconds(2);
|
||||
private static readonly TimeSpan ReconnectMaxDelay = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan UserCountMinInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly PresenceTracker _presenceTracker;
|
||||
private readonly DirectoryClaimStore _claimStore;
|
||||
private readonly ILogger<ServerDirectoryService> _logger;
|
||||
|
||||
// Single-slot, latest-wins channel coalesces bursts of presence changes into one update.
|
||||
private readonly Channel<int> _userCountUpdates = Channel.CreateBounded<int>(
|
||||
new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest });
|
||||
|
||||
private HubConnection? _connection;
|
||||
private int _lastReportedUserCount = -1;
|
||||
|
||||
// Set true when a registration error code arrives (HostAlreadyClaimed/InvalidToken/HostConflict).
|
||||
// Once set, we stop attempting register on this connection AND on any reconnects, since the
|
||||
// hub won't kick us off and we'd otherwise tight-loop. Operator must restart after fixing config.
|
||||
private bool _registrationPermanentlyFailed;
|
||||
|
||||
public ServerDirectoryService(
|
||||
IConfiguration configuration,
|
||||
PresenceTracker presenceTracker,
|
||||
DirectoryClaimStore claimStore,
|
||||
ILogger<ServerDirectoryService> logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_presenceTracker = presenceTracker;
|
||||
_claimStore = claimStore;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -38,19 +53,44 @@ public sealed class ServerDirectoryService : BackgroundService
|
||||
return;
|
||||
}
|
||||
|
||||
var host = _configuration["Server:PublicHost"];
|
||||
var hosts = _configuration.GetSection("Server:PublicHosts").Get<string[]>()
|
||||
?.Where(h => !string.IsNullOrWhiteSpace(h))
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
if (hosts.Length == 0)
|
||||
{
|
||||
_logger.LogWarning("PublicServer is enabled but Server:PublicHost is not set — skipping directory registration");
|
||||
_logger.LogWarning("PublicServer is enabled but Server:PublicHosts is empty — skipping directory registration");
|
||||
return;
|
||||
}
|
||||
|
||||
var serverName = _configuration["Server:Name"] ?? "EchoHub Server";
|
||||
var description = _configuration["Server:Description"];
|
||||
var tags = _configuration.GetSection("Server:Tags").Get<string[]>()
|
||||
?.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
var version = ResolveVersion();
|
||||
|
||||
_logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Host})", serverName, host);
|
||||
_logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Hosts})", serverName, string.Join(", ", hosts));
|
||||
|
||||
_presenceTracker.UserCountChanged += OnUserCountChanged;
|
||||
try
|
||||
{
|
||||
await RunConnectionLoopAsync(serverName, description, hosts, version, tags, stoppingToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_presenceTracker.UserCountChanged -= OnUserCountChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunConnectionLoopAsync(
|
||||
string serverName,
|
||||
string? description,
|
||||
string[] hosts,
|
||||
string version,
|
||||
string[] tags,
|
||||
CancellationToken stoppingToken)
|
||||
{
|
||||
// Outer loop: rebuilds the connection if automatic reconnect permanently fails
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -76,9 +116,15 @@ public sealed class ServerDirectoryService : BackgroundService
|
||||
|
||||
connection.Reconnected += async _ =>
|
||||
{
|
||||
if (_registrationPermanentlyFailed)
|
||||
{
|
||||
_logger.LogWarning("Reconnected to directory but previous registration permanently failed — not re-registering. Restart the server after fixing configuration.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Reconnected to directory — re-registering server");
|
||||
_lastReportedUserCount = -1;
|
||||
await RegisterAsync(serverName, description, host);
|
||||
await RegisterAsync(serverName, description, hosts, version, tags);
|
||||
};
|
||||
|
||||
connection.Closed += ex =>
|
||||
@@ -97,10 +143,10 @@ public sealed class ServerDirectoryService : BackgroundService
|
||||
return;
|
||||
|
||||
_logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl);
|
||||
await RegisterAsync(serverName, description, host);
|
||||
await RegisterAsync(serverName, description, hosts, version, tags);
|
||||
|
||||
// Poll user count until the connection is permanently closed or cancellation
|
||||
await PollUserCountAsync(connection, connectionPermanentlyClosed.Task, stoppingToken);
|
||||
// Push user-count updates as PresenceTracker raises events, until the connection closes or cancellation
|
||||
await ProcessUserCountUpdatesAsync(connection, connectionPermanentlyClosed.Task, stoppingToken);
|
||||
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
return;
|
||||
@@ -151,33 +197,62 @@ public sealed class ServerDirectoryService : BackgroundService
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task PollUserCountAsync(HubConnection connection, Task connectionClosed, CancellationToken ct)
|
||||
private void OnUserCountChanged(int newCount)
|
||||
{
|
||||
// Single-slot channel: latest write wins, so a burst of presence changes coalesces.
|
||||
_userCountUpdates.Writer.TryWrite(newCount);
|
||||
}
|
||||
|
||||
private async Task ProcessUserCountUpdatesAsync(HubConnection connection, Task connectionClosed, CancellationToken ct)
|
||||
{
|
||||
var lastSentAt = DateTimeOffset.MinValue;
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var delayTask = Task.Delay(UpdateInterval, ct);
|
||||
var completed = await Task.WhenAny(delayTask, connectionClosed);
|
||||
var waitTask = _userCountUpdates.Reader.WaitToReadAsync(ct).AsTask();
|
||||
var completed = await Task.WhenAny(waitTask, connectionClosed);
|
||||
|
||||
if (completed == connectionClosed)
|
||||
return;
|
||||
|
||||
// Observe the delay task (may throw if cancelled)
|
||||
try { await delayTask; }
|
||||
bool hasUpdate;
|
||||
try { hasUpdate = await waitTask; }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
if (!hasUpdate)
|
||||
return;
|
||||
|
||||
if (!_userCountUpdates.Reader.TryRead(out var count))
|
||||
continue;
|
||||
|
||||
// Throttle: enforce a minimum interval between sends. While we wait, drain newer
|
||||
// values so the eventual send carries the latest count, not a stale snapshot.
|
||||
var elapsed = DateTimeOffset.UtcNow - lastSentAt;
|
||||
if (elapsed < UserCountMinInterval)
|
||||
{
|
||||
try { await Task.Delay(UserCountMinInterval - elapsed, ct); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
while (_userCountUpdates.Reader.TryRead(out var newer))
|
||||
count = newer;
|
||||
}
|
||||
|
||||
if (count == _lastReportedUserCount)
|
||||
continue;
|
||||
|
||||
if (connection.State != HubConnectionState.Connected)
|
||||
continue;
|
||||
|
||||
var currentCount = _presenceTracker.GetOnlineUserCount();
|
||||
|
||||
if (currentCount == _lastReportedUserCount)
|
||||
// No point pushing presence to a row we don't own (or never claimed)
|
||||
if (_registrationPermanentlyFailed || !_claimStore.Status.IsRegistered)
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
await connection.InvokeAsync("UpdateUserCount", currentCount, ct);
|
||||
_lastReportedUserCount = currentCount;
|
||||
_logger.LogDebug("Updated directory user count to {Count}", currentCount);
|
||||
await connection.InvokeAsync("UpdateUserCount", count, ct);
|
||||
_lastReportedUserCount = count;
|
||||
lastSentAt = DateTimeOffset.UtcNow;
|
||||
_logger.LogDebug("Updated directory user count to {Count}", count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -192,18 +267,22 @@ public sealed class ServerDirectoryService : BackgroundService
|
||||
return delay > ReconnectMaxDelay ? ReconnectMaxDelay : delay;
|
||||
}
|
||||
|
||||
private async Task RegisterAsync(string name, string? description, string host)
|
||||
private async Task RegisterAsync(string name, string? description, string[] hosts, string version, string[] tags)
|
||||
{
|
||||
if (_connection?.State != HubConnectionState.Connected)
|
||||
return;
|
||||
|
||||
if (_registrationPermanentlyFailed)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var userCount = _presenceTracker.GetOnlineUserCount();
|
||||
var dto = new RegisterServerDto(name, description, host, userCount);
|
||||
await _connection.InvokeAsync("RegisterServer", dto);
|
||||
_lastReportedUserCount = userCount;
|
||||
_logger.LogInformation("Registered with directory as {Name} at {Host}", name, host);
|
||||
// ClaimToken is null on first-ever registration; otherwise the token persisted on first claim.
|
||||
var dto = new RegisterServerDto(name, description, hosts, userCount, version, tags, _claimStore.ClaimToken);
|
||||
|
||||
var envelope = await _connection.InvokeAsync<Response<RegisterServerResult>>("RegisterServer", dto);
|
||||
await HandleRegistrationResponseAsync(envelope, userCount, name, hosts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -211,6 +290,153 @@ public sealed class ServerDirectoryService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRegistrationResponseAsync(Response<RegisterServerResult>? envelope, int userCount, string name, string[] hosts)
|
||||
{
|
||||
if (envelope is null)
|
||||
{
|
||||
_registrationPermanentlyFailed = true;
|
||||
_logger.LogError("Directory returned a null envelope for RegisterServer — treating as malformed. Server will not retry until restarted.");
|
||||
_claimStore.SetFailure(DirectoryRegistrationErrors.MalformedResponse, null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pin protocol version. Spec: fail hard on mismatch — bumps are coordinated.
|
||||
if (!string.Equals(envelope.Version, DirectoryProtocol.Version, StringComparison.Ordinal))
|
||||
{
|
||||
_registrationPermanentlyFailed = true;
|
||||
_logger.LogError(
|
||||
"Directory protocol version mismatch: client expects {Expected}, hub returned {Actual}. " +
|
||||
"Refusing to operate. Coordinate a deploy that aligns both sides.",
|
||||
DirectoryProtocol.Version, envelope.Version ?? "(null)");
|
||||
_claimStore.SetFailure(DirectoryRegistrationErrors.ProtocolVersionMismatch, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!envelope.IsSuccess)
|
||||
{
|
||||
await HandleRegistrationErrorAsync(envelope.Errors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (envelope.Data is null)
|
||||
{
|
||||
_registrationPermanentlyFailed = true;
|
||||
_logger.LogError("Directory returned IsSuccess=true but Data was null — treating as malformed. Server will not retry until restarted.");
|
||||
_claimStore.SetFailure(DirectoryRegistrationErrors.MalformedResponse, null);
|
||||
return;
|
||||
}
|
||||
|
||||
var data = envelope.Data;
|
||||
var serverId = data.ServerId;
|
||||
|
||||
// Persist a freshly-issued claim token *before* anything else acks success — durability guarantee for first claim.
|
||||
if (!string.IsNullOrEmpty(data.ClaimToken))
|
||||
{
|
||||
await _claimStore.SaveClaimAsync(data.ClaimToken, serverId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No fresh token (re-register): just keep the persisted ServerId in sync defensively.
|
||||
await _claimStore.UpdateServerIdAsync(serverId);
|
||||
}
|
||||
|
||||
_claimStore.SetSuccess(serverId);
|
||||
_lastReportedUserCount = userCount;
|
||||
_logger.LogInformation("Registered with directory as {Name} at {Hosts} (ServerId {ServerId})", name, string.Join(", ", hosts), serverId);
|
||||
}
|
||||
|
||||
private Task HandleRegistrationErrorAsync(ErrorDetail[]? errors)
|
||||
{
|
||||
_registrationPermanentlyFailed = true;
|
||||
|
||||
var firstError = errors is { Length: > 0 } ? errors[0] : null;
|
||||
var code = firstError?.Code ?? "UnknownError";
|
||||
var conflictingHosts = ExtractConflictingHosts(firstError);
|
||||
var conflicts = conflictingHosts is { Length: > 0 }
|
||||
? string.Join(", ", conflictingHosts)
|
||||
: "(none reported)";
|
||||
|
||||
switch (code)
|
||||
{
|
||||
case DirectoryRegistrationErrors.HostAlreadyClaimed:
|
||||
_logger.LogError(
|
||||
"Directory rejected registration: host(s) already claimed by another server: {ConflictingHosts}. " +
|
||||
"Change Server:PublicHosts or contact the directory admin to release the claim. Server will not retry until restarted.",
|
||||
conflicts);
|
||||
break;
|
||||
|
||||
case DirectoryRegistrationErrors.InvalidToken:
|
||||
_logger.LogError(
|
||||
"Directory rejected registration: persisted claim token is invalid (likely deleted by admin or stale). " +
|
||||
"Delete the claim file ({ClaimFile}) to claim fresh, or contact the directory admin. Server will not retry until restarted.",
|
||||
_claimStore.FilePath);
|
||||
break;
|
||||
|
||||
case DirectoryRegistrationErrors.HostConflict:
|
||||
_logger.LogError(
|
||||
"Directory rejected registration: token is valid but newly-advertised host(s) conflict with another server's row: {ConflictingHosts}. " +
|
||||
"Remove the conflicting entries from Server:PublicHosts. Server will not retry until restarted.",
|
||||
conflicts);
|
||||
break;
|
||||
|
||||
case DirectoryRegistrationErrors.InvalidInput:
|
||||
_logger.LogError(
|
||||
"Directory rejected registration as InvalidInput ({Message}). Likely a client/hub contract drift — check Server config. Server will not retry until restarted.",
|
||||
firstError?.Message ?? "(no message)");
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.LogError(
|
||||
"Directory rejected registration with unknown error code: {Error} ({Message}). Server will not retry until restarted.",
|
||||
code, firstError?.Message ?? "(no message)");
|
||||
break;
|
||||
}
|
||||
|
||||
_claimStore.SetFailure(code, conflictingHosts);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pulls <c>ConflictingHosts</c> out of an error's loosely-typed <c>Data</c> payload.
|
||||
/// Tolerates both PascalCase and camelCase keys since SignalR's wire casing depends on
|
||||
/// the hub's serializer config and the field is typed <c>object?</c>.
|
||||
/// </summary>
|
||||
private static string[]? ExtractConflictingHosts(ErrorDetail? error)
|
||||
{
|
||||
if (error?.Data is not JsonElement element || element.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
|
||||
if (!element.TryGetProperty("ConflictingHosts", out var hostsProp)
|
||||
&& !element.TryGetProperty("conflictingHosts", out hostsProp))
|
||||
return null;
|
||||
|
||||
if (hostsProp.ValueKind != JsonValueKind.Array)
|
||||
return null;
|
||||
|
||||
List<string> hosts = [];
|
||||
foreach (var item in hostsProp.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String && item.GetString() is { } s)
|
||||
hosts.Add(s);
|
||||
}
|
||||
|
||||
return hosts.Count == 0 ? null : hosts.ToArray();
|
||||
}
|
||||
|
||||
private static string ResolveVersion()
|
||||
{
|
||||
var assembly = typeof(ServerDirectoryService).Assembly;
|
||||
var informational = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
|
||||
if (!string.IsNullOrWhiteSpace(informational))
|
||||
{
|
||||
// Strip git SHA suffix that SourceLink appends (e.g. "0.2.10+abc123")
|
||||
var plus = informational.IndexOf('+');
|
||||
return plus >= 0 ? informational[..plus] : informational;
|
||||
}
|
||||
|
||||
return assembly.GetName().Version?.ToString() ?? "0.0.0";
|
||||
}
|
||||
|
||||
private static async Task DisposeConnectionAsync(HubConnection connection)
|
||||
{
|
||||
try
|
||||
@@ -243,4 +469,44 @@ public sealed class ServerDirectoryService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
internal record RegisterServerDto(string Name, string? Description, string Host, int UserCount);
|
||||
internal record RegisterServerDto(
|
||||
string Name,
|
||||
string? Description,
|
||||
string[] Hosts,
|
||||
int UserCount,
|
||||
string Version,
|
||||
string[] Tags,
|
||||
string? ClaimToken);
|
||||
|
||||
internal record RegisterServerResult(Guid ServerId, string? ClaimToken);
|
||||
|
||||
/// <summary>
|
||||
/// Envelope wrapping every directory hub response. Mirrors the EchoHubSpace contract.
|
||||
/// </summary>
|
||||
internal record Response<T>(bool IsSuccess, T? Data, ErrorDetail[]? Errors, string? Version);
|
||||
|
||||
/// <summary>
|
||||
/// Error entry inside a <see cref="Response{T}"/>. <c>Data</c> is loosely-typed because the
|
||||
/// payload shape varies by error code (e.g. <c>{ ConflictingHosts: string[] }</c> for host errors).
|
||||
/// </summary>
|
||||
internal record ErrorDetail(string Code, string? Message, JsonElement? Data);
|
||||
|
||||
internal static class DirectoryProtocol
|
||||
{
|
||||
/// <summary>
|
||||
/// Pinned envelope protocol version. Bumps are coordinated across both repos.
|
||||
/// </summary>
|
||||
public const string Version = "1.0";
|
||||
}
|
||||
|
||||
internal static class DirectoryRegistrationErrors
|
||||
{
|
||||
public const string InvalidInput = "InvalidInput";
|
||||
public const string InvalidToken = "InvalidToken";
|
||||
public const string HostAlreadyClaimed = "HostAlreadyClaimed";
|
||||
public const string HostConflict = "HostConflict";
|
||||
|
||||
// Client-side synthetic codes (never returned by hub, generated locally for status reporting)
|
||||
public const string ProtocolVersionMismatch = "ProtocolVersionMismatch";
|
||||
public const string MalformedResponse = "MalformedResponse";
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"Name": "My EchoHub Server",
|
||||
"Description": "A self-hosted EchoHub chat server",
|
||||
"PublicServer": false,
|
||||
"PublicHost": "",
|
||||
"PublicHosts": [],
|
||||
"Tags": [],
|
||||
"Admins": []
|
||||
},
|
||||
"Storage": {
|
||||
|
||||
@@ -190,7 +190,7 @@ internal sealed class FakeChatService : IChatService
|
||||
return Task.FromResult(SendMessageError);
|
||||
}
|
||||
|
||||
public Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count) =>
|
||||
public Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0) =>
|
||||
Task.FromResult(HistoryToReturn);
|
||||
|
||||
public Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
|
||||
|
||||
Reference in New Issue
Block a user