qt-chat-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

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:
M.gitignore | 2+-
ACLAUDE.md | 28++++++++++++++++++++++++++++
MCMakeLists.txt | 77++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
AMakefile | 45+++++++++++++++++++++++++++++++++++++++++++++
Aderivation.nix | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dderivation_demo.nix | 57---------------------------------------------------------
Mflake.lock | 45++++++++++++++++++++++++++++++++++++++++-----
Mflake.nix | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mmain.cpp | 10+++-------
Mmain.qml | 138-------------------------------------------------------------------------------
Dnetworkmanager.cpp | 1003-------------------------------------------------------------------------------
Dnetworkmanager.h | 111-------------------------------------------------------------------------------
Mqml/components/ChatListItem.qml | 21++++++++++-----------
Mqml/components/MessageBubble.qml | 6+++---
Mqml/components/MessageInput.qml | 33++++++++++++++++++++++++---------
Mqml/components/TopBar.qml | 27++++++++++++++++++++++++---
Mqml/models/ChatListModel.qml | 117+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mqml/models/MessageCache.qml | 10----------
Mqml/models/MessageListModel.qml | 206+++++++++++--------------------------------------------------------------------
Mqml/views/ChatListView.qml | 264+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mqml/views/LoginView.qml | 28+++++++++++++++-------------
Mqml/views/MessageThreadView.qml | 16+++++++---------
Mqml/views/ProfileView.qml | 3+--
Mqml/views/SettingsView.qml | 2+-
Mqml/views/SplashView.qml | 37++++++++++++++++++++-----------------
Arust/Cargo.lock | 2428+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arust/Cargo.toml | 42++++++++++++++++++++++++++++++++++++++++++
Arust/build.rs | 7+++++++
Arust/schemas/chat_insert.json | 22++++++++++++++++++++++
Arust/schemas/chat_participation_insert.json | 19+++++++++++++++++++
Arust/schemas/chat_read.json | 24++++++++++++++++++++++++
Arust/schemas/message_insert.json | 25+++++++++++++++++++++++++
Arust/schemas/message_read.json | 27+++++++++++++++++++++++++++
Arust/schemas/traildepot/.gitignore | 16++++++++++++++++
Arust/schemas/traildepot/config.textproto | 12++++++++++++
Arust/schemas/user_chats_read.json | 28++++++++++++++++++++++++++++
Arust/src/generated/mod.rs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arust/src/http_client.rs | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arust/src/keychain.rs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arust/src/lib.rs | 9+++++++++
Arust/src/message_cache.rs | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arust/src/network_manager.rs | 654+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arust/src/sse_client.rs | 240+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Arust/src/types.rs | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/generate-types.sh | 17+++++++++++++++++
Mtraildepot/config.textproto | 9++++++---
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, &current_user_id) + } else { + cache.prepend_messages(chat_id as i64, messages, &current_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, &current_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, &current_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 )"