Browse Source

chatgui: rlottie animated emoji with hover-to-animate, drag-to-select

- Add LottieIcon class (TGS gzip decompress + rlottie C API render)
- Add AnimTimer singleton (33ms tick, activate/deactivate refcount)
- Add EmojiButton (hover-triggered animation in emoji picker)
- Add AnimatedEmoji support to EmojiData (5 TGS mappings)
- MessageDelegate: QTextLayout-based animated emoji overlay (text + reactions)
- ChatView: drag-to-select (click deselects, 5px drag starts selection)
- ChatView: hoveredIndexChanged signal for hover-triggered animations
- MessageList: wire AnimTimer tick to viewport update, hover → activate
- CMake: AUTORCC, rlottie, zlib dependencies
- Add 5 TGS animations as Qt resources
feature/x25519-migration
Evgeny 3 months ago
parent
commit
62b3695a77
  1. 3
      .gitignore
  2. 812
      Makefile
  3. 1
      src/Makefile.am
  4. 20
      src/config_parser.c
  5. 1
      src/config_parser.h
  6. 3
      src/etcp_api.h
  7. 585
      src/msg_transport.c
  8. 118
      src/msg_transport.h
  9. 23
      src/utun_instance.c
  10. 4
      src/utun_instance.h
  11. 1
      tests/Makefile.am
  12. 25
      tools/chatgui/CMakeLists.txt
  13. 13
      tools/chatgui/chat_tz.txt
  14. 116
      tools/chatgui/db/db_manager.cpp
  15. 23
      tools/chatgui/db/db_manager.h
  16. 269376
      tools/chatgui/db/sqlite3.c
  17. 14347
      tools/chatgui/db/sqlite3.h
  18. 96
      tools/chatgui/doc/db_schema.md
  19. BIN
      tools/chatgui/resources/animations/greeting.tgs
  20. BIN
      tools/chatgui/resources/animations/robot.tgs
  21. BIN
      tools/chatgui/resources/animations/sparkles_emoji.tgs
  22. BIN
      tools/chatgui/resources/animations/stop.tgs
  23. BIN
      tools/chatgui/resources/animations/white_flag_emoji.tgs
  24. 9
      tools/chatgui/resources/chatgui.qrc
  25. 44
      tools/chatgui/src/animtimer.cpp
  26. 32
      tools/chatgui/src/animtimer.h
  27. 37
      tools/chatgui/src/channeldelegate.cpp
  28. 116
      tools/chatgui/src/chatview.cpp
  29. 36
      tools/chatgui/src/chatview.h
  30. 46
      tools/chatgui/src/debug_ui.h
  31. 23
      tools/chatgui/src/emoji.cpp
  32. 4
      tools/chatgui/src/emoji.h
  33. 94
      tools/chatgui/src/emojipanel.cpp
  34. 9
      tools/chatgui/src/inputbar.cpp
  35. 72
      tools/chatgui/src/lottieicon.cpp
  36. 30
      tools/chatgui/src/lottieicon.h
  37. 341
      tools/chatgui/src/messagedelegate.cpp
  38. 3
      tools/chatgui/src/messagedelegate.h
  39. 59
      tools/chatgui/src/messagelist.cpp
  40. 1
      tools/chatgui/src/messagelist.h
  41. 143
      tools/chatgui/transport/msg_client.cpp
  42. 65
      tools/chatgui/transport/msg_client.h

3
.gitignore vendored

@ -97,6 +97,9 @@ utun.conf
tools/bping/bping tools/bping/bping
tools/proxy/udp_proxy tools/proxy/udp_proxy
# Reference projects (not part of utun)
tools/tdesktop-dev/
# Debug/temp artifacts # Debug/temp artifacts
*.bak *.bak
*.debug *.debug

812
Makefile

@ -1,28 +1,800 @@
# Bootstrap Makefile for utun # Makefile.in generated by automake 1.16.5 from Makefile.am.
# All build artifacts (.o) go to build/ directory # Makefile. Generated from Makefile.in by configure.
# Final binary (utun) is copied to project root
BUILD_DIR = build # Copyright (C) 1994-2021 Free Software Foundation, Inc.
all: # This Makefile.in is free software; the Free Software Foundation
@mkdir -p $(BUILD_DIR) # gives unlimited permission to copy and/or distribute it,
@if test ! -f $(BUILD_DIR)/config.status || test ./configure -nt $(BUILD_DIR)/config.status; then \ # with or without modifications, as long as this notice is preserved.
echo "Configuring in $(BUILD_DIR)/..."; \
cd $(BUILD_DIR) && ../configure; \ # This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY, to the extent permitted by law; without
# even the implied warranty of MERCHANTABILITY or FITNESS FOR A
# PARTICULAR PURPOSE.
# Main Makefile.am for utun - root directory configuration
am__is_gnu_make = { \
if test -z '$(MAKELEVEL)'; then \
false; \
elif test -n '$(MAKE_HOST)'; then \
true; \
elif test -n '$(MAKE_VERSION)' && test -n '$(CURDIR)'; then \
true; \
else \
false; \
fi; \
}
am__make_running_with_option = \
case $${target_option-} in \
?) ;; \
*) echo "am__make_running_with_option: internal error: invalid" \
"target option '$${target_option-}' specified" >&2; \
exit 1;; \
esac; \
has_opt=no; \
sane_makeflags=$$MAKEFLAGS; \
if $(am__is_gnu_make); then \
sane_makeflags=$$MFLAGS; \
else \
case $$MAKEFLAGS in \
*\\[\ \ ]*) \
bs=\\; \
sane_makeflags=`printf '%s\n' "$$MAKEFLAGS" \
| sed "s/$$bs$$bs[$$bs $$bs ]*//g"`;; \
esac; \
fi; \
skip_next=no; \
strip_trailopt () \
{ \
flg=`printf '%s\n' "$$flg" | sed "s/$$1.*$$//"`; \
}; \
for flg in $$sane_makeflags; do \
test $$skip_next = yes && { skip_next=no; continue; }; \
case $$flg in \
*=*|--*) continue;; \
-*I) strip_trailopt 'I'; skip_next=yes;; \
-*I?*) strip_trailopt 'I';; \
-*O) strip_trailopt 'O'; skip_next=yes;; \
-*O?*) strip_trailopt 'O';; \
-*l) strip_trailopt 'l'; skip_next=yes;; \
-*l?*) strip_trailopt 'l';; \
-[dEDm]) skip_next=yes;; \
-[JT]) skip_next=yes;; \
esac; \
case $$flg in \
*$$target_option*) has_opt=yes; break;; \
esac; \
done; \
test $$has_opt = yes
am__make_dryrun = (target_option=n; $(am__make_running_with_option))
am__make_keepgoing = (target_option=k; $(am__make_running_with_option))
pkgdatadir = $(datadir)/utun
pkgincludedir = $(includedir)/utun
pkglibdir = $(libdir)/utun
pkglibexecdir = $(libexecdir)/utun
am__cd = CDPATH="$${ZSH_VERSION+.}$(PATH_SEPARATOR)" && cd
install_sh_DATA = $(install_sh) -c -m 644
install_sh_PROGRAM = $(install_sh) -c
install_sh_SCRIPT = $(install_sh) -c
INSTALL_HEADER = $(INSTALL_DATA)
transform = $(program_transform_name)
NORMAL_INSTALL = :
PRE_INSTALL = :
POST_INSTALL = :
NORMAL_UNINSTALL = :
PRE_UNINSTALL = :
POST_UNINSTALL = :
build_triplet = x86_64-pc-linux-gnu
host_triplet = x86_64-pc-linux-gnu
subdir = .
ACLOCAL_M4 = $(top_srcdir)/aclocal.m4
am__aclocal_m4_deps = $(top_srcdir)/configure.ac
am__configure_deps = $(am__aclocal_m4_deps) $(CONFIGURE_DEPENDENCIES) \
$(ACLOCAL_M4)
DIST_COMMON = $(srcdir)/Makefile.am $(top_srcdir)/configure \
$(am__configure_deps) $(am__DIST_COMMON)
am__CONFIG_DISTCLEAN_FILES = config.status config.cache config.log \
configure.lineno config.status.lineno
mkinstalldirs = $(install_sh) -d
CONFIG_HEADER = config.h
CONFIG_CLEAN_FILES =
CONFIG_CLEAN_VPATH_FILES =
AM_V_P = $(am__v_P_$(V))
am__v_P_ = $(am__v_P_$(AM_DEFAULT_VERBOSITY))
am__v_P_0 = false
am__v_P_1 = :
AM_V_GEN = $(am__v_GEN_$(V))
am__v_GEN_ = $(am__v_GEN_$(AM_DEFAULT_VERBOSITY))
am__v_GEN_0 = @echo " GEN " $@;
am__v_GEN_1 =
AM_V_at = $(am__v_at_$(V))
am__v_at_ = $(am__v_at_$(AM_DEFAULT_VERBOSITY))
am__v_at_0 = @
am__v_at_1 =
SOURCES =
DIST_SOURCES =
RECURSIVE_TARGETS = all-recursive check-recursive cscopelist-recursive \
ctags-recursive dvi-recursive html-recursive info-recursive \
install-data-recursive install-dvi-recursive \
install-exec-recursive install-html-recursive \
install-info-recursive install-pdf-recursive \
install-ps-recursive install-recursive installcheck-recursive \
installdirs-recursive pdf-recursive ps-recursive \
tags-recursive uninstall-recursive
am__can_run_installinfo = \
case $$AM_UPDATE_INFO_DIR in \
n|no|NO) false;; \
*) (install-info --version) >/dev/null 2>&1;; \
esac
RECURSIVE_CLEAN_TARGETS = mostlyclean-recursive clean-recursive \
distclean-recursive maintainer-clean-recursive
am__recursive_targets = \
$(RECURSIVE_TARGETS) \
$(RECURSIVE_CLEAN_TARGETS) \
$(am__extra_recursive_targets)
AM_RECURSIVE_TARGETS = $(am__recursive_targets:-recursive=) TAGS CTAGS \
cscope distdir distdir-am dist dist-all distcheck
am__tagged_files = $(HEADERS) $(SOURCES) $(TAGS_FILES) $(LISP) \
config.h.in
# Read a list of newline-separated strings from the standard input,
# and print each of them once, without duplicates. Input order is
# *not* preserved.
am__uniquify_input = $(AWK) '\
BEGIN { nonempty = 0; } \
{ items[$$0] = 1; nonempty = 1; } \
END { if (nonempty) { for (i in items) print i; }; } \
'
# Make sure the list of sources is unique. This is necessary because,
# e.g., the same source file might be shared among _SOURCES variables
# for different programs/libraries.
am__define_uniq_tagged_files = \
list='$(am__tagged_files)'; \
unique=`for i in $$list; do \
if test -f "$$i"; then echo $$i; else echo $(srcdir)/$$i; fi; \
done | $(am__uniquify_input)`
DIST_SUBDIRS = $(SUBDIRS)
am__DIST_COMMON = $(srcdir)/Makefile.in $(srcdir)/config.h.in \
$(top_srcdir)/build-aux/ar-lib $(top_srcdir)/build-aux/compile \
$(top_srcdir)/build-aux/config.guess \
$(top_srcdir)/build-aux/config.sub \
$(top_srcdir)/build-aux/install-sh \
$(top_srcdir)/build-aux/missing build-aux/ar-lib \
build-aux/compile build-aux/config.guess build-aux/config.sub \
build-aux/depcomp build-aux/install-sh build-aux/missing
DISTFILES = $(DIST_COMMON) $(DIST_SOURCES) $(TEXINFOS) $(EXTRA_DIST)
distdir = $(PACKAGE)-$(VERSION)
top_distdir = $(distdir)
am__remove_distdir = \
if test -d "$(distdir)"; then \
find "$(distdir)" -type d ! -perm -200 -exec chmod u+w {} ';' \
&& rm -rf "$(distdir)" \
|| { sleep 5 && rm -rf "$(distdir)"; }; \
else :; fi
am__post_remove_distdir = $(am__remove_distdir)
am__relativize = \
dir0=`pwd`; \
sed_first='s,^\([^/]*\)/.*$$,\1,'; \
sed_rest='s,^[^/]*/*,,'; \
sed_last='s,^.*/\([^/]*\)$$,\1,'; \
sed_butlast='s,/*[^/]*$$,,'; \
while test -n "$$dir1"; do \
first=`echo "$$dir1" | sed -e "$$sed_first"`; \
if test "$$first" != "."; then \
if test "$$first" = ".."; then \
dir2=`echo "$$dir0" | sed -e "$$sed_last"`/"$$dir2"; \
dir0=`echo "$$dir0" | sed -e "$$sed_butlast"`; \
else \
first2=`echo "$$dir2" | sed -e "$$sed_first"`; \
if test "$$first2" = "$$first"; then \
dir2=`echo "$$dir2" | sed -e "$$sed_rest"`; \
else \
dir2="../$$dir2"; \
fi; \
dir0="$$dir0"/"$$first"; \
fi; \
fi; \
dir1=`echo "$$dir1" | sed -e "$$sed_rest"`; \
done; \
reldir="$$dir2"
DIST_ARCHIVES = $(distdir).tar.gz
GZIP_ENV = --best
DIST_TARGETS = dist-gzip
# Exists only to be overridden by the user if desired.
AM_DISTCHECK_DVI_TARGET = dvi
distuninstallcheck_listfiles = find . -type f -print
am__distuninstallcheck_listfiles = $(distuninstallcheck_listfiles) \
| sed 's|^\./|$(prefix)/|' | grep -v '$(infodir)/dir$$'
distcleancheck_listfiles = find . -type f -print
ACLOCAL = ${SHELL} '/home/vnc1/proj/utun3/build-aux/missing' aclocal-1.16
AMTAR = $${TAR-tar}
AM_DEFAULT_VERBOSITY = 1
AR = ar
AUTOCONF = ${SHELL} '/home/vnc1/proj/utun3/build-aux/missing' autoconf
AUTOHEADER = ${SHELL} '/home/vnc1/proj/utun3/build-aux/missing' autoheader
AUTOMAKE = ${SHELL} '/home/vnc1/proj/utun3/build-aux/missing' automake-1.16
AWK = mawk
CC = gcc
CCDEPMODE = depmode=gcc3
CFLAGS = -g -O2
CPPFLAGS =
CSCOPE = cscope
CTAGS = ctags
CYGPATH_W = echo
DEFS = -DHAVE_CONFIG_H
DEPDIR = .deps
ECHO_C =
ECHO_N = -n
ECHO_T =
ETAGS = etags
EXEEXT =
INSTALL = /usr/bin/install -c
INSTALL_DATA = ${INSTALL} -m 644
INSTALL_PROGRAM = ${INSTALL}
INSTALL_SCRIPT = ${INSTALL}
INSTALL_STRIP_PROGRAM = $(install_sh) -c -s
LDFLAGS =
LIBOBJS =
LIBS =
LTLIBOBJS =
MAKEINFO = ${SHELL} '/home/vnc1/proj/utun3/build-aux/missing' makeinfo
MKDIR_P = /usr/bin/mkdir -p
OBJEXT = o
PACKAGE = utun
PACKAGE_BUGREPORT = https://github.com/anomalyco/utun3/issues
PACKAGE_NAME = utun
PACKAGE_STRING = utun 2.0.0
PACKAGE_TARNAME = utun
PACKAGE_URL =
PACKAGE_VERSION = 2.0.0
PATH_SEPARATOR = :
RANLIB = ranlib
SET_MAKE =
SHELL = /bin/bash
STRIP =
TUN_LIBS =
VERSION = 2.0.0
abs_builddir = /home/vnc1/proj/utun3
abs_srcdir = /home/vnc1/proj/utun3
abs_top_builddir = /home/vnc1/proj/utun3
abs_top_srcdir = /home/vnc1/proj/utun3
ac_ct_AR = ar
ac_ct_CC = gcc
am__include = include
am__leading_dot = .
am__quote =
am__tar = $${TAR-tar} chof - "$$tardir"
am__untar = $${TAR-tar} xf -
bindir = ${exec_prefix}/bin
build = x86_64-pc-linux-gnu
build_alias =
build_cpu = x86_64
build_os = linux-gnu
build_vendor = pc
builddir = .
datadir = ${datarootdir}
datarootdir = ${prefix}/share
docdir = ${datarootdir}/doc/${PACKAGE_TARNAME}
dvidir = ${docdir}
exec_prefix = ${prefix}
host = x86_64-pc-linux-gnu
host_alias =
host_cpu = x86_64
host_os = linux-gnu
host_vendor = pc
htmldir = ${docdir}
includedir = ${prefix}/include
infodir = ${datarootdir}/info
install_sh = ${SHELL} /home/vnc1/proj/utun3/build-aux/install-sh
libdir = ${exec_prefix}/lib
libexecdir = ${exec_prefix}/libexec
localedir = ${datarootdir}/locale
localstatedir = ${prefix}/var
mandir = ${datarootdir}/man
mkdir_p = $(MKDIR_P)
oldincludedir = /usr/include
pdfdir = ${docdir}
prefix = /usr/local
program_transform_name = s,x,x,
psdir = ${docdir}
runstatedir = ${localstatedir}/run
sbindir = ${exec_prefix}/sbin
sharedstatedir = ${prefix}/com
srcdir = .
sysconfdir = ${prefix}/etc
target_alias =
top_build_prefix =
top_builddir = .
top_srcdir = .
# Order matters: lib -> src -> tests (tests depend on src objects)
SUBDIRS = lib src tests
all: config.h
$(MAKE) $(AM_MAKEFLAGS) all-recursive
.SUFFIXES:
am--refresh: Makefile
@:
$(srcdir)/Makefile.in: $(srcdir)/Makefile.am $(am__configure_deps)
@for dep in $?; do \
case '$(am__configure_deps)' in \
*$$dep*) \
echo ' cd $(srcdir) && $(AUTOMAKE) --foreign'; \
$(am__cd) $(srcdir) && $(AUTOMAKE) --foreign \
&& exit 0; \
exit 1;; \
esac; \
done; \
echo ' cd $(top_srcdir) && $(AUTOMAKE) --foreign Makefile'; \
$(am__cd) $(top_srcdir) && \
$(AUTOMAKE) --foreign Makefile
Makefile: $(srcdir)/Makefile.in $(top_builddir)/config.status
@case '$?' in \
*config.status*) \
echo ' $(SHELL) ./config.status'; \
$(SHELL) ./config.status;; \
*) \
echo ' cd $(top_builddir) && $(SHELL) ./config.status $@ $(am__maybe_remake_depfiles)'; \
cd $(top_builddir) && $(SHELL) ./config.status $@ $(am__maybe_remake_depfiles);; \
esac;
$(top_builddir)/config.status: $(top_srcdir)/configure $(CONFIG_STATUS_DEPENDENCIES)
$(SHELL) ./config.status --recheck
$(top_srcdir)/configure: $(am__configure_deps)
$(am__cd) $(srcdir) && $(AUTOCONF)
$(ACLOCAL_M4): $(am__aclocal_m4_deps)
$(am__cd) $(srcdir) && $(ACLOCAL) $(ACLOCAL_AMFLAGS)
$(am__aclocal_m4_deps):
config.h: stamp-h1
@test -f $@ || rm -f stamp-h1
@test -f $@ || $(MAKE) $(AM_MAKEFLAGS) stamp-h1
stamp-h1: $(srcdir)/config.h.in $(top_builddir)/config.status
@rm -f stamp-h1
cd $(top_builddir) && $(SHELL) ./config.status config.h
$(srcdir)/config.h.in: $(am__configure_deps)
($(am__cd) $(top_srcdir) && $(AUTOHEADER))
rm -f stamp-h1
touch $@
distclean-hdr:
-rm -f config.h stamp-h1
# This directory's subdirectories are mostly independent; you can cd
# into them and run 'make' without going through this Makefile.
# To change the values of 'make' variables: instead of editing Makefiles,
# (1) if the variable is set in 'config.status', edit 'config.status'
# (which will cause the Makefiles to be regenerated when you run 'make');
# (2) otherwise, pass the desired values on the 'make' command line.
$(am__recursive_targets):
@fail=; \
if $(am__make_keepgoing); then \
failcom='fail=yes'; \
else \
failcom='exit 1'; \
fi; \
dot_seen=no; \
target=`echo $@ | sed s/-recursive//`; \
case "$@" in \
distclean-* | maintainer-clean-*) list='$(DIST_SUBDIRS)' ;; \
*) list='$(SUBDIRS)' ;; \
esac; \
for subdir in $$list; do \
echo "Making $$target in $$subdir"; \
if test "$$subdir" = "."; then \
dot_seen=yes; \
local_target="$$target-am"; \
else \
local_target="$$target"; \
fi; \
($(am__cd) $$subdir && $(MAKE) $(AM_MAKEFLAGS) $$local_target) \
|| eval $$failcom; \
done; \
if test "$$dot_seen" = "no"; then \
$(MAKE) $(AM_MAKEFLAGS) "$$target-am" || exit 1; \
fi; test -z "$$fail"
ID: $(am__tagged_files)
$(am__define_uniq_tagged_files); mkid -fID $$unique
tags: tags-recursive
TAGS: tags
tags-am: $(TAGS_DEPENDENCIES) $(am__tagged_files)
set x; \
here=`pwd`; \
if ($(ETAGS) --etags-include --version) >/dev/null 2>&1; then \
include_option=--etags-include; \
empty_fix=.; \
else \
include_option=--include; \
empty_fix=; \
fi; \
list='$(SUBDIRS)'; for subdir in $$list; do \
if test "$$subdir" = .; then :; else \
test ! -f $$subdir/TAGS || \
set "$$@" "$$include_option=$$here/$$subdir/TAGS"; \
fi; \
done; \
$(am__define_uniq_tagged_files); \
shift; \
if test -z "$(ETAGS_ARGS)$$*$$unique"; then :; else \
test -n "$$unique" || unique=$$empty_fix; \
if test $$# -gt 0; then \
$(ETAGS) $(ETAGSFLAGS) $(AM_ETAGSFLAGS) $(ETAGS_ARGS) \
"$$@" $$unique; \
else \
$(ETAGS) $(ETAGSFLAGS) $(AM_ETAGSFLAGS) $(ETAGS_ARGS) \
$$unique; \
fi; \
fi
ctags: ctags-recursive
CTAGS: ctags
ctags-am: $(TAGS_DEPENDENCIES) $(am__tagged_files)
$(am__define_uniq_tagged_files); \
test -z "$(CTAGS_ARGS)$$unique" \
|| $(CTAGS) $(CTAGSFLAGS) $(AM_CTAGSFLAGS) $(CTAGS_ARGS) \
$$unique
GTAGS:
here=`$(am__cd) $(top_builddir) && pwd` \
&& $(am__cd) $(top_srcdir) \
&& gtags -i $(GTAGS_ARGS) "$$here"
cscope: cscope.files
test ! -s cscope.files \
|| $(CSCOPE) -b -q $(AM_CSCOPEFLAGS) $(CSCOPEFLAGS) -i cscope.files $(CSCOPE_ARGS)
clean-cscope:
-rm -f cscope.files
cscope.files: clean-cscope cscopelist
cscopelist: cscopelist-recursive
cscopelist-am: $(am__tagged_files)
list='$(am__tagged_files)'; \
case "$(srcdir)" in \
[\\/]* | ?:[\\/]*) sdir="$(srcdir)" ;; \
*) sdir=$(subdir)/$(srcdir) ;; \
esac; \
for i in $$list; do \
if test -f "$$i"; then \
echo "$(subdir)/$$i"; \
else \
echo "$$sdir/$$i"; \
fi; \
done >> $(top_builddir)/cscope.files
distclean-tags:
-rm -f TAGS ID GTAGS GRTAGS GSYMS GPATH tags
-rm -f cscope.out cscope.in.out cscope.po.out cscope.files
distdir: $(BUILT_SOURCES)
$(MAKE) $(AM_MAKEFLAGS) distdir-am
distdir-am: $(DISTFILES)
$(am__remove_distdir)
test -d "$(distdir)" || mkdir "$(distdir)"
@srcdirstrip=`echo "$(srcdir)" | sed 's/[].[^$$\\*]/\\\\&/g'`; \
topsrcdirstrip=`echo "$(top_srcdir)" | sed 's/[].[^$$\\*]/\\\\&/g'`; \
list='$(DISTFILES)'; \
dist_files=`for file in $$list; do echo $$file; done | \
sed -e "s|^$$srcdirstrip/||;t" \
-e "s|^$$topsrcdirstrip/|$(top_builddir)/|;t"`; \
case $$dist_files in \
*/*) $(MKDIR_P) `echo "$$dist_files" | \
sed '/\//!d;s|^|$(distdir)/|;s,/[^/]*$$,,' | \
sort -u` ;; \
esac; \
for file in $$dist_files; do \
if test -f $$file || test -d $$file; then d=.; else d=$(srcdir); fi; \
if test -d $$d/$$file; then \
dir=`echo "/$$file" | sed -e 's,/[^/]*$$,,'`; \
if test -d "$(distdir)/$$file"; then \
find "$(distdir)/$$file" -type d ! -perm -700 -exec chmod u+rwx {} \;; \
fi; \
if test -d $(srcdir)/$$file && test $$d != $(srcdir); then \
cp -fpR $(srcdir)/$$file "$(distdir)$$dir" || exit 1; \
find "$(distdir)/$$file" -type d ! -perm -700 -exec chmod u+rwx {} \;; \
fi; \
cp -fpR $$d/$$file "$(distdir)$$dir" || exit 1; \
else \
test -f "$(distdir)/$$file" \
|| cp -p $$d/$$file "$(distdir)/$$file" \
|| exit 1; \
fi; \
done
@list='$(DIST_SUBDIRS)'; for subdir in $$list; do \
if test "$$subdir" = .; then :; else \
$(am__make_dryrun) \
|| test -d "$(distdir)/$$subdir" \
|| $(MKDIR_P) "$(distdir)/$$subdir" \
|| exit 1; \
dir1=$$subdir; dir2="$(distdir)/$$subdir"; \
$(am__relativize); \
new_distdir=$$reldir; \
dir1=$$subdir; dir2="$(top_distdir)"; \
$(am__relativize); \
new_top_distdir=$$reldir; \
echo " (cd $$subdir && $(MAKE) $(AM_MAKEFLAGS) top_distdir="$$new_top_distdir" distdir="$$new_distdir" \\"; \
echo " am__remove_distdir=: am__skip_length_check=: am__skip_mode_fix=: distdir)"; \
($(am__cd) $$subdir && \
$(MAKE) $(AM_MAKEFLAGS) \
top_distdir="$$new_top_distdir" \
distdir="$$new_distdir" \
am__remove_distdir=: \
am__skip_length_check=: \
am__skip_mode_fix=: \
distdir) \
|| exit 1; \
fi; \
done
-test -n "$(am__skip_mode_fix)" \
|| find "$(distdir)" -type d ! -perm -755 \
-exec chmod u+rwx,go+rx {} \; -o \
! -type d ! -perm -444 -links 1 -exec chmod a+r {} \; -o \
! -type d ! -perm -400 -exec chmod a+r {} \; -o \
! -type d ! -perm -444 -exec $(install_sh) -c -m a+r {} {} \; \
|| chmod -R a+r "$(distdir)"
dist-gzip: distdir
tardir=$(distdir) && $(am__tar) | eval GZIP= gzip $(GZIP_ENV) -c >$(distdir).tar.gz
$(am__post_remove_distdir)
dist-bzip2: distdir
tardir=$(distdir) && $(am__tar) | BZIP2=$${BZIP2--9} bzip2 -c >$(distdir).tar.bz2
$(am__post_remove_distdir)
dist-lzip: distdir
tardir=$(distdir) && $(am__tar) | lzip -c $${LZIP_OPT--9} >$(distdir).tar.lz
$(am__post_remove_distdir)
dist-xz: distdir
tardir=$(distdir) && $(am__tar) | XZ_OPT=$${XZ_OPT--e} xz -c >$(distdir).tar.xz
$(am__post_remove_distdir)
dist-zstd: distdir
tardir=$(distdir) && $(am__tar) | zstd -c $${ZSTD_CLEVEL-$${ZSTD_OPT--19}} >$(distdir).tar.zst
$(am__post_remove_distdir)
dist-tarZ: distdir
@echo WARNING: "Support for distribution archives compressed with" \
"legacy program 'compress' is deprecated." >&2
@echo WARNING: "It will be removed altogether in Automake 2.0" >&2
tardir=$(distdir) && $(am__tar) | compress -c >$(distdir).tar.Z
$(am__post_remove_distdir)
dist-shar: distdir
@echo WARNING: "Support for shar distribution archives is" \
"deprecated." >&2
@echo WARNING: "It will be removed altogether in Automake 2.0" >&2
shar $(distdir) | eval GZIP= gzip $(GZIP_ENV) -c >$(distdir).shar.gz
$(am__post_remove_distdir)
dist-zip: distdir
-rm -f $(distdir).zip
zip -rq $(distdir).zip $(distdir)
$(am__post_remove_distdir)
dist dist-all:
$(MAKE) $(AM_MAKEFLAGS) $(DIST_TARGETS) am__post_remove_distdir='@:'
$(am__post_remove_distdir)
# This target untars the dist file and tries a VPATH configuration. Then
# it guarantees that the distribution is self-contained by making another
# tarfile.
distcheck: dist
case '$(DIST_ARCHIVES)' in \
*.tar.gz*) \
eval GZIP= gzip $(GZIP_ENV) -dc $(distdir).tar.gz | $(am__untar) ;;\
*.tar.bz2*) \
bzip2 -dc $(distdir).tar.bz2 | $(am__untar) ;;\
*.tar.lz*) \
lzip -dc $(distdir).tar.lz | $(am__untar) ;;\
*.tar.xz*) \
xz -dc $(distdir).tar.xz | $(am__untar) ;;\
*.tar.Z*) \
uncompress -c $(distdir).tar.Z | $(am__untar) ;;\
*.shar.gz*) \
eval GZIP= gzip $(GZIP_ENV) -dc $(distdir).shar.gz | unshar ;;\
*.zip*) \
unzip $(distdir).zip ;;\
*.tar.zst*) \
zstd -dc $(distdir).tar.zst | $(am__untar) ;;\
esac
chmod -R a-w $(distdir)
chmod u+w $(distdir)
mkdir $(distdir)/_build $(distdir)/_build/sub $(distdir)/_inst
chmod a-w $(distdir)
test -d $(distdir)/_build || exit 0; \
dc_install_base=`$(am__cd) $(distdir)/_inst && pwd | sed -e 's,^[^:\\/]:[\\/],/,'` \
&& dc_destdir="$${TMPDIR-/tmp}/am-dc-$$$$/" \
&& am__cwd=`pwd` \
&& $(am__cd) $(distdir)/_build/sub \
&& ../../configure \
$(AM_DISTCHECK_CONFIGURE_FLAGS) \
$(DISTCHECK_CONFIGURE_FLAGS) \
--srcdir=../.. --prefix="$$dc_install_base" \
&& $(MAKE) $(AM_MAKEFLAGS) \
&& $(MAKE) $(AM_MAKEFLAGS) $(AM_DISTCHECK_DVI_TARGET) \
&& $(MAKE) $(AM_MAKEFLAGS) check \
&& $(MAKE) $(AM_MAKEFLAGS) install \
&& $(MAKE) $(AM_MAKEFLAGS) installcheck \
&& $(MAKE) $(AM_MAKEFLAGS) uninstall \
&& $(MAKE) $(AM_MAKEFLAGS) distuninstallcheck_dir="$$dc_install_base" \
distuninstallcheck \
&& chmod -R a-w "$$dc_install_base" \
&& ({ \
(cd ../.. && umask 077 && mkdir "$$dc_destdir") \
&& $(MAKE) $(AM_MAKEFLAGS) DESTDIR="$$dc_destdir" install \
&& $(MAKE) $(AM_MAKEFLAGS) DESTDIR="$$dc_destdir" uninstall \
&& $(MAKE) $(AM_MAKEFLAGS) DESTDIR="$$dc_destdir" \
distuninstallcheck_dir="$$dc_destdir" distuninstallcheck; \
} || { rm -rf "$$dc_destdir"; exit 1; }) \
&& rm -rf "$$dc_destdir" \
&& $(MAKE) $(AM_MAKEFLAGS) dist \
&& rm -rf $(DIST_ARCHIVES) \
&& $(MAKE) $(AM_MAKEFLAGS) distcleancheck \
&& cd "$$am__cwd" \
|| exit 1
$(am__post_remove_distdir)
@(echo "$(distdir) archives ready for distribution: "; \
list='$(DIST_ARCHIVES)'; for i in $$list; do echo $$i; done) | \
sed -e 1h -e 1s/./=/g -e 1p -e 1x -e '$$p' -e '$$x'
distuninstallcheck:
@test -n '$(distuninstallcheck_dir)' || { \
echo 'ERROR: trying to run $@ with an empty' \
'$$(distuninstallcheck_dir)' >&2; \
exit 1; \
}; \
$(am__cd) '$(distuninstallcheck_dir)' || { \
echo 'ERROR: cannot chdir into $(distuninstallcheck_dir)' >&2; \
exit 1; \
}; \
test `$(am__distuninstallcheck_listfiles) | wc -l` -eq 0 \
|| { echo "ERROR: files left after uninstall:" ; \
if test -n "$(DESTDIR)"; then \
echo " (check DESTDIR support)"; \
fi ; \
$(distuninstallcheck_listfiles) ; \
exit 1; } >&2
distcleancheck: distclean
@if test '$(srcdir)' = . ; then \
echo "ERROR: distcleancheck can only run from a VPATH build" ; \
exit 1 ; \
fi fi
@$(MAKE) -C $(BUILD_DIR) @test `$(distcleancheck_listfiles) | wc -l` -eq 0 \
|| { echo "ERROR: files left in build directory after distclean:" ; \
$(distcleancheck_listfiles) ; \
exit 1; } >&2
check-am: all-am
check: check-recursive
all-am: Makefile config.h
installdirs: installdirs-recursive
installdirs-am:
install: install-recursive
install-exec: install-exec-recursive
install-data: install-data-recursive
uninstall: uninstall-recursive
install-am: all-am
@$(MAKE) $(AM_MAKEFLAGS) install-exec-am install-data-am
installcheck: installcheck-recursive
install-strip:
if test -z '$(STRIP)'; then \
$(MAKE) $(AM_MAKEFLAGS) INSTALL_PROGRAM="$(INSTALL_STRIP_PROGRAM)" \
install_sh_PROGRAM="$(INSTALL_STRIP_PROGRAM)" INSTALL_STRIP_FLAG=-s \
install; \
else \
$(MAKE) $(AM_MAKEFLAGS) INSTALL_PROGRAM="$(INSTALL_STRIP_PROGRAM)" \
install_sh_PROGRAM="$(INSTALL_STRIP_PROGRAM)" INSTALL_STRIP_FLAG=-s \
"INSTALL_PROGRAM_ENV=STRIPPROG='$(STRIP)'" install; \
fi
mostlyclean-generic:
clean-generic:
distclean-generic:
-test -z "$(CONFIG_CLEAN_FILES)" || rm -f $(CONFIG_CLEAN_FILES)
-test . = "$(srcdir)" || test -z "$(CONFIG_CLEAN_VPATH_FILES)" || rm -f $(CONFIG_CLEAN_VPATH_FILES)
maintainer-clean-generic:
@echo "This command is intended for maintainers to use"
@echo "it deletes files that may require special tools to rebuild."
clean: clean-recursive
clean-am: clean-generic clean-local mostlyclean-am
distclean: distclean-recursive
-rm -f $(am__CONFIG_DISTCLEAN_FILES)
-rm -f Makefile
distclean-am: clean-am distclean-generic distclean-hdr distclean-tags
dvi: dvi-recursive
dvi-am:
html: html-recursive
html-am:
info: info-recursive
info-am:
install-data-am:
install-dvi: install-dvi-recursive
install-dvi-am:
install-exec-am:
install-html: install-html-recursive
install-html-am:
install-info: install-info-recursive
install-info-am:
install-man:
install-pdf: install-pdf-recursive
install-pdf-am:
install-ps: install-ps-recursive
install-ps-am:
installcheck-am:
maintainer-clean: maintainer-clean-recursive
-rm -f $(am__CONFIG_DISTCLEAN_FILES)
-rm -rf $(top_srcdir)/autom4te.cache
-rm -f Makefile
maintainer-clean-am: distclean-am maintainer-clean-generic
mostlyclean: mostlyclean-recursive
mostlyclean-am: mostlyclean-generic
pdf: pdf-recursive
pdf-am:
ps: ps-recursive
ps-am:
uninstall-am:
.MAKE: $(am__recursive_targets) all install-am install-strip
clean: .PHONY: $(am__recursive_targets) CTAGS GTAGS TAGS all all-am \
-rm -rf $(BUILD_DIR) am--refresh check check-am clean clean-cscope clean-generic \
clean-local cscope cscopelist-am ctags ctags-am dist dist-all \
dist-bzip2 dist-gzip dist-lzip dist-shar dist-tarZ dist-xz \
dist-zip dist-zstd distcheck distclean distclean-generic \
distclean-hdr distclean-tags distcleancheck distdir \
distuninstallcheck dvi dvi-am html html-am info info-am \
install install-am install-data install-data-am install-dvi \
install-dvi-am install-exec install-exec-am install-html \
install-html-am install-info install-info-am install-man \
install-pdf install-pdf-am install-ps install-ps-am \
install-strip installcheck installcheck-am installdirs \
installdirs-am maintainer-clean maintainer-clean-generic \
mostlyclean mostlyclean-generic pdf pdf-am ps ps-am tags \
tags-am uninstall uninstall-am
distclean: clean .PRECIOUS: Makefile
-rm -f config.status config.log config.h stamp-h1
-rm -rf autom4te.cache/
check: all
@$(MAKE) -C $(BUILD_DIR) check
install: # Clean build directory
@$(MAKE) -C $(BUILD_DIR) install clean-local:
-rm -rf build/
.PHONY: all clean distclean check install # Tell versions [3.59,3.63) of GNU make to not export all variables.
# Otherwise a system limit (for SysV at least) may be exceeded.
.NOEXPORT:

1
src/Makefile.am

@ -35,6 +35,7 @@ utun_CORE_SOURCES = \
packet_dump.c \ packet_dump.c \
etcp_api.c \ etcp_api.c \
control_server.c \ control_server.c \
msg_transport.c \
firewall.c \ firewall.c \
eim_nat.c \ eim_nat.c \
nat_transport.c \ nat_transport.c \

20
src/config_parser.c

@ -46,7 +46,8 @@ typedef enum {
SECTION_ALLOWED_KEYS, SECTION_ALLOWED_KEYS,
SECTION_NAT, SECTION_NAT,
SECTION_TCP_PROXY_CLIENT, SECTION_TCP_PROXY_CLIENT,
SECTION_TCP_PROXY_SERVER SECTION_TCP_PROXY_SERVER,
SECTION_MSG_TRANSPORT
} section_type_t; } section_type_t;
static char* trim(char *str) { static char* trim(char *str) {
@ -439,6 +440,17 @@ static int parse_control(const char *key, const char *value, struct global_confi
return -1; return -1;
} }
static int parse_msg_transport(const char *key, const char *value, struct global_config *global, const char *filename, int line_num) {
if (strcmp(key, "port") == 0) {
char port_str[16];
snprintf(port_str, sizeof(port_str), "%s", value);
parse_sockaddr("127.0.0.1", port_str, &global->msg_transport_sock);
return 0;
}
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown msg_transport option '%s'. Valid: port", filename, line_num, key);
return -1;
}
static int parse_tcp_proxy_client(const char *key, const char *value, struct global_config *global, const char *filename, int line_num) { static int parse_tcp_proxy_client(const char *key, const char *value, struct global_config *global, const char *filename, int line_num) {
if (strcmp(key, "enabled") == 0) { if (strcmp(key, "enabled") == 0) {
global->tcp_proxy_client_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; global->tcp_proxy_client_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0;
@ -700,6 +712,7 @@ static section_type_t parse_section_header(const char *line, char *name, size_t
if (strcasecmp(section, "nat") == 0) return SECTION_NAT; if (strcasecmp(section, "nat") == 0) return SECTION_NAT;
if (strcasecmp(section, "tcp_proxy_client") == 0) return SECTION_TCP_PROXY_CLIENT; if (strcasecmp(section, "tcp_proxy_client") == 0) return SECTION_TCP_PROXY_CLIENT;
if (strcasecmp(section, "tcp_proxy_server") == 0) return SECTION_TCP_PROXY_SERVER; if (strcasecmp(section, "tcp_proxy_server") == 0) return SECTION_TCP_PROXY_SERVER;
if (strcasecmp(section, "msg_transport") == 0) return SECTION_MSG_TRANSPORT;
char *colon = strchr(section, ':'); char *colon = strchr(section, ':');
if (!colon) return SECTION_UNKNOWN; if (!colon) return SECTION_UNKNOWN;
@ -882,6 +895,11 @@ static struct utun_config* parse_config_internal(FILE *fp, const char *filename)
cfg->global.tcp_recv_buf = atoi(value); cfg->global.tcp_recv_buf = atoi(value);
} }
break; break;
case SECTION_MSG_TRANSPORT:
if (parse_msg_transport(key, value, &cfg->global, filename, line_num) < 0) {
// parse_msg_transport already printed the error
}
break;
default: default:
DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "%s:%d: Key outside section: %s", filename, line_num, key); DEBUG_WARN(DEBUG_CATEGORY_CONFIG, "%s:%d: Key outside section: %s", filename, line_num, key);
break; break;

1
src/config_parser.h

@ -100,6 +100,7 @@ struct global_config {
int mtu; int mtu;
struct sockaddr_storage control_sock; struct sockaddr_storage control_sock;
char control_ip[MAX_ADDR_LEN]; // Control server IP address char control_ip[MAX_ADDR_LEN]; // Control server IP address
struct sockaddr_storage msg_transport_sock;
// Debug and logging configuration // Debug and logging configuration
char log_file[256]; // Path to log file (empty = stdout) char log_file[256]; // Path to log file (empty = stdout)

3
src/etcp_api.h

@ -28,7 +28,8 @@
#define ETCP_ID_TCP_PROXY 0x04 // TCP proxy exit node (server) #define ETCP_ID_TCP_PROXY 0x04 // TCP proxy exit node (server)
#define ETCP_ID_UDP_PROXY 0x05 // UDP datagram прокси (client ↔ exit) #define ETCP_ID_UDP_PROXY 0x05 // UDP datagram прокси (client ↔ exit)
#define ETCP_ID_ICMP_PROXY 0x06 // ICMP echo прокси (ping через exit) #define ETCP_ID_ICMP_PROXY 0x06 // ICMP echo прокси (ping через exit)
#define ETCP_ID_TCP_PROXY_CLIENT 0x07 // TCP proxy client (клиент) #define ETCP_ID_TCP_PROXY_CLIENT 0x07 // TCP proxy client (клиент)
#define ETCP_ID_MSG_TRANSPORT 0x10 // msg_transport — локальный IPC транспорт сообщений
// Forward declarations // Forward declarations
struct ETCP_CONN; struct ETCP_CONN;

585
src/msg_transport.c

@ -0,0 +1,585 @@
/*
* msg_transport.c — TCP-сервер транспорта сообщений (local IPC → ETCP)
*
* Принимает TCP-соединения от локальных приложений (протокол msg_transport.h),
* привязывает к remote peer и пересылает данные через etcp_router.
*/
#include "msg_transport.h"
#include "utun_instance.h"
#include "etcp_router.h"
#include "etcp.h"
#include "../lib/u_async.h"
#include "../lib/debug_config.h"
#include "../lib/mem.h"
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#include <winsock2.h>
#include <ws2tcpip.h>
#else
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#endif
#ifndef DEBUG_CATEGORY_MSGTRANSPORT
#define DEBUG_CATEGORY_MSGTRANSPORT DEBUG_CATEGORY_GENERAL
#endif
/* ============================================================================
* Внутренние структуры
* ============================================================================ */
struct msg_client {
struct msg_client* next;
struct msg_transport* transport;
socket_t fd;
void* socket_id;
uint8_t recv_buffer[MSG_MAX_MSG_SIZE];
uint16_t recv_len;
uint64_t bound_peer_id; /* 0 = не забинден */
};
struct msg_transport {
struct UTUN_INSTANCE* instance;
struct UASYNC* ua;
socket_t listen_fd;
void* listen_socket_id;
struct sockaddr_storage bind_addr;
struct msg_client* clients;
uint32_t client_count;
uint32_t max_clients;
uint8_t svc_id;
struct msg_transport_counters counters;
};
/* ============================================================================
* Forward declarations
* ============================================================================ */
static void accept_callback(socket_t fd, void* arg);
static void client_read_callback(socket_t fd, void* arg);
static void client_except_callback(socket_t fd, void* arg);
static void close_client(struct msg_transport* t, struct msg_client* client);
static void handle_client_data(struct msg_transport* t, struct msg_client* client);
static void on_etcp_recv(struct ETCP_CONN* conn, struct ll_entry* entry);
static int forward_to_peer(struct msg_transport* t, uint64_t dst_peer_id,
const uint8_t* data, size_t data_len, int is_signed);
static void send_response(struct msg_client* client, uint8_t type, uint64_t id,
const uint8_t* data, size_t data_len);
static void send_error(struct msg_client* client, uint8_t error_code, const char* msg);
/* ============================================================================
* Инициализация / завершение
* ============================================================================ */
int msg_transport_init(struct msg_transport** out,
struct UTUN_INSTANCE* instance,
struct UASYNC* ua,
struct sockaddr_storage* bind_addr,
uint32_t max_clients,
uint8_t svc_id) {
if (!out || !instance || !ua || !bind_addr) {
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "msg_transport_init: invalid args out=%p inst=%p ua=%p addr=%p",
(void*)out, (void*)instance, (void*)ua, (void*)bind_addr);
return -1;
}
if (bind_addr->ss_family == 0) {
DEBUG_INFO(DEBUG_CATEGORY_MSGTRANSPORT, "msg_transport_init: bind_addr has no family, skipping");
*out = NULL;
return 0;
}
struct msg_transport* t = u_calloc(1, sizeof(*t));
if (!t) {
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "msg_transport_init: u_calloc failed");
return -1;
}
t->instance = instance;
t->ua = ua;
t->max_clients = max_clients ? max_clients : 8;
t->svc_id = svc_id;
memcpy(&t->bind_addr, bind_addr, sizeof(*bind_addr));
int family = bind_addr->ss_family;
t->listen_fd = socket(family, SOCK_STREAM, IPPROTO_TCP);
#ifdef _WIN32
if (t->listen_fd == INVALID_SOCKET) {
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "socket() failed: %d", WSAGetLastError());
u_free(t); return -1;
}
u_long nonblock = 1;
ioctlsocket(t->listen_fd, FIONBIO, &nonblock);
int reuse = 1;
setsockopt(t->listen_fd, SOL_SOCKET, SO_REUSEADDR, (const char*)&reuse, sizeof(reuse));
#else
if (t->listen_fd < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "socket() failed: %s", strerror(errno));
u_free(t); return -1;
}
int reuse = 1;
setsockopt(t->listen_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
int flags = fcntl(t->listen_fd, F_GETFL, 0);
if (flags >= 0) fcntl(t->listen_fd, F_SETFL, flags | O_NONBLOCK);
#endif
socklen_t addr_len = (family == AF_INET) ? sizeof(struct sockaddr_in) : sizeof(struct sockaddr_in6);
#ifdef _WIN32
if (bind(t->listen_fd, (struct sockaddr*)bind_addr, addr_len) == SOCKET_ERROR) {
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "bind() failed: %d", WSAGetLastError());
closesocket(t->listen_fd); u_free(t); return -1;
}
if (listen(t->listen_fd, 5) == SOCKET_ERROR) {
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "listen() failed: %d", WSAGetLastError());
closesocket(t->listen_fd); u_free(t); return -1;
}
#else
if (bind(t->listen_fd, (struct sockaddr*)bind_addr, addr_len) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "bind() failed: %s", strerror(errno));
close(t->listen_fd); u_free(t); return -1;
}
if (listen(t->listen_fd, 5) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "listen() failed: %s", strerror(errno));
close(t->listen_fd); u_free(t); return -1;
}
#endif
t->listen_socket_id = uasync_add_socket_t(ua, t->listen_fd,
accept_callback, NULL, NULL, t);
if (!t->listen_socket_id) {
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "uasync_add_socket_t failed for listen_fd");
#ifdef _WIN32
closesocket(t->listen_fd);
#else
close(t->listen_fd);
#endif
u_free(t); return -1;
}
if (etcp_router_bind(instance, svc_id, on_etcp_recv) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "etcp_router_bind(svc_id=%u) failed", svc_id);
uasync_remove_socket_t(ua, t->listen_fd);
#ifdef _WIN32
closesocket(t->listen_fd);
#else
close(t->listen_fd);
#endif
u_free(t); return -1;
}
if (family == AF_INET) {
struct sockaddr_in* sin = (struct sockaddr_in*)bind_addr;
DEBUG_INFO(DEBUG_CATEGORY_MSGTRANSPORT, "msg_transport listening on %s:%d svc_id=%u",
ip_to_str(&sin->sin_addr, AF_INET).str, ntohs(sin->sin_port), svc_id);
} else {
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)bind_addr;
DEBUG_INFO(DEBUG_CATEGORY_MSGTRANSPORT, "msg_transport listening on [%s]:%d svc_id=%u",
ip_to_str(&sin6->sin6_addr, AF_INET6).str, ntohs(sin6->sin6_port), svc_id);
}
*out = t;
return 0;
}
void msg_transport_shutdown(struct msg_transport* t) {
if (!t) return;
while (t->clients) close_client(t, t->clients);
#ifdef _WIN32
if (t->listen_fd != INVALID_SOCKET) {
uasync_remove_socket_t(t->ua, t->listen_fd);
t->listen_socket_id = NULL;
closesocket(t->listen_fd);
t->listen_fd = INVALID_SOCKET;
}
#else
if (t->listen_fd >= 0) {
uasync_remove_socket_t(t->ua, t->listen_fd);
t->listen_socket_id = NULL;
close(t->listen_fd);
t->listen_fd = -1;
}
#endif
DEBUG_INFO(DEBUG_CATEGORY_MSGTRANSPORT, "msg_transport shutdown complete svc_id=%u", t->svc_id);
memset(t, 0, sizeof(*t));
u_free(t);
}
void msg_transport_get_counters(struct msg_transport* t, struct msg_transport_counters* c) {
if (t && c) memcpy(c, &t->counters, sizeof(*c));
}
/* ============================================================================
* TCP-клиенты: accept / read / close
* ============================================================================ */
static void accept_callback(socket_t fd, void* arg) {
struct msg_transport* t = (struct msg_transport*)arg;
struct sockaddr_storage client_addr;
socklen_t addr_len = sizeof(client_addr);
#ifdef _WIN32
socket_t client_fd = accept(fd, (struct sockaddr*)&client_addr, &addr_len);
if (client_fd == INVALID_SOCKET) {
int err = WSAGetLastError();
if (err != WSAEWOULDBLOCK)
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "accept() failed: %d", err);
return;
}
u_long nonblock = 1;
ioctlsocket(client_fd, FIONBIO, &nonblock);
#else
socket_t client_fd = accept(fd, (struct sockaddr*)&client_addr, &addr_len);
if (client_fd < 0) {
if (errno != EAGAIN && errno != EWOULDBLOCK)
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "accept() failed: %s", strerror(errno));
return;
}
int flags = fcntl(client_fd, F_GETFL, 0);
if (flags >= 0) fcntl(client_fd, F_SETFL, flags | O_NONBLOCK);
#endif
if (t->client_count >= t->max_clients) {
DEBUG_WARN(DEBUG_CATEGORY_MSGTRANSPORT, "max_clients=%u reached, rejecting", t->max_clients);
#ifdef _WIN32
closesocket(client_fd);
#else
close(client_fd);
#endif
return;
}
struct msg_client* client = u_calloc(1, sizeof(*client));
if (!client) {
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "u_calloc(msg_client) failed");
#ifdef _WIN32
closesocket(client_fd);
#else
close(client_fd);
#endif
return;
}
client->fd = client_fd;
client->transport = t;
client->socket_id = uasync_add_socket_t(t->ua, client_fd,
client_read_callback, NULL,
client_except_callback, client);
if (!client->socket_id) {
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "uasync_add_socket_t failed for client");
u_free(client);
#ifdef _WIN32
closesocket(client_fd);
#else
close(client_fd);
#endif
return;
}
client->next = t->clients;
t->clients = client;
t->client_count++;
t->counters.accepted++;
DEBUG_INFO(DEBUG_CATEGORY_MSGTRANSPORT, "msg_transport client connected (total=%u)", t->client_count);
}
static void client_read_callback(socket_t fd, void* arg) {
struct msg_client* client = (struct msg_client*)arg;
struct msg_transport* t = client->transport;
uint8_t* buf = client->recv_buffer + client->recv_len;
size_t buf_space = MSG_MAX_MSG_SIZE - client->recv_len;
#ifdef _WIN32
int received = recv(fd, (char*)buf, (int)buf_space, 0);
if (received == SOCKET_ERROR) {
int err = WSAGetLastError();
if (err != WSAEWOULDBLOCK) {
if (err == 10054) { close_client(t, client); return; }
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "recv() error: %d", err);
close_client(t, client);
}
return;
}
#else
ssize_t received = recv(fd, buf, buf_space, 0);
if (received < 0) {
if (errno != EAGAIN && errno != EWOULDBLOCK) {
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "recv() error: %s", strerror(errno));
close_client(t, client);
}
return;
}
#endif
if (received == 0) {
close_client(t, client);
return;
}
client->recv_len += (uint16_t)received;
if (client->recv_len > MSG_MAX_MSG_SIZE) {
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "recv buffer overflow");
close_client(t, client);
return;
}
handle_client_data(t, client);
}
static void client_except_callback(socket_t fd, void* arg) {
struct msg_client* client = (struct msg_client*)arg;
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "client socket exception");
close_client(client->transport, client);
(void)fd;
}
static void close_client(struct msg_transport* t, struct msg_client* client) {
if (!t || !client) return;
#ifdef _WIN32
if (client->fd != INVALID_SOCKET) {
uasync_remove_socket_t(t->ua, client->fd);
closesocket(client->fd);
}
#else
if (client->fd >= 0) {
uasync_remove_socket_t(t->ua, client->fd);
close(client->fd);
}
#endif
struct msg_client** curr = &t->clients;
while (*curr) {
if (*curr == client) { *curr = client->next; break; }
curr = &(*curr)->next;
}
t->client_count--;
t->counters.closed++;
u_free(client);
DEBUG_INFO(DEBUG_CATEGORY_MSGTRANSPORT, "msg_transport client disconnected (total=%u)", t->client_count);
}
/* ============================================================================
* Обработка входящих сообщений от TCP-клиента
* ============================================================================ */
static void handle_client_data(struct msg_transport* t, struct msg_client* client) {
while (client->recv_len >= MSG_HDR_SIZE) {
struct msg_header* hdr = (struct msg_header*)client->recv_buffer;
if (msg_validate_header(hdr) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "invalid header size=%u type=0x%02x", hdr->size, hdr->type);
send_error(client, MSG_ERR_INVALID_MSG, "Invalid message header");
close_client(t, client);
return;
}
if (client->recv_len < hdr->size) break;
uint8_t* payload = client->recv_buffer + MSG_HDR_SIZE;
uint16_t payload_size = hdr->size - MSG_HDR_SIZE;
switch (hdr->type) {
case MSG_CTL_BIND: {
uint64_t peer_id = hdr->id;
if (peer_id == 0) {
send_error(client, MSG_ERR_INVALID_MSG, "BIND with zero peer_id");
break;
}
/* Проверить конфликт: этот peer_id уже занят другим клиентом */
struct msg_client* other = t->clients;
while (other) {
if (other != client && other->bound_peer_id == peer_id) {
send_error(client, MSG_ERR_ID_TAKEN, "peer_id already bound by another client");
DEBUG_WARN(DEBUG_CATEGORY_MSGTRANSPORT, "BIND peer=%016llx rejected: already bound",
(unsigned long long)peer_id);
break;
}
other = other->next;
}
if (other) break; /* конфликт */
client->bound_peer_id = peer_id;
send_response(client, MSG_RSP_BIND_OK, peer_id, NULL, 0);
t->counters.bind_count++;
DEBUG_INFO(DEBUG_CATEGORY_MSGTRANSPORT, "BIND peer=%016llx", (unsigned long long)peer_id);
break;
}
case MSG_CTL_UNBIND:
client->bound_peer_id = 0;
t->counters.unbind_count++;
DEBUG_INFO(DEBUG_CATEGORY_MSGTRANSPORT, "UNBIND");
break;
case MSG_DATA_SEND: {
uint64_t dst = hdr->id ? hdr->id : client->bound_peer_id;
if (dst == 0) {
send_error(client, MSG_ERR_NOT_BOUND, "Not bound to any peer");
break;
}
if (payload_size == 0) break;
int is_signed = (hdr->options & MSG_OPT_SIGNED) ? 1 : 0;
if (forward_to_peer(t, dst, payload, payload_size, is_signed) != 0) {
send_error(client, MSG_ERR_SEND_FAILED, "Failed to forward message to peer");
t->counters.drops_no_route++;
}
break;
}
default:
DEBUG_WARN(DEBUG_CATEGORY_MSGTRANSPORT, "unknown message type 0x%02x from client", hdr->type);
send_error(client, MSG_ERR_INVALID_MSG, "Unknown message type");
break;
}
uint16_t msg_size = hdr->size;
if (client->recv_len > msg_size)
memmove(client->recv_buffer, client->recv_buffer + msg_size, client->recv_len - msg_size);
client->recv_len -= msg_size;
}
}
/* ============================================================================
* Отправка пиру через etcp_router
* ============================================================================ */
static int forward_to_peer(struct msg_transport* t, uint64_t dst_peer_id,
const uint8_t* data, size_t data_len, int is_signed) {
struct ETCP_ROUTER_CONN* rconn = etcp_router_conn_get(t->instance, dst_peer_id, t->svc_id);
if (!rconn) {
DEBUG_WARN(DEBUG_CATEGORY_MSGTRANSPORT, "forward_to_peer: no route to %016llx", (unsigned long long)dst_peer_id);
return -1;
}
int ret;
if (is_signed) {
ret = etcp_router_conn_send_signed(rconn, data, data_len);
} else {
ret = etcp_router_conn_send(rconn, data, data_len);
}
if (ret != 0) {
DEBUG_WARN(DEBUG_CATEGORY_MSGTRANSPORT, "forward_to_peer: send failed to %016llx (signed=%d)",
(unsigned long long)dst_peer_id, is_signed);
return -1;
}
t->counters.msg_sent_to_peer++;
return 0;
}
/* ============================================================================
* Приём от пира через etcp_router → доставка TCP-клиенту
* ============================================================================ */
static void on_etcp_recv(struct ETCP_CONN* conn, struct ll_entry* entry) {
if (!conn || !entry) return;
struct UTUN_INSTANCE* inst = conn->instance;
if (!inst) { queue_dgram_free(entry); queue_entry_free(entry); return; }
struct msg_transport* t = inst->msg_t;
if (!t) {
DEBUG_WARN(DEBUG_CATEGORY_MSGTRANSPORT, "on_etcp_recv: msg_transport not initialized on instance");
queue_dgram_free(entry); queue_entry_free(entry); return;
}
uint8_t* payload = entry->dgram + 1;
size_t payload_len = entry->len - 1;
uint64_t src_node_id = conn->peer_node_id;
DEBUG_TRACE(DEBUG_CATEGORY_MSGTRANSPORT, "on_etcp_recv: from %016llx len=%zu",
(unsigned long long)src_node_id, payload_len);
/* Ищем клиента привязанного к этому пиру */
struct msg_client* client = t->clients;
while (client) {
if (client->bound_peer_id == src_node_id) break;
client = client->next;
}
if (!client) {
DEBUG_WARN(DEBUG_CATEGORY_MSGTRANSPORT, "on_etcp_recv: no client bound to %016llx, dropping",
(unsigned long long)src_node_id);
t->counters.drops_no_client++;
queue_dgram_free(entry);
queue_entry_free(entry);
return;
}
t->counters.msg_recv_from_peer++;
/* Формируем MSG_DATA_RECV */
size_t total = MSG_HDR_SIZE + payload_len;
uint8_t* buf = u_malloc(total);
if (!buf) {
DEBUG_ERROR(DEBUG_CATEGORY_MSGTRANSPORT, "on_etcp_recv: u_malloc failed");
queue_dgram_free(entry); queue_entry_free(entry); return;
}
struct msg_header* hdr = (struct msg_header*)buf;
msg_build_header(hdr, (uint16_t)payload_len, MSG_DATA_RECV, src_node_id, 0);
memcpy(buf + MSG_HDR_SIZE, payload, payload_len);
#ifdef _WIN32
send(client->fd, (const char*)buf, (int)total, 0);
#else
send(client->fd, buf, total, 0);
#endif
u_free(buf);
t->counters.msg_sent_to_client++;
queue_dgram_free(entry);
queue_entry_free(entry);
}
/* ============================================================================
* Помощники отправки ответов TCP-клиенту
* ============================================================================ */
static void send_response(struct msg_client* client, uint8_t type, uint64_t id,
const uint8_t* data, size_t data_len) {
size_t total = MSG_HDR_SIZE + data_len;
uint8_t* buf = u_malloc(total);
if (!buf) return;
struct msg_header* hdr = (struct msg_header*)buf;
msg_build_header(hdr, (uint16_t)data_len, type, id, 0);
if (data_len > 0) memcpy(buf + MSG_HDR_SIZE, data, data_len);
#ifdef _WIN32
send(client->fd, (const char*)buf, (int)total, 0);
#else
send(client->fd, buf, total, 0);
#endif
u_free(buf);
}
static void send_error(struct msg_client* client, uint8_t error_code, const char* msg) {
size_t msg_len = strlen(msg);
size_t total = MSG_HDR_SIZE + 1 + msg_len;
uint8_t* buf = u_malloc(total);
if (!buf) return;
struct msg_header* hdr = (struct msg_header*)buf;
msg_build_header(hdr, (uint16_t)(1 + msg_len), MSG_RSP_ERROR, 0, 0);
buf[MSG_HDR_SIZE] = error_code;
memcpy(buf + MSG_HDR_SIZE + 1, msg, msg_len);
#ifdef _WIN32
send(client->fd, (const char*)buf, (int)total, 0);
#else
send(client->fd, buf, total, 0);
#endif
u_free(buf);
}

118
src/msg_transport.h

@ -0,0 +1,118 @@
/*
* msg_transport.h — TCP-сервер транспорта сообщений (local IPC → ETCP)
*
* Принимает TCP-соединения от локальных приложений.
* Каждое соединение привязывается (BIND) к конкретному remote peer node_id.
* Сообщения от локального клиента пересылаются пиру через etcp_router,
* сообщения от пира доставляются локальному клиенту.
*
* Формат фрейма на TCP:
* [size:2 (uint16_t, native endian)] [type:1] [id:8 (uint64_t)] [options:1] [data...]
* size включает само поле size.
*/
#ifndef MSG_TRANSPORT_H
#define MSG_TRANSPORT_H
#include <stdint.h>
#include "../lib/socket_compat.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ============================================================================
* Протокол
* ============================================================================ */
#define MSG_MAX_MSG_SIZE 8192
#define MSG_SIGN_SIZE 64 /* Ed25519 */
/* Options byte bits */
#define MSG_OPT_SIGNED 0x01
/* Типы сообщений */
#define MSG_CTL_BIND 0x01 /* GUI→utun: привязать TCP-соединение к пиру id */
#define MSG_CTL_UNBIND 0x02 /* GUI→utun: отвязать */
#define MSG_DATA_SEND 0x10 /* GUI→utun: отправить сообщение пиру id */
#define MSG_DATA_RECV 0x11 /* utun→GUI: получено сообщение от пира id */
#define MSG_RSP_BIND_OK 0xE1 /* utun→GUI: BIND успешен */
#define MSG_RSP_ERROR 0xFF /* utun→GUI: ошибка */
/* Коды ошибок для MSG_RSP_ERROR */
#define MSG_ERR_INVALID_MSG 0x01
#define MSG_ERR_NOT_BOUND 0x02
#define MSG_ERR_NO_ROUTE 0x03
#define MSG_ERR_SEND_FAILED 0x04
#define MSG_ERR_ID_TAKEN 0x06
#pragma pack(push, 1)
struct msg_header {
uint16_t size; /* полный размер сообщения, включая это поле */
uint8_t type; /* тип (MSG_CTL_* / MSG_DATA_* / MSG_RSP_*) */
uint64_t id; /* peer node_id */
uint8_t options; /* битовые флаги (MSG_OPT_*) */
};
#pragma pack(pop)
#define MSG_HDR_SIZE sizeof(struct msg_header) /* 12 */
static inline void msg_build_header(struct msg_header* hdr,
uint16_t payload_size,
uint8_t msg_type,
uint64_t id,
uint8_t options) {
hdr->size = MSG_HDR_SIZE + payload_size;
hdr->type = msg_type;
hdr->id = id;
hdr->options = options;
}
static inline int msg_validate_header(const struct msg_header* hdr) {
if (hdr->size < MSG_HDR_SIZE || hdr->size > MSG_MAX_MSG_SIZE) return -1;
return 0;
}
/* ============================================================================
* Счётчики (диагностика)
* ============================================================================ */
struct UTUN_INSTANCE;
struct UASYNC;
struct msg_transport;
struct msg_transport_counters {
uint64_t accepted;
uint64_t closed;
uint64_t bind_count;
uint64_t unbind_count;
uint64_t msg_sent_to_peer;
uint64_t msg_recv_from_peer;
uint64_t msg_sent_to_client;
uint64_t errors;
uint64_t drops_no_client;
uint64_t drops_no_route;
};
/* ============================================================================
* API
* ============================================================================ */
int msg_transport_init(struct msg_transport** out,
struct UTUN_INSTANCE* instance,
struct UASYNC* ua,
struct sockaddr_storage* bind_addr,
uint32_t max_clients,
uint8_t svc_id);
void msg_transport_shutdown(struct msg_transport* t);
void msg_transport_get_counters(struct msg_transport* t,
struct msg_transport_counters* c);
#ifdef __cplusplus
}
#endif
#endif /* MSG_TRANSPORT_H */

23
src/utun_instance.c

@ -12,6 +12,7 @@
#include "etcp_connections.h" #include "etcp_connections.h"
#include "etcp.h" #include "etcp.h"
#include "control_server.h" #include "control_server.h"
#include "msg_transport.h"
#include "../lib/u_async.h" #include "../lib/u_async.h"
#include "../lib/debug_config.h" #include "../lib/debug_config.h"
#include <stdlib.h> #include <stdlib.h>
@ -289,6 +290,13 @@ void utun_instance_destroy(struct UTUN_INSTANCE *instance) {
// Stop running if not already // Stop running if not already
instance->running = 0; instance->running = 0;
// Shutdown message transport server
if (instance->msg_t) {
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "Shutting down message transport server");
msg_transport_shutdown(instance->msg_t);
instance->msg_t = NULL;
}
// Shutdown control server first // Shutdown control server first
if (instance->control_srv) { if (instance->control_srv) {
DEBUG_INFO(DEBUG_CATEGORY_CONTROL, "Shutting down control server"); DEBUG_INFO(DEBUG_CATEGORY_CONTROL, "Shutting down control server");
@ -482,6 +490,21 @@ int utun_instance_init(struct UTUN_INSTANCE *instance) {
} }
} }
// Initialize message transport server if configured
if (instance->config->global.msg_transport_sock.ss_family != 0) {
int mt_ret = msg_transport_init(&instance->msg_t, instance, instance->ua,
&instance->config->global.msg_transport_sock,
8, ETCP_ID_MSG_TRANSPORT);
if (mt_ret != 0) {
DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "Failed to initialize msg_transport, continuing without IPC transport");
instance->msg_t = NULL;
} else {
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "Message transport server initialized");
}
} else {
instance->msg_t = NULL;
}
// Start the main loop // Start the main loop
instance->running = 1; instance->running = 1;

4
src/utun_instance.h

@ -31,6 +31,7 @@ struct ETCP_BINDINGS;
struct ETCP_ROUTER_BINDINGS; struct ETCP_ROUTER_BINDINGS;
struct ROUTE_BGP; struct ROUTE_BGP;
struct control_server; struct control_server;
struct msg_transport;
struct PING_CONTEXT; struct PING_CONTEXT;
// uTun instance configuration // uTun instance configuration
@ -92,6 +93,9 @@ struct UTUN_INSTANCE {
// Control server for monitoring // Control server for monitoring
struct control_server* control_srv; struct control_server* control_srv;
// Message transport server (local IPC → ETCP)
struct msg_transport* msg_t;
// Firewall // Firewall
struct firewall_ctx fw; struct firewall_ctx fw;

1
tests/Makefile.am

@ -102,6 +102,7 @@ ETCP_FULL_OBJS = \
$(top_builddir)/src/utun-eim_nat.o \ $(top_builddir)/src/utun-eim_nat.o \
$(top_builddir)/src/utun-nat_transport.o \ $(top_builddir)/src/utun-nat_transport.o \
$(top_builddir)/src/utun-control_server.o \ $(top_builddir)/src/utun-control_server.o \
$(top_builddir)/src/utun-msg_transport.o \
$(TUN_PLATFORM_OBJ) \ $(TUN_PLATFORM_OBJ) \
$(top_builddir)/src/utun-utun_instance.o \ $(top_builddir)/src/utun-utun_instance.o \
$(top_builddir)/src/proxy/utun-tcp_proxy_client.o \ $(top_builddir)/src/proxy/utun-tcp_proxy_client.o \

25
tools/chatgui/CMakeLists.txt

@ -1,18 +1,22 @@
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.16)
project(chatgui VERSION 0.1.0 LANGUAGES CXX) project(chatgui VERSION 0.1.0 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
find_package(Qt5 COMPONENTS Widgets QUIET) find_package(Qt5 COMPONENTS Widgets Network QUIET)
if(NOT Qt5_FOUND) if(NOT Qt5_FOUND)
find_package(Qt6 COMPONENTS Widgets REQUIRED) find_package(Qt6 COMPONENTS Widgets Network REQUIRED)
set(QT_LIBS Qt6::Widgets) set(QT_LIBS Qt6::Widgets Qt6::Network)
else() else()
set(QT_LIBS Qt5::Widgets) set(QT_LIBS Qt5::Widgets Qt5::Network)
endif() endif()
find_package(rlottie REQUIRED)
find_package(ZLIB REQUIRED)
add_executable(chatgui add_executable(chatgui
src/main.cpp src/main.cpp
src/mainwindow.cpp src/mainwindow.cpp
@ -26,6 +30,15 @@ add_executable(chatgui
src/emojipanel.cpp src/emojipanel.cpp
src/emojitabbar.cpp src/emojitabbar.cpp
src/inputbar.cpp src/inputbar.cpp
src/lottieicon.cpp
src/animtimer.cpp
transport/msg_client.cpp
db/db_manager.cpp
db/sqlite3.c
resources/chatgui.qrc
) )
target_link_libraries(chatgui PRIVATE ${QT_LIBS}) target_include_directories(chatgui PRIVATE ${CMAKE_SOURCE_DIR}/db)
target_compile_definitions(chatgui PRIVATE SQLITE_THREADSAFE=1)
set_source_files_properties(db/sqlite3.c PROPERTIES LANGUAGE C)
target_link_libraries(chatgui PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB pthread dl)

13
tools/chatgui/chat_tz.txt

@ -0,0 +1,13 @@
Архитектура чата (группового)
!! пока мысли - не использовать это как готовое тз
1. сообщения чата (текст + ссылки на аттачи) хранятся в таблице:
id (primary key), timestamp, user_id, msg (utf8 text 4k max), sign
2. реакции: key(msg_id, user_id), type, sign
3. аттачи: key(msg_id, user_id), filename, sign
Структура каталогов:
chats.db - база
<caht_id>

116
tools/chatgui/db/db_manager.cpp

@ -0,0 +1,116 @@
#include "db_manager.h"
#include "../src/debug_ui.h"
DbManager::DbManager(const QString& path, QObject* parent)
: QObject(parent)
{
int rc = sqlite3_open(path.toUtf8().constData(), &m_db);
if (rc != SQLITE_OK) {
GUI_ERROR("Cannot open database: %s", sqlite3_errmsg(m_db));
sqlite3_close(m_db);
m_db = nullptr;
return;
}
sqlite3_exec(m_db, "PRAGMA journal_mode=WAL", nullptr, nullptr, nullptr);
initTables();
GUI_INFO("Database opened: %s", path.toUtf8().constData());
}
DbManager::~DbManager() {
if (m_db) {
sqlite3_close(m_db);
m_db = nullptr;
GUI_INFO("Database closed");
}
}
void DbManager::initTables() {
sqlite3_exec(m_db, "PRAGMA foreign_keys = ON", nullptr, nullptr, nullptr);
const char* sql =
"CREATE TABLE IF NOT EXISTS local_identity ("
" id INTEGER PRIMARY KEY CHECK (id = 1),"
" node_id INTEGER NOT NULL UNIQUE,"
" name TEXT NOT NULL,"
" x25519_pubkey BLOB NOT NULL,"
" ed25519_pubkey BLOB,"
" created_at TEXT DEFAULT (datetime('now')),"
" updated_at TEXT DEFAULT (datetime('now'))"
");"
"CREATE TABLE IF NOT EXISTS nodes ("
" node_id INTEGER PRIMARY KEY,"
" name TEXT,"
" x25519_pubkey BLOB,"
" ed25519_pubkey BLOB,"
" hop_count INTEGER DEFAULT 0,"
" is_online INTEGER DEFAULT 0,"
" last_seen_ver INTEGER DEFAULT 0,"
" connectivity_json TEXT,"
" created_at TEXT DEFAULT (datetime('now')),"
" updated_at TEXT DEFAULT (datetime('now'))"
");"
"CREATE TABLE IF NOT EXISTS node_addresses ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" node_id INTEGER NOT NULL REFERENCES nodes(node_id) ON DELETE CASCADE,"
" family INTEGER NOT NULL DEFAULT 4,"
" ip BLOB NOT NULL,"
" port INTEGER NOT NULL,"
" addr_type INTEGER NOT NULL DEFAULT 0,"
" socket_type INTEGER DEFAULT 0,"
" priority INTEGER DEFAULT 0"
");"
"CREATE INDEX IF NOT EXISTS idx_node_addr_node ON node_addresses(node_id);"
"CREATE TABLE IF NOT EXISTS node_subnets ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" node_id INTEGER NOT NULL REFERENCES nodes(node_id) ON DELETE CASCADE,"
" family INTEGER NOT NULL DEFAULT 4,"
" network BLOB NOT NULL,"
" prefix_length INTEGER NOT NULL"
");"
"CREATE INDEX IF NOT EXISTS idx_subnet_node ON node_subnets(node_id);"
"CREATE TABLE IF NOT EXISTS channels ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" channel_id TEXT NOT NULL UNIQUE,"
" name TEXT NOT NULL,"
" member_ids_json TEXT NOT NULL,"
" is_dm INTEGER DEFAULT 0,"
" last_message TEXT,"
" last_msg_at TEXT,"
" created_at TEXT DEFAULT (datetime('now'))"
");"
"CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_cid ON channels(channel_id);"
"CREATE TABLE IF NOT EXISTS messages ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" channel_id TEXT NOT NULL REFERENCES channels(channel_id) ON DELETE CASCADE,"
" author_node_id INTEGER NOT NULL,"
" content TEXT NOT NULL,"
" content_type TEXT DEFAULT 'text/plain',"
" timestamp INTEGER NOT NULL,"
" local_seq INTEGER DEFAULT 0,"
" signature BLOB,"
" is_outgoing INTEGER DEFAULT 0,"
" is_read INTEGER DEFAULT 0,"
" created_at TEXT DEFAULT (datetime('now'))"
");"
"CREATE INDEX IF NOT EXISTS idx_msg_channel_time ON messages(channel_id, timestamp);"
"CREATE INDEX IF NOT EXISTS idx_msg_author ON messages(author_node_id);"
"CREATE UNIQUE INDEX IF NOT EXISTS idx_msg_dedup ON messages(channel_id, author_node_id, timestamp);";
char* errMsg = nullptr;
int rc = sqlite3_exec(m_db, sql, nullptr, nullptr, &errMsg);
if (rc != SQLITE_OK) {
GUI_ERROR("Failed to create tables: %s", errMsg);
sqlite3_free(errMsg);
return;
}
GUI_INFO("Database tables initialized (6 tables, 6 indexes)");
}

23
tools/chatgui/db/db_manager.h

@ -0,0 +1,23 @@
#ifndef DB_MANAGER_H
#define DB_MANAGER_H
#include <sqlite3.h>
#include <QString>
#include <QObject>
class DbManager : public QObject {
Q_OBJECT
public:
explicit DbManager(const QString& path, QObject* parent = nullptr);
~DbManager() override;
bool isOpen() const { return m_db != nullptr; }
sqlite3* handle() { return m_db; }
private:
void initTables();
sqlite3* m_db = nullptr;
};
#endif

269376
tools/chatgui/db/sqlite3.c

File diff suppressed because it is too large Load Diff

14347
tools/chatgui/db/sqlite3.h

File diff suppressed because it is too large Load Diff

96
tools/chatgui/doc/db_schema.md

@ -0,0 +1,96 @@
# База данных децентрализованного чата (chatgui)
## Архитектура хранения
Чат **децентрализованный** — нет центрального сервера. Каждый узел (нода uTun) хранит свою
**локальную SQLite БД**. В ней:
- **Информация о узлах (и о себе)** (как подключиться: адреса, порты, ключи — всё из NODEINFO). плюс nickname. - и список узлов = список участников. каждый узел имеет parent node (дерево).
- **История сообщений** —
Данные о других узлах поступают из NODEINFO gossip-протокола uTun и синхронизируются в фоне.
Сообщения приходят через `msg_transport` (TCP, локальный IPC).
аттачи - загружают и кешируют узлы в соответствии со своими настройками.
## Структура таблиц SQLite
### `local_identity` — наш собственный узел (точнее все мои устройства)
между моими устройствами синхронизируется контент - список чатов и сообщения.
```sql
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY,
node_id INTEGER NOT NULL UNIQUE, // id из utun
name TEXT NOT NULL, // ник
last_seen INTEGER DEFAULT 0,
online_rating INTEGER DEFAULT 0, // рейттинг связности с узлом. чем больше число тем чаще узел онлайн и с ним хорошая связь
speed_rating INTEGER DEFAULT 0, // рейтинг скорости обмена с узлом
rtt_rating INTEGER DEFAULT 0, // rtt до узла (через промежуточные узлы если есть)
connectivity_json TEXT, // как связаться с узлом
parent_node_id INTEGER DEFAULT NULL, // каждая нода должна иметь родителя (кроме корневой)
main_node_id INTEGER DEFAULT NULL, // если несколько устройств у клиента, id главной ноды
created_at TEXT DEFAULT (datetime('now')),
);
```
### `channels` — чаты (группы узлов)
```sql
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
last_msg_at TEXT,
);
```
### `messages` — список пользователей в чате
```sql
CREATE TABLE IF NOT EXISTS channel_members (
channel_id INTEGER,
node_id INTEGER, // если несколько устройств то ноды всех стройств
);
```
### `messages` — история сообщений (для каждой группы создаем новую таблицу)
```sql
CREATE TABLE IF NOT EXISTS messages_ch<n> (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id TEXT NOT NULL REFERENCES channels(channel_id) ON DELETE CASCADE,
author_node_id INTEGER NOT NULL,
content TEXT NOT NULL,
content_type TEXT DEFAULT 'text/plain',
timestamp INTEGER NOT NULL,
local_seq INTEGER DEFAULT 0,
signature BLOB,
is_outgoing INTEGER DEFAULT 0,
is_read INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_msg_channel_time ON messages(channel_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_msg_author ON messages(author_node_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_msg_dedup ON messages(channel_id, author_node_id, timestamp);
```
**Пояснение:** каждое сообщение привязано к каналу. `author_node_id` — кто автор
(из `nodes.node_id`). `timestamp` — unix время в миллисекундах *по часам отправителя*
(в децентрализованной системе нет глобальных часов, но для порядка в UI достаточно).
`local_seq` — монотонно возрастающий номер в пределах канала для детерминированной
сортировки при одинаковых timestamp.
**Дедупликация:** сообщение идентифицируется тройкой `(channel_id, author_node_id,
timestamp)`. Уникальный индекс `idx_msg_dedup` предотвращает дубликаты при
повторной доставке через разные пути.
| Поле | Тип | Описание |
|---|---|---|
| `channel_id` | TEXT | Ссылка на канал |
| `author_node_id` | INTEGER | Кто отправил (node_id) |
| `content` | TEXT | Текст сообщения |
| `content_type` | TEXT | MIME-тип: `text/plain`, в будущем `image/png` и т.д. |
| `timestamp` | INTEGER | Unix timestamp (мс) — когда отправлено |
| `local_seq` | INTEGER | Локальный порядковый номер в канале |
| `signature` | BLOB | Ed25519 подпись (64 байта), NULL если без подписи |
| `is_outgoing` | INTEGER | 1 = отправлено нами |
| `is_read` | INTEGER | 1 = прочитано (для галочек в UI) |

BIN
tools/chatgui/resources/animations/greeting.tgs

Binary file not shown.

BIN
tools/chatgui/resources/animations/robot.tgs

Binary file not shown.

BIN
tools/chatgui/resources/animations/sparkles_emoji.tgs

Binary file not shown.

BIN
tools/chatgui/resources/animations/stop.tgs

Binary file not shown.

BIN
tools/chatgui/resources/animations/white_flag_emoji.tgs

Binary file not shown.

9
tools/chatgui/resources/chatgui.qrc

@ -0,0 +1,9 @@
<RCC>
<qresource prefix="/">
<file>animations/sparkles_emoji.tgs</file>
<file>animations/white_flag_emoji.tgs</file>
<file>animations/greeting.tgs</file>
<file>animations/stop.tgs</file>
<file>animations/robot.tgs</file>
</qresource>
</RCC>

44
tools/chatgui/src/animtimer.cpp

@ -0,0 +1,44 @@
#include "animtimer.h"
#include "lottieicon.h"
AnimTimer::AnimTimer() {
m_timer.setInterval(33);
connect(&m_timer, &QTimer::timeout, this, &AnimTimer::onTimer);
m_timer.start();
}
AnimTimer *AnimTimer::instance() {
static AnimTimer s;
return &s;
}
void AnimTimer::registerIcon(const QString &key, LottieIcon *icon) {
m_icons.insert(key, icon);
}
void AnimTimer::unregisterIcon(const QString &key) {
m_icons.remove(key);
}
LottieIcon *AnimTimer::icon(const QString &key) const {
return m_icons.value(key, nullptr);
}
void AnimTimer::activate() {
if (m_activeRef++ == 0) {
for (auto *icon : m_icons)
icon->advanceFrame();
emit ticked();
}
}
void AnimTimer::deactivate() {
if (m_activeRef > 0) m_activeRef--;
}
void AnimTimer::onTimer() {
if (m_activeRef <= 0) return;
for (auto *icon : m_icons)
icon->advanceFrame();
emit ticked();
}

32
tools/chatgui/src/animtimer.h

@ -0,0 +1,32 @@
#pragma once
#include <QObject>
#include <QTimer>
#include <QHash>
class LottieIcon;
class AnimTimer : public QObject {
Q_OBJECT
public:
static AnimTimer *instance();
void registerIcon(const QString &key, LottieIcon *icon);
void unregisterIcon(const QString &key);
LottieIcon *icon(const QString &key) const;
void activate();
void deactivate();
signals:
void ticked();
private slots:
void onTimer();
private:
AnimTimer();
QTimer m_timer;
QHash<QString, LottieIcon *> m_icons;
int m_activeRef = 0;
};

37
tools/chatgui/src/channeldelegate.cpp

@ -50,17 +50,38 @@ void ChannelDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
if (!lastMsg.isEmpty()) { if (!lastMsg.isEmpty()) {
QFont msgFont = option.font; QFont msgFont = option.font;
msgFont.setPointSize(msgFont.pointSize() - 1); msgFont.setPointSize(msgFont.pointSize() - 1);
painter->setFont(msgFont); QColor nickColor(0x33, 0x90, 0xEC);
QColor msgColor = (option.state & QStyle::State_Selected)
QColor msgColor = (option.state & QStyle::State_Selected) ? option.palette.highlightedText().color().darker(120)
? option.palette.highlightedText().color().darker(120) : QColor(128, 128, 128);
: QColor(128, 128, 128); if (option.state & QStyle::State_Selected) nickColor = nickColor.lighter(140);
painter->setPen(msgColor);
QRect msgRect(textRect.left(), nameRect.bottom() + 4, QRect msgRect(textRect.left(), nameRect.bottom() + 4,
textRect.width(), option.rect.bottom() - nameRect.bottom() - margin); textRect.width(), option.rect.bottom() - nameRect.bottom() - margin);
QString elidedMsg = painter->fontMetrics().elidedText(lastMsg, Qt::ElideRight, msgRect.width()); QFontMetrics msgFm(msgFont);
painter->drawText(msgRect, Qt::AlignLeft | Qt::AlignTop | Qt::TextWordWrap, elidedMsg);
int colon = lastMsg.indexOf(QString::fromUtf8(": "));
if (colon >= 0) {
QString nick = lastMsg.left(colon + 1);
QString body = lastMsg.mid(colon + 2);
painter->setFont(msgFont);
painter->setPen(nickColor);
QRect nickRect = msgRect;
nickRect.setWidth(msgFm.horizontalAdvance(nick));
painter->drawText(nickRect, Qt::AlignLeft | Qt::AlignTop, nick);
painter->setPen(msgColor);
QRect bodyRect(msgRect.left() + nickRect.width(), msgRect.top(),
msgRect.width() - nickRect.width(), msgRect.height());
QString elidedBody = msgFm.elidedText(body, Qt::ElideRight, bodyRect.width());
painter->drawText(bodyRect, Qt::AlignLeft | Qt::AlignTop | Qt::TextWordWrap, elidedBody);
} else {
painter->setFont(msgFont);
painter->setPen(msgColor);
QString elided = msgFm.elidedText(lastMsg, Qt::ElideRight, msgRect.width());
painter->drawText(msgRect, Qt::AlignLeft | Qt::AlignTop | Qt::TextWordWrap, elided);
}
} }
painter->restore(); painter->restore();

116
tools/chatgui/src/chatview.cpp

@ -1,10 +1,16 @@
#include "chatview.h" #include "chatview.h"
#include "messagedelegate.h"
#include <QPainter> #include <QPainter>
#include <QClipboard>
#include <QApplication>
#include <QDebug> #include <QDebug>
#include <QMouseEvent>
#include <QKeyEvent>
ChatView::ChatView(QWidget *parent) ChatView::ChatView(QWidget *parent)
: QListView(parent) : QListView(parent)
{ {
setMouseTracking(true);
} }
void ChatView::setBackgroundImage(const QString &path) { void ChatView::setBackgroundImage(const QString &path) {
@ -32,3 +38,113 @@ void ChatView::paintEvent(QPaintEvent *event) {
} }
QListView::paintEvent(event); QListView::paintEvent(event);
} }
bool ChatView::isRowSelected(const QModelIndex &idx) const {
if (!m_selStart.isValid() || !m_selEnd.isValid() || !idx.isValid())
return false;
int r = idx.row();
int a = m_selStart.row();
int b = m_selEnd.row();
if (a > b) qSwap(a, b);
return r >= a && r <= b;
}
void ChatView::mousePressEvent(QMouseEvent *event) {
if (event->button() != Qt::LeftButton) {
QListView::mousePressEvent(event);
return;
}
if (hasSelection()) {
m_selStart = QPersistentModelIndex();
m_selEnd = QPersistentModelIndex();
m_textSel = ChatTextSelection{};
m_selecting = false;
viewport()->update();
}
QModelIndex idx = indexAt(viewport()->mapFrom(this, event->pos()));
m_pressIndex = QPersistentModelIndex(idx);
m_pressPos = event->pos();
event->accept();
}
void ChatView::mouseMoveEvent(QMouseEvent *event) {
if (!m_selecting && m_pressIndex.isValid()) {
int dx = event->pos().x() - m_pressPos.x();
int dy = event->pos().y() - m_pressPos.y();
if (dx * dx + dy * dy >= 25) {
m_selStart = m_pressIndex;
m_selEnd = m_pressIndex;
m_selecting = true;
QRect r = visualRect(QModelIndex(m_pressIndex));
m_textSel.index = m_pressIndex;
m_textSel.clickPos = m_pressPos - r.topLeft();
m_textSel.dragPos = m_textSel.clickPos;
m_textSel.active = true;
viewport()->update();
}
}
if (!m_selecting) {
QModelIndex idx = indexAt(viewport()->mapFrom(this, event->pos()));
if (idx != QModelIndex(m_lastHovered)) {
m_lastHovered = idx;
emit hoveredIndexChanged(idx);
}
}
if (!m_selecting) {
QListView::mouseMoveEvent(event);
return;
}
QModelIndex idx = indexAt(viewport()->mapFrom(this, event->pos()));
if (idx.isValid()) {
m_selEnd = idx;
}
if (m_textSel.active) {
QRect r = visualRect(QModelIndex(m_textSel.index));
m_textSel.dragPos = event->pos() - r.topLeft();
}
viewport()->update();
event->accept();
}
void ChatView::mouseReleaseEvent(QMouseEvent *event) {
if (event->button() == Qt::LeftButton && m_selecting) {
m_selecting = false;
viewport()->update();
}
m_pressIndex = QPersistentModelIndex();
QListView::mouseReleaseEvent(event);
}
void ChatView::keyPressEvent(QKeyEvent *event) {
if (event == QKeySequence::Copy) {
copySelection();
return;
}
QListView::keyPressEvent(event);
}
void ChatView::copySelection() {
if (!hasSelection()) return;
auto *m = model();
if (!m) return;
if (selStart() == selEnd()) {
QModelIndex idx(selStart());
QString text = idx.data(MsgTextRole).toString();
if (m_textSel.startChar >= 0 && m_textSel.endChar > m_textSel.startChar)
text = text.mid(m_textSel.startChar, m_textSel.endChar - m_textSel.startChar);
QApplication::clipboard()->setText(text);
} else {
QString r;
int a = selStart().row(), b = selEnd().row();
if (a > b) qSwap(a, b);
for (int i = a; i <= b; ++i) {
QModelIndex idx = m->index(i, 0);
if (!r.isEmpty()) r += '\n';
r += '[' + idx.data(MsgTimeRole).toString() + "] ";
r += idx.data(MsgAuthorRole).toString() + ": ";
r += idx.data(MsgTextRole).toString();
}
QApplication::clipboard()->setText(r);
}
}

36
tools/chatgui/src/chatview.h

@ -1,6 +1,17 @@
#pragma once #pragma once
#include <QListView> #include <QListView>
#include <QPersistentModelIndex>
#include <QPoint>
struct ChatTextSelection {
QPersistentModelIndex index;
QPoint clickPos;
QPoint dragPos;
int startChar = -1;
int endChar = -1;
bool active = false;
};
class ChatView : public QListView { class ChatView : public QListView {
Q_OBJECT Q_OBJECT
@ -9,11 +20,36 @@ public:
void setBackgroundImage(const QString &path); void setBackgroundImage(const QString &path);
bool hasSelection() const { return m_selStart.isValid(); }
QModelIndex selStart() const { return QModelIndex(m_selStart); }
QModelIndex selEnd() const { return QModelIndex(m_selEnd); }
bool isSelecting() const { return m_selecting; }
const ChatTextSelection &textSelection() const { return m_textSel; }
bool isRowSelected(const QModelIndex &idx) const;
void setTextSelChars(int start, int end) { m_textSel.startChar = start; m_textSel.endChar = end; }
signals:
void hoveredIndexChanged(const QModelIndex &idx);
protected: protected:
void paintEvent(QPaintEvent *event) override; void paintEvent(QPaintEvent *event) override;
void resizeEvent(QResizeEvent *event) override; void resizeEvent(QResizeEvent *event) override;
void scrollContentsBy(int dx, int dy) override; void scrollContentsBy(int dx, int dy) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
private: private:
void copySelection();
QPixmap m_background; QPixmap m_background;
QPersistentModelIndex m_selStart;
QPersistentModelIndex m_selEnd;
ChatTextSelection m_textSel;
QPersistentModelIndex m_lastHovered;
QPersistentModelIndex m_pressIndex;
QPoint m_pressPos;
bool m_selecting = false;
}; };

46
tools/chatgui/src/debug_ui.h

@ -0,0 +1,46 @@
#pragma once
#if !defined(NDEBUG) && !defined(GUI_DEBUG_DISABLE)
#include <cstdio>
#include <cstdarg>
namespace GuiDebugPriv {
inline FILE *s_file = stderr;
inline void setFile(const char *path) {
if (s_file && s_file != stderr) fclose(s_file);
s_file = path ? fopen(path, "w") : nullptr;
if (!s_file) s_file = stderr;
}
}
inline void guiDebugFile(const char *path) { GuiDebugPriv::setFile(path); }
inline void guiDebugLog(char lvl, const char *file, int line,
const char *func, const char *fmt, ...) {
const char *base = strrchr(file, '/');
if (!base) base = strrchr(file, '\\');
fprintf(GuiDebugPriv::s_file, "[%c] %s:%d %s(): ",
lvl, base ? base + 1 : file, line, func);
va_list ap;
va_start(ap, fmt);
vfprintf(GuiDebugPriv::s_file, fmt, ap);
va_end(ap);
fprintf(GuiDebugPriv::s_file, "\n");
fflush(GuiDebugPriv::s_file);
}
#define GUI_ERROR(fmt, ...) guiDebugLog('E', __FILE__, __LINE__, __func__, fmt, ##__VA_ARGS__)
#define GUI_WARN(fmt, ...) guiDebugLog('W', __FILE__, __LINE__, __func__, fmt, ##__VA_ARGS__)
#define GUI_INFO(fmt, ...) guiDebugLog('I', __FILE__, __LINE__, __func__, fmt, ##__VA_ARGS__)
#define GUI_DEBUG(fmt, ...) guiDebugLog('D', __FILE__, __LINE__, __func__, fmt, ##__VA_ARGS__)
#else
#define GUI_ERROR(...) ((void)0)
#define GUI_WARN(...) ((void)0)
#define GUI_INFO(...) ((void)0)
#define GUI_DEBUG(...) ((void)0)
#endif

23
tools/chatgui/src/emoji.cpp

@ -1,5 +1,7 @@
#include "emoji.h" #include "emoji.h"
QHash<QString, QString> g_animatedEmojiMap;
EmojiData EmojiData::unicodeEmoji(const QString &u, const QString &s) { EmojiData EmojiData::unicodeEmoji(const QString &u, const QString &s) {
EmojiData d; EmojiData d;
d.type = EmojiType::Unicode; d.type = EmojiType::Unicode;
@ -8,6 +10,16 @@ EmojiData EmojiData::unicodeEmoji(const QString &u, const QString &s) {
return d; return d;
} }
EmojiData EmojiData::animatedEmoji(const QString &u, const QString &s, const QString &animPath) {
EmojiData d;
d.type = EmojiType::Animation;
d.unicode = u;
d.shortcode = s;
d.animPath = animPath;
g_animatedEmojiMap[u] = animPath;
return d;
}
static QVector<EmojiData> smileys() { static QVector<EmojiData> smileys() {
return { return {
EmojiData::unicodeEmoji("\xF0\x9F\x98\x80", ":grinning:"), EmojiData::unicodeEmoji("\xF0\x9F\x98\x80", ":grinning:"),
@ -109,10 +121,21 @@ static QVector<EmojiData> hearts() {
}; };
} }
static QVector<EmojiData> animations() {
return {
EmojiData::animatedEmoji("\xE2\x9C\xA8", ":sparkles:", ":/animations/sparkles_emoji.tgs"),
EmojiData::animatedEmoji("\xF0\x9F\x8F\xB3\xEF\xB8\x8F", ":flag:", ":/animations/white_flag_emoji.tgs"),
EmojiData::animatedEmoji("\xF0\x9F\x91\x8B", ":wave:", ":/animations/greeting.tgs"),
EmojiData::animatedEmoji("\xE2\x9C\x8B", ":raised_hand:", ":/animations/stop.tgs"),
EmojiData::animatedEmoji("\xF0\x9F\xA4\x96", ":robot:", ":/animations/robot.tgs"),
};
}
QVector<EmojiCategory> builtinEmojiSet() { QVector<EmojiCategory> builtinEmojiSet() {
return { return {
{"Smileys & People", smileys()}, {"Smileys & People", smileys()},
{"Gestures", gestures()}, {"Gestures", gestures()},
{"Hearts & Symbols", hearts()}, {"Hearts & Symbols", hearts()},
{"Animations", animations()},
}; };
} }

4
tools/chatgui/src/emoji.h

@ -2,6 +2,7 @@
#include <QString> #include <QString>
#include <QVector> #include <QVector>
#include <QHash>
enum class EmojiType { Unicode, Image, Animation }; enum class EmojiType { Unicode, Image, Animation };
@ -13,6 +14,7 @@ struct EmojiData {
QString animPath; QString animPath;
static EmojiData unicodeEmoji(const QString &u, const QString &s); static EmojiData unicodeEmoji(const QString &u, const QString &s);
static EmojiData animatedEmoji(const QString &u, const QString &s, const QString &animPath);
}; };
struct EmojiCategory { struct EmojiCategory {
@ -21,3 +23,5 @@ struct EmojiCategory {
}; };
QVector<EmojiCategory> builtinEmojiSet(); QVector<EmojiCategory> builtinEmojiSet();
extern QHash<QString, QString> g_animatedEmojiMap;

94
tools/chatgui/src/emojipanel.cpp

@ -1,12 +1,16 @@
#include "emojipanel.h" #include "emojipanel.h"
#include "emoji.h" #include "emoji.h"
#include "emojitabbar.h" #include "emojitabbar.h"
#include "animtimer.h"
#include "lottieicon.h"
#include <QPushButton> #include <QPushButton>
#include <QScrollArea> #include <QScrollArea>
#include <QTabWidget> #include <QTabWidget>
#include <QGridLayout> #include <QGridLayout>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QPainter>
#include <QTimer>
namespace { namespace {
@ -15,6 +19,68 @@ public:
using QTabWidget::setTabBar; using QTabWidget::setTabBar;
}; };
class EmojiButton : public QPushButton {
public:
EmojiButton(const EmojiData &data, QWidget *parent = nullptr)
: QPushButton(data.unicode, parent)
, m_data(data)
{
setFixedSize(32, 32);
setCursor(Qt::PointingHandCursor);
setStyleSheet(
"QPushButton { border: none; border-radius: 4px; font-size: 18px; background: transparent; }"
"QPushButton:hover { background: rgba(0,0,0,0.08); }"
);
if (data.type == EmojiType::Animation) {
setMouseTracking(true);
m_icon = AnimTimer::instance()->icon(data.animPath);
m_timer.setInterval(33);
connect(&m_timer, &QTimer::timeout, this, [this]() {
if (m_icon && m_icon->isValid()) {
m_frame = (m_frame + 1) % m_icon->frameCount();
update();
}
});
}
}
const QString &unicode() const { return m_data.unicode; }
protected:
void enterEvent(QEvent *e) override {
QPushButton::enterEvent(e);
if (m_icon && m_icon->isValid()) {
m_hovered = true;
m_frame = 0;
m_timer.start();
}
}
void leaveEvent(QEvent *e) override {
QPushButton::leaveEvent(e);
m_hovered = false;
m_timer.stop();
update();
}
void paintEvent(QPaintEvent *e) override {
if (m_hovered && m_icon && m_icon->isValid()) {
QPainter p(this);
p.setRenderHint(QPainter::SmoothPixmapTransform);
int sz = qMin(width(), height()) - 4;
QImage frame = m_icon->renderFrame(m_frame, QSize(sz, sz));
int x = (width() - sz) / 2, y = (height() - sz) / 2;
p.drawImage(x, y, frame);
} else {
QPushButton::paintEvent(e);
}
}
private:
EmojiData m_data;
LottieIcon *m_icon = nullptr;
QTimer m_timer;
int m_frame = 0;
bool m_hovered = false;
};
} // namespace } // namespace
EmojiPanel::EmojiPanel(QWidget *parent) EmojiPanel::EmojiPanel(QWidget *parent)
@ -61,14 +127,20 @@ QWidget *EmojiPanel::buildCategoryPage(const EmojiCategory &cat) {
int cols = 9; int cols = 9;
for (int i = 0; i < cat.items.size(); ++i) { for (int i = 0; i < cat.items.size(); ++i) {
auto *btn = new QPushButton(cat.items[i].unicode, container); const auto &e = cat.items[i];
btn->setFixedSize(32, 32); QPushButton *btn;
btn->setCursor(Qt::PointingHandCursor); if (e.type == EmojiType::Animation)
btn->setStyleSheet( btn = new EmojiButton(e, container);
"QPushButton { border: none; border-radius: 4px; font-size: 18px; background: transparent; }" else {
"QPushButton:hover { background: rgba(0,0,0,0.08); }" btn = new QPushButton(e.unicode, container);
); btn->setFixedSize(32, 32);
btn->setToolTip(cat.items[i].shortcode); btn->setCursor(Qt::PointingHandCursor);
btn->setStyleSheet(
"QPushButton { border: none; border-radius: 4px; font-size: 18px; background: transparent; }"
"QPushButton:hover { background: rgba(0,0,0,0.08); }"
);
}
btn->setToolTip(e.shortcode);
connect(btn, &QPushButton::clicked, this, &EmojiPanel::onEmojiClicked); connect(btn, &QPushButton::clicked, this, &EmojiPanel::onEmojiClicked);
grid->addWidget(btn, i / cols, i % cols); grid->addWidget(btn, i / cols, i % cols);
} }
@ -80,7 +152,7 @@ QWidget *EmojiPanel::buildCategoryPage(const EmojiCategory &cat) {
void EmojiPanel::onEmojiClicked() { void EmojiPanel::onEmojiClicked() {
auto *btn = qobject_cast<QPushButton *>(sender()); auto *btn = qobject_cast<QPushButton *>(sender());
if (btn) { if (!btn) return;
emit emojiSelected(btn->text()); auto *eb = dynamic_cast<EmojiButton *>(btn);
} emit emojiSelected(eb ? eb->unicode() : btn->text());
} }

9
tools/chatgui/src/inputbar.cpp

@ -1,10 +1,11 @@
#include "inputbar.h" #include "inputbar.h"
#include "emojipanel.h" #include "emojipanel.h"
#include "debug_ui.h"
#include <QDebug>
#include <QTextEdit> #include <QTextEdit>
#include <QPushButton> #include <QPushButton>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QFileDialog> #include <QFileDialog>
#include <QDebug>
#include <QKeyEvent> #include <QKeyEvent>
#include <QFontMetrics> #include <QFontMetrics>
#include <QScrollBar> #include <QScrollBar>
@ -117,7 +118,11 @@ void InputBar::adjustInputHeight() {
} }
void InputBar::onSendClicked() { void InputBar::onSendClicked() {
QString text = m_input->toPlainText().trimmed(); QString text = m_input->toPlainText();
GUI_INFO("send: len=%d hasLF=%d text='%s'",
text.length(), text.contains('\n'), qPrintable(text));
while (!text.isEmpty() && (text.back() == ' ' || text.back() == '\t'))
text.chop(1);
if (text.isEmpty()) return; if (text.isEmpty()) return;
emit sendMessage(text); emit sendMessage(text);
m_input->clear(); m_input->clear();

72
tools/chatgui/src/lottieicon.cpp

@ -0,0 +1,72 @@
#include "lottieicon.h"
#include <rlottie_capi.h>
#include <zlib.h>
#include <cstring>
static QByteArray unpackGzip(const QByteArray &data) {
if (data.isEmpty()) return {};
z_stream strm = {};
int ret = inflateInit2(&strm, 16 + MAX_WBITS);
if (ret != Z_OK) return {};
strm.avail_in = (uInt)data.size();
strm.next_in = (Bytef *)data.constData();
QByteArray result;
result.resize(data.size() * 3);
size_t total = 0;
do {
size_t remain = (size_t)result.size() - total;
if (remain < 4096) {
result.resize(result.size() * 2);
remain = (size_t)result.size() - total;
}
strm.avail_out = (uInt)remain;
strm.next_out = (Bytef *)result.data() + total;
ret = inflate(&strm, Z_NO_FLUSH);
if (ret == Z_STREAM_ERROR || ret == Z_DATA_ERROR) {
inflateEnd(&strm);
return {};
}
total += remain - strm.avail_out;
} while (ret != Z_STREAM_END);
inflateEnd(&strm);
result.resize((int)total);
return result;
}
LottieIcon::LottieIcon(const QByteArray &tgsData) {
QByteArray json = unpackGzip(tgsData);
if (json.isEmpty()) return;
m_jsonData = json;
m_anim = lottie_animation_from_data(m_jsonData.constData(), "", "");
if (!m_anim) return;
lottie_animation_get_size(m_anim, &m_w, &m_h);
m_totalFrames = (int)lottie_animation_get_totalframe(m_anim);
m_frameRate = lottie_animation_get_framerate(m_anim);
if (m_totalFrames <= 0) m_totalFrames = 1;
m_valid = true;
}
LottieIcon::~LottieIcon() {
if (m_anim) lottie_animation_destroy(m_anim);
}
QImage LottieIcon::renderFrame(int frameNum, QSize targetSize) const {
if (!m_valid || targetSize.width() <= 0 || targetSize.height() <= 0)
return QImage();
int w = targetSize.width();
int h = targetSize.height();
size_t stride = (size_t)w * 4;
std::vector<uint32_t> buffer((size_t)w * (size_t)h);
int frame = frameNum % m_totalFrames;
lottie_animation_render(m_anim, (size_t)frame, buffer.data(),
(size_t)w, (size_t)h, stride);
QImage img(w, h, QImage::Format_ARGB32_Premultiplied);
for (int y = 0; y < h; y++)
std::memcpy(img.scanLine(y), &buffer[(size_t)y * (size_t)w], stride);
return img;
}

30
tools/chatgui/src/lottieicon.h

@ -0,0 +1,30 @@
#pragma once
#include <QImage>
#include <QByteArray>
#include <QSize>
#include <cstdint>
class LottieIcon {
public:
explicit LottieIcon(const QByteArray &tgsData);
~LottieIcon();
bool isValid() const { return m_valid; }
QSize originalSize() const { return QSize((int)m_w, (int)m_h); }
int frameCount() const { return m_totalFrames; }
double frameRate() const { return m_frameRate; }
int currentFrame() const { return m_currentFrame; }
QImage renderFrame(int frameNum, QSize targetSize) const;
void advanceFrame() { m_currentFrame = (m_currentFrame + 1) % m_totalFrames; }
private:
bool m_valid = false;
struct Lottie_Animation_S *m_anim = nullptr;
int m_currentFrame = 0;
int m_totalFrames = 0;
double m_frameRate = 60.0;
size_t m_w = 0, m_h = 0;
QByteArray m_jsonData;
};

341
tools/chatgui/src/messagedelegate.cpp

@ -1,9 +1,16 @@
#include "messagedelegate.h" #include "messagedelegate.h"
#include "chatview.h"
#include "debug_ui.h"
#include "emoji.h"
#include "animtimer.h"
#include "lottieicon.h"
#include <cmath>
#include <QPainter> #include <QPainter>
#include <QPainterPath> #include <QPainterPath>
#include <QApplication> #include <QApplication>
#include <QAbstractItemView> #include <QAbstractItemView>
#include <QFontMetrics> #include <QFontMetrics>
#include <QTextLayout>
MessageDelegate::MessageDelegate(QObject *parent) MessageDelegate::MessageDelegate(QObject *parent)
: QStyledItemDelegate(parent) {} : QStyledItemDelegate(parent) {}
@ -21,6 +28,30 @@ static int measureTextHeight(const QFont &font, const QString &text, int width)
return r.height(); return r.height();
} }
static int calcMaxLineWidth(const QFont &font, const QString &text, int availW) {
int maxW = 0;
int pos = 0;
while (pos <= text.length()) {
int end = text.indexOf('\n', pos);
if (end < 0) end = text.length();
QString para = text.mid(pos, end - pos);
if (!para.isEmpty()) {
QTextLayout layout(para, font);
layout.beginLayout();
while (true) {
QTextLine line = layout.createLine();
if (!line.isValid()) break;
line.setLineWidth(availW);
line.setPosition(QPointF(0, 0));
maxW = qMax(maxW, (int)line.naturalTextWidth());
}
layout.endLayout();
}
pos = end + 1;
}
return maxW;
}
MessageDelegate::Layout MessageDelegate::calcLayout( MessageDelegate::Layout MessageDelegate::calcLayout(
const QStyleOptionViewItem &option, const QModelIndex &index) const const QStyleOptionViewItem &option, const QModelIndex &index) const
{ {
@ -36,26 +67,50 @@ MessageDelegate::Layout MessageDelegate::calcLayout(
QString text = index.data(MsgTextRole).toString(); QString text = index.data(MsgTextRole).toString();
QString qText = hasQuote ? index.data(MsgQuoteTextRole).toString() : QString(); QString qText = hasQuote ? index.data(MsgQuoteTextRole).toString() : QString();
int bubbleW = L.viewWidth - kMarginH - kAvatar - kGap - kMarginH; int availW = L.viewWidth - kMarginH - kAvatar - kGap - kMarginH;
if (bubbleW < 100) bubbleW = 100; L.narrow = (availW < 400);
L.bubbleWidth = bubbleW; if (L.narrow) availW = L.viewWidth - 2 * kMarginH;
int textLimitW = availW - 2 * kPadH;
int limitW = text.contains(' ') ? qMin(textLimitW, 700)
: qMin(textLimitW, 1200);
int textW = bubbleW - 2 * kPadH; QFontMetrics fm(option.font);
int contentW = calcMaxLineWidth(option.font, text, limitW);
QString author = index.data(MsgAuthorRole).toString();
QFont boldFm = option.font;
boldFm.setBold(true);
int authorW = QFontMetrics(boldFm).horizontalAdvance(author);
contentW = qMax(contentW, authorW);
if (hasQuote) {
QString qAuthor = index.data(MsgQuoteAuthorRole).toString();
int qAuthorW = QFontMetrics(boldFm).horizontalAdvance(qAuthor);
contentW = qMax(contentW, qAuthorW + kQuoteBarW + kQuotePadH + 4);
int qTextW = qMin(contentW + 8, limitW);
int qWidth = calcMaxLineWidth(option.font, qText, qTextW);
contentW = qMax(contentW, qWidth + kQuoteBarW + kQuotePadH + 4);
}
int textW = qMax(200 - 2 * kPadH, contentW);
textW = qMin(textW, textLimitW);
int bubbleW = textW + 2 * kPadH;
L.bubbleWidth = bubbleW;
int qH = 0; int qH = 0;
if (hasQuote) { if (hasQuote) {
int qTextW = bubbleW - 2 * kPadH - kQuoteBarW - kQuotePadH; int qTextW = bubbleW - 2 * kPadH - kQuoteBarW - kQuotePadH;
int qTextH = measureTextHeight(option.font, qText, qTextW); int qTextH = measureTextHeight(option.font, qText, qTextW);
int qOneLineH = QFontMetrics(option.font).height(); int qOneLineH = fm.height();
qH = kQuotePadV + qOneLineH + (qText.isEmpty() ? 0 : 2 + qTextH) + kQuotePadV; qH = kQuotePadV + qOneLineH + (qText.isEmpty() ? 0 : 2 + qTextH) + kQuotePadV;
} }
int textH = measureTextHeight(option.font, text, textW); int textH = measureTextHeight(option.font, text, textW);
int authorH = QFontMetrics(option.font).height(); int authorH = fm.height();
int bubbleH = kPadTop + authorH + 2 + (hasQuote ? qH + kQuoteGap : 0) + textH + kStatusH + kPadBot; int bubbleH = kPadTop + authorH + 2 + (hasQuote ? qH + kQuoteGap : 0) + textH + kStatusH + kPadBot;
L.totalHeight = qMax(bubbleH, kAvatar) + kItemGap; L.totalHeight = qMax(bubbleH, kAvatar) + kItemGap;
int contentLeft = kMarginH + kAvatar + kGap; int contentLeft = L.narrow ? kMarginH : (kMarginH + kAvatar + kGap);
L.bubbleRect = QRect(contentLeft, 0, bubbleW, bubbleH); L.bubbleRect = QRect(contentLeft, 0, bubbleW, bubbleH);
@ -85,12 +140,207 @@ MessageDelegate::Layout MessageDelegate::calcLayout(
contentTop + textH + 2, contentTop + textH + 2,
textW, kStatusH); textW, kStatusH);
int avatarY = L.bubbleRect.bottom() - kAvatar; if (!L.narrow) {
L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar); int avatarY = L.bubbleRect.bottom() - kAvatar;
L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar);
}
return L; return L;
} }
static int paraLayoutHeight(const QFont &font, const QString &text,
int textWidth, int globalChar, qreal globalY,
int yTop, int yBot, int xTop, int xBot,
bool &foundStart, bool &foundEnd,
int &startChar, int &endChar) {
if (text.isEmpty()) return QFontMetrics(font).lineSpacing();
QTextLayout layout(text, font);
layout.beginLayout();
qreal localY = 0;
while (true) {
QTextLine line = layout.createLine();
if (!line.isValid()) break;
line.setLineWidth(textWidth);
line.setPosition(QPointF(0, globalY + localY));
localY += line.height();
if (!foundStart && globalY + localY > yTop) {
startChar = globalChar + line.xToCursor(xTop);
foundStart = true;
}
if (!foundEnd && globalY >= yBot) break;
if (!foundEnd && globalY + localY >= yBot) {
endChar = globalChar + line.xToCursor(xBot);
foundEnd = true;
}
}
layout.endLayout();
return (int)ceil(localY);
}
static void calcTextSelection(const QFont &font, const QString &text,
int textWidth, const QPoint &p1, const QPoint &p2,
int &startChar, int &endChar) {
startChar = 0;
endChar = 0;
if (text.isEmpty() || textWidth <= 0) return;
int yTop = qMin(p1.y(), p2.y());
int yBot = qMax(p1.y(), p2.y());
int xTop = (p1.y() < p2.y()) ? p1.x() : p2.x();
int xBot = (p1.y() < p2.y()) ? p2.x() : p1.x();
int globalChar = 0;
qreal globalY = 0;
bool foundStart = false, foundEnd = false;
int paraStart = 0;
while (paraStart <= text.length()) {
int paraEnd = text.indexOf('\n', paraStart);
if (paraEnd < 0) paraEnd = text.length();
QString para = text.mid(paraStart, paraEnd - paraStart);
int paraH = paraLayoutHeight(font, para, textWidth,
globalChar, globalY,
yTop, yBot, xTop, xBot,
foundStart, foundEnd,
startChar, endChar);
globalChar += para.length();
globalY += paraH;
if (paraEnd < text.length())
globalChar++;
paraStart = paraEnd + 1;
if (foundStart && foundEnd) break;
}
if (!foundStart) startChar = 0;
if (!foundEnd) endChar = text.length();
if (startChar == endChar && endChar < text.length()) endChar = startChar + 1;
if (startChar > endChar) qSwap(startChar, endChar);
}
static void drawTextSelectionHighlight(QPainter *p, const QFont &font,
const QString &text, int textWidth,
const QRect &textRect,
const QColor &selColor,
int startChar, int endChar) {
if (startChar >= endChar) return;
int globalChar = 0;
qreal globalY = 0;
int paraStart = 0;
while (paraStart <= text.length()) {
int paraEnd = text.indexOf('\n', paraStart);
if (paraEnd < 0) paraEnd = text.length();
QString para = text.mid(paraStart, paraEnd - paraStart);
if (para.isEmpty()) {
globalY += QFontMetrics(font).lineSpacing();
} else {
QTextLayout layout(para, font);
layout.beginLayout();
qreal localY = 0;
while (true) {
QTextLine line = layout.createLine();
if (!line.isValid()) break;
line.setLineWidth(textWidth);
line.setPosition(QPointF(0, globalY + localY));
int ls = globalChar + line.textStart();
int le = ls + line.textLength();
localY += line.height();
if (ls >= endChar || le <= startChar) continue;
int cs = qMax(startChar, ls);
int ce = qMin(endChar, le);
qreal x1 = line.cursorToX(cs - globalChar);
qreal x2 = line.cursorToX(ce - globalChar);
QRectF hr(textRect.left() + x1,
textRect.top() + globalY + line.y(),
x2 - x1, line.height());
p->fillRect(hr, selColor);
}
layout.endLayout();
globalY += localY;
}
globalChar += para.length();
if (paraEnd < text.length())
globalChar++;
paraStart = paraEnd + 1;
}
}
static void drawAnimatedEmojiOverlay(QPainter *p, const QFont &font,
const QString &text, int textWidth,
const QRect &textRect) {
if (g_animatedEmojiMap.isEmpty()) return;
int globalChar = 0;
qreal globalY = 0;
int paraStart = 0;
while (paraStart <= text.length()) {
int paraEnd = text.indexOf('\n', paraStart);
if (paraEnd < 0) paraEnd = text.length();
QString para = text.mid(paraStart, paraEnd - paraStart);
if (!para.isEmpty()) {
QTextLayout layout(para, font);
layout.beginLayout();
qreal localY = 0;
while (true) {
QTextLine line = layout.createLine();
if (!line.isValid()) break;
line.setLineWidth((qreal)textWidth);
line.setPosition(QPointF(0, globalY + localY));
localY += line.height();
int ls = globalChar + line.textStart();
int le = ls + line.textLength();
for (auto it = g_animatedEmojiMap.begin(); it != g_animatedEmojiMap.end(); ++it) {
const QString &unicode = it.key();
int pos = text.indexOf(unicode, ls);
while (pos >= 0 && pos + unicode.length() <= le) {
int linePos = pos - globalChar - line.textStart();
int cp1 = linePos, cp2 = linePos + unicode.length();
qreal x1 = line.cursorToX(&cp1);
qreal x2 = line.cursorToX(&cp2);
LottieIcon *icon = AnimTimer::instance()->icon(it.value());
if (icon && icon->isValid()) {
int eW = qMax(1, (int)((x2 - x1) * 1.3));
int eH = qMax(1, (int)(line.height() * 1.3));
QImage frame = icon->renderFrame(icon->currentFrame(), QSize(eW, eH));
qreal cx = x1 + (x2 - x1 - eW) * 0.5;
qreal cy = (line.height() - eH) * 0.5;
p->drawImage(QPointF(textRect.left() + cx, textRect.top() + line.y() + cy), frame);
}
pos = text.indexOf(unicode, pos + unicode.length());
}
}
}
layout.endLayout();
globalY += localY;
} else {
globalY += QFontMetrics(font).lineSpacing();
}
globalChar += para.length();
if (paraEnd < text.length()) globalChar++;
paraStart = paraEnd + 1;
}
}
void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
const QModelIndex &index) const { const QModelIndex &index) const {
painter->save(); painter->save();
@ -99,13 +349,35 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
Layout L = calcLayout(option, index); Layout L = calcLayout(option, index);
auto *cv = qobject_cast<const ChatView *>(option.widget);
bool singleTextSel = false;
bool multiRowSel = false;
if (cv && cv->hasSelection()) {
if (cv->isRowSelected(index)) {
if (cv->selStart() == cv->selEnd()) {
singleTextSel = true;
} else {
multiRowSel = true;
}
}
}
drawBubble(painter, L); drawBubble(painter, L);
if (multiRowSel) {
painter->save();
painter->setPen(Qt::NoPen);
painter->setBrush(QColor(0x33, 0x90, 0xEC, 40));
painter->drawRect(L.bubbleRect);
painter->restore();
}
if (index.data(MsgHasQuoteRole).toBool()) { if (index.data(MsgHasQuoteRole).toBool()) {
drawQuote(painter, L, index, option); drawQuote(painter, L, index, option);
} }
drawAvatar(painter, L, index); if (!L.narrow)
drawAvatar(painter, L, index);
QFont authorFont = option.font; QFont authorFont = option.font;
authorFont.setBold(true); authorFont.setBold(true);
@ -118,8 +390,28 @@ void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &optio
QFont textFont = option.font; QFont textFont = option.font;
textFont.setPointSize(textFont.pointSize()); textFont.setPointSize(textFont.pointSize());
painter->setFont(textFont); painter->setFont(textFont);
painter->drawText(L.textRect, Qt::AlignLeft | Qt::TextWordWrap,
index.data(MsgTextRole).toString()); QString msgText = index.data(MsgTextRole).toString();
painter->drawText(L.textRect, Qt::AlignLeft | Qt::TextWordWrap, msgText);
if (singleTextSel && cv) {
const auto &ts = cv->textSelection();
QPoint localP1(ts.clickPos.x() - L.textRect.left(),
ts.clickPos.y() - L.textRect.top());
QPoint localP2(ts.dragPos.x() - L.textRect.left(),
ts.dragPos.y() - L.textRect.top());
int startChar, endChar;
calcTextSelection(textFont, msgText, L.textRect.width(),
localP1, localP2, startChar, endChar);
const_cast<ChatView *>(cv)->setTextSelChars(startChar, endChar);
drawTextSelectionHighlight(painter, textFont, msgText,
L.textRect.width(), L.textRect,
QColor(0x33, 0x90, 0xEC, 60),
startChar, endChar);
}
drawAnimatedEmojiOverlay(painter, textFont, msgText,
L.textRect.width(), L.textRect);
drawStatusBar(painter, L, index, option); drawStatusBar(painter, L, index, option);
@ -211,13 +503,24 @@ void MessageDelegate::drawStatusBar(QPainter *p, const Layout &L,
} }
rx += colors.size() * 11 + 8; rx += colors.size() * 11 + 8;
} else { } else {
QFont emojiFont = opt.font; LottieIcon *rIcon = nullptr;
emojiFont.setPointSize(emojiFont.pointSize() - 1); if (g_animatedEmojiMap.contains(emoji))
p->setFont(emojiFont); rIcon = AnimTimer::instance()->icon(g_animatedEmojiMap[emoji]);
p->setPen(opt.palette.text().color());
QRect emojiRect(rx, r.top(), 20, r.height()); if (rIcon && rIcon->isValid()) {
p->drawText(emojiRect, Qt::AlignLeft | Qt::AlignVCenter, emoji); int iconSz = qMin((int)(20 * 1.3), r.height());
rx += 20; QImage frame = rIcon->renderFrame(rIcon->currentFrame(), QSize(iconSz, iconSz));
p->drawImage(rx, r.top() + (r.height() - iconSz) / 2, frame);
rx += iconSz + 2;
} else {
QFont emojiFont = opt.font;
emojiFont.setPointSize(emojiFont.pointSize() - 1);
p->setFont(emojiFont);
p->setPen(opt.palette.text().color());
QRect emojiRect(rx, r.top(), 20, r.height());
p->drawText(emojiRect, Qt::AlignLeft | Qt::AlignVCenter, emoji);
rx += 20;
}
QFont countFont = opt.font; QFont countFont = opt.font;
countFont.setPointSize(countFont.pointSize() - 2); countFont.setPointSize(countFont.pointSize() - 2);

3
tools/chatgui/src/messagedelegate.h

@ -28,9 +28,9 @@ private:
struct Layout { struct Layout {
int viewWidth; int viewWidth;
int bubbleWidth; int bubbleWidth;
QRect authorRect;
QRect avatarRect; QRect avatarRect;
QRect bubbleRect; QRect bubbleRect;
QRect authorRect;
QRect quoteRect; QRect quoteRect;
QRect quoteBarRect; QRect quoteBarRect;
QRect quoteAuthorRect; QRect quoteAuthorRect;
@ -38,6 +38,7 @@ private:
QRect textRect; QRect textRect;
QRect statusRect; QRect statusRect;
int totalHeight; int totalHeight;
bool narrow = false;
}; };
Layout calcLayout(const QStyleOptionViewItem &option, Layout calcLayout(const QStyleOptionViewItem &option,

59
tools/chatgui/src/messagelist.cpp

@ -3,10 +3,16 @@
#include "messagedelegate.h" #include "messagedelegate.h"
#include "inputbar.h" #include "inputbar.h"
#include "emojipanel.h" #include "emojipanel.h"
#include "emoji.h"
#include "animtimer.h"
#include "lottieicon.h"
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QPainter> #include <QPainter>
#include <QPixmap> #include <QPixmap>
#include <QDateTime> #include <QDateTime>
#include <QFile>
#include <vector>
#include <memory>
static QPixmap makeAvatar(const QColor &color, const QString &letter, int size = 36) { static QPixmap makeAvatar(const QColor &color, const QString &letter, int size = 36) {
QPixmap pix(size, size); QPixmap pix(size, size);
@ -76,6 +82,24 @@ MessageList::MessageList(QWidget *parent)
m_view->setBackgroundImage("../resources/bg.jpg"); m_view->setBackgroundImage("../resources/bg.jpg");
layout->addWidget(m_view, 1); layout->addWidget(m_view, 1);
static std::vector<std::unique_ptr<LottieIcon>> s_icons;
if (s_icons.empty()) {
for (const auto &cat : builtinEmojiSet()) {
for (const auto &e : cat.items) {
if (e.type == EmojiType::Animation && !e.animPath.isEmpty()) {
QFile f(e.animPath);
if (f.open(QIODevice::ReadOnly)) {
auto icon = std::make_unique<LottieIcon>(f.readAll());
if (icon->isValid()) {
AnimTimer::instance()->registerIcon(e.animPath, icon.get());
s_icons.push_back(std::move(icon));
}
}
}
}
}
}
m_emojiPanel = new EmojiPanel(this); m_emojiPanel = new EmojiPanel(this);
layout->addWidget(m_emojiPanel); layout->addWidget(m_emojiPanel);
@ -89,12 +113,38 @@ MessageList::MessageList(QWidget *parent)
setMsg(item, setMsg(item,
QString::fromUtf8("You"), text, QString::fromUtf8("You"), text,
QDateTime::currentDateTime().toString("HH:mm"), QDateTime::currentDateTime().toString("HH:mm"),
av, false, false, QString(), QString(), {}); av, false, false, QString(), QString(),
QVariantList{makeReaction(QString::fromUtf8("\xF0\x9F\x91\x8B"), 1)});
m_model->appendRow(item); m_model->appendRow(item);
m_view->scrollToBottom(); m_view->scrollToBottom();
}); });
populateStubData(); populateStubData();
connect(AnimTimer::instance(), &AnimTimer::ticked, this, [this]() {
m_view->viewport()->update();
});
connect(m_view, &ChatView::hoveredIndexChanged, this, [this](const QModelIndex &idx) {
bool hasAnim = false;
if (idx.isValid()) {
QString text = idx.data(MsgTextRole).toString();
for (auto it = g_animatedEmojiMap.begin(); it != g_animatedEmojiMap.end(); ++it) {
if (text.contains(it.key())) { hasAnim = true; break; }
}
if (!hasAnim) {
QVariantList reactions = idx.data(MsgReactionsRole).toList();
for (const QVariant &rv : reactions) {
if (g_animatedEmojiMap.contains(rv.toMap()["emoji"].toString()))
{ hasAnim = true; break; }
}
}
}
bool wasActive = m_animHoverActive;
m_animHoverActive = hasAnim;
if (!wasActive && m_animHoverActive) AnimTimer::instance()->activate();
else if (wasActive && !m_animHoverActive) AnimTimer::instance()->deactivate();
});
} }
void MessageList::populateStubData() { void MessageList::populateStubData() {
QColor cAlice(74, 144, 226), cBob(126, 211, 33), cCarol(245, 166, 35); QColor cAlice(74, 144, 226), cBob(126, 211, 33), cCarol(245, 166, 35);
@ -120,7 +170,8 @@ void MessageList::populateStubData() {
"10:17", avBob, false, "10:17", avBob, false,
false, QString(), QString(), false, QString(), QString(),
QVariantList{makeReaction(QString::fromUtf8("\xF0\x9F\x91\x8D"), 2, QVariantList{makeReaction(QString::fromUtf8("\xF0\x9F\x91\x8D"), 2,
{cAlice, cCarol})}); {cAlice, cCarol}),
makeReaction(QString::fromUtf8("\xF0\x9F\x91\x8B"), 3)});
m_model->appendRow(m1); m_model->appendRow(m1);
auto *m2 = new QStandardItem(); auto *m2 = new QStandardItem();
@ -178,10 +229,10 @@ void MessageList::populateStubData() {
auto *m6 = new QStandardItem(); auto *m6 = new QStandardItem();
setMsg(m6, setMsg(m6,
QString::fromUtf8("Carol"), QString::fromUtf8("Carol"),
QString::fromUtf8("Sounds good. Let's coordinate in #dev-team. I'll prepare the release notes."), QString::fromUtf8("Sounds good \xF0\x9F\x91\x8B. Let's coordinate in #dev-team. I'll prepare the release notes."),
"10:33", avCarol, false, "10:33", avCarol, false,
false, QString(), QString(), false, QString(), QString(),
{}); QVariantList{makeReaction(QString::fromUtf8("\xF0\x9F\xA4\x96"), 2)});
m_model->appendRow(m6); m_model->appendRow(m6);
auto *m7 = new QStandardItem(); auto *m7 = new QStandardItem();

1
tools/chatgui/src/messagelist.h

@ -17,5 +17,6 @@ private:
QStandardItemModel *m_model; QStandardItemModel *m_model;
InputBar *m_inputBar; InputBar *m_inputBar;
EmojiPanel *m_emojiPanel; EmojiPanel *m_emojiPanel;
bool m_animHoverActive = false;
void populateStubData(); void populateStubData();
}; };

143
tools/chatgui/transport/msg_client.cpp

@ -0,0 +1,143 @@
/*
* msg_client.cpp — C++ транспортный клиент
*/
#include "msg_client.h"
#include <cstring>
MsgClient::MsgClient(QObject* parent) : QObject(parent), m_socket(new QTcpSocket(this)) {
QObject::connect(m_socket, &QTcpSocket::connected, this, &MsgClient::connected);
QObject::connect(m_socket, &QTcpSocket::disconnected, this, [this]() {
if (m_boundPeerId) {
m_boundPeerId = 0;
emit unbound();
}
emit disconnected();
});
QObject::connect(m_socket, &QTcpSocket::readyRead, this, &MsgClient::onReadyRead);
QObject::connect(m_socket, &QAbstractSocket::errorOccurred, this, &MsgClient::onSocketError);
}
MsgClient::~MsgClient() {
disconnectFromHost();
}
void MsgClient::connectToHost(const QString& host, quint16 port) {
m_recvBuffer.clear();
m_boundPeerId = 0;
m_socket->connectToHost(host, port);
}
void MsgClient::disconnectFromHost() {
if (m_socket->state() != QAbstractSocket::UnconnectedState)
m_socket->disconnectFromHost();
m_recvBuffer.clear();
if (m_boundPeerId) {
m_boundPeerId = 0;
emit unbound();
}
}
bool MsgClient::isConnected() const {
return m_socket->state() == QAbstractSocket::ConnectedState;
}
void MsgClient::bind(quint64 peerId) {
QByteArray msg = buildMessage(kCtlBind, peerId, 0, QByteArray());
m_socket->write(msg);
}
void MsgClient::unbind() {
if (!m_boundPeerId) return;
QByteArray msg = buildMessage(kCtlUnbind, 0, 0, QByteArray());
m_socket->write(msg);
m_boundPeerId = 0;
emit unbound();
}
void MsgClient::send(const QByteArray& data) {
if (!m_boundPeerId) return;
QByteArray msg = buildMessage(kDataSend, m_boundPeerId, 0, data);
m_socket->write(msg);
}
void MsgClient::send(const uint8_t* data, size_t len) {
send(QByteArray(reinterpret_cast<const char*>(data), static_cast<int>(len)));
}
void MsgClient::sendSigned(const QByteArray& data) {
if (!m_boundPeerId) return;
QByteArray msg = buildMessage(kDataSend, m_boundPeerId, kOptSigned, data);
m_socket->write(msg);
}
/* ============================================================================
* Внутренние
* ============================================================================ */
void MsgClient::onReadyRead() {
m_recvBuffer.append(m_socket->readAll());
if (m_recvBuffer.size() > static_cast<int>(kMaxMsgSize)) {
m_recvBuffer.clear();
emit errorOccurred(0xFF, "recv buffer overflow");
return;
}
processMessages();
}
void MsgClient::onSocketError(QAbstractSocket::SocketError) {
emit errorOccurred(0xFF, m_socket->errorString());
}
void MsgClient::processMessages() {
while (static_cast<size_t>(m_recvBuffer.size()) >= kHdrSize) {
uint16_t totalSize;
std::memcpy(&totalSize, m_recvBuffer.constData(), 2);
if (totalSize < kHdrSize || totalSize > kMaxMsgSize) {
m_recvBuffer.clear();
emit errorOccurred(0xFF, "invalid message size");
return;
}
if (m_recvBuffer.size() < totalSize) break;
uint8_t type = static_cast<uint8_t>(m_recvBuffer[2]);
uint64_t id;
std::memcpy(&id, m_recvBuffer.constData() + 3, 8);
QByteArray payload = m_recvBuffer.mid(static_cast<int>(kHdrSize),
totalSize - static_cast<uint16_t>(kHdrSize));
dispatch(type, id, payload);
m_recvBuffer.remove(0, totalSize);
}
}
void MsgClient::dispatch(uint8_t type, quint64 id, const QByteArray& payload) {
switch (type) {
case kRspBindOk:
m_boundPeerId = id;
emit bound(id);
break;
case kDataRecv:
emit received(id, payload);
break;
case kRspError:
uint8_t code = payload.size() > 0 ? static_cast<uint8_t>(payload[0]) : 0;
QString msg = QString::fromUtf8(payload.mid(1));
emit errorOccurred(code, msg);
break;
}
}
QByteArray MsgClient::buildMessage(uint8_t type, quint64 id, uint8_t options, const QByteArray& data) {
uint16_t totalSize = static_cast<uint16_t>(kHdrSize + data.size());
QByteArray msg;
msg.resize(totalSize);
std::memcpy(msg.data(), &totalSize, 2);
msg[2] = static_cast<char>(type);
std::memcpy(msg.data() + 3, &id, 8);
msg[11] = static_cast<char>(options);
if (!data.isEmpty())
std::memcpy(msg.data() + kHdrSize, data.constData(), data.size());
return msg;
}

65
tools/chatgui/transport/msg_client.h

@ -0,0 +1,65 @@
/*
* msg_client.h — C++ транспортный клиент для chat GUI
*
* Подключается к utun msg_transport по localhost TCP.
* Протокол: [size:2][type:1][id:8][options:1][data...] (msg_transport.h).
*/
#ifndef MSG_CLIENT_H
#define MSG_CLIENT_H
#include <QObject>
#include <QByteArray>
#include <QTcpSocket>
#include <cstdint>
class MsgClient : public QObject {
Q_OBJECT
public:
explicit MsgClient(QObject* parent = nullptr);
~MsgClient() override;
void connectToHost(const QString& host, quint16 port);
void disconnectFromHost();
bool isConnected() const;
void bind(quint64 peerId);
void unbind();
bool isBound() const { return m_boundPeerId != 0; }
quint64 boundPeerId() const { return m_boundPeerId; }
void send(const QByteArray& data);
void send(const uint8_t* data, size_t len);
void sendSigned(const QByteArray& data);
signals:
void connected();
void disconnected();
void bound(quint64 peerId);
void unbound();
void received(quint64 peerId, const QByteArray& data);
void errorOccurred(uint8_t code, const QString& message);
private:
static constexpr size_t kMaxMsgSize = 8192;
static constexpr uint8_t kCtlBind = 0x01;
static constexpr uint8_t kCtlUnbind = 0x02;
static constexpr uint8_t kDataSend = 0x10;
static constexpr uint8_t kDataRecv = 0x11;
static constexpr uint8_t kRspBindOk = 0xE1;
static constexpr uint8_t kRspError = 0xFF;
static constexpr uint8_t kOptSigned = 0x01;
static constexpr size_t kHdrSize = 12;
QTcpSocket* m_socket;
QByteArray m_recvBuffer;
quint64 m_boundPeerId = 0;
void onReadyRead();
void onSocketError(QAbstractSocket::SocketError);
void processMessages();
void dispatch(uint8_t type, quint64 id, const QByteArray& payload);
QByteArray buildMessage(uint8_t type, quint64 id, uint8_t options, const QByteArray& data);
};
#endif // MSG_CLIENT_H
Loading…
Cancel
Save