commit 389ab657254aa68f897499fa5aa8c3dac8b46181
parent 733fc6cb51a0e46c9b7308b15245e27533a5315d
Author: Silas Brack <silasbrack@gmail.com>
Date: Fri, 3 Apr 2026 17:36:03 +0300
add-rust (#1)
Reviewed-on: https://git.fnarglebeast.com/silas/qt-chat-app/pulls/1
Co-authored-by: Silas Brack <silasbrack@gmail.com>
Co-committed-by: Silas Brack <silasbrack@gmail.com>
Diffstat:
46 files changed, 4934 insertions(+), 1669 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -1,3 +1,3 @@
result
tags
-
+target
diff --git a/CLAUDE.md b/CLAUDE.md
@@ -0,0 +1,28 @@
+# Beegram Development Guidelines
+
+## Philosophy
+
+- **Explicit over clever** - No magic helpers, readable top-to-bottom
+- **Pure functions** - Isolate logic from I/O
+- **Linear flow** - Avoid callbacks where possible
+- **Minimize shared state** - Pass values explicitly
+- **Minimize indirection** - Don't hide logic behind abstractions
+
+## Architecture
+
+```
+Frontend (QML)
+ │
+ ▼
+network_manager.rs ← Owns state, dispatches to Qt
+ │
+ ▼
+http_client.rs ← Pure async functions (stateless)
+sse_client.rs ← Channel-based events
+keychain.rs ← Credential storage
+types.rs ← Shared types
+```
+
+- `http_client.rs`: Pure functions taking `(client, token, params) -> Result`
+- `network_manager.rs`: Owns state, passes it explicitly to pure functions
+- Each frontend adapter owns its own state, calls shared pure functions
diff --git a/CMakeLists.txt b/CMakeLists.txt
@@ -1,35 +1,78 @@
-cmake_minimum_required(VERSION 3.5.0)
+cmake_minimum_required(VERSION 3.16)
project(demo VERSION 1.0.0 LANGUAGES CXX)
-set(CMAKE_CXX_STANDARD 11)
+set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
-if(CMAKE_VERSION VERSION_LESS "3.7.0")
- set(CMAKE_INCLUDE_CURRENT_DIR ON)
-endif()
-
-# Add here any library you need
+# Find Qt components
find_package(Qt6 COMPONENTS Widgets Qml Quick Network REQUIRED)
-find_package(Qt6Keychain REQUIRED)
-# Add the resource file as it *must* be compiled (also possible with
-# qmake or manually with rcc)
-# https://doc.qt.io/qt-5/resources.html
-# https://doc.qt.io/qt-5/qtcore-cmake-qt5-add-resources.html
-set(SOURCES main.cpp networkmanager.cpp) # A variable is needed as I guess it will add more sources inside
-qt_add_resources(SOURCES qml.qrc)
+# Add the resource file
+qt_add_resources(QRC_SOURCES qml.qrc)
+
+# CXX-Qt CMake integration
+include(FetchContent)
+FetchContent_Declare(
+ Corrosion
+ GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git
+ GIT_TAG v0.5
+)
+FetchContent_MakeAvailable(Corrosion)
+
+# Find CxxQt package (provided by cxx-qt-build)
+find_package(CxxQt QUIET)
+if(NOT CxxQt_FOUND)
+ # Fall back to FetchContent if not found
+ FetchContent_Declare(
+ CxxQtCMake
+ GIT_REPOSITORY https://github.com/KDAB/cxx-qt-cmake.git
+ GIT_TAG main
+ )
+ FetchContent_MakeAvailable(CxxQtCMake)
+endif()
+
+# Import the Rust crate
+cxx_qt_import_crate(
+ MANIFEST_PATH rust/Cargo.toml
+ CRATES beegram_network
+ QT_MODULES Qt6::Core Qt6::Network
+)
+# Create executable
add_executable(demo
- ${SOURCES}
+ main.cpp
+ ${QRC_SOURCES}
+)
+
+# Ensure demo depends on the Rust crate being built first
+add_dependencies(demo beegram_network)
+
+target_link_libraries(demo
+ Qt6::Widgets
+ Qt6::Qml
+ Qt6::Quick
+ Qt6::Network
+ beegram_network
)
-# Add here any library you need
-target_link_libraries(demo Qt6::Widgets Qt6::Qml Qt6::Quick Qt6::Network qt6keychain)
+# macOS: Link Security and SystemConfiguration frameworks for Rust crate
+if(APPLE)
+ target_link_libraries(demo
+ "-framework Security"
+ "-framework SystemConfiguration"
+ "-framework CoreFoundation"
+ )
+endif()
+
+# Add CXX-Qt generated include directories
+target_include_directories(demo PRIVATE
+ ${CMAKE_BINARY_DIR}/cargo/build/${Rust_CARGO_TARGET}/cxxbridge
+)
# Install the binary
install(TARGETS demo DESTINATION bin)
diff --git a/Makefile b/Makefile
@@ -0,0 +1,45 @@
+TRAIL := trail --data-dir traildepot
+
+# JSON schemas from TrailBase
+rust/schemas/message_read.json:
+ $(TRAIL) schema message --mode select > $@
+
+rust/schemas/message_insert.json:
+ $(TRAIL) schema message --mode insert > $@
+
+rust/schemas/chat_read.json:
+ $(TRAIL) schema chat --mode select > $@
+
+rust/schemas/chat_insert.json:
+ $(TRAIL) schema chat --mode insert > $@
+
+rust/schemas/user_chats_read.json:
+ $(TRAIL) schema user_chats --mode select > $@
+
+rust/schemas/chat_participation_insert.json:
+ $(TRAIL) schema chat_participation --mode insert > $@
+
+# Rust types from schemas
+SCHEMAS := $(wildcard rust/schemas/*.json)
+
+rust/src/generated/mod.rs: $(SCHEMAS)
+ quicktype \
+ --lang rust \
+ --density dense \
+ --visibility public \
+ --derive-debug \
+ --derive-clone \
+ $(addprefix -s schema ,$(SCHEMAS)) \
+ -o $@
+
+# Convenience targets
+.PHONY: schemas types clean
+
+schemas: rust/schemas/message_read.json rust/schemas/message_insert.json \
+ rust/schemas/chat_read.json rust/schemas/chat_insert.json \
+ rust/schemas/user_chats_read.json rust/schemas/chat_participation_insert.json
+
+types: rust/src/generated/mod.rs
+
+clean:
+ rm -f rust/schemas/*.json rust/src/generated/mod.rs
diff --git a/derivation.nix b/derivation.nix
@@ -0,0 +1,92 @@
+{ lib
+, stdenv
+, cmake
+, qt6
+, wrapQtAppsHook
+, rust
+, rustPlatform
+, pkg-config
+, git
+, cacert
+, Security ? null
+, SystemConfiguration ? null
+, libsecret ? null
+, dbus ? null
+, llvmPackages ? null
+}:
+
+let
+ pname = "Beegram";
+ version = "1.0.0";
+
+ buildInputs = [
+ qt6.qtbase
+ qt6.qtdeclarative
+ qt6.qt5compat
+ ] ++ lib.optionals stdenv.isDarwin [
+ Security
+ SystemConfiguration
+ ] ++ lib.optionals stdenv.isLinux [
+ libsecret
+ dbus
+ ];
+
+ nativeBuildInputs = [
+ cmake
+ wrapQtAppsHook
+ rust
+ pkg-config
+ git
+ cacert
+ ];
+
+in
+stdenv.mkDerivation rec {
+ inherit pname version;
+
+ src = ./.;
+
+ inherit nativeBuildInputs buildInputs;
+
+ env = lib.optionalAttrs (llvmPackages != null) {
+ LIBCLANG_PATH = "${llvmPackages.libclang.lib}/lib";
+ };
+
+ preConfigure = ''
+ export CARGO_HOME=$TMPDIR/cargo
+ mkdir -p $CARGO_HOME
+ '';
+
+ postInstall = lib.optionalString stdenv.isDarwin ''
+ mkdir -p $out/Applications/${pname}.app/Contents/{MacOS,Resources}
+ mv $out/bin/demo $out/Applications/${pname}.app/Contents/MacOS/
+
+ cat > $out/Applications/${pname}.app/Contents/Info.plist << EOF
+ <?xml version="1.0" encoding="UTF-8"?>
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+ <plist version="1.0">
+ <dict>
+ <key>CFBundleExecutable</key>
+ <string>demo</string>
+ <key>CFBundleIdentifier</key>
+ <string>com.example.telegram-clone</string>
+ <key>CFBundleName</key>
+ <string>${pname}</string>
+ <key>CFBundleVersion</key>
+ <string>${version}</string>
+ <key>CFBundlePackageType</key>
+ <string>APPL</string>
+ </dict>
+ </plist>
+ EOF
+
+ rmdir $out/bin || true
+ '';
+
+ meta = with lib; {
+ description = "Beegram, a messaging application built using Qt";
+ license = licenses.mit;
+ maintainers = [ ];
+ platforms = platforms.unix;
+ };
+}
diff --git a/derivation_demo.nix b/derivation_demo.nix
@@ -1,57 +0,0 @@
-{ lib
-, stdenv
-, cmake
-, qt6
-, wrapQtAppsHook
-, qtkeychain
-}:
-stdenv.mkDerivation rec {
- pname = "Beegram";
- version = "1.0.0";
-
- src = ./.;
-
- nativeBuildInputs = [ cmake wrapQtAppsHook ];
-
- buildInputs = [
- qt6.qtbase
- qt6.qtdeclarative
- qt6.qt5compat
- qtkeychain
- ];
-
- # macOS-specific: Create .app bundle
- postInstall = lib.optionalString stdenv.isDarwin ''
- mkdir -p $out/Applications/${pname}.app/Contents/{MacOS,Resources}
- mv $out/bin/demo $out/Applications/${pname}.app/Contents/MacOS/
-
- # Create Info.plist
- cat > $out/Applications/${pname}.app/Contents/Info.plist << EOF
- <?xml version="1.0" encoding="UTF-8"?>
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
- <plist version="1.0">
- <dict>
- <key>CFBundleExecutable</key>
- <string>demo</string>
- <key>CFBundleIdentifier</key>
- <string>com.example.telegram-clone</string>
- <key>CFBundleName</key>
- <string>${pname}</string>
- <key>CFBundleVersion</key>
- <string>${version}</string>
- <key>CFBundlePackageType</key>
- <string>APPL</string>
- </dict>
- </plist>
- EOF
-
- # Remove empty bin directory
- rmdir $out/bin || true
- '';
-
- meta = with lib; {
- description = "Beegram, a messaging application built using Qt.";
- license = licenses.mit;
- maintainers = [ ];
- };
-}
diff --git a/flake.lock b/flake.lock
@@ -2,23 +2,58 @@
"nodes": {
"nixpkgs": {
"locked": {
- "lastModified": 1769089682,
- "narHash": "sha256-9yA/LIuAVQq0lXelrZPjLuLVuZdm03p8tfmHhnDIkms=",
+ "lastModified": 1751274312,
+ "narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "078d69f03934859a181e81ba987c2bb033eebfc5",
+ "rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
"type": "github"
},
"original": {
"owner": "NixOS",
- "ref": "nixos-25.11",
+ "ref": "nixos-24.11",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "nixpkgs_2": {
+ "locked": {
+ "lastModified": 1744536153,
+ "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
- "nixpkgs": "nixpkgs"
+ "nixpkgs": "nixpkgs",
+ "rust-overlay": "rust-overlay"
+ }
+ },
+ "rust-overlay": {
+ "inputs": {
+ "nixpkgs": "nixpkgs_2"
+ },
+ "locked": {
+ "lastModified": 1770347142,
+ "narHash": "sha256-uz+ZSqXpXEPtdRPYwvgsum/CfNq7AUQ/0gZHqTigiPM=",
+ "owner": "oxalica",
+ "repo": "rust-overlay",
+ "rev": "2859683cd9ef7858d324c5399b0d8d6652bf4044",
+ "type": "github"
+ },
+ "original": {
+ "owner": "oxalica",
+ "repo": "rust-overlay",
+ "type": "github"
}
}
},
diff --git a/flake.nix b/flake.nix
@@ -2,42 +2,125 @@
description = "Beegram, a messaging application built using Qt.";
inputs = {
- # Pin to a version compatible with macOS 13
- nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
+ rust-overlay.url = "github:oxalica/rust-overlay";
};
- outputs = { self, nixpkgs }:
+ outputs = { self, nixpkgs, rust-overlay }:
let
- # Support both Intel and Apple Silicon Macs
systems = [ "x86_64-darwin" "aarch64-darwin" "x86_64-linux" ];
forAllSystems = nixpkgs.lib.genAttrs systems;
in
{
packages = forAllSystems (system:
let
- pkgs = nixpkgs.legacyPackages.${system};
+ pkgs = import nixpkgs {
+ inherit system;
+ overlays = [ rust-overlay.overlays.default ];
+ };
+ in
+ {
+ default = pkgs.qt6Packages.callPackage ./derivation.nix ({
+ rust = pkgs.rust-bin.stable.latest.default;
+ rustPlatform = pkgs.rustPlatform;
+ pkg-config = pkgs.pkg-config;
+ llvmPackages = pkgs.llvmPackages;
+ git = pkgs.git;
+ cacert = pkgs.cacert;
+ } // pkgs.lib.optionalAttrs pkgs.stdenv.isDarwin {
+ Security = pkgs.darwin.apple_sdk.frameworks.Security;
+ SystemConfiguration = pkgs.darwin.apple_sdk.frameworks.SystemConfiguration;
+ } // pkgs.lib.optionalAttrs pkgs.stdenv.isLinux {
+ libsecret = pkgs.libsecret;
+ dbus = pkgs.dbus;
+ });
+
+ # TrailBase backend server
+ trailbase = pkgs.callPackage ./trailbase.nix { };
+ }
+ );
+
+ checks = forAllSystems (system:
+ let
+ pkgs = import nixpkgs {
+ inherit system;
+ overlays = [ rust-overlay.overlays.default ];
+ };
+ in
+ {
+ # Verify generated Rust types match JSON schemas
+ generated-types = pkgs.runCommand "check-generated-types" {
+ buildInputs = [ pkgs.quicktype pkgs.gnumake pkgs.diffutils ];
+ src = self;
+ } ''
+ cp -r $src $TMPDIR/src
+ chmod -R +w $TMPDIR/src
+ cd $TMPDIR/src
+
+ # Regenerate types from schemas
+ make rust/src/generated/mod.rs
+
+ # Compare with committed file
+ if ! diff -q $src/rust/src/generated/mod.rs rust/src/generated/mod.rs > /dev/null 2>&1; then
+ echo "ERROR: Generated types are out of sync with JSON schemas!"
+ echo "Run 'make types' to regenerate."
+ diff $src/rust/src/generated/mod.rs rust/src/generated/mod.rs || true
+ exit 1
+ fi
+
+ echo "Generated types are in sync with schemas."
+ touch $out
+ '';
+ }
+ );
+
+ apps = forAllSystems (system:
+ let
+ pkgs = import nixpkgs { inherit system; };
+ trailbase = pkgs.callPackage ./trailbase.nix { };
in
{
- default = pkgs.qt6Packages.callPackage ./derivation_demo.nix { };
+ trail = {
+ type = "app";
+ program = toString (pkgs.writeShellScript "trail-run" ''
+ ${trailbase}/bin/trail run
+ '');
+ };
}
);
- # Development shell with Qt tools
devShells = forAllSystems (system:
let
- pkgs = nixpkgs.legacyPackages.${system};
+ pkgs = import nixpkgs {
+ inherit system;
+ overlays = [ rust-overlay.overlays.default ];
+ };
+ rust = pkgs.rust-bin.stable.latest.default.override {
+ extensions = [ "rust-src" "rust-analyzer" ];
+ };
in
{
default = pkgs.mkShell {
buildInputs = with pkgs; [
cmake
+ pkg-config
+ rust
+ quicktype
+ gnumake
qt6.qtbase
qt6.qtdeclarative
qt6.qt5compat
- # qtkeychain
- qt6Packages.wrapQtAppsHook
+ qt6.wrapQtAppsHook
(pkgs.callPackage ./trailbase.nix { })
+ ] ++ lib.optionals stdenv.isDarwin [
+ darwin.apple_sdk.frameworks.Security
+ darwin.apple_sdk.frameworks.SystemConfiguration
+ ] ++ lib.optionals stdenv.isLinux [
+ libsecret
+ dbus
];
+
+ LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
};
}
);
diff --git a/main.cpp b/main.cpp
@@ -1,22 +1,18 @@
-//https://riptutorial.com/qml/example/14475/creating-a-qtquick-window-from-cplusplus
#include <QApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QIcon>
-#include "networkmanager.h"
+#include "beegram_network/src/network_manager.cxxqt.h"
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
-
- // Set application icon
app.setWindowIcon(QIcon(":/logo.png"));
QQmlApplicationEngine engine;
- // Create and register NetworkManager
- NetworkManager networkManager;
- engine.rootContext()->setContextProperty("NetworkManager", &networkManager);
+ auto networkManager = std::make_unique<RustNetworkManager>();
+ engine.rootContext()->setContextProperty("NetworkManager", networkManager.get());
engine.load(QUrl(QStringLiteral("qrc:///main.qml")));
diff --git a/main.qml b/main.qml
@@ -10,138 +10,9 @@ Window {
minimumHeight: 600
title: "Beegram"
- // Global message cache - inline implementation
- QtObject {
- id: globalMessageCache
-
- // Map of chatId -> array of messages
- property var messagesByChat: ({})
-
- function getMessages(chatId) {
- if (!messagesByChat) {
- messagesByChat = {}
- }
- if (!messagesByChat[chatId]) {
- messagesByChat[chatId] = []
- }
- return messagesByChat[chatId]
- }
-
- function addMessage(chatId, messageData) {
- if (!messagesByChat[chatId]) {
- messagesByChat[chatId] = []
- }
-
- var messages = messagesByChat[chatId]
- for (var i = 0; i < messages.length; i++) {
- if (messages[i].id === messageData.id) {
- return false
- }
- }
-
- messages.push(messageData)
- messagesByChat[chatId] = messages
- messageAdded(chatId, messageData)
- return true
- }
-
- function setMessages(chatId, messagesArray) {
- messagesByChat[chatId] = messagesArray.slice()
- }
-
- function prependMessages(chatId, messagesArray) {
- if (!messagesByChat[chatId]) {
- messagesByChat[chatId] = []
- }
- messagesByChat[chatId] = messagesArray.concat(messagesByChat[chatId])
- }
-
- function clearMessages(chatId) {
- messagesByChat[chatId] = []
- }
-
- signal messageAdded(int chatId, var messageData)
- signal messagesFetchedForChat(int chatId, string cursor)
- }
-
- // Listen for SSE messages and route to cache
- Connections {
- target: NetworkManager
- function onMessageReceived(messageData) {
- var chatId = messageData.chat_id
- if (chatId === undefined || chatId < 0) {
- return
- }
-
- globalMessageCache.addMessage(chatId, messageData)
- }
-
- // Handle fetched messages globally to prevent duplication
- function onMessagesFetched(messages, cursor) {
- if (messages.length === 0) {
- return
- }
-
- // Determine which chat these messages belong to
- var chatId = messages[0].chat_id
-
- // Filter and transform messages for this chat only
- var transformedMessages = []
- for (var i = 0; i < messages.length; i++) {
- if (messages[i].chat_id !== chatId) {
- continue
- }
-
- transformedMessages.push({
- id: messages[i].id,
- sender_id: messages[i].sender_id,
- content: messages[i].content,
- sent_at: messages[i].sent_at,
- chat_id: messages[i].chat_id,
- isRead: true
- })
- }
-
- // Check if this is initial load or pagination
- var existingMessages = globalMessageCache.getMessages(chatId)
- if (existingMessages.length === 0) {
- // Initial load: reverse and set
- transformedMessages.reverse()
- globalMessageCache.setMessages(chatId, transformedMessages)
- } else {
- // Pagination: check for duplicates before prepending
- var newMessages = []
- for (var i = 0; i < transformedMessages.length; i++) {
- var isDuplicate = false
- for (var j = 0; j < existingMessages.length; j++) {
- if (transformedMessages[i].id === existingMessages[j].id) {
- isDuplicate = true
- break
- }
- }
- if (!isDuplicate) {
- newMessages.push(transformedMessages[i])
- }
- }
-
- if (newMessages.length > 0) {
- newMessages.reverse()
- globalMessageCache.prependMessages(chatId, newMessages)
- }
- }
-
- // Signal that messages were updated
- globalMessageCache.messagesFetchedForChat(chatId, cursor)
- }
- }
-
- // Signal for when fetched messages are processed
- signal messagesFetchedForChat(int chatId, string cursor)
-
// Simple stack-based navigation using Loader
property var navigationStack: []
property var stackView: stackViewItem // Expose for child views
- property var messageCache: globalMessageCache // Expose globally
property bool credentialsChecked: false
Component.onCompleted: {
@@ -154,24 +25,15 @@ Window {
target: NetworkManager
function onAutoLoginSuccess() {
- console.log("Auto-login successful, navigating to chat list")
credentialsChecked = true
stackViewItem.replace("qrc:/qml/views/ChatListView.qml")
}
function onAutoLoginFailed(reason) {
- console.log("Auto-login failed:", reason)
credentialsChecked = true
-
// Show "Get Started" button on splash screen
- console.log("contentLoader.item exists:", contentLoader.item !== null)
- console.log("contentLoader.source:", contentLoader.source)
if (contentLoader.item) {
- console.log("Setting showGetStartedButton to true")
contentLoader.item.showGetStartedButton = true
- console.log("showGetStartedButton is now:", contentLoader.item.showGetStartedButton)
- } else {
- console.log("ERROR: contentLoader.item is null!")
}
}
}
diff --git a/networkmanager.cpp b/networkmanager.cpp
@@ -1,1003 +0,0 @@
-#include "networkmanager.h"
-#include <QNetworkRequest>
-#include <QJsonDocument>
-#include <QJsonObject>
-#include <QJsonArray>
-#include <QUrlQuery>
-#include <QDebug>
-#include <qt6keychain/keychain.h>
-
-NetworkManager::NetworkManager(QObject *parent)
- : QObject(parent)
- , m_manager(new QNetworkAccessManager(this))
- , m_currentUserId("gpJmuOPMT7yUqP3lAAI_hA==")
- , m_sseReply(nullptr)
- , m_currentSseChat(-1)
- , m_reconnectTimer(new QTimer(this))
- , m_reconnectAttempts(0)
- , m_trayIcon(new QSystemTrayIcon(this))
-{
- m_reconnectTimer->setSingleShot(true);
- connect(m_reconnectTimer, &QTimer::timeout, this, &NetworkManager::attemptReconnect);
-
- // Initialize system tray icon
- m_trayIcon->setIcon(QIcon(":/logo.png"));
- m_trayIcon->show();
-}
-
-NetworkManager::~NetworkManager()
-{
- unsubscribeFromChat();
-}
-
-void NetworkManager::login(const QString& email, const QString& password)
-{
- // Create the request
- QNetworkRequest request;
- request.setUrl(QUrl("http://localhost:4000/api/auth/v1/login"));
- request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
-
- // Create JSON body
- QJsonObject jsonObj;
- jsonObj["email"] = email;
- jsonObj["password"] = password;
- QJsonDocument jsonDoc(jsonObj);
- QByteArray jsonData = jsonDoc.toJson();
-
- // Make the POST request
- QNetworkReply* reply = m_manager->post(request, jsonData);
-
- // Connect to the finished signal
- connect(reply, &QNetworkReply::finished, this, &NetworkManager::onLoginFinished);
-}
-
-void NetworkManager::onLoginFinished()
-{
- QNetworkReply* reply = qobject_cast<QNetworkReply*>(sender());
- if (!reply) {
- return;
- }
-
- // Check for network errors
- if (reply->error() != QNetworkReply::NoError) {
- QString errorMsg = reply->errorString();
- qDebug() << "Network error during login:" << errorMsg;
- emit loginFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- // Get HTTP status code
- int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
-
- // Check if status code is success (200-299)
- if (statusCode < 200 || statusCode >= 300) {
- QString errorMsg = QString("Login failed with status code: %1").arg(statusCode);
- qDebug() << errorMsg;
- emit loginFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- // Read response data
- QByteArray responseData = reply->readAll();
-
- // Parse JSON response
- QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData);
- if (jsonDoc.isNull() || !jsonDoc.isObject()) {
- QString errorMsg = "Invalid JSON response from login";
- qDebug() << errorMsg;
- emit loginFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- // Extract auth_token from response
- QJsonObject jsonObj = jsonDoc.object();
- if (!jsonObj.contains("auth_token")) {
- QString errorMsg = "Login response missing auth_token";
- qDebug() << errorMsg;
- emit loginFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- QString authToken = jsonObj["auth_token"].toString();
- if (authToken.isEmpty()) {
- QString errorMsg = "Login response has empty auth_token";
- qDebug() << errorMsg;
- emit loginFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- // Store the auth token
- m_authToken = authToken;
- qDebug() << "Login successful, token stored";
-
- // Save token to keychain
- saveTokenToKeychain(authToken);
-
- emit authenticationChanged();
- emit loginSuccess(authToken);
-
- // Clean up
- reply->deleteLater();
-}
-
-void NetworkManager::postChat(const QString& name)
-{
- // Create the request
- QNetworkRequest request;
- request.setUrl(QUrl("http://localhost:4000/api/records/v1/chat"));
- request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
-
- // Add Authorization header if token exists
- if (!m_authToken.isEmpty()) {
- request.setRawHeader("Authorization", QString("Bearer %1").arg(m_authToken).toUtf8());
- }
-
- QJsonObject jsonObj;
- jsonObj["title"] = name;
- jsonObj["created_by"] = m_currentUserId;
- QJsonDocument jsonDoc(jsonObj);
- QByteArray jsonData = jsonDoc.toJson();
-
- // Make the POST request
- QNetworkReply* reply = m_manager->post(request, jsonData);
-
- // Connect to the finished signal
- connect(reply, &QNetworkReply::finished, this, &NetworkManager::onPostChatFinished);
-}
-
-void NetworkManager::onPostChatFinished()
-{
- QNetworkReply* reply = qobject_cast<QNetworkReply*>(sender());
- if (!reply) {
- return;
- }
-
- // Get HTTP status code
- int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
-
- // Read response data for debugging
- QByteArray responseData = reply->readAll();
-
- // Check for network errors
- if (reply->error() != QNetworkReply::NoError) {
- QString errorMsg = reply->errorString();
- qDebug() << "Network error:" << errorMsg;
- qDebug() << "Response body:" << responseData;
- emit requestFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- // Check if status code is success (200-299)
- if (statusCode < 200 || statusCode >= 300) {
- QString errorMsg = QString("HTTP error: %1").arg(statusCode);
- qDebug() << errorMsg;
- qDebug() << "Response body:" << responseData;
- emit requestFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- // Parse JSON response
- QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData);
- if (jsonDoc.isNull() || !jsonDoc.isObject()) {
- QString errorMsg = "Invalid JSON response";
- qDebug() << errorMsg;
- emit requestFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- // Convert to QVariantMap for QML
- QJsonObject jsonObj = jsonDoc.object();
- QVariantMap chatData = jsonObj.toVariantMap();
-
- // Extract chat ID and create participation entry
- int chatId = chatData.value("id", -1).toInt();
- if (chatId > 0) {
- // Create participation entry and pass chatData for later emission
- createChatParticipation(chatId, chatData);
- } else {
- emit chatCreated(chatData);
- }
-
- // Clean up
- reply->deleteLater();
-}
-
-void NetworkManager::createChatParticipation(int chatId, const QVariantMap& chatData)
-{
- // Create the request
- QNetworkRequest request;
- request.setUrl(QUrl("http://localhost:4000/api/records/v1/chat_participation"));
- request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
-
- // Add Authorization header if token exists
- if (!m_authToken.isEmpty()) {
- request.setRawHeader("Authorization", QString("Bearer %1").arg(m_authToken).toUtf8());
- }
-
- // Create JSON body
- QJsonObject jsonObj;
- jsonObj["chat_id"] = chatId;
- jsonObj["user_id"] = m_currentUserId;
- QJsonDocument jsonDoc(jsonObj);
- QByteArray jsonData = jsonDoc.toJson();
-
- // Make the POST request
- QNetworkReply* reply = m_manager->post(request, jsonData);
-
- // Store chat data for later emission
- reply->setProperty("chatData", chatData);
-
- // Connect to the finished signal
- connect(reply, &QNetworkReply::finished, this, &NetworkManager::onPostChatParticipationFinished);
-}
-
-void NetworkManager::onPostChatParticipationFinished()
-{
- QNetworkReply* reply = qobject_cast<QNetworkReply*>(sender());
- if (!reply) {
- return;
- }
-
- // Check for network errors
- if (reply->error() != QNetworkReply::NoError) {
- QString errorMsg = reply->errorString();
- qDebug() << "Network error creating chat participation:" << errorMsg;
- emit requestFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- // Get HTTP status code
- int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
-
- // Check if status code is success (200-299)
- if (statusCode < 200 || statusCode >= 300) {
- QString errorMsg = QString("HTTP error creating chat participation: %1").arg(statusCode);
- qDebug() << errorMsg;
- emit requestFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- // Retrieve the stored chat data and emit chatCreated signal
- QVariant chatDataVariant = reply->property("chatData");
- if (chatDataVariant.isValid()) {
- QVariantMap chatData = chatDataVariant.toMap();
- emit chatCreated(chatData);
- }
-
- // Clean up
- reply->deleteLater();
-}
-
-void NetworkManager::fetchChats(const QString& cursor)
-{
- QUrl url("http://localhost:4000/api/records/v1/user_chats");
- QUrlQuery query;
- // query.addQueryItem("filter[user_id][$eq]", "EbBlvV9iR_-_UumVnu2JIw==");
- query.addQueryItem("limit", "15");
- query.addQueryItem("order", "-created_at,-id");
- if (!cursor.isEmpty()) {
- query.addQueryItem("cursor", cursor);
- }
- url.setQuery(query);
-
- QNetworkRequest request(url);
- if (!m_authToken.isEmpty()) {
- request.setRawHeader("Authorization", QString("Bearer %1").arg(m_authToken).toUtf8());
- }
- QNetworkReply* reply = m_manager->get(request);
- connect(reply, &QNetworkReply::finished, this, &NetworkManager::onFetchChatsFinished);
-}
-
-void NetworkManager::onFetchChatsFinished()
-{
- QNetworkReply* reply = qobject_cast<QNetworkReply*>(sender());
- if (!reply) {
- return;
- }
-
- // Check for network errors
- if (reply->error() != QNetworkReply::NoError) {
- QString errorMsg = reply->errorString();
- qDebug() << "Network error fetching chats:" << errorMsg;
- emit requestFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- // Get HTTP status code
- int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
-
- // Check if status code is success (200-299)
- if (statusCode < 200 || statusCode >= 300) {
- QString errorMsg = QString("HTTP error fetching chats: %1").arg(statusCode);
- qDebug() << errorMsg;
- emit requestFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- // Read response data
- QByteArray responseData = reply->readAll();
-
- // Parse JSON response
- QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData);
- if (jsonDoc.isNull()) {
- QString errorMsg = "Invalid JSON response";
- qDebug() << errorMsg;
- emit requestFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- QVariantList chatsList;
- QString nextCursor = "";
-
- // Handle paginated response format: {"cursor": "...", "records": [...]}
- if (jsonDoc.isObject()) {
- QJsonObject rootObj = jsonDoc.object();
-
- // Extract cursor for next page
- if (rootObj.contains("cursor")) {
- nextCursor = rootObj["cursor"].toString();
- }
-
- if (rootObj.contains("records") && rootObj["records"].isArray()) {
- QJsonArray jsonArray = rootObj["records"].toArray();
- for (const QJsonValue& value : jsonArray) {
- if (value.isObject()) {
- chatsList.append(value.toObject().toVariantMap());
- }
- }
- } else {
- QString errorMsg = "Invalid JSON response - missing 'records' array";
- qDebug() << errorMsg;
- emit requestFailed(errorMsg);
- reply->deleteLater();
- return;
- }
- }
- // Handle direct array format: [...]
- else if (jsonDoc.isArray()) {
- QJsonArray jsonArray = jsonDoc.array();
- for (const QJsonValue& value : jsonArray) {
- if (value.isObject()) {
- chatsList.append(value.toObject().toVariantMap());
- }
- }
- } else {
- QString errorMsg = "Invalid JSON response format";
- qDebug() << errorMsg;
- emit requestFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- emit chatsFetched(chatsList, nextCursor);
-
- // Clean up
- reply->deleteLater();
-}
-
-void NetworkManager::postMessage(int chatId, const QString& content)
-{
- // Create the request
- QNetworkRequest request;
- request.setUrl(QUrl("http://localhost:4000/api/records/v1/message"));
- request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
-
- // Add Authorization header if token exists
- if (!m_authToken.isEmpty()) {
- request.setRawHeader("Authorization", QString("Bearer %1").arg(m_authToken).toUtf8());
- }
-
- // Create JSON body
- QJsonObject jsonObj;
- jsonObj["chat_id"] = chatId;
- jsonObj["content"] = content;
- jsonObj["sender_id"] = m_currentUserId;
- QJsonDocument jsonDoc(jsonObj);
- QByteArray jsonData = jsonDoc.toJson();
-
- // Make the POST request
- QNetworkReply* reply = m_manager->post(request, jsonData);
-
- // Connect to the finished signal
- connect(reply, &QNetworkReply::finished, this, &NetworkManager::onPostMessageFinished);
-}
-
-void NetworkManager::onPostMessageFinished()
-{
- QNetworkReply* reply = qobject_cast<QNetworkReply*>(sender());
- if (!reply) {
- return;
- }
-
- // Check for network errors
- if (reply->error() != QNetworkReply::NoError) {
- QString errorMsg = reply->errorString();
- qDebug() << "Network error creating message:" << errorMsg;
- emit requestFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- // Get HTTP status code
- int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
-
- // Check if status code is success (200-299)
- if (statusCode < 200 || statusCode >= 300) {
- QString errorMsg = QString("HTTP error creating message: %1").arg(statusCode);
- qDebug() << errorMsg;
- emit requestFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- // Read response data
- QByteArray responseData = reply->readAll();
-
- // Parse JSON response
- QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData);
- if (jsonDoc.isNull() || !jsonDoc.isObject()) {
- QString errorMsg = "Invalid JSON response";
- qDebug() << errorMsg;
- emit requestFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- // Convert to QVariantMap for QML
- QJsonObject jsonObj = jsonDoc.object();
- QVariantMap messageData = jsonObj.toVariantMap();
-
- emit messageCreated(messageData);
-
- // Clean up
- reply->deleteLater();
-}
-
-void NetworkManager::fetchMessages(int chatId, const QString& cursor)
-{
- // Create the GET request with properly encoded URL
- QUrl url("http://localhost:4000/api/records/v1/message");
- QUrlQuery query;
- query.addQueryItem("limit", "20");
- query.addQueryItem("order", "-sent_at,-id");
- query.addQueryItem("filter[chat_id][$eq]", QString::number(chatId));
-
- // Add cursor parameter if provided
- if (!cursor.isEmpty()) {
- query.addQueryItem("cursor", cursor);
- }
-
- url.setQuery(query);
-
- QNetworkRequest request(url);
-
- // Add Authorization header if token exists
- if (!m_authToken.isEmpty()) {
- request.setRawHeader("Authorization", QString("Bearer %1").arg(m_authToken).toUtf8());
- }
-
- // Make the GET request
- QNetworkReply* reply = m_manager->get(request);
-
- // Connect to the finished signal
- connect(reply, &QNetworkReply::finished, this, &NetworkManager::onFetchMessagesFinished);
-}
-
-void NetworkManager::onFetchMessagesFinished()
-{
- QNetworkReply* reply = qobject_cast<QNetworkReply*>(sender());
- if (!reply) {
- return;
- }
-
- // Check for network errors
- if (reply->error() != QNetworkReply::NoError) {
- QString errorMsg = reply->errorString();
- qDebug() << "Network error fetching messages:" << errorMsg;
- emit requestFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- // Get HTTP status code
- int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
-
- // Check if status code is success (200-299)
- if (statusCode < 200 || statusCode >= 300) {
- QString errorMsg = QString("HTTP error fetching messages: %1").arg(statusCode);
- qDebug() << errorMsg;
- emit requestFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- // Read response data
- QByteArray responseData = reply->readAll();
-
- // Parse JSON response
- QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData);
- if (jsonDoc.isNull()) {
- QString errorMsg = "Invalid JSON response";
- qDebug() << errorMsg;
- emit requestFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- QVariantList messagesList;
- QString nextCursor = "";
-
- // Handle paginated response format: {"cursor": "...", "records": [...]}
- if (jsonDoc.isObject()) {
- QJsonObject rootObj = jsonDoc.object();
-
- // Extract cursor for next page
- if (rootObj.contains("cursor")) {
- nextCursor = rootObj["cursor"].toString();
- }
-
- if (rootObj.contains("records") && rootObj["records"].isArray()) {
- QJsonArray jsonArray = rootObj["records"].toArray();
- for (const QJsonValue& value : jsonArray) {
- if (value.isObject()) {
- messagesList.append(value.toObject().toVariantMap());
- }
- }
- } else {
- QString errorMsg = "Invalid JSON response - missing 'records' array";
- qDebug() << errorMsg;
- emit requestFailed(errorMsg);
- reply->deleteLater();
- return;
- }
- }
- // Handle direct array format: [...]
- else if (jsonDoc.isArray()) {
- QJsonArray jsonArray = jsonDoc.array();
- for (const QJsonValue& value : jsonArray) {
- if (value.isObject()) {
- messagesList.append(value.toObject().toVariantMap());
- }
- }
- } else {
- QString errorMsg = "Invalid JSON response format";
- qDebug() << errorMsg;
- emit requestFailed(errorMsg);
- reply->deleteLater();
- return;
- }
-
- emit messagesFetched(messagesList, nextCursor);
-
- // Clean up
- reply->deleteLater();
-}
-
-// SSE Methods
-
-void NetworkManager::subscribeToChat(int chatId)
-{
- if (chatId < 0) return;
-
- // Unsubscribe from previous chat if any
- if (m_currentSseChat != chatId) {
- unsubscribeFromChat();
- }
-
- // Build SSE URL with filter
- QString urlStr = QString(
- "http://localhost:4000/api/records/v1/message/subscribe/*"
- "?filter[chat_id][$eq]=%1"
- ).arg(chatId);
-
- QUrl url(urlStr);
- QNetworkRequest request(url);
-
- // SSE requires Accept header
- request.setRawHeader("Accept", "text/event-stream");
- request.setRawHeader("Cache-Control", "no-cache");
-
- // Add Authorization header if token exists
- if (!m_authToken.isEmpty()) {
- request.setRawHeader("Authorization", QString("Bearer %1").arg(m_authToken).toUtf8());
- }
-
- // Create persistent GET request
- m_sseReply = m_manager->get(request);
- m_currentSseChat = chatId;
-
- // Connect signals
- connect(m_sseReply, &QNetworkReply::readyRead,
- this, &NetworkManager::onSseReadyRead);
- connect(m_sseReply, &QNetworkReply::finished,
- this, &NetworkManager::onSseFinished);
- connect(m_sseReply,
- QOverload<QNetworkReply::NetworkError>::of(&QNetworkReply::errorOccurred),
- this, &NetworkManager::onSseError);
-
- emit sseConnected(chatId);
- resetReconnectBackoff();
-}
-
-void NetworkManager::subscribeToChats(const QVariantList& chatIds)
-{
- if (chatIds.isEmpty()) return;
-
- // Unsubscribe from previous subscription if any
- unsubscribeFromChat();
-
- // Build regex pattern: (id1|id2|id3|...)
- QStringList idStrings;
- for (const QVariant& chatIdVariant : chatIds) {
- int chatId = chatIdVariant.toInt();
- if (chatId > 0) {
- idStrings.append(QString::number(chatId));
- }
- }
-
- if (idStrings.isEmpty()) return;
-
- QString chatIdList = idStrings.join(",");
-
- // Build SSE URL with comma-separated chat IDs
- QString urlStr = QString(
- "http://localhost:4000/api/records/v1/message/subscribe/*"
- "?chat_id=%1"
- ).arg(chatIdList);
-
- QUrl url(urlStr);
- QNetworkRequest request(url);
-
- // SSE requires Accept header
- request.setRawHeader("Accept", "text/event-stream");
- request.setRawHeader("Cache-Control", "no-cache");
-
- // Add Authorization header if token exists
- if (!m_authToken.isEmpty()) {
- request.setRawHeader("Authorization", QString("Bearer %1").arg(m_authToken).toUtf8());
- }
-
- // Create persistent GET request
- m_sseReply = m_manager->get(request);
- m_currentSseChat = -1; // Set to -1 to indicate multi-chat subscription
-
- // Connect signals
- connect(m_sseReply, &QNetworkReply::readyRead,
- this, &NetworkManager::onSseReadyRead);
- connect(m_sseReply, &QNetworkReply::finished,
- this, &NetworkManager::onSseFinished);
- connect(m_sseReply,
- QOverload<QNetworkReply::NetworkError>::of(&QNetworkReply::errorOccurred),
- this, &NetworkManager::onSseError);
-
- emit sseConnected(-1); // -1 indicates multi-chat subscription
- resetReconnectBackoff();
-}
-
-void NetworkManager::unsubscribeFromChat()
-{
- if (!m_sseReply) return;
-
- int previousChat = m_currentSseChat;
-
- // Disconnect signals
- disconnect(m_sseReply, nullptr, this, nullptr);
-
- // Abort and cleanup
- m_sseReply->abort();
- m_sseReply->deleteLater();
- m_sseReply = nullptr;
-
- // Reset state
- m_currentSseChat = -1;
- m_sseBuffer.clear();
- m_recentMessageIds.clear();
- m_reconnectTimer->stop();
-
- emit sseDisconnected(previousChat, "User initiated");
-}
-
-void NetworkManager::onSseReadyRead()
-{
- if (!m_sseReply) return;
-
- // Read available data and append to buffer
- QByteArray chunk = m_sseReply->readAll();
- m_sseBuffer.append(chunk);
-
- // Process complete events (delimited by \n\n)
- while (m_sseBuffer.contains("\n\n")) {
- int eventEnd = m_sseBuffer.indexOf("\n\n");
- QByteArray eventData = m_sseBuffer.left(eventEnd);
- m_sseBuffer.remove(0, eventEnd + 2); // Remove event + delimiter
-
- // Parse the event
- QString eventStr = QString::fromUtf8(eventData);
- parseSSEEvent(eventStr);
- }
-
- // Prevent buffer from growing indefinitely
- if (m_sseBuffer.size() > 65536) { // 64KB limit
- qWarning() << "SSE: Buffer overflow, clearing";
- m_sseBuffer.clear();
- }
-}
-
-void NetworkManager::parseSSEEvent(const QString& eventData)
-{
- QStringList lines = eventData.split('\n');
-
- QString eventType;
- QStringList dataLines;
- QString eventId;
-
- // Parse event fields
- for (const QString& line : lines) {
- if (line.startsWith("event:")) {
- eventType = line.mid(6).trimmed();
- }
- else if (line.startsWith("data:")) {
- dataLines.append(line.mid(5).trimmed());
- }
- else if (line.startsWith("id:")) {
- eventId = line.mid(3).trimmed();
- }
- else if (line.startsWith(":")) {
- // Comment, ignore
- continue;
- }
- }
-
- // Combine multi-line data
- QString data = dataLines.join("\n");
- if (data.isEmpty()) return;
-
- // Parse JSON data
- QJsonDocument jsonDoc = QJsonDocument::fromJson(data.toUtf8());
- if (!jsonDoc.isObject()) {
- qWarning() << "SSE: Invalid JSON in event:" << data;
- return;
- }
-
- QJsonObject jsonObj = jsonDoc.object();
-
- // Trailbase wraps the actual data inside an "Insert" key
- QVariantMap messageData;
- if (jsonObj.contains("Insert")) {
- messageData = jsonObj["Insert"].toObject().toVariantMap();
- } else {
- // Fallback to direct parsing if no wrapper
- messageData = jsonObj.toVariantMap();
- }
-
- // Extract message ID for deduplication
- int messageId = messageData.value("id", 0).toInt();
- if (messageId <= 0) {
- qWarning() << "SSE: Message without valid ID";
- return;
- }
-
- // Check for duplicates
- if (isDuplicateMessage(messageId)) {
- return;
- }
-
- // Verify chat_id matches subscription (only if single chat subscription)
- int messageChatId = messageData.value("chat_id", -1).toInt();
- if (m_currentSseChat >= 0 && messageChatId != m_currentSseChat) {
- qWarning() << "SSE: Message for wrong chat" << messageChatId
- << "expected" << m_currentSseChat;
- return;
- }
-
- // Cache message ID
- addToRecentMessages(messageId);
-
- // Show system tray notification
- QString content = messageData.value("content", "New message").toString();
- QString notificationText = content.length() > 100
- ? content.left(100) + "..."
- : content;
- m_trayIcon->showMessage("New Message",
- notificationText,
- QSystemTrayIcon::Information,
- 5000);
-
- // Emit to QML layer
- emit messageReceived(messageData);
-}
-
-bool NetworkManager::isDuplicateMessage(int messageId)
-{
- return m_recentMessageIds.contains(messageId);
-}
-
-void NetworkManager::addToRecentMessages(int messageId)
-{
- m_recentMessageIds.insert(messageId);
-
- // Keep cache bounded (FIFO eviction)
- // QSet doesn't guarantee order, so when full, clear half
- if (m_recentMessageIds.size() > 50) {
- auto it = m_recentMessageIds.begin();
- for (int i = 0; i < 25 && it != m_recentMessageIds.end(); ++i) {
- it = m_recentMessageIds.erase(it);
- }
- }
-}
-
-void NetworkManager::onSseFinished()
-{
- if (!m_sseReply) return;
-
- int chatId = m_currentSseChat;
- QString reason = "Connection closed by server";
-
- // Cleanup
- m_sseReply->deleteLater();
- m_sseReply = nullptr;
- m_sseBuffer.clear();
-
- emit sseDisconnected(chatId, reason);
-
- // Attempt reconnection if chat is still active
- if (m_currentSseChat >= 0) {
- scheduleReconnect();
- } else {
- m_currentSseChat = -1;
- }
-}
-
-void NetworkManager::onSseError(QNetworkReply::NetworkError error)
-{
- QString errorMsg = m_sseReply ? m_sseReply->errorString() : "Unknown error";
- qWarning() << "SSE: Network error:" << error << errorMsg;
-
- emit sseError(errorMsg);
-
- // Reconnection will be handled by onSseFinished
-}
-
-void NetworkManager::attemptReconnect()
-{
- if (m_currentSseChat < 0) return;
-
- int chatId = m_currentSseChat;
-
- subscribeToChat(chatId);
-}
-
-void NetworkManager::scheduleReconnect()
-{
- // Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
- int delay = qMin(1000 * (1 << m_reconnectAttempts), 30000);
- m_reconnectAttempts++;
-
- m_reconnectTimer->start(delay);
-}
-
-void NetworkManager::resetReconnectBackoff()
-{
- m_reconnectAttempts = 0;
- m_reconnectTimer->stop();
-}
-
-// Keychain Methods
-
-void NetworkManager::saveTokenToKeychain(const QString& token)
-{
- QKeychain::WritePasswordJob *job = new QKeychain::WritePasswordJob("Beegram", this);
- job->setKey("auth_token");
- job->setTextData(token);
-
- connect(job, &QKeychain::WritePasswordJob::finished, this, [this, job]() {
- onKeychainWriteFinished(job);
- });
-
- job->start();
-}
-
-void NetworkManager::loadTokenFromKeychain()
-{
- QKeychain::ReadPasswordJob *job = new QKeychain::ReadPasswordJob("Beegram", this);
- job->setKey("auth_token");
-
- connect(job, &QKeychain::ReadPasswordJob::finished, this, [this, job]() {
- onKeychainReadFinished(job);
- });
-
- job->start();
-}
-
-void NetworkManager::deleteTokenFromKeychain()
-{
- QKeychain::DeletePasswordJob *job = new QKeychain::DeletePasswordJob("Beegram", this);
- job->setKey("auth_token");
-
- connect(job, &QKeychain::DeletePasswordJob::finished, this, [this, job]() {
- onKeychainDeleteFinished(job);
- });
-
- job->start();
-}
-
-// Keychain Slots
-
-void NetworkManager::onKeychainReadFinished(QKeychain::ReadPasswordJob* job)
-{
- if (job->error() == QKeychain::NoError) {
- QString token = job->textData();
- if (!token.isEmpty()) {
- m_authToken = token;
- qDebug() << "Token loaded from keychain";
- emit authenticationChanged();
- emit autoLoginSuccess();
- } else {
- qDebug() << "No token found in keychain";
- emit autoLoginFailed("No saved credentials");
- }
- } else {
- qDebug() << "Keychain read error:" << job->errorString();
- emit autoLoginFailed("Failed to read saved credentials");
- }
-
- job->deleteLater();
-}
-
-void NetworkManager::onKeychainWriteFinished(QKeychain::WritePasswordJob* job)
-{
- if (job->error() != QKeychain::NoError) {
- qWarning() << "Failed to save token to keychain:" << job->errorString();
- } else {
- qDebug() << "Token saved to keychain successfully";
- }
-
- job->deleteLater();
-}
-
-void NetworkManager::onKeychainDeleteFinished(QKeychain::DeletePasswordJob* job)
-{
- if (job->error() != QKeychain::NoError && job->error() != QKeychain::EntryNotFound) {
- qWarning() << "Failed to delete token from keychain:" << job->errorString();
- } else {
- qDebug() << "Token deleted from keychain";
- }
-
- job->deleteLater();
-}
-
-// Public Methods
-
-void NetworkManager::logout()
-{
- // Clear in-memory token
- m_authToken.clear();
-
- // Unsubscribe from any active SSE connections
- unsubscribeFromChat();
-
- // Delete token from keychain
- deleteTokenFromKeychain();
-
- emit authenticationChanged();
- qDebug() << "User logged out";
-}
-
-void NetworkManager::checkSavedCredentials()
-{
- qDebug() << "Checking for saved credentials...";
- loadTokenFromKeychain();
-}
diff --git a/networkmanager.h b/networkmanager.h
@@ -1,111 +0,0 @@
-#ifndef NETWORKMANAGER_H
-#define NETWORKMANAGER_H
-
-#include <QObject>
-#include <QNetworkAccessManager>
-#include <QNetworkReply>
-#include <QVariantMap>
-#include <QTimer>
-#include <QSet>
-#include <QSystemTrayIcon>
-
-namespace QKeychain {
- class ReadPasswordJob;
- class WritePasswordJob;
- class DeletePasswordJob;
-}
-
-class NetworkManager : public QObject
-{
- Q_OBJECT
- Q_PROPERTY(QString currentUserId READ currentUserId CONSTANT)
- Q_PROPERTY(bool isAuthenticated READ isAuthenticated NOTIFY authenticationChanged)
-
-public:
- explicit NetworkManager(QObject *parent = nullptr);
- ~NetworkManager();
-
- QString currentUserId() const { return m_currentUserId; }
- bool isAuthenticated() const { return !m_authToken.isEmpty(); }
-
- Q_INVOKABLE void login(const QString& email, const QString& password);
- Q_INVOKABLE void logout();
- Q_INVOKABLE void checkSavedCredentials();
- Q_INVOKABLE void postChat(const QString& name);
- Q_INVOKABLE void fetchChats(const QString& cursor = "");
- Q_INVOKABLE void postMessage(int chatId, const QString& content);
- Q_INVOKABLE void fetchMessages(int chatId, const QString& cursor = "");
-
- // SSE methods
- Q_INVOKABLE void subscribeToChat(int chatId);
- Q_INVOKABLE void subscribeToChats(const QVariantList& chatIds);
- Q_INVOKABLE void unsubscribeFromChat();
-
-signals:
- void loginSuccess(QString authToken);
- void loginFailed(QString error);
- void authenticationChanged();
- void autoLoginSuccess();
- void autoLoginFailed(QString reason);
- void chatCreated(QVariantMap chatData);
- void chatsFetched(QVariantList chats, QString cursor);
- void messageCreated(QVariantMap messageData);
- void messagesFetched(QVariantList messages, QString cursor);
- void requestFailed(QString error);
-
- // SSE signals
- void messageReceived(QVariantMap messageData);
- void sseConnected(int chatId);
- void sseDisconnected(int chatId, QString reason);
- void sseError(QString error);
-
-private slots:
- void onLoginFinished();
- void onKeychainReadFinished(QKeychain::ReadPasswordJob* job);
- void onKeychainWriteFinished(QKeychain::WritePasswordJob* job);
- void onKeychainDeleteFinished(QKeychain::DeletePasswordJob* job);
- void onPostChatFinished();
- void onPostChatParticipationFinished();
- void onFetchChatsFinished();
- void onPostMessageFinished();
- void onFetchMessagesFinished();
-
- // SSE slots
- void onSseReadyRead();
- void onSseFinished();
- void onSseError(QNetworkReply::NetworkError error);
- void attemptReconnect();
-
-private:
- // Helper methods
- void createChatParticipation(int chatId, const QVariantMap& chatData);
-
- // Keychain methods
- void saveTokenToKeychain(const QString& token);
- void loadTokenFromKeychain();
- void deleteTokenFromKeychain();
-
- // Helper methods for SSE
- void parseSSEEvent(const QString& eventData);
- bool isDuplicateMessage(int messageId);
- void addToRecentMessages(int messageId);
- void scheduleReconnect();
- void resetReconnectBackoff();
-
- QNetworkAccessManager* m_manager;
- QString m_authToken;
- QString m_currentUserId;
-
- // SSE members
- QNetworkReply* m_sseReply;
- int m_currentSseChat;
- QByteArray m_sseBuffer;
- QSet<int> m_recentMessageIds;
- QTimer* m_reconnectTimer;
- int m_reconnectAttempts;
-
- // System tray
- QSystemTrayIcon* m_trayIcon;
-};
-
-#endif // NETWORKMANAGER_H
diff --git a/qml/components/ChatListItem.qml b/qml/components/ChatListItem.qml
@@ -5,15 +5,14 @@ import "../styles"
Rectangle {
id: root
height: 72
- color: mouseArea.containsMouse ? "#f5f5f5" : "white"
+ color: isSelected ? TelegramStyle.primaryColor + "22" : (mouseArea.containsMouse ? "#f5f5f5" : "white")
+ border.color: isSelected ? TelegramStyle.primaryColor : "transparent"
+ border.width: isSelected ? 2 : 0
property var chatData: null
+ property bool isSelected: false
signal clicked()
- Behavior on color {
- ColorAnimation { duration: 100 }
- }
-
RowLayout {
anchors.fill: parent
anchors.margins: TelegramStyle.standardMargin
@@ -25,7 +24,7 @@ Rectangle {
name: chatData ? chatData.title : ""
chatId: chatData ? chatData.id : 0
imageSource: chatData ? chatData.avatar : ""
- showOnlineIndicator: chatData ? chatData.isOnline : false
+ showOnlineIndicator: chatData ? chatData.is_online : false
}
// Content
@@ -40,7 +39,7 @@ Rectangle {
Text {
Layout.fillWidth: true
text: chatData ? chatData.title : ""
- font.bold: chatData ? chatData.unreadCount > 0 : false
+ font.bold: chatData ? chatData.unread_count > 0 : false
font.pixelSize: TelegramStyle.fontSizeNormal
color: TelegramStyle.textPrimary
elide: Text.ElideRight
@@ -49,7 +48,7 @@ Rectangle {
Text {
text: chatData ? chatData.created_at : ""
font.pixelSize: TelegramStyle.fontSizeSmall
- color: chatData && chatData.unreadCount > 0 ? TelegramStyle.unreadBadge : TelegramStyle.textSecondary
+ color: chatData && chatData.unread_count > 0 ? TelegramStyle.unreadBadge : TelegramStyle.textSecondary
}
}
@@ -59,7 +58,7 @@ Rectangle {
Text {
Layout.fillWidth: true
- text: chatData ? chatData.lastMessage : ""
+ text: chatData ? chatData.last_message : ""
font.pixelSize: TelegramStyle.fontSizeSmall
color: TelegramStyle.textSecondary
elide: Text.ElideRight
@@ -67,7 +66,7 @@ Rectangle {
// Unread badge
Rectangle {
- visible: chatData ? chatData.unreadCount > 0 : false
+ visible: chatData ? chatData.unread_count > 0 : false
width: 20
height: 20
radius: 10
@@ -75,7 +74,7 @@ Rectangle {
Text {
anchors.centerIn: parent
- text: chatData ? chatData.unreadCount : ""
+ text: chatData ? chatData.unread_count : ""
color: "white"
font.pixelSize: 11
font.bold: true
diff --git a/qml/components/MessageBubble.qml b/qml/components/MessageBubble.qml
@@ -8,7 +8,7 @@ Item {
height: childrenRect.height
property var messageData: null
- property bool isOutgoing: messageData ? messageData.isOutgoing : false
+ property bool isOutgoing: messageData ? messageData.is_outgoing : false
Item {
id: contentContainer
@@ -52,7 +52,7 @@ Item {
Text {
id: messageText
anchors.centerIn: parent
- text: messageData ? messageData.text : ""
+ text: messageData ? messageData.content : ""
wrapMode: Text.WordWrap
font.pixelSize: TelegramStyle.fontSizeNormal
color: TelegramStyle.textPrimary
@@ -63,7 +63,7 @@ Item {
// Timestamp
Text {
Layout.alignment: isOutgoing ? Qt.AlignRight : Qt.AlignLeft
- text: messageData ? messageData.timestamp : ""
+ text: messageData ? messageData.sent_at : ""
font.pixelSize: TelegramStyle.fontSizeSmall
color: TelegramStyle.textSecondary
}
diff --git a/qml/components/MessageInput.qml b/qml/components/MessageInput.qml
@@ -18,9 +18,19 @@ Rectangle {
spacing: TelegramStyle.smallMargin
// Attach button
- Item {
+ Rectangle {
+ id: attachButton
Layout.preferredWidth: 40
Layout.preferredHeight: 40
+ radius: 20
+ color: activeFocus ? TelegramStyle.divider : "transparent"
+ border.color: activeFocus ? TelegramStyle.primaryColor : "transparent"
+ border.width: activeFocus ? 2 : 0
+ activeFocusOnTab: true
+ KeyNavigation.tab: messageInput
+
+ Keys.onReturnPressed: root.attachClicked()
+ Keys.onSpacePressed: root.attachClicked()
Text {
anchors.centerIn: parent
@@ -41,8 +51,8 @@ Rectangle {
Layout.fillHeight: true
radius: 20
color: "#f5f5f5"
- border.color: TelegramStyle.divider
- border.width: 1
+ border.color: messageInput.activeFocus ? TelegramStyle.primaryColor : TelegramStyle.divider
+ border.width: messageInput.activeFocus ? 2 : 1
TextInput {
id: messageInput
@@ -52,6 +62,8 @@ Rectangle {
font.pixelSize: TelegramStyle.fontSizeNormal
color: TelegramStyle.textPrimary
clip: true
+ activeFocusOnTab: true
+ KeyNavigation.tab: sendButton
// Placeholder text
Text {
@@ -77,6 +89,13 @@ Rectangle {
Layout.preferredHeight: 40
radius: 20
color: messageInput.text.trim().length > 0 ? TelegramStyle.primaryColor : TelegramStyle.divider
+ border.color: activeFocus ? TelegramStyle.textPrimary : "transparent"
+ border.width: activeFocus ? 2 : 0
+ activeFocusOnTab: true
+ KeyNavigation.tab: attachButton
+
+ Keys.onReturnPressed: clicked()
+ Keys.onSpacePressed: clicked()
Behavior on color {
ColorAnimation { duration: 150 }
@@ -93,18 +112,14 @@ Rectangle {
anchors.fill: parent
enabled: messageInput.text.trim().length > 0
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
- onClicked: {
- if (messageInput.text.trim().length > 0) {
- root.sendMessage(messageInput.text)
- messageInput.text = ""
- }
- }
+ onClicked: sendButton.clicked()
}
function clicked() {
if (messageInput.text.trim().length > 0) {
root.sendMessage(messageInput.text)
messageInput.text = ""
+ messageInput.forceActiveFocus()
}
}
}
diff --git a/qml/components/TopBar.qml b/qml/components/TopBar.qml
@@ -23,10 +23,17 @@ Rectangle {
spacing: TelegramStyle.smallMargin
// Back button
- Item {
+ Rectangle {
+ id: backButton
visible: showBackButton
Layout.preferredWidth: 40
Layout.fillHeight: true
+ color: activeFocus ? Qt.darker(TelegramStyle.primaryColor, 1.2) : "transparent"
+ radius: 4
+ activeFocusOnTab: true
+
+ Keys.onReturnPressed: root.backClicked()
+ Keys.onSpacePressed: root.backClicked()
Text {
anchors.centerIn: parent
@@ -54,10 +61,17 @@ Rectangle {
}
// Add button
- Item {
+ Rectangle {
+ id: addButton
visible: showAddButton
Layout.preferredWidth: 40
Layout.fillHeight: true
+ color: activeFocus ? Qt.darker(TelegramStyle.primaryColor, 1.2) : "transparent"
+ radius: 4
+ activeFocusOnTab: true
+
+ Keys.onReturnPressed: root.addClicked()
+ Keys.onSpacePressed: root.addClicked()
Text {
anchors.centerIn: parent
@@ -74,10 +88,17 @@ Rectangle {
}
// Menu button
- Item {
+ Rectangle {
+ id: menuButton
visible: showMenuButton
Layout.preferredWidth: 40
Layout.fillHeight: true
+ color: activeFocus ? Qt.darker(TelegramStyle.primaryColor, 1.2) : "transparent"
+ radius: 4
+ activeFocusOnTab: true
+
+ Keys.onReturnPressed: root.menuClicked()
+ Keys.onSpacePressed: root.menuClicked()
Text {
anchors.centerIn: parent
diff --git a/qml/models/ChatListModel.qml b/qml/models/ChatListModel.qml
@@ -1,93 +1,98 @@
import QtQuick
+// Simplified chat list model - all business logic is in Rust
Item {
id: root
- property ListModel model: ListModel {}
+ property ListModel sourceModel: ListModel {}
+ property ListModel filteredModel: ListModel {}
+ property string filter: ""
property string currentCursor: ""
property bool hasMore: true
property bool isLoadingMore: false
- function addChat(serverData) {
- // Transform server response to chat object format
- model.append({
- id: serverData.id || 123,
- title: serverData.title || serverData.name || "Unknown",
- lastMessage: serverData.lastMessage || "",
- created_at: serverData.created_at || serverData.timestamp || "now",
- unreadCount: serverData.unreadCount || 0,
- avatar: serverData.avatar || "",
- isOnline: serverData.isOnline || false
- })
-
- // Re-subscribe to include the new chat
- subscribeToAllChats()
- }
-
- function loadChats(chats, cursor, isInitialLoad) {
- // Clear existing chats on initial load
- if (isInitialLoad) {
- model.clear()
- }
+ // Re-filter when filter changes
+ onFilterChanged: applyFilter()
- // Load chats from server
- for (var i = 0; i < chats.length; i++) {
- var chat = chats[i]
- model.append({
- id: chat.id,
- title: chat.title,
- lastMessage: "",
- created_at: chat.created_at,
- unreadCount: 0,
- avatar: "",
- isOnline: false
- })
+ function applyFilter() {
+ filteredModel.clear()
+ var query = filter.toLowerCase()
+ for (var i = 0; i < sourceModel.count; i++) {
+ var chat = sourceModel.get(i)
+ if (!query || chat.title.toLowerCase().indexOf(query) !== -1) {
+ filteredModel.append(chat)
+ }
}
-
- // Update cursor for next page
- currentCursor = cursor || ""
- hasMore = (cursor !== undefined && cursor !== null && cursor !== "")
- isLoadingMore = false
}
function loadMore() {
- if (!hasMore || isLoadingMore) {
- return
- }
+ if (!hasMore || isLoadingMore) return
isLoadingMore = true
NetworkManager.fetchChats(currentCursor)
}
function subscribeToAllChats() {
- // Collect all chat IDs from the model
var chatIds = []
- for (var i = 0; i < model.count; i++) {
- var chatId = model.get(i).id
- if (chatId > 0) {
- chatIds.push(chatId)
+ for (var i = 0; i < sourceModel.count; i++) {
+ var chatId = sourceModel.get(i).id
+ if (chatId > 0) chatIds.push(chatId)
+ }
+ if (chatIds.length > 0) {
+ NetworkManager.subscribeToChats(JSON.stringify(chatIds))
+ }
+ }
+
+ function getChatById(chatId) {
+ for (var i = 0; i < sourceModel.count; i++) {
+ if (sourceModel.get(i).id === chatId) {
+ return { index: i, chat: sourceModel.get(i) }
}
}
+ return null
+ }
- // Subscribe to all chats if we have any
- if (chatIds.length > 0) {
- NetworkManager.subscribeToChats(chatIds)
+ function updateChat(chatId, newTitle) {
+ var found = getChatById(chatId)
+ if (found) {
+ sourceModel.setProperty(found.index, "title", newTitle)
+ applyFilter()
}
}
- // Listen for fetched chats from server
Connections {
target: NetworkManager
- function onChatsFetched(chats, cursor) {
- var isInitial = (model.count === 0)
- root.loadChats(chats, cursor, isInitial)
- // Subscribe to all chats after loading
+ function onChatsFetched(chatsJson, cursor) {
+ var chats = JSON.parse(chatsJson)
+ var isInitial = (root.currentCursor === "")
+
+ if (isInitial) sourceModel.clear()
+
+ for (var i = 0; i < chats.length; i++) {
+ sourceModel.append(chats[i])
+ }
+
+ root.currentCursor = cursor || ""
+ root.hasMore = (cursor !== undefined && cursor !== null && cursor !== "")
+ root.isLoadingMore = false
+ root.applyFilter()
root.subscribeToAllChats()
}
+
+ function onChatCreated(chatJson) {
+ var chat = JSON.parse(chatJson)
+ sourceModel.insert(0, chat)
+ root.applyFilter()
+ root.subscribeToAllChats()
+ }
+
+ function onChatUpdated(chatJson) {
+ var chat = JSON.parse(chatJson)
+ root.updateChat(chat.id, chat.title)
+ }
}
Component.onCompleted: {
- // Fetch chats from server
isLoadingMore = true
NetworkManager.fetchChats("")
}
diff --git a/qml/models/MessageCache.qml b/qml/models/MessageCache.qml
@@ -7,10 +7,6 @@ QtObject {
// Map of chatId -> array of messages
property var messagesByChat: ({})
- Component.onCompleted: {
- console.log("MessageCache: Initialized")
- }
-
// Get messages for a specific chat
function getMessages(chatId) {
if (!messagesByChat) {
@@ -32,12 +28,10 @@ QtObject {
var messages = messagesByChat[chatId]
for (var i = 0; i < messages.length; i++) {
if (messages[i].id === messageData.id) {
- console.log("MessageCache: Ignoring duplicate message", messageData.id)
return false
}
}
- console.log("MessageCache: Adding message", messageData.id, "to chat", chatId)
messages.push(messageData)
messagesByChat[chatId] = messages
return true
@@ -45,7 +39,6 @@ QtObject {
// Set all messages for a chat (from fetch)
function setMessages(chatId, messagesArray) {
- console.log("MessageCache: Setting", messagesArray.length, "messages for chat", chatId)
messagesByChat[chatId] = messagesArray.slice() // Create a copy
}
@@ -54,13 +47,11 @@ QtObject {
if (!messagesByChat[chatId]) {
messagesByChat[chatId] = []
}
- console.log("MessageCache: Prepending", messagesArray.length, "messages to chat", chatId)
messagesByChat[chatId] = messagesArray.concat(messagesByChat[chatId])
}
// Clear messages for a chat
function clearMessages(chatId) {
- console.log("MessageCache: Clearing messages for chat", chatId)
messagesByChat[chatId] = []
}
@@ -70,7 +61,6 @@ QtObject {
function onMessageReceived(messageData) {
var chatId = messageData.chat_id
if (chatId === undefined || chatId < 0) {
- console.warn("MessageCache: Received message without valid chat_id")
return
}
diff --git a/qml/models/MessageListModel.qml b/qml/models/MessageListModel.qml
@@ -1,5 +1,6 @@
import QtQuick
+// Simplified message list model - caching and transformation done in Rust
Item {
id: root
@@ -8,205 +9,54 @@ Item {
property string currentCursor: ""
property bool hasMore: true
property bool isLoadingMore: false
- property var messageCache: null // Injected from parent
-
- function syncFromCache() {
- if (chatId < 0 || !messageCache) return
-
- var cachedMessages = messageCache.getMessages(chatId)
- model.clear()
-
- for (var i = 0; i < cachedMessages.length; i++) {
- var msg = cachedMessages[i]
- var senderId = msg.sender_id || ""
- model.append({
- id: msg.id,
- senderId: senderId,
- text: msg.content,
- timestamp: msg.sent_at,
- isOutgoing: (senderId === NetworkManager.currentUserId),
- isRead: msg.isRead !== undefined ? msg.isRead : true
- })
- }
- }
-
- function loadMessages(messages, cursor, isInitialLoad) {
- if (chatId < 0) return
-
- if (messageCache) {
- // Use cache if available - FILTER messages to only include this chat
- var transformedMessages = []
- for (var i = 0; i < messages.length; i++) {
- // Only include messages that actually belong to this chat
- if (messages[i].chat_id !== chatId) {
- continue
- }
-
- transformedMessages.push({
- id: messages[i].id,
- sender_id: messages[i].sender_id,
- content: messages[i].content,
- sent_at: messages[i].sent_at,
- chat_id: messages[i].chat_id,
- isRead: true
- })
- }
-
- if (isInitialLoad) {
- transformedMessages.reverse()
- messageCache.setMessages(chatId, transformedMessages)
- } else {
- transformedMessages.reverse()
- messageCache.prependMessages(chatId, transformedMessages)
- }
-
- syncFromCache()
- } else {
- // Fallback: load directly into model (old behavior)
- if (isInitialLoad) {
- model.clear()
- for (var i = messages.length - 1; i >= 0; i--) {
- var message = messages[i]
- // Filter to only this chat
- if (message.chat_id !== chatId) {
- continue
- }
- var senderId = message.sender_id || ""
- model.append({
- id: message.id,
- senderId: senderId,
- text: message.content,
- timestamp: message.sent_at,
- isOutgoing: (senderId === NetworkManager.currentUserId),
- isRead: true
- })
- }
- } else {
- for (var i = 0; i < messages.length; i++) {
- var message = messages[i]
- // Filter to only this chat
- if (message.chat_id !== chatId) {
- continue
- }
- var senderId = message.sender_id || ""
- model.insert(0, {
- id: message.id,
- senderId: senderId,
- text: message.content || "",
- timestamp: message.sent_at || "",
- isOutgoing: (senderId === NetworkManager.currentUserId),
- isRead: true
- })
- }
- }
- }
-
- // Update cursor for next page
- currentCursor = cursor || ""
- hasMore = (cursor !== undefined && cursor !== null && cursor !== "")
- isLoadingMore = false
- }
function loadMore() {
- if (!hasMore || isLoadingMore || chatId < 0) {
- return
- }
-
+ if (!hasMore || isLoadingMore || chatId < 0) return
isLoadingMore = true
NetworkManager.fetchMessages(chatId, currentCursor)
}
- // Listen for when the global cache receives fetched messages
Connections {
- target: messageCache !== null ? messageCache : null
- function onMessagesFetchedForChat(chatId, cursor) {
- if (!messageCache) return
+ target: NetworkManager
- // Only process if this is for our chat
- if (chatId !== root.chatId) {
- return
- }
-
- // Sync from cache
- syncFromCache()
-
- // Update pagination state
- currentCursor = cursor || ""
- hasMore = (cursor !== undefined && cursor !== null && cursor !== "")
- isLoadingMore = false
- }
- }
-
- // Listen for cache updates (SSE messages)
- Connections {
- target: messageCache !== null ? messageCache : null
- function onMessageAdded(chatId, messageData) {
- if (!messageCache) return
+ function onMessagesFetched(messagesJson, cursor) {
+ var messages = JSON.parse(messagesJson)
+ var isInitial = (root.currentCursor === "")
- // Only update if this is our chat
- if (chatId !== root.chatId) {
- return
+ if (isInitial) {
+ model.clear()
+ // Messages come from Rust already reversed for display
+ for (var i = 0; i < messages.length; i++) {
+ model.append(messages[i])
+ }
+ } else {
+ // Prepend older messages (pagination)
+ for (var i = messages.length - 1; i >= 0; i--) {
+ model.insert(0, messages[i])
+ }
}
- // Refresh from cache
- root.syncFromCache()
+ root.currentCursor = cursor || ""
+ root.hasMore = (cursor !== undefined && cursor !== null && cursor !== "")
+ root.isLoadingMore = false
}
- }
-
- // Fallback: Listen for SSE messages directly if no cache
- Connections {
- target: messageCache === null ? NetworkManager : null
- function onMessageReceived(messageData) {
- if (messageCache !== null) return
- // Only process if message belongs to this chat
- var messageChatId = messageData.chat_id
- if (messageChatId !== root.chatId || root.chatId < 0) {
- return
+ function onMessageReceived(messageJson) {
+ var msg = JSON.parse(messageJson)
+ // Only add if for this chat (Rust already deduplicated)
+ if (msg.chat_id === root.chatId) {
+ model.append(msg)
}
-
- // Transform and add to model
- var senderId = messageData.sender_id || ""
- model.append({
- id: messageData.id || 0,
- senderId: senderId,
- text: messageData.content,
- timestamp: messageData.sent_at,
- isOutgoing: (senderId === NetworkManager.currentUserId),
- isRead: false
- })
}
}
onChatIdChanged: {
if (chatId >= 0) {
- var hasCachedMessages = false
- if (messageCache) {
- // Check if we have cached messages first
- var cachedMessages = messageCache.getMessages(chatId)
- if (cachedMessages.length > 0) {
- syncFromCache()
- hasCachedMessages = true
- }
- } else {
- // No cache, clear the model
- model.clear()
- }
-
- // Reset pagination state
+ model.clear()
currentCursor = ""
hasMore = true
-
- // Only fetch from server if we don't have cached messages
- if (!hasCachedMessages) {
- isLoadingMore = true
- NetworkManager.fetchMessages(chatId, "")
- } else {
- isLoadingMore = false
- }
-
- // Note: No need to subscribe individually - we're already subscribed
- // to all chats globally via ChatListModel.subscribeToAllChats()
+ isLoadingMore = true
+ NetworkManager.fetchMessages(chatId, "")
}
}
}
diff --git a/qml/views/ChatListView.qml b/qml/views/ChatListView.qml
@@ -4,10 +4,114 @@ import "../components"
import "../models"
import "../styles"
-Item {
+FocusScope {
id: root
+ focus: true
property bool isLoading: false
+ property bool gPressed: false
+ property bool searchActive: false
+ property bool renameActive: false
+ property int renameChatId: -1
+
+ Component.onCompleted: forceActiveFocus()
+
+ function activateSearch() {
+ searchActive = true
+ searchInput.forceActiveFocus()
+ }
+
+ function deactivateSearch() {
+ searchActive = false
+ chatListModel.filter = ""
+ searchInput.text = ""
+ root.forceActiveFocus()
+ }
+
+ function activateRename() {
+ var model = chatListView.model
+ if (chatListView.currentIndex >= 0 && chatListView.currentIndex < model.count) {
+ var chat = model.get(chatListView.currentIndex)
+ renameChatId = chat.id
+ renameInput.text = chat.title
+ renameActive = true
+ renameInput.forceActiveFocus()
+ renameInput.selectAll()
+ }
+ }
+
+ function deactivateRename() {
+ renameActive = false
+ renameChatId = -1
+ renameInput.text = ""
+ root.forceActiveFocus()
+ }
+
+ function submitRename() {
+ if (renameChatId > 0 && renameInput.text.trim().length > 0) {
+ NetworkManager.updateChat(renameChatId, renameInput.text.trim())
+ }
+ deactivateRename()
+ }
+
+ Keys.onPressed: function(event) {
+ // Handle Escape for search/rename
+ if (searchActive) {
+ if (event.key === Qt.Key_Escape) {
+ deactivateSearch()
+ event.accepted = true
+ }
+ return
+ }
+ if (renameActive) {
+ if (event.key === Qt.Key_Escape) {
+ deactivateRename()
+ event.accepted = true
+ }
+ return
+ }
+
+ var listView = chatListView
+ var model = listView.model
+
+ if (event.key === Qt.Key_Slash) {
+ activateSearch()
+ event.accepted = true
+ } else if (event.key === Qt.Key_R) {
+ activateRename()
+ event.accepted = true
+ } else if (event.key === Qt.Key_J || (event.key === Qt.Key_N && event.modifiers & Qt.ControlModifier)) {
+ if (listView.currentIndex < model.count - 1) {
+ listView.currentIndex++
+ }
+ gPressed = false
+ event.accepted = true
+ } else if (event.key === Qt.Key_K || (event.key === Qt.Key_P && event.modifiers & Qt.ControlModifier)) {
+ if (listView.currentIndex > 0) {
+ listView.currentIndex--
+ }
+ gPressed = false
+ event.accepted = true
+ } else if (event.key === Qt.Key_G && event.modifiers & Qt.ShiftModifier) {
+ listView.currentIndex = model.count - 1
+ gPressed = false
+ event.accepted = true
+ } else if (event.key === Qt.Key_G && !event.modifiers) {
+ if (gPressed) {
+ listView.currentIndex = 0
+ gPressed = false
+ } else {
+ gPressed = true
+ }
+ event.accepted = true
+ } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
+ chatListView.openCurrentChat()
+ gPressed = false
+ event.accepted = true
+ } else {
+ gPressed = false
+ }
+ }
// Dynamic chat list model
ChatListModel {
@@ -17,12 +121,12 @@ Item {
// Handle network responses
Connections {
target: NetworkManager
- function onChatCreated(chatData) {
- chatListModel.addChat(chatData)
+ function onChatCreated(chatJson) {
+ // ChatListModel handles adding the chat - just reset loading state
root.isLoading = false
}
function onRequestFailed(error) {
- console.error("Failed to create chat:", error)
+ // Error already logged in Rust - just update UI state
root.isLoading = false
}
}
@@ -51,6 +155,141 @@ Item {
}
}
+ // Search bar
+ Rectangle {
+ Layout.fillWidth: true
+ height: searchActive ? 48 : 0
+ visible: searchActive
+ color: "white"
+ border.color: TelegramStyle.divider
+ border.width: 1
+
+ Behavior on height {
+ NumberAnimation { duration: 150 }
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.margins: TelegramStyle.smallMargin
+ spacing: TelegramStyle.smallMargin
+
+ Text {
+ text: "/"
+ font.pixelSize: TelegramStyle.fontSizeNormal
+ color: TelegramStyle.textSecondary
+ }
+
+ TextInput {
+ id: searchInput
+ Layout.fillWidth: true
+ font.pixelSize: TelegramStyle.fontSizeNormal
+ color: TelegramStyle.textPrimary
+ clip: true
+
+ onTextChanged: chatListModel.filter = text
+
+ Keys.onEscapePressed: root.deactivateSearch()
+ Keys.onReturnPressed: {
+ // Open first matching chat
+ if (chatListView.count > 0) {
+ chatListView.currentIndex = 0
+ chatListView.openCurrentChat()
+ root.deactivateSearch()
+ }
+ }
+
+ Text {
+ visible: searchInput.text.length === 0
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Search chats..."
+ color: TelegramStyle.textSecondary
+ font.pixelSize: TelegramStyle.fontSizeNormal
+ }
+ }
+
+ Text {
+ text: "Esc"
+ font.pixelSize: TelegramStyle.fontSizeSmall
+ color: TelegramStyle.textSecondary
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.deactivateSearch()
+ }
+ }
+ }
+ }
+
+ // Rename bar
+ Rectangle {
+ Layout.fillWidth: true
+ height: renameActive ? 48 : 0
+ visible: renameActive
+ color: "white"
+ border.color: TelegramStyle.primaryColor
+ border.width: 2
+
+ Behavior on height {
+ NumberAnimation { duration: 150 }
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.margins: TelegramStyle.smallMargin
+ spacing: TelegramStyle.smallMargin
+
+ Text {
+ text: "Rename:"
+ font.pixelSize: TelegramStyle.fontSizeNormal
+ color: TelegramStyle.textSecondary
+ }
+
+ TextInput {
+ id: renameInput
+ Layout.fillWidth: true
+ font.pixelSize: TelegramStyle.fontSizeNormal
+ color: TelegramStyle.textPrimary
+ clip: true
+
+ Keys.onEscapePressed: root.deactivateRename()
+ Keys.onReturnPressed: root.submitRename()
+
+ Text {
+ visible: renameInput.text.length === 0
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Enter new name..."
+ color: TelegramStyle.textSecondary
+ font.pixelSize: TelegramStyle.fontSizeNormal
+ }
+ }
+
+ Text {
+ text: "Enter"
+ font.pixelSize: TelegramStyle.fontSizeSmall
+ color: TelegramStyle.primaryColor
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.submitRename()
+ }
+ }
+
+ Text {
+ text: "Esc"
+ font.pixelSize: TelegramStyle.fontSizeSmall
+ color: TelegramStyle.textSecondary
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: root.deactivateRename()
+ }
+ }
+ }
+ }
+
// Chat list
Rectangle {
Layout.fillWidth: true
@@ -60,8 +299,21 @@ Item {
ListView {
id: chatListView
anchors.fill: parent
- model: chatListModel.model
+ model: chatListModel.filteredModel
clip: true
+ keyNavigationEnabled: false
+ currentIndex: 0
+ highlight: null
+ highlightFollowsCurrentItem: false
+
+ function openCurrentChat() {
+ if (currentIndex >= 0 && currentIndex < model.count) {
+ var chatId = model.get(currentIndex).id
+ if (typeof stackView !== 'undefined' && stackView) {
+ stackView.push(Qt.resolvedUrl("MessageThreadView.qml"), { chatId: chatId })
+ }
+ }
+ }
// Detect when user scrolls to bottom
onAtYEndChanged: {
@@ -74,7 +326,9 @@ Item {
delegate: ChatListItem {
width: chatListView.width
chatData: model
+ isSelected: chatListView.currentIndex === index
onClicked: {
+ chatListView.currentIndex = index
// Navigate to message thread
if (typeof stackView !== 'undefined' && stackView) {
stackView.push(Qt.resolvedUrl("MessageThreadView.qml"), { chatId: model.id })
diff --git a/qml/views/LoginView.qml b/qml/views/LoginView.qml
@@ -7,17 +7,7 @@ Item {
function performLogin() {
errorMessage.text = ""
-
- if (emailInput.text === "") {
- errorMessage.text = "Please enter your email"
- return
- }
-
- if (passwordInput.text === "") {
- errorMessage.text = "Please enter your password"
- return
- }
-
+ // Rust validates and emits loginFailed with message if invalid
NetworkManager.login(emailInput.text, passwordInput.text)
}
@@ -72,6 +62,10 @@ Item {
font.pixelSize: TelegramStyle.fontSizeNormal
color: TelegramStyle.textPrimary
selectByMouse: true
+ activeFocusOnTab: true
+ KeyNavigation.tab: passwordInput
+
+ Keys.onReturnPressed: root.performLogin()
Text {
anchors.fill: parent
@@ -112,6 +106,8 @@ Item {
color: TelegramStyle.textPrimary
echoMode: TextInput.Password
selectByMouse: true
+ activeFocusOnTab: true
+ KeyNavigation.tab: loginButton
Text {
anchors.fill: parent
@@ -146,7 +142,14 @@ Item {
Layout.fillWidth: true
height: 48
radius: 6
- color: loginMouseArea.containsMouse ? TelegramStyle.accentColor : TelegramStyle.primaryColor
+ color: loginMouseArea.containsMouse || activeFocus ? TelegramStyle.accentColor : TelegramStyle.primaryColor
+ border.color: activeFocus ? TelegramStyle.textPrimary : "transparent"
+ border.width: activeFocus ? 2 : 0
+ activeFocusOnTab: true
+ KeyNavigation.tab: emailInput
+
+ Keys.onReturnPressed: root.performLogin()
+ Keys.onSpacePressed: root.performLogin()
Behavior on color {
ColorAnimation { duration: 100 }
@@ -177,7 +180,6 @@ Item {
target: NetworkManager
function onLoginSuccess(authToken) {
- console.log("Login successful!")
// Navigate to chat list
if (typeof stackView !== 'undefined' && stackView) {
stackView.replace(Qt.resolvedUrl("ChatListView.qml"))
diff --git a/qml/views/MessageThreadView.qml b/qml/views/MessageThreadView.qml
@@ -15,18 +15,17 @@ Item {
MessageListModel {
id: messageListModel
chatId: root.chatId
- messageCache: typeof mainWindow !== 'undefined' ? mainWindow.messageCache : null
}
// Handle network responses
Connections {
target: NetworkManager
- function onMessageCreated(messageData) {
+ function onMessageCreated(messageJson) {
+ // JSON is available via: var messageData = JSON.parse(messageJson)
// Don't add message locally - wait for SSE to receive it
root.isSending = false
}
function onRequestFailed(error) {
- console.error("Failed:", error)
root.isSending = false
}
@@ -62,7 +61,7 @@ Item {
}
}
onMenuClicked: {
- console.log("Menu clicked for chat:", chatId)
+ // TODO: implement chat menu
}
}
@@ -131,13 +130,12 @@ Item {
Layout.fillWidth: true
enabled: !root.isSending && root.chatId >= 0
onSendMessage: (text) => {
- if (text.trim().length > 0 && root.chatId >= 0) {
- root.isSending = true
- NetworkManager.postMessage(root.chatId, text)
- }
+ // Rust validates (trims, checks empty)
+ root.isSending = true
+ NetworkManager.postMessage(root.chatId, text)
}
onAttachClicked: () => {
- console.log("Attach clicked")
+ // TODO: implement attachment picker
}
}
}
diff --git a/qml/views/ProfileView.qml b/qml/views/ProfileView.qml
@@ -175,7 +175,6 @@ Item {
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
- console.log("Logging out...")
NetworkManager.logout()
// Navigate back to login screen
if (typeof stackView !== 'undefined' && stackView) {
@@ -325,7 +324,7 @@ Rectangle {
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
- console.log("Profile action clicked:", label)
+ // TODO: implement profile action
}
}
diff --git a/qml/views/SettingsView.qml b/qml/views/SettingsView.qml
@@ -139,7 +139,7 @@ Item {
stackView.push(Qt.resolvedUrl("ProfileView.qml"))
}
} else {
- console.log("Settings item clicked:", model.title)
+ // TODO: implement other settings actions
}
}
}
diff --git a/qml/views/SplashView.qml b/qml/views/SplashView.qml
@@ -4,18 +4,11 @@ import "../styles"
Item {
id: root
+ anchors.fill: parent
// Property to control button visibility
property bool showGetStartedButton: false
- onShowGetStartedButtonChanged: {
- console.log("SplashView: showGetStartedButton changed to:", showGetStartedButton)
- }
-
- Component.onCompleted: {
- console.log("SplashView loaded!")
- }
-
Rectangle {
anchors.fill: parent
color: TelegramStyle.backgroundColor
@@ -28,9 +21,11 @@ Item {
// Logo
Image {
Layout.alignment: Qt.AlignHCenter
+ Layout.preferredWidth: 200
+ Layout.preferredHeight: 200
+ sourceSize.width: 200
+ sourceSize.height: 200
source: "qrc:/logo.png"
- width: 200
- height: 200
fillMode: Image.PreserveAspectFit
smooth: true
}
@@ -51,8 +46,21 @@ Item {
Layout.alignment: Qt.AlignHCenter
height: 48
radius: 6
- color: buttonMouseArea.containsMouse ? TelegramStyle.accentColor : TelegramStyle.primaryColor
+ color: buttonMouseArea.containsMouse || activeFocus ? TelegramStyle.accentColor : TelegramStyle.primaryColor
+ border.color: activeFocus ? TelegramStyle.textPrimary : "transparent"
+ border.width: activeFocus ? 2 : 0
visible: root.showGetStartedButton
+ activeFocusOnTab: true
+ focus: visible
+
+ Keys.onReturnPressed: navigateToLogin()
+ Keys.onSpacePressed: navigateToLogin()
+
+ function navigateToLogin() {
+ if (typeof stackView !== 'undefined' && stackView) {
+ stackView.replace("qrc:/qml/views/LoginView.qml")
+ }
+ }
Behavior on color {
ColorAnimation { duration: 100 }
@@ -71,12 +79,7 @@ Item {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
- onClicked: {
- // Navigate to LoginView
- if (typeof stackView !== 'undefined' && stackView) {
- stackView.replace("qrc:/qml/views/LoginView.qml")
- }
- }
+ onClicked: getStartedButton.navigateToLogin()
}
}
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
@@ -0,0 +1,2428 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "beegram_network"
+version = "0.1.0"
+dependencies = [
+ "cxx",
+ "cxx-qt",
+ "cxx-qt-build",
+ "cxx-qt-lib",
+ "futures",
+ "reqwest",
+ "reqwest-eventsource",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "trailbase-client",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+
+[[package]]
+name = "bumpalo"
+version = "3.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "cc"
+version = "1.2.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "clang-format"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "696283b40e1a39d208ee614b92e5f6521d16962edeb47c48372585ec92419943"
+dependencies = [
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "clap"
+version = "4.5.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806"
+dependencies = [
+ "clap_builder",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2"
+dependencies = [
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
+
+[[package]]
+name = "codespan-reporting"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
+dependencies = [
+ "termcolor",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "codespan-reporting"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
+dependencies = [
+ "serde",
+ "termcolor",
+ "unicode-width 0.2.2",
+]
+
+[[package]]
+name = "convert_case"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cxx"
+version = "1.0.194"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "747d8437319e3a2f43d93b341c137927ca70c0f5dabeea7a005a73665e247c7e"
+dependencies = [
+ "cc",
+ "cxx-build",
+ "cxxbridge-cmd",
+ "cxxbridge-flags",
+ "cxxbridge-macro",
+ "foldhash",
+ "link-cplusplus",
+]
+
+[[package]]
+name = "cxx-build"
+version = "1.0.194"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0f4697d190a142477b16aef7da8a99bfdc41e7e8b1687583c0d23a79c7afc1e"
+dependencies = [
+ "cc",
+ "codespan-reporting 0.13.1",
+ "indexmap",
+ "proc-macro2",
+ "quote",
+ "scratch",
+ "syn",
+]
+
+[[package]]
+name = "cxx-gen"
+version = "0.7.194"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "035b6c61a944483e8a4b2ad4fb8b13830d63491bd004943716ad16d85dcc64bc"
+dependencies = [
+ "codespan-reporting 0.13.1",
+ "indexmap",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "cxx-qt"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb4ce9106b3ee7ef85f77d5f69ec30ec3037ea1edacd3e29a61b26ff47ecc637"
+dependencies = [
+ "cxx",
+ "cxx-qt-build",
+ "cxx-qt-macro",
+ "qt-build-utils",
+ "static_assertions",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "cxx-qt-build"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23a7884708e645dc34a2c475bd8c505a2ffeb7c9cb0843f5c580c002c939ea30"
+dependencies = [
+ "cc",
+ "codespan-reporting 0.11.1",
+ "cxx-gen",
+ "cxx-qt-gen",
+ "proc-macro2",
+ "qt-build-utils",
+ "quote",
+ "serde",
+ "serde_json",
+ "version_check",
+]
+
+[[package]]
+name = "cxx-qt-gen"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6b431dc58e4a9b7a55b675be0941331cc2cab0a494e4742ebf7bb393c3fc39"
+dependencies = [
+ "clang-format",
+ "convert_case",
+ "indoc",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "cxx-qt-lib"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527b46c28be6bf3dd02f1567706641fe247a8798defab8268c84602955741217"
+dependencies = [
+ "cxx",
+ "cxx-qt",
+ "cxx-qt-build",
+ "qt-build-utils",
+]
+
+[[package]]
+name = "cxx-qt-macro"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "971d8811fd1c8dd06c17284edcfc8e9663dac30d7e3d52159405f836a81923f1"
+dependencies = [
+ "cxx-qt-gen",
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "cxxbridge-cmd"
+version = "1.0.194"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0956799fa8678d4c50eed028f2de1c0552ae183c76e976cf7ca8c4e36a7c328"
+dependencies = [
+ "clap",
+ "codespan-reporting 0.13.1",
+ "indexmap",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "cxxbridge-flags"
+version = "1.0.194"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23384a836ab4f0ad98ace7e3955ad2de39de42378ab487dc28d3990392cb283a"
+
+[[package]]
+name = "cxxbridge-macro"
+version = "1.0.194"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6acc6b5822b9526adfb4fc377b67128fdd60aac757cc4a741a6278603f763cf"
+dependencies = [
+ "indexmap",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "eventsource-stream"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab"
+dependencies = [
+ "futures-core",
+ "nom",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-timer"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "h2"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+
+[[package]]
+name = "http"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "pin-utils",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hyper-tls"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
+dependencies = [
+ "bytes",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "system-configuration",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "windows-registry",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
+
+[[package]]
+name = "icu_properties"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
+
+[[package]]
+name = "icu_provider"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "indoc"
+version = "2.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
+
+[[package]]
+name = "iri-string"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+
+[[package]]
+name = "jobserver"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+dependencies = [
+ "getrandom 0.3.4",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.85"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "jsonwebtoken"
+version = "9.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
+dependencies = [
+ "base64",
+ "js-sys",
+ "ring",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.182"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
+
+[[package]]
+name = "link-cplusplus"
+version = "1.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
+
+[[package]]
+name = "litemap"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "mio"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "native-tls"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d5d26952a508f321b4d3d2e80e78fc2603eaefcdf0c30783867f19586518bdc"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "openssl"
+version = "0.10.75"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "once_cell",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.111"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "qt-build-utils"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63c7e24653d0b3084180066306b26e532b152470b13a050c2d2960284d4b9f53"
+dependencies = [
+ "cc",
+ "thiserror 1.0.69",
+ "versions",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
+dependencies = [
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64",
+ "bytes",
+ "encoding_rs",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-tls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "mime",
+ "native-tls",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-native-tls",
+ "tokio-rustls",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "reqwest-eventsource"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "632c55746dbb44275691640e7b40c907c16a2dc1a5842aa98aaec90da6ec6bde"
+dependencies = [
+ "eventsource-stream",
+ "futures-core",
+ "futures-timer",
+ "mime",
+ "nom",
+ "pin-project-lite",
+ "reqwest",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
+
+[[package]]
+name = "rustix"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "schannel"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "scratch"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2"
+
+[[package]]
+name = "security-framework"
+version = "3.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38"
+dependencies = [
+ "bitflags",
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "socket2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
+dependencies = [
+ "libc",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.115"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "system-configuration"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
+dependencies = [
+ "bitflags",
+ "core-foundation 0.9.4",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
+dependencies = [
+ "fastrand",
+ "getrandom 0.3.4",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl 2.0.18",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.49.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "iri-string",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "trailbase-client"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7079308aed930a095f807b3bab795986d8fc7073d1266b96437fa251521d8daa"
+dependencies = [
+ "eventsource-stream",
+ "futures",
+ "jsonwebtoken",
+ "parking_lot",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.18",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "versions"
+version = "6.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f25d498b63d1fdb376b4250f39ab3a5ee8d103957346abacd911e2d8b612c139"
+dependencies = [
+ "itertools",
+ "nom",
+]
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.2+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "js-sys",
+ "once_cell",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-streams"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.85"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-registry"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
+dependencies = [
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+
+[[package]]
+name = "writeable"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
+
+[[package]]
+name = "yoke"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.39"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.39"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
@@ -0,0 +1,42 @@
+[package]
+name = "beegram_network"
+version = "0.1.0"
+edition = "2021"
+authors = ["Beegram Team"]
+description = "Rust backend for Beegram chat application using CXX-Qt"
+
+[lib]
+crate-type = ["staticlib"]
+
+[dependencies]
+# CXX-Qt core
+cxx = "1.0"
+cxx-qt = "0.7"
+cxx-qt-lib = "0.7"
+
+# Async runtime
+tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros"] }
+
+# TrailBase client (type-safe API)
+trailbase-client = "0.5"
+
+# HTTP client for SSE (reqwest-eventsource needs raw reqwest for custom headers)
+reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
+
+# SSE client
+reqwest-eventsource = "0.6"
+futures = "0.3"
+
+# JSON serialization
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+
+# Error handling
+thiserror = "2"
+
+# Logging
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+
+[build-dependencies]
+cxx-qt-build = "0.7"
diff --git a/rust/build.rs b/rust/build.rs
@@ -0,0 +1,7 @@
+use cxx_qt_build::CxxQtBuilder;
+
+fn main() {
+ CxxQtBuilder::new()
+ .file("src/network_manager.rs")
+ .build();
+}
diff --git a/rust/schemas/chat_insert.json b/rust/schemas/chat_insert.json
@@ -0,0 +1,22 @@
+{
+ "properties": {
+ "created_at": {
+ "type": "string"
+ },
+ "created_by": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "title": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "title",
+ "created_by"
+ ],
+ "title": "chat",
+ "type": "object"
+}
diff --git a/rust/schemas/chat_participation_insert.json b/rust/schemas/chat_participation_insert.json
@@ -0,0 +1,19 @@
+{
+ "properties": {
+ "chat_id": {
+ "type": "integer"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "user_id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "chat_id",
+ "user_id"
+ ],
+ "title": "chat_participation",
+ "type": "object"
+}
diff --git a/rust/schemas/chat_read.json b/rust/schemas/chat_read.json
@@ -0,0 +1,24 @@
+{
+ "properties": {
+ "created_at": {
+ "type": "string"
+ },
+ "created_by": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "title": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "title",
+ "created_at",
+ "created_by"
+ ],
+ "title": "chat",
+ "type": "object"
+}
diff --git a/rust/schemas/message_insert.json b/rust/schemas/message_insert.json
@@ -0,0 +1,25 @@
+{
+ "properties": {
+ "chat_id": {
+ "type": "integer"
+ },
+ "content": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "sender_id": {
+ "type": "string"
+ },
+ "sent_at": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "chat_id",
+ "content"
+ ],
+ "title": "message",
+ "type": "object"
+}
diff --git a/rust/schemas/message_read.json b/rust/schemas/message_read.json
@@ -0,0 +1,27 @@
+{
+ "properties": {
+ "chat_id": {
+ "type": "integer"
+ },
+ "content": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "sender_id": {
+ "type": "string"
+ },
+ "sent_at": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "chat_id",
+ "content",
+ "sent_at"
+ ],
+ "title": "message",
+ "type": "object"
+}
diff --git a/rust/schemas/traildepot/.gitignore b/rust/schemas/traildepot/.gitignore
@@ -0,0 +1,16 @@
+# Deployment-specific directories:
+backups/
+data/
+secrets/
+uploads/
+wasm/
+scripts/
+
+metadata.textproto
+
+# Runtime files, will be overriden by `trail`.
+trailbase.d.ts
+trailbase.js
+
+# Any potential MaxMind GeoIP dbs.
+*.mmdb
diff --git a/rust/schemas/traildepot/config.textproto b/rust/schemas/traildepot/config.textproto
@@ -0,0 +1,11 @@
+# Auto-generated config.Config textproto
+email {}
+server {
+ application_name: "TrailBase"
+ logs_retention_sec: 604800
+}
+auth {
+ auth_token_ttl_sec: 3600
+ refresh_token_ttl_sec: 2592000
+}
+jobs {}
+\ No newline at end of file
diff --git a/rust/schemas/user_chats_read.json b/rust/schemas/user_chats_read.json
@@ -0,0 +1,28 @@
+{
+ "properties": {
+ "created_at": {
+ "type": "string"
+ },
+ "created_by": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "title": {
+ "type": "string"
+ },
+ "user_id": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "user_id",
+ "id",
+ "title",
+ "created_at",
+ "created_by"
+ ],
+ "title": "user_chats",
+ "type": "object"
+}
diff --git a/rust/src/generated/mod.rs b/rust/src/generated/mod.rs
@@ -0,0 +1,68 @@
+//! Auto-generated types from TrailBase JSON schemas
+//!
+//! Generated using: ./scripts/generate-types.sh
+//! Do not edit manually - regenerate from schemas instead.
+
+use serde::{Deserialize, Serialize};
+
+/// Chat record from the database (read)
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Chat {
+ pub id: i64,
+ pub title: String,
+ pub created_at: String,
+ pub created_by: String,
+}
+
+/// Input for creating a new chat (insert)
+/// Note: created_by is auto-populated by TrailBase from the authenticated user
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct NewChat {
+ pub title: String,
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ pub id: Option<i64>,
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ pub created_at: Option<String>,
+}
+
+/// Message record from the database (read)
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Message {
+ pub id: i64,
+ pub chat_id: i64,
+ pub content: String,
+ #[serde(default)]
+ pub sender_id: Option<String>,
+ pub sent_at: String,
+}
+
+/// Input for creating a new message (insert)
+/// Note: sender_id is auto-populated by TrailBase from the authenticated user
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct NewMessage {
+ pub chat_id: i64,
+ pub content: String,
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ pub id: Option<i64>,
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ pub sent_at: Option<String>,
+}
+
+/// User chats view record (from user_chats view)
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct UserChat {
+ pub id: i64,
+ pub title: String,
+ pub created_at: String,
+ pub created_by: String,
+ pub user_id: String,
+}
+
+/// Input for creating chat participation (insert)
+/// Note: user_id is auto-populated by TrailBase from the authenticated user
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct NewChatParticipation {
+ pub chat_id: i64,
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ pub id: Option<i64>,
+}
diff --git a/rust/src/http_client.rs b/rust/src/http_client.rs
@@ -0,0 +1,140 @@
+//! HTTP client operations using the TrailBase client library
+//!
+//! This module provides type-safe access to the TrailBase API.
+
+use crate::types::{
+ Chat, Message, NetworkError, NewChat, NewChatParticipation, NewMessage,
+ UserChat, BASE_URL,
+};
+use trailbase_client::{Client, CompareOp, Filter, ListArguments, Pagination};
+
+/// Create a new TrailBase client
+pub fn create_client() -> Client {
+ Client::new(BASE_URL, None).expect("Failed to create TrailBase client")
+}
+
+/// Perform login and return the auth token
+/// Note: This also sets the tokens on the client internally
+pub async fn login(client: &Client, email: &str, password: &str) -> Result<String, NetworkError> {
+ let tokens = client.login(email, password).await?;
+ Ok(tokens.auth_token)
+}
+
+/// Create a client with pre-existing tokens (for auto-login)
+pub fn create_client_with_token(token: &str) -> Client {
+ use trailbase_client::Tokens;
+ let tokens = Tokens {
+ auth_token: token.to_string(),
+ refresh_token: None,
+ csrf_token: None,
+ };
+ Client::new(BASE_URL, Some(tokens)).expect("Failed to create TrailBase client")
+}
+
+/// Create a new chat and return its ID
+pub async fn post_chat(client: &Client, title: &str) -> Result<Chat, NetworkError> {
+ let new_chat = NewChat {
+ title: title.to_string(),
+ id: None,
+ created_at: None,
+ };
+
+ let api = client.records("chat");
+ let id = api.create(&new_chat).await?;
+
+ // Read back the created chat to get full data
+ let chat: Chat = api.read(&id).await?;
+ Ok(chat)
+}
+
+/// Create chat participation entry
+pub async fn create_chat_participation(client: &Client, chat_id: i64) -> Result<String, NetworkError> {
+ let new_participation = NewChatParticipation {
+ chat_id,
+ id: None,
+ };
+
+ let api = client.records("chat_participation");
+ let id = api.create(&new_participation).await?;
+ Ok(id)
+}
+
+/// Fetch chats with pagination (from user_chats view)
+pub async fn fetch_chats(
+ client: &Client,
+ cursor: Option<&str>,
+) -> Result<(Vec<UserChat>, Option<String>), NetworkError> {
+ let api = client.records("user_chats");
+
+ let pagination = Pagination::new()
+ .with_limit(15)
+ .with_cursor(cursor.map(|c| c.to_string()));
+
+ let args = ListArguments::new()
+ .with_pagination(pagination)
+ .with_order(&["-created_at", "-id"]);
+
+ let response = api.list(args).await?;
+ Ok((response.records, response.cursor))
+}
+
+/// Post a new message and return it
+pub async fn post_message(
+ client: &Client,
+ chat_id: i64,
+ content: &str,
+) -> Result<Message, NetworkError> {
+ let new_message = NewMessage {
+ chat_id,
+ content: content.to_string(),
+ id: None,
+ sent_at: None,
+ };
+
+ let api = client.records("message");
+ let id = api.create(&new_message).await?;
+
+ // Read back the created message to get full data
+ let message: Message = api.read(&id).await?;
+ Ok(message)
+}
+
+/// Fetch messages for a chat with pagination
+pub async fn fetch_messages(
+ client: &Client,
+ chat_id: i64,
+ cursor: Option<&str>,
+) -> Result<(Vec<Message>, Option<String>), NetworkError> {
+ let api = client.records("message");
+
+ let pagination = Pagination::new()
+ .with_limit(20)
+ .with_cursor(cursor.map(|c| c.to_string()));
+
+ let filter = Filter::new("chat_id", CompareOp::Equal, chat_id.to_string());
+
+ let args = ListArguments::new()
+ .with_pagination(pagination)
+ .with_order(&["-sent_at", "-id"])
+ .with_filters(filter);
+
+ let response = api.list(args).await?;
+ Ok((response.records, response.cursor))
+}
+
+/// Update a chat's title
+pub async fn update_chat(client: &Client, chat_id: i64, title: &str) -> Result<Chat, NetworkError> {
+ let api = client.records("chat");
+
+ let update_data = NewChat {
+ title: title.to_string(),
+ id: Some(chat_id),
+ created_at: None,
+ };
+
+ api.update(&chat_id.to_string(), &update_data).await?;
+
+ // Read back the updated chat
+ let chat: Chat = api.read(&chat_id.to_string()).await?;
+ Ok(chat)
+}
diff --git a/rust/src/keychain.rs b/rust/src/keychain.rs
@@ -0,0 +1,94 @@
+//! Credential storage module
+//!
+//! Uses file-based storage in the user's app data directory.
+//! Note: For production, consider using the system keychain with proper code signing.
+
+use crate::types::KeychainError;
+use std::fs;
+use std::path::PathBuf;
+
+/// Get the path to the token file
+fn token_file_path() -> Result<PathBuf, KeychainError> {
+ let home = std::env::var("HOME").map_err(|_| KeychainError::NotFound)?;
+ let app_dir = PathBuf::from(home).join(".config").join("beegram");
+ Ok(app_dir.join("auth_token"))
+}
+
+/// Ensure the app config directory exists
+fn ensure_config_dir() -> Result<(), KeychainError> {
+ let path = token_file_path()?;
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent).map_err(|e| {
+ tracing::error!("Failed to create config directory: {:?}", e);
+ KeychainError::NotFound
+ })?;
+ }
+ Ok(())
+}
+
+/// Save authentication token to file
+pub fn save_token(token: &str) -> Result<(), KeychainError> {
+ ensure_config_dir()?;
+ let path = token_file_path()?;
+ tracing::info!("Saving token to {:?}", path);
+
+ fs::write(&path, token).map_err(|e| {
+ tracing::error!("Failed to save token: {:?}", e);
+ KeychainError::NotFound
+ })?;
+
+ // Set file permissions to owner-only (Unix)
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let perms = fs::Permissions::from_mode(0o600);
+ let _ = fs::set_permissions(&path, perms);
+ }
+
+ tracing::info!("Token saved successfully");
+ Ok(())
+}
+
+/// Load authentication token from file
+pub fn load_token() -> Result<String, KeychainError> {
+ let path = token_file_path()?;
+ tracing::info!("Loading token from {:?}", path);
+
+ match fs::read_to_string(&path) {
+ Ok(token) if !token.is_empty() => {
+ tracing::info!("Token loaded (length={})", token.len());
+ Ok(token)
+ }
+ Ok(_) => {
+ tracing::info!("Empty token file");
+ Err(KeychainError::NotFound)
+ }
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+ tracing::info!("No token file found");
+ Err(KeychainError::NotFound)
+ }
+ Err(e) => {
+ tracing::error!("Failed to read token: {:?}", e);
+ Err(KeychainError::NotFound)
+ }
+ }
+}
+
+/// Delete authentication token file
+pub fn delete_token() -> Result<(), KeychainError> {
+ let path = token_file_path()?;
+ match fs::remove_file(&path) {
+ Ok(()) => {
+ tracing::info!("Token deleted");
+ Ok(())
+ }
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+ tracing::debug!("No token to delete");
+ Ok(())
+ }
+ Err(e) => {
+ tracing::warn!("Failed to delete token: {:?}", e);
+ Err(KeychainError::NotFound)
+ }
+ }
+}
diff --git a/rust/src/lib.rs b/rust/src/lib.rs
@@ -0,0 +1,9 @@
+//! Beegram Network - Rust backend for the Beegram chat application
+
+pub mod generated;
+pub mod http_client;
+pub mod keychain;
+pub mod message_cache;
+pub mod network_manager;
+pub mod sse_client;
+pub mod types;
diff --git a/rust/src/message_cache.rs b/rust/src/message_cache.rs
@@ -0,0 +1,168 @@
+//! Message caching with O(1) deduplication
+//!
+//! Stores messages by chat_id with HashSet-based dedup to avoid
+//! duplicate messages from SSE reconnections.
+
+use crate::generated::{Chat, Message, UserChat};
+use serde::Serialize;
+use std::collections::{HashMap, HashSet};
+
+/// Display-ready chat for QML (with computed/default fields)
+#[derive(Debug, Clone, Serialize)]
+pub struct DisplayChat {
+ pub id: i64,
+ pub title: String,
+ pub created_at: String,
+ pub created_by: String,
+ // Display-only fields (defaults for now)
+ pub avatar: String,
+ pub is_online: bool,
+ pub last_message: String,
+ pub unread_count: i32,
+}
+
+impl DisplayChat {
+ pub fn from_chat(chat: &Chat) -> Self {
+ Self {
+ id: chat.id,
+ title: chat.title.clone(),
+ created_at: chat.created_at.clone(),
+ created_by: chat.created_by.clone(),
+ avatar: String::new(),
+ is_online: false,
+ last_message: String::new(),
+ unread_count: 0,
+ }
+ }
+
+ pub fn from_user_chat(chat: &UserChat) -> Self {
+ Self {
+ id: chat.id,
+ title: chat.title.clone(),
+ created_at: chat.created_at.clone(),
+ created_by: chat.created_by.clone(),
+ avatar: String::new(),
+ is_online: false,
+ last_message: String::new(),
+ unread_count: 0,
+ }
+ }
+}
+
+/// Display-ready message for QML (pre-computed fields)
+#[derive(Debug, Clone, Serialize)]
+pub struct DisplayMessage {
+ pub id: i64,
+ pub chat_id: i64,
+ pub sender_id: String,
+ pub content: String,
+ pub sent_at: String,
+ pub is_outgoing: bool,
+ pub is_read: bool,
+}
+
+impl DisplayMessage {
+ /// Transform a Message into a DisplayMessage
+ pub fn from_message(msg: &Message, current_user_id: &str) -> Self {
+ let sender_id = msg.sender_id.clone().unwrap_or_default();
+ Self {
+ id: msg.id,
+ chat_id: msg.chat_id,
+ sender_id: sender_id.clone(),
+ content: msg.content.clone(),
+ sent_at: msg.sent_at.clone(),
+ is_outgoing: sender_id == current_user_id,
+ is_read: true,
+ }
+ }
+}
+
+/// Message cache with O(1) deduplication
+pub struct MessageCache {
+ /// Messages by chat_id
+ messages: HashMap<i64, Vec<DisplayMessage>>,
+ /// Seen message IDs for deduplication
+ seen_ids: HashSet<i64>,
+}
+
+impl Default for MessageCache {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl MessageCache {
+ pub fn new() -> Self {
+ Self {
+ messages: HashMap::new(),
+ seen_ids: HashSet::new(),
+ }
+ }
+
+ /// Add a message if not already seen. Returns Some(DisplayMessage) if new, None if duplicate.
+ pub fn add_message(&mut self, msg: Message, current_user_id: &str) -> Option<DisplayMessage> {
+ if self.seen_ids.contains(&msg.id) {
+ return None;
+ }
+
+ self.seen_ids.insert(msg.id);
+ let display_msg = DisplayMessage::from_message(&msg, current_user_id);
+
+ self.messages
+ .entry(msg.chat_id)
+ .or_default()
+ .push(display_msg.clone());
+
+ Some(display_msg)
+ }
+
+ /// Get all messages for a chat
+ pub fn get_messages(&self, chat_id: i64) -> &[DisplayMessage] {
+ self.messages.get(&chat_id).map(|v| v.as_slice()).unwrap_or(&[])
+ }
+
+ /// Set messages for a chat (initial load), replacing any existing
+ pub fn set_messages(&mut self, chat_id: i64, messages: Vec<Message>, current_user_id: &str) -> Vec<DisplayMessage> {
+ let display_messages: Vec<DisplayMessage> = messages
+ .iter()
+ .map(|msg| {
+ self.seen_ids.insert(msg.id);
+ DisplayMessage::from_message(msg, current_user_id)
+ })
+ .collect();
+
+ self.messages.insert(chat_id, display_messages.clone());
+ display_messages
+ }
+
+ /// Prepend older messages (for pagination)
+ pub fn prepend_messages(&mut self, chat_id: i64, messages: Vec<Message>, current_user_id: &str) -> Vec<DisplayMessage> {
+ let display_messages: Vec<DisplayMessage> = messages
+ .iter()
+ .filter_map(|msg| {
+ if self.seen_ids.contains(&msg.id) {
+ None
+ } else {
+ self.seen_ids.insert(msg.id);
+ Some(DisplayMessage::from_message(msg, current_user_id))
+ }
+ })
+ .collect();
+
+ let chat_messages = self.messages.entry(chat_id).or_default();
+ let mut new_messages = display_messages.clone();
+ new_messages.append(chat_messages);
+ *chat_messages = new_messages;
+
+ display_messages
+ }
+
+ /// Clear messages for a chat
+ pub fn clear_chat(&mut self, chat_id: i64) {
+ if let Some(messages) = self.messages.remove(&chat_id) {
+ for msg in messages {
+ self.seen_ids.remove(&msg.id);
+ }
+ }
+ }
+}
diff --git a/rust/src/network_manager.rs b/rust/src/network_manager.rs
@@ -0,0 +1,654 @@
+//! CXX-Qt NetworkManager - Full Rust implementation
+//!
+//! Implements the NetworkManager QObject with:
+//! - HTTP client using trailbase-client for type-safe API access
+//! - Keychain storage using keyring
+//! - SSE for real-time messages using reqwest-eventsource
+
+use cxx_qt::{CxxQtType, Threading};
+use cxx_qt_lib::QString;
+use std::pin::Pin;
+use std::sync::Arc;
+use tokio::sync::Mutex;
+
+#[cxx_qt::bridge]
+mod ffi {
+ unsafe extern "C++" {
+ include!("cxx-qt-lib/qstring.h");
+ type QString = cxx_qt_lib::QString;
+ }
+
+ unsafe extern "RustQt" {
+ #[qobject]
+ #[qproperty(QString, current_user_id)]
+ #[qproperty(bool, is_authenticated, READ, NOTIFY = authentication_changed)]
+ type RustNetworkManager = super::RustNetworkManagerRust;
+
+ // ==================== Signals ====================
+
+ #[qsignal]
+ #[cxx_name = "loginSuccess"]
+ fn login_success(self: Pin<&mut RustNetworkManager>, auth_token: QString);
+
+ #[qsignal]
+ #[cxx_name = "loginFailed"]
+ fn login_failed(self: Pin<&mut RustNetworkManager>, error: QString);
+
+ #[qsignal]
+ #[cxx_name = "authenticationChanged"]
+ fn authentication_changed(self: Pin<&mut RustNetworkManager>);
+
+ #[qsignal]
+ #[cxx_name = "autoLoginSuccess"]
+ fn auto_login_success(self: Pin<&mut RustNetworkManager>);
+
+ #[qsignal]
+ #[cxx_name = "autoLoginFailed"]
+ fn auto_login_failed(self: Pin<&mut RustNetworkManager>, reason: QString);
+
+ #[qsignal]
+ #[cxx_name = "requestFailed"]
+ fn request_failed(self: Pin<&mut RustNetworkManager>, error: QString);
+
+ // JSON string signals for QML compatibility
+ #[qsignal]
+ #[cxx_name = "chatCreated"]
+ fn chat_created_json(self: Pin<&mut RustNetworkManager>, chat_json: QString);
+
+ #[qsignal]
+ #[cxx_name = "chatUpdated"]
+ fn chat_updated_json(self: Pin<&mut RustNetworkManager>, chat_json: QString);
+
+ #[qsignal]
+ #[cxx_name = "chatsFetched"]
+ fn chats_fetched_json(self: Pin<&mut RustNetworkManager>, chats_json: QString, cursor: QString);
+
+ #[qsignal]
+ #[cxx_name = "messageCreated"]
+ fn message_created_json(self: Pin<&mut RustNetworkManager>, message_json: QString);
+
+ #[qsignal]
+ #[cxx_name = "messagesFetched"]
+ fn messages_fetched_json(self: Pin<&mut RustNetworkManager>, messages_json: QString, cursor: QString);
+
+ #[qsignal]
+ #[cxx_name = "messageReceived"]
+ fn message_received_json(self: Pin<&mut RustNetworkManager>, message_json: QString);
+
+ #[qsignal]
+ #[cxx_name = "sseConnected"]
+ fn sse_connected(self: Pin<&mut RustNetworkManager>, chat_id: i32);
+
+ #[qsignal]
+ #[cxx_name = "sseDisconnected"]
+ fn sse_disconnected(self: Pin<&mut RustNetworkManager>, chat_id: i32, reason: QString);
+
+ #[qsignal]
+ #[cxx_name = "sseError"]
+ fn sse_error(self: Pin<&mut RustNetworkManager>, error: QString);
+
+ // ==================== Invokable Methods ====================
+
+ #[qinvokable]
+ fn login(self: Pin<&mut RustNetworkManager>, email: QString, password: QString);
+
+ #[qinvokable]
+ fn logout(self: Pin<&mut RustNetworkManager>);
+
+ #[qinvokable]
+ #[cxx_name = "checkSavedCredentials"]
+ fn check_saved_credentials(self: Pin<&mut RustNetworkManager>);
+
+ #[qinvokable]
+ #[cxx_name = "postChat"]
+ fn post_chat(self: Pin<&mut RustNetworkManager>, name: QString);
+
+ #[qinvokable]
+ #[cxx_name = "updateChat"]
+ fn update_chat(self: Pin<&mut RustNetworkManager>, chat_id: i32, title: QString);
+
+ #[qinvokable]
+ #[cxx_name = "fetchChats"]
+ fn fetch_chats(self: Pin<&mut RustNetworkManager>, cursor: QString);
+
+ #[qinvokable]
+ #[cxx_name = "postMessage"]
+ fn post_message(self: Pin<&mut RustNetworkManager>, chat_id: i32, content: QString);
+
+ #[qinvokable]
+ #[cxx_name = "fetchMessages"]
+ fn fetch_messages(self: Pin<&mut RustNetworkManager>, chat_id: i32, cursor: QString);
+
+ #[qinvokable]
+ #[cxx_name = "subscribeToChat"]
+ fn subscribe_to_chat(self: Pin<&mut RustNetworkManager>, chat_id: i32);
+
+ #[qinvokable]
+ #[cxx_name = "subscribeToChats"]
+ fn subscribe_to_chats(self: Pin<&mut RustNetworkManager>, chat_ids_json: QString);
+
+ #[qinvokable]
+ #[cxx_name = "unsubscribeFromChat"]
+ fn unsubscribe_from_chat(self: Pin<&mut RustNetworkManager>);
+ }
+
+ impl cxx_qt::Threading for RustNetworkManager {}
+}
+
+use crate::http_client;
+use crate::keychain;
+use crate::message_cache::{DisplayChat, MessageCache};
+use crate::sse_client::{SseCommand, SseEvent};
+use tokio::runtime::Runtime;
+use tokio::sync::mpsc;
+use trailbase_client::Client;
+
+/// Rust implementation of the NetworkManager
+pub struct RustNetworkManagerRust {
+ current_user_id: QString,
+ is_authenticated: bool,
+ /// Auth token stored for SSE connections (trailbase-client handles its own token internally)
+ auth_token: Arc<Mutex<String>>,
+ runtime: Arc<Runtime>,
+ /// TrailBase client for type-safe API access (Mutex allows replacing with authenticated client)
+ client: Arc<Mutex<Client>>,
+ sse_command_tx: Arc<Mutex<Option<mpsc::Sender<SseCommand>>>>,
+ /// Message cache with deduplication
+ message_cache: Arc<Mutex<MessageCache>>,
+}
+
+impl Default for RustNetworkManagerRust {
+ fn default() -> Self {
+ // Initialize tracing subscriber (ignore if already initialized)
+ use tracing_subscriber::{fmt, EnvFilter};
+ let _ = fmt()
+ .with_env_filter(EnvFilter::from_default_env())
+ .try_init();
+
+ let runtime = Runtime::new().expect("Failed to create Tokio runtime");
+ let client = http_client::create_client();
+
+ Self {
+ current_user_id: QString::default(),
+ is_authenticated: false,
+ auth_token: Arc::new(Mutex::new(String::new())),
+ runtime: Arc::new(runtime),
+ client: Arc::new(Mutex::new(client)),
+ sse_command_tx: Arc::new(Mutex::new(None)),
+ message_cache: Arc::new(Mutex::new(MessageCache::new())),
+ }
+ }
+}
+
+impl ffi::RustNetworkManager {
+ fn login(mut self: Pin<&mut Self>, email: QString, password: QString) {
+ let email_str = email.to_string();
+ let password_str = password.to_string();
+
+ // Validate before making network request
+ if email_str.trim().is_empty() {
+ self.as_mut().login_failed(QString::from("Email is required"));
+ return;
+ }
+ if password_str.trim().is_empty() {
+ self.as_mut().login_failed(QString::from("Password is required"));
+ return;
+ }
+
+ let qt_thread = self.qt_thread();
+ let client = self.rust().client.clone();
+ let auth_token = self.rust().auth_token.clone();
+
+ self.rust().runtime.spawn(async move {
+ let client_guard = client.lock().await;
+ match http_client::login(&client_guard, &email_str, &password_str).await {
+ Ok(token) => {
+ // Get user ID from the client (extracted from JWT)
+ let user_id = client_guard.user().map(|u| u.sub).unwrap_or_default();
+ drop(client_guard); // Release lock before storing token
+
+ // Store token for SSE connections
+ {
+ let mut auth = auth_token.lock().await;
+ *auth = token.clone();
+ }
+
+ // Save to keychain
+ if let Err(e) = keychain::save_token(&token) {
+ tracing::warn!("Failed to save token to keychain: {}", e);
+ }
+
+ qt_thread
+ .queue(move |mut qobject: Pin<&mut ffi::RustNetworkManager>| {
+ qobject.as_mut().set_current_user_id(QString::from(&user_id));
+ qobject.as_mut().rust_mut().is_authenticated = true;
+ qobject.as_mut().authentication_changed();
+ qobject.as_mut().login_success(QString::from(&token));
+ })
+ .unwrap();
+ }
+ Err(e) => {
+ tracing::error!("login error: {:?}", e);
+ let error_msg = e.to_string();
+ qt_thread
+ .queue(move |mut qobject: Pin<&mut ffi::RustNetworkManager>| {
+ qobject.as_mut().login_failed(QString::from(&error_msg));
+ })
+ .unwrap();
+ }
+ }
+ });
+ }
+
+ fn logout(mut self: Pin<&mut Self>) {
+ // Cancel any active SSE subscription
+ let sse_command_tx = self.as_mut().rust().sse_command_tx.clone();
+ let auth_token = self.as_mut().rust().auth_token.clone();
+ let client = self.as_mut().rust().client.clone();
+ let runtime = self.as_mut().rust().runtime.clone();
+
+ runtime.spawn(async move {
+ // Cancel SSE
+ if let Some(tx) = sse_command_tx.lock().await.take() {
+ let _ = tx.send(SseCommand::Stop).await;
+ }
+
+ // Clear auth token
+ auth_token.lock().await.clear();
+
+ // Logout from the client (clears internal tokens)
+ {
+ let client_guard = client.lock().await;
+ let _ = client_guard.logout().await;
+ }
+
+ // Delete from keychain
+ if let Err(e) = keychain::delete_token() {
+ tracing::warn!("Failed to delete token from keychain: {}", e);
+ }
+ });
+
+ self.as_mut().rust_mut().is_authenticated = false;
+ self.as_mut().authentication_changed();
+ }
+
+ fn check_saved_credentials(self: Pin<&mut Self>) {
+ let qt_thread = self.qt_thread();
+ let auth_token = self.rust().auth_token.clone();
+ let client = self.rust().client.clone();
+
+ self.rust().runtime.spawn(async move {
+ match keychain::load_token() {
+ Ok(token) if !token.is_empty() => {
+ // Replace the client with one that has the token and validate it
+ let new_client = http_client::create_client_with_token(&token);
+ let user_id = new_client.user().map(|u| u.sub);
+
+ match user_id {
+ Some(user_id) => {
+ // Token is valid - store it and update client
+ {
+ let mut auth = auth_token.lock().await;
+ *auth = token.clone();
+ }
+ {
+ let mut client_guard = client.lock().await;
+ *client_guard = new_client;
+ }
+
+ qt_thread
+ .queue(move |mut qobject: Pin<&mut ffi::RustNetworkManager>| {
+ qobject.as_mut().set_current_user_id(QString::from(&user_id));
+ qobject.as_mut().rust_mut().is_authenticated = true;
+ qobject.as_mut().authentication_changed();
+ qobject.as_mut().auto_login_success();
+ })
+ .unwrap();
+ }
+ None => {
+ // Token is invalid/expired - clean it up
+ tracing::info!("Saved token is invalid or expired, removing");
+ let _ = keychain::delete_token();
+
+ qt_thread
+ .queue(move |mut qobject: Pin<&mut ffi::RustNetworkManager>| {
+ qobject.as_mut().auto_login_failed(QString::from(
+ "Session expired, please log in again",
+ ));
+ })
+ .unwrap();
+ }
+ }
+ }
+ Ok(_) => {
+ qt_thread
+ .queue(move |mut qobject: Pin<&mut ffi::RustNetworkManager>| {
+ qobject.as_mut().auto_login_failed(QString::from("No saved credentials"));
+ })
+ .unwrap();
+ }
+ Err(e) => {
+ let error_msg = format!("Failed to read saved credentials: {}", e);
+ qt_thread
+ .queue(move |mut qobject: Pin<&mut ffi::RustNetworkManager>| {
+ qobject.as_mut().auto_login_failed(QString::from(&error_msg));
+ })
+ .unwrap();
+ }
+ }
+ });
+ }
+
+ fn post_chat(self: Pin<&mut Self>, name: QString) {
+ let qt = self.qt_thread();
+ let client = self.rust().client.clone();
+ let name_str = name.to_string();
+
+ self.rust().runtime.spawn(async move {
+ let client_guard = client.lock().await;
+ match http_client::post_chat(&client_guard, &name_str).await {
+ Ok(chat) => {
+ // Create chat participation (ignore errors)
+ let _ = http_client::create_chat_participation(&client_guard, chat.id).await;
+
+ let display_chat = DisplayChat::from_chat(&chat);
+ let json = serde_json::to_string(&display_chat).unwrap_or_default();
+ qt.queue(move |mut q| {
+ q.as_mut().chat_created_json(QString::from(&json));
+ }).unwrap();
+ }
+ Err(e) => {
+ tracing::error!("post_chat error: {:?}", e);
+ let msg = e.to_string();
+ qt.queue(move |mut q| {
+ q.as_mut().request_failed(QString::from(&msg));
+ }).unwrap();
+ }
+ }
+ });
+ }
+
+ fn update_chat(self: Pin<&mut Self>, chat_id: i32, title: QString) {
+ let qt = self.qt_thread();
+ let client = self.rust().client.clone();
+ let title_str = title.to_string();
+
+ self.rust().runtime.spawn(async move {
+ let client_guard = client.lock().await;
+ match http_client::update_chat(&client_guard, chat_id as i64, &title_str).await {
+ Ok(chat) => {
+ let display_chat = DisplayChat::from_chat(&chat);
+ let json = serde_json::to_string(&display_chat).unwrap_or_default();
+ qt.queue(move |mut q| {
+ q.as_mut().chat_updated_json(QString::from(&json));
+ }).unwrap();
+ }
+ Err(e) => {
+ tracing::error!("update_chat error: {:?}", e);
+ let msg = e.to_string();
+ qt.queue(move |mut q| {
+ q.as_mut().request_failed(QString::from(&msg));
+ }).unwrap();
+ }
+ }
+ });
+ }
+
+ fn fetch_chats(self: Pin<&mut Self>, cursor: QString) {
+ let qt = self.qt_thread();
+ let client = self.rust().client.clone();
+ let cursor_str = cursor.to_string();
+
+ self.rust().runtime.spawn(async move {
+ let cursor_opt = if cursor_str.is_empty() { None } else { Some(cursor_str.as_str()) };
+ let client_guard = client.lock().await;
+
+ match http_client::fetch_chats(&client_guard, cursor_opt).await {
+ Ok((chats, next_cursor)) => {
+ let display_chats: Vec<DisplayChat> = chats.iter().map(DisplayChat::from_user_chat).collect();
+ let json = serde_json::to_string(&display_chats).unwrap_or_default();
+ qt.queue(move |mut q| {
+ q.as_mut().chats_fetched_json(
+ QString::from(&json),
+ QString::from(&next_cursor.unwrap_or_default()),
+ );
+ }).unwrap();
+ }
+ Err(e) => {
+ tracing::error!("fetch_chats error: {:?}", e);
+ let msg = e.to_string();
+ qt.queue(move |mut q| {
+ q.as_mut().request_failed(QString::from(&msg));
+ }).unwrap();
+ }
+ }
+ });
+ }
+
+ fn post_message(mut self: Pin<&mut Self>, chat_id: i32, content: QString) {
+ // Validate and trim content
+ let content_str = content.to_string().trim().to_string();
+ if content_str.is_empty() {
+ self.as_mut().request_failed(QString::from("Message cannot be empty"));
+ return;
+ }
+
+ let qt = self.qt_thread();
+ let client = self.rust().client.clone();
+
+ self.rust().runtime.spawn(async move {
+ let client_guard = client.lock().await;
+ match http_client::post_message(&client_guard, chat_id as i64, &content_str).await {
+ Ok(message) => {
+ let json = serde_json::to_string(&message).unwrap_or_default();
+ qt.queue(move |mut q| {
+ q.as_mut().message_created_json(QString::from(&json));
+ }).unwrap();
+ }
+ Err(e) => {
+ tracing::error!("post_message error: {:?}", e);
+ let msg = e.to_string();
+ qt.queue(move |mut q| {
+ q.as_mut().request_failed(QString::from(&msg));
+ }).unwrap();
+ }
+ }
+ });
+ }
+
+ fn fetch_messages(self: Pin<&mut Self>, chat_id: i32, cursor: QString) {
+ let qt = self.qt_thread();
+ let client = self.rust().client.clone();
+ let message_cache = self.rust().message_cache.clone();
+ let current_user_id = self.rust().current_user_id.to_string();
+ let cursor_str = cursor.to_string();
+ let is_initial_load = cursor_str.is_empty();
+
+ self.rust().runtime.spawn(async move {
+ let cursor_opt = if is_initial_load { None } else { Some(cursor_str.as_str()) };
+ let client_guard = client.lock().await;
+
+ match http_client::fetch_messages(&client_guard, chat_id as i64, cursor_opt).await {
+ Ok((messages, next_cursor)) => {
+ // Transform through cache (handles dedup and adds is_outgoing)
+ let display_messages = {
+ let mut cache: tokio::sync::MutexGuard<'_, MessageCache> = message_cache.lock().await;
+ if is_initial_load {
+ cache.set_messages(chat_id as i64, messages, ¤t_user_id)
+ } else {
+ cache.prepend_messages(chat_id as i64, messages, ¤t_user_id)
+ }
+ };
+
+ let json = serde_json::to_string(&display_messages).unwrap_or_default();
+ qt.queue(move |mut q| {
+ q.as_mut().messages_fetched_json(
+ QString::from(&json),
+ QString::from(&next_cursor.unwrap_or_default()),
+ );
+ }).unwrap();
+ }
+ Err(e) => {
+ tracing::error!("fetch_messages error: {:?}", e);
+ let msg = e.to_string();
+ qt.queue(move |mut q| {
+ q.as_mut().request_failed(QString::from(&msg));
+ }).unwrap();
+ }
+ }
+ });
+ }
+
+ fn subscribe_to_chat(self: Pin<&mut Self>, chat_id: i32) {
+ let qt = self.qt_thread();
+ let token = self.rust().auth_token.clone();
+ let sse_command_tx = self.rust().sse_command_tx.clone();
+ let message_cache = self.rust().message_cache.clone();
+ let current_user_id = self.rust().current_user_id.to_string();
+
+ // Create channels for SSE client
+ let (command_tx, command_rx) = mpsc::channel::<SseCommand>(1);
+ let (event_tx, mut event_rx) = mpsc::channel::<SseEvent>(32);
+
+ self.rust().runtime.spawn(async move {
+ // Cancel previous subscription if any
+ {
+ let mut cmd_guard = sse_command_tx.lock().await;
+ if let Some(old_tx) = cmd_guard.take() {
+ let _ = old_tx.send(SseCommand::Stop).await;
+ }
+ *cmd_guard = Some(command_tx);
+ }
+
+ let auth_token = token.lock().await.clone();
+
+ // Spawn the SSE client (deduplication is handled inside sse_client)
+ let sse_handle = tokio::spawn(crate::sse_client::subscribe_to_chat(
+ auth_token, chat_id, event_tx, command_rx,
+ ));
+
+ // Process events from SSE client
+ while let Some(event) = event_rx.recv().await {
+ let qt = qt.clone();
+ match event {
+ SseEvent::Connected { chat_id } => {
+ qt.queue(move |mut qobj| qobj.as_mut().sse_connected(chat_id)).unwrap();
+ }
+ SseEvent::Message { data } => {
+ // Parse Value to Message and transform through cache
+ if let Ok(msg) = serde_json::from_value::<crate::generated::Message>(data) {
+ let mut cache: tokio::sync::MutexGuard<'_, MessageCache> = message_cache.lock().await;
+ if let Some(display_msg) = cache.add_message(msg, ¤t_user_id) {
+ let json = serde_json::to_string(&display_msg).unwrap_or_default();
+ qt.queue(move |mut qobj| {
+ qobj.as_mut().message_received_json(QString::from(&json));
+ }).unwrap();
+ }
+ // If None, message was duplicate - don't emit
+ }
+ }
+ SseEvent::Disconnected { chat_id, reason } => {
+ qt.queue(move |mut qobj| {
+ qobj.as_mut().sse_disconnected(chat_id, QString::from(&reason));
+ }).unwrap();
+ }
+ SseEvent::Error { message } => {
+ qt.queue(move |mut qobj| {
+ qobj.as_mut().sse_error(QString::from(&message));
+ }).unwrap();
+ }
+ }
+ }
+
+ let _ = sse_handle.await;
+ });
+ }
+
+ fn subscribe_to_chats(self: Pin<&mut Self>, chat_ids_json: QString) {
+ // Parse JSON array of chat IDs from QML
+ let chat_ids: Vec<i32> = match serde_json::from_str(&chat_ids_json.to_string()) {
+ Ok(ids) => ids,
+ Err(e) => {
+ tracing::error!("Failed to parse chat IDs JSON: {}", e);
+ return;
+ }
+ };
+
+ if chat_ids.is_empty() {
+ return;
+ }
+
+ let qt = self.qt_thread();
+ let token = self.rust().auth_token.clone();
+ let sse_command_tx = self.rust().sse_command_tx.clone();
+ let message_cache = self.rust().message_cache.clone();
+ let current_user_id = self.rust().current_user_id.to_string();
+
+ // Create channels for SSE client
+ let (command_tx, command_rx) = mpsc::channel::<SseCommand>(1);
+ let (event_tx, mut event_rx) = mpsc::channel::<SseEvent>(32);
+
+ self.rust().runtime.spawn(async move {
+ // Cancel previous subscription if any
+ {
+ let mut cmd_guard = sse_command_tx.lock().await;
+ if let Some(old_tx) = cmd_guard.take() {
+ let _ = old_tx.send(SseCommand::Stop).await;
+ }
+ *cmd_guard = Some(command_tx);
+ }
+
+ let auth_token = token.lock().await.clone();
+
+ // Spawn the SSE client for multiple chats
+ let sse_handle = tokio::spawn(crate::sse_client::subscribe_to_chats(
+ auth_token, chat_ids, event_tx, command_rx,
+ ));
+
+ // Process events from SSE client
+ while let Some(event) = event_rx.recv().await {
+ let qt = qt.clone();
+ match event {
+ SseEvent::Connected { chat_id } => {
+ qt.queue(move |mut qobj| qobj.as_mut().sse_connected(chat_id)).unwrap();
+ }
+ SseEvent::Message { data } => {
+ // Parse Value to Message and transform through cache
+ if let Ok(msg) = serde_json::from_value::<crate::generated::Message>(data) {
+ let mut cache: tokio::sync::MutexGuard<'_, MessageCache> = message_cache.lock().await;
+ if let Some(display_msg) = cache.add_message(msg, ¤t_user_id) {
+ let json = serde_json::to_string(&display_msg).unwrap_or_default();
+ qt.queue(move |mut qobj| {
+ qobj.as_mut().message_received_json(QString::from(&json));
+ }).unwrap();
+ }
+ }
+ }
+ SseEvent::Disconnected { chat_id, reason } => {
+ qt.queue(move |mut qobj| {
+ qobj.as_mut().sse_disconnected(chat_id, QString::from(&reason));
+ }).unwrap();
+ }
+ SseEvent::Error { message } => {
+ qt.queue(move |mut qobj| {
+ qobj.as_mut().sse_error(QString::from(&message));
+ }).unwrap();
+ }
+ }
+ }
+
+ let _ = sse_handle.await;
+ });
+ }
+
+ fn unsubscribe_from_chat(self: Pin<&mut Self>) {
+ let sse_command_tx = self.rust().sse_command_tx.clone();
+ let runtime = self.rust().runtime.clone();
+
+ runtime.spawn(async move {
+ if let Some(tx) = sse_command_tx.lock().await.take() {
+ let _ = tx.send(SseCommand::Stop).await;
+ }
+ });
+ }
+}
diff --git a/rust/src/sse_client.rs b/rust/src/sse_client.rs
@@ -0,0 +1,240 @@
+//! Server-Sent Events (SSE) client with auto-reconnection
+//!
+//! Handles real-time message subscriptions from the TrailBase API.
+
+use crate::types::{SseEventWrapper, BASE_URL, MAX_RECONNECT_DELAY_MS};
+use futures::StreamExt;
+use reqwest_eventsource::{Event, EventSource};
+use serde_json::Value;
+use std::collections::HashSet;
+use std::time::Duration;
+use tokio::sync::mpsc;
+
+const MAX_SEEN_IDS: usize = 100;
+
+/// Commands that can be sent to the SSE task
+#[derive(Debug)]
+pub enum SseCommand {
+ /// Stop the SSE connection
+ Stop,
+}
+
+/// Events emitted by the SSE client
+#[derive(Debug)]
+pub enum SseEvent {
+ /// Successfully connected
+ Connected { chat_id: i32 },
+ /// Received a message
+ Message { data: Value },
+ /// Connection was disconnected
+ Disconnected { chat_id: i32, reason: String },
+ /// An error occurred
+ Error { message: String },
+}
+
+/// Parse an SSE event and extract the message data
+fn parse_sse_message(data: &str, expected_chat_id: Option<i32>) -> Option<Value> {
+ let json: Value = serde_json::from_str(data).ok()?;
+
+ // TrailBase wraps data in "Insert" key
+ let wrapper: SseEventWrapper = serde_json::from_value(json.clone()).ok()?;
+ let message = wrapper.insert.or(Some(json))?;
+
+ // Validate chat_id if we're subscribed to a specific chat
+ if let Some(expected) = expected_chat_id {
+ let msg_chat_id = message.get("chat_id")?.as_i64()? as i32;
+ if msg_chat_id != expected {
+ tracing::debug!(
+ "Message for wrong chat: got {}, expected {}",
+ msg_chat_id,
+ expected
+ );
+ return None;
+ }
+ }
+
+ // Validate message has an ID
+ if message.get("id").is_none() {
+ tracing::warn!("SSE: Message without valid ID");
+ return None;
+ }
+
+ Some(message)
+}
+
+/// Start an SSE subscription for a single chat
+pub async fn subscribe_to_chat(
+ auth_token: String,
+ chat_id: i32,
+ event_tx: mpsc::Sender<SseEvent>,
+ command_rx: mpsc::Receiver<SseCommand>,
+) {
+ let url = format!(
+ "{}/api/records/v1/message/subscribe/*?filter[chat_id][$eq]={}",
+ BASE_URL, chat_id
+ );
+
+ run_sse_loop(url, auth_token, Some(chat_id), event_tx, command_rx).await;
+}
+
+/// Start an SSE subscription for multiple chats
+pub async fn subscribe_to_chats(
+ auth_token: String,
+ chat_ids: Vec<i32>,
+ event_tx: mpsc::Sender<SseEvent>,
+ command_rx: mpsc::Receiver<SseCommand>,
+) {
+ if chat_ids.is_empty() {
+ return;
+ }
+
+ let chat_id_list = chat_ids
+ .iter()
+ .map(|id| id.to_string())
+ .collect::<Vec<_>>()
+ .join(",");
+
+ let url = format!(
+ "{}/api/records/v1/message/subscribe/*?chat_id={}",
+ BASE_URL, chat_id_list
+ );
+
+ // -1 indicates multi-chat subscription
+ run_sse_loop(url, auth_token, None, event_tx, command_rx).await;
+}
+
+/// Main SSE event loop with reconnection logic and deduplication
+async fn run_sse_loop(
+ url: String,
+ auth_token: String,
+ expected_chat_id: Option<i32>,
+ event_tx: mpsc::Sender<SseEvent>,
+ mut command_rx: mpsc::Receiver<SseCommand>,
+) {
+ let chat_id_for_signals = expected_chat_id.unwrap_or(-1);
+ let mut reconnect_attempts = 0u32;
+ let mut seen_ids: HashSet<i64> = HashSet::new();
+
+ loop {
+ // Check for stop command before connecting
+ if let Ok(SseCommand::Stop) = command_rx.try_recv() {
+ let _ = event_tx
+ .send(SseEvent::Disconnected {
+ chat_id: chat_id_for_signals,
+ reason: "User initiated".to_string(),
+ })
+ .await;
+ return;
+ }
+
+ // Create EventSource with proper headers
+ let request = reqwest::Client::new()
+ .get(&url)
+ .header("Accept", "text/event-stream")
+ .header("Cache-Control", "no-cache")
+ .bearer_auth(&auth_token);
+
+ let mut es = EventSource::new(request).expect("Failed to create EventSource");
+
+ // Notify connected
+ let _ = event_tx
+ .send(SseEvent::Connected {
+ chat_id: chat_id_for_signals,
+ })
+ .await;
+
+ reconnect_attempts = 0; // Reset on successful connect
+
+ // Process events
+ loop {
+ tokio::select! {
+ // Check for stop command
+ cmd = command_rx.recv() => {
+ if matches!(cmd, Some(SseCommand::Stop) | None) {
+ es.close();
+ let _ = event_tx.send(SseEvent::Disconnected {
+ chat_id: chat_id_for_signals,
+ reason: "User initiated".to_string(),
+ }).await;
+ return;
+ }
+ }
+
+ // Process SSE events
+ event = es.next() => {
+ match event {
+ Some(Ok(Event::Open)) => {
+ tracing::info!("SSE connection opened for chat {:?}", expected_chat_id);
+ }
+ Some(Ok(Event::Message(message))) => {
+ if let Some(data) = parse_sse_message(&message.data, expected_chat_id) {
+ // Deduplicate by message ID
+ if let Some(msg_id) = data.get("id").and_then(|v| v.as_i64()) {
+ if seen_ids.contains(&msg_id) {
+ continue;
+ }
+ seen_ids.insert(msg_id);
+
+ // Keep bounded
+ if seen_ids.len() > MAX_SEEN_IDS {
+ // Remove oldest entries (arbitrary since HashSet is unordered)
+ let to_remove: Vec<_> = seen_ids.iter().take(MAX_SEEN_IDS / 2).cloned().collect();
+ for id in to_remove {
+ seen_ids.remove(&id);
+ }
+ }
+ }
+
+ let _ = event_tx.send(SseEvent::Message { data }).await;
+ }
+ }
+ Some(Err(e)) => {
+ let error_msg = e.to_string();
+ tracing::warn!("SSE error: {}", error_msg);
+ let _ = event_tx.send(SseEvent::Error { message: error_msg }).await;
+ break; // Exit inner loop to reconnect
+ }
+ None => {
+ // Stream ended
+ tracing::info!("SSE stream ended");
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // Connection lost - notify and schedule reconnect
+ let _ = event_tx
+ .send(SseEvent::Disconnected {
+ chat_id: chat_id_for_signals,
+ reason: "Connection closed by server".to_string(),
+ })
+ .await;
+
+ // Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
+ let delay = std::cmp::min(
+ 1000u64 * (1u64 << reconnect_attempts),
+ MAX_RECONNECT_DELAY_MS,
+ );
+ reconnect_attempts += 1;
+
+ tracing::info!(
+ "Scheduling SSE reconnect in {}ms (attempt {})",
+ delay,
+ reconnect_attempts
+ );
+
+ // Wait for delay or stop command
+ tokio::select! {
+ _ = tokio::time::sleep(Duration::from_millis(delay)) => {
+ // Continue to reconnect
+ }
+ cmd = command_rx.recv() => {
+ if matches!(cmd, Some(SseCommand::Stop) | None) {
+ return;
+ }
+ }
+ }
+ }
+}
diff --git a/rust/src/types.rs b/rust/src/types.rs
@@ -0,0 +1,73 @@
+//! Shared types for the Beegram network layer
+//!
+//! Record types are auto-generated from TrailBase schemas.
+//! See the `generated` module for schema-derived types.
+
+use serde::{Deserialize, Serialize};
+
+// Re-export auto-generated types from TrailBase schemas
+pub use crate::generated::{Chat, Message, NewChat, NewChatParticipation, NewMessage, UserChat};
+
+/// Base URL for the TrailBase API
+pub const BASE_URL: &str = "http://localhost:4000";
+
+/// Maximum number of message IDs to cache for deduplication
+pub const MAX_MESSAGE_CACHE: usize = 50;
+
+/// Maximum SSE buffer size (64KB)
+pub const MAX_SSE_BUFFER_SIZE: usize = 65536;
+
+/// Maximum reconnect delay in milliseconds
+pub const MAX_RECONNECT_DELAY_MS: u64 = 30000;
+
+// ==================== Auth Types ====================
+
+/// Login request payload
+#[derive(Debug, Serialize)]
+pub struct LoginRequest {
+ pub email: String,
+ pub password: String,
+}
+
+/// Login response from the API
+#[derive(Debug, Deserialize)]
+pub struct LoginResponse {
+ pub auth_token: String,
+}
+
+// ==================== SSE Types ====================
+
+/// SSE event wrapper from TrailBase
+#[derive(Debug, Deserialize)]
+pub struct SseEventWrapper {
+ #[serde(rename = "Insert")]
+ pub insert: Option<serde_json::Value>,
+}
+
+// ==================== Error Types ====================
+
+/// Network error types
+#[derive(Debug, thiserror::Error)]
+pub enum NetworkError {
+ #[error("TrailBase client error: {0}")]
+ TrailBase(#[from] trailbase_client::Error),
+
+ #[error("JSON error: {0}")]
+ Json(#[from] serde_json::Error),
+
+ #[error("API error: {status} - {message}")]
+ Api { status: u16, message: String },
+
+ #[error("Missing field: {0}")]
+ MissingField(String),
+
+ #[error("Invalid response: {0}")]
+ InvalidResponse(String),
+}
+
+/// Credential storage error types
+#[derive(Debug, thiserror::Error)]
+pub enum KeychainError {
+ #[error("No token found")]
+ NotFound,
+}
diff --git a/scripts/generate-types.sh b/scripts/generate-types.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# Regenerate Rust types from TrailBase schemas
+set -e
+
+cd "$(dirname "$0")/.."
+
+# Start TrailBase in background
+echo "Starting TrailBase..."
+trail --data-dir traildepot run &
+TRAIL_PID=$!
+trap "kill $TRAIL_PID 2>/dev/null; wait $TRAIL_PID 2>/dev/null" EXIT
+sleep 3
+
+# Generate schemas and types
+make schemas types
+
+echo "Done!"
diff --git a/traildepot/config.textproto b/traildepot/config.textproto
@@ -12,11 +12,13 @@ jobs {}
record_apis: [{
name: "chat"
table_name: "chat"
- acl_world: [READ, CREATE]
+ autofill_missing_user_id_columns: true
+ acl_authenticated: [READ, CREATE]
}, {
name: "message"
table_name: "message"
- acl_world: [READ, CREATE, UPDATE, DELETE]
+ autofill_missing_user_id_columns: true
+ acl_authenticated: [READ, CREATE, UPDATE, DELETE]
enable_subscriptions: true
create_access_rule: "EXISTS( SELECT 1 FROM chat_participation AS cp WHERE cp.chat_id = _REQ_.chat_id AND cp.user_id = _USER_.id )"
read_access_rule: "EXISTS( SELECT 1 FROM chat_participation AS cp WHERE cp.chat_id = _ROW_.chat_id AND cp.user_id = _USER_.id )"
@@ -34,7 +36,8 @@ record_apis: [{
}, {
name: "chat_participation"
table_name: "chat_participation"
- acl_world: [CREATE, READ, DELETE]
+ autofill_missing_user_id_columns: true
+ acl_authenticated: [CREATE, READ, DELETE]
create_access_rule: "EXISTS( SELECT 1 FROM chat_participation AS cp WHERE cp.chat_id = _REQ_.chat_id AND cp.user_id = _USER_.id )"
read_access_rule: "EXISTS( SELECT 1 FROM chat_participation AS cp WHERE cp.chat_id = _ROW_.chat_id AND cp.user_id = _USER_.id )"
delete_access_rule: "EXISTS( SELECT 1 FROM chat_participation AS cp WHERE cp.chat_id = _ROW_.chat_id AND cp.user_id = _USER_.id )"