13 Commits
33 changed files with 1412 additions and 145 deletions

No files matched your search

+10
View File
@@ -58,6 +58,7 @@ socket_t socket_create_udp(int family);
int socket_set_nonblocking(socket_t sock);
/* Закрыть сокет средствами ОС; на POSIX sock=0 оставляет дескриптор открытым и возвращает 0. */
int socket_close_wrapper(socket_t sock);
// Последняя ошибка сокета текущего потока: errno на POSIX, WSAGetLastError на Windows.
static inline int socket_get_error(void) {
#ifdef _WIN32
return WSAGetLastError();
@@ -66,6 +67,15 @@ static inline int socket_get_error(void) {
#endif
}
// Восстановить сохранённую ошибку после логирования и освобождения ресурсов.
static inline void socket_set_error(int error) {
#ifdef _WIN32
WSASetLastError(error);
#else
errno = error;
#endif
}
// Пробует установить оба буфера SO_SNDBUF/SO_RCVBUF: 0 при успехе обоих, -1 при любой ошибке; отката нет.
int socket_set_buffers(socket_t sock, int sndbuf, int rcvbuf);
// Устанавливает SO_REUSEADDR в значение reuse; 0 / -1.
+42 -48
View File
@@ -271,6 +271,8 @@ static void auto_socket_post_sockets_changed(struct AUTO_SOCKET* as) {
int old_v4 = as->v4_usable, old_v6 = as->v6_usable;
as->v4_usable = as_check_usable(as, AF_INET);
as->v6_usable = as_check_usable(as, AF_INET6);
if ((old_v4 || old_v6) && !as->v4_usable && !as->v6_usable)
DEBUG_WARN(DEBUG_CATEGORY_AS, "[as] no usable sockets; waiting for network changes");
int need_reconnect = 0;
if ((!old_v4 && as->v4_usable) || (!old_v6 && as->v6_usable))
@@ -354,11 +356,9 @@ static struct ETCP_SOCKET* create_iface_udp_socket(struct AUTO_SOCKET* as, uint3
struct ETCP_SOCKET* es = etcp_socket_add(inst, &server);
if (!es) {
int error = socket_get_error();
if (attempt == 0 && saved_port)
DEBUG_WARN(DEBUG_CATEGORY_AS, "[as] UDP reuse port failed if=%s fam=%d port=%u — trying random", ifname, family, port);
else
DEBUG_WARN(DEBUG_CATEGORY_AS, "[as] UDP socket create failed if=%s fam=%d port=%u att=%d err=%d",
ifname, family, port, attempt + 1, socket_get_error());
DEBUG_WARN(DEBUG_CATEGORY_AS, "[as] UDP socket create failed iface=%s ifidx=%u family=%s port=%u attempt=%d reused=%d err=%d",
ifname, ifindex, family == AF_INET6 ? "IPv6" : "IPv4", port, attempt + 1,
attempt == 0 && saved_port != 0, error);
if (error != AS_ADDR_IN_USE) return NULL;
continue;
}
@@ -367,11 +367,11 @@ static struct ETCP_SOCKET* create_iface_udp_socket(struct AUTO_SOCKET* as, uint3
{ const struct sockaddr_storage* ia = es->interface_addr.ss_family ? &es->interface_addr : &es->local_addr;
if (ia->ss_family == AF_INET && ((struct sockaddr_in*)ia)->sin_addr.s_addr == INADDR_ANY) {
DEBUG_INFO(DEBUG_CATEGORY_AS, "[as] UDP socket %s has INADDR_ANY — removing", es->name);
DEBUG_WARN(DEBUG_CATEGORY_AS, "[as] UDP socket %s has INADDR_ANY — removing", es->name);
ncd_remove_socket_links(inst, es); etcp_socket_remove(es); return NULL;
}
if (ia->ss_family == AF_INET6 && IN6_IS_ADDR_UNSPECIFIED(&((struct sockaddr_in6*)ia)->sin6_addr)) {
DEBUG_INFO(DEBUG_CATEGORY_AS, "[as] UDP socket %s has :: — removing", es->name);
DEBUG_WARN(DEBUG_CATEGORY_AS, "[as] UDP socket %s has :: — removing", es->name);
ncd_remove_socket_links(inst, es); etcp_socket_remove(es); return NULL;
}
}
@@ -439,24 +439,22 @@ static struct stcp_server* create_iface_tcp_socket(struct AUTO_SOCKET* as, uint3
}
struct ETCP_SOCKET* ts = tcp_socket_add(inst, &server);
if (!ts) { DEBUG_WARN(DEBUG_CATEGORY_AS, "[as] TCP tcp_socket_add failed if=%s fam=%d port=%u", ifname, family, port); continue; }
if (!ts) { DEBUG_WARN(DEBUG_CATEGORY_AS, "[as] TCP tcp_socket_add failed if=%s fam=%d port=%u", ifname, family, port); return NULL; }
{ const struct sockaddr_storage* ia = (ts->interface_addr.ss_family) ? &ts->interface_addr : &ts->local_addr;
if ((ia->ss_family == AF_INET && ((const struct sockaddr_in*)ia)->sin_addr.s_addr == INADDR_ANY) ||
(ia->ss_family == AF_INET6 && IN6_IS_ADDR_UNSPECIFIED(&((const struct sockaddr_in6*)ia)->sin6_addr))) {
DEBUG_INFO(DEBUG_CATEGORY_AS, "[as] TCP socket %s has null addr — removing", server.name);
tcp_socket_remove(ts); continue;
DEBUG_WARN(DEBUG_CATEGORY_AS, "[as] TCP socket %s has null addr — removing", server.name);
tcp_socket_remove(ts); return NULL;
}
}
struct stcp_server* tsrv = stcp_server_listen(&scfg, port, tcp_server_on_link, ts);
if (!tsrv) {
int error = socket_get_error();
if (attempt == 0 && saved_port)
DEBUG_WARN(DEBUG_CATEGORY_AS, "[as] TCP reuse port failed if=%s fam=%d port=%u — trying random", ifname, family, port);
else
DEBUG_WARN(DEBUG_CATEGORY_AS, "[as] TCP socket create failed if=%s fam=%d port=%u att=%d err=%d",
ifname, family, port, attempt + 1, socket_get_error());
DEBUG_WARN(DEBUG_CATEGORY_AS, "[as] TCP socket create failed iface=%s ifidx=%u family=%s port=%u attempt=%d reused=%d err=%d",
ifname, ifindex, family == AF_INET6 ? "IPv6" : "IPv4", port, attempt + 1,
attempt == 0 && saved_port != 0, error);
tcp_socket_remove(ts);
if (error != AS_ADDR_IN_USE) return NULL;
continue;
@@ -1046,7 +1044,7 @@ static void reconcile_prune_ifa(struct AUTO_SOCKET* as, struct auto_sock_iface*
* и оповещает GUI если были изменения. */
static int reconcile_iface(struct AUTO_SOCKET* as, uint32_t ifindex, const char* ifname) {
if (!ifindex) { DEBUG_ERROR(DEBUG_CATEGORY_AS, "[as] invalid interface index: %s", ifname); return -1; }
int has_v4 = 0, has_v6 = 0, changed = 0, result = 0;
int has_v4 = 0, has_v6 = 0, changed = 0;
uint8_t v4_type = CFG_SERVER_TYPE_NAT, v6_type = CFG_SERVER_TYPE_NAT;
if (scan_and_classify_iface(ifindex, ifname, &has_v4, &has_v6, &v4_type, &v6_type) < 0) return -1;
if (as->instance->config->global.auto_socket_skip_no_default_route) {
@@ -1102,9 +1100,8 @@ static int reconcile_iface(struct AUTO_SOCKET* as, uint32_t ifindex, const char*
else *slot = create_iface_udp_socket(as, ifindex, ifname, family, type);
if (*slot) { if (tcp) *tcp_addr = (*slot)->interface_addr; changed = 1; }
else {
DEBUG_ERROR(DEBUG_CATEGORY_AS, "[as] incomplete endpoint iface=%s family=%d protocol=%s",
ifname, family, tcp ? "TCP" : "UDP");
result = -1;
DEBUG_WARN(DEBUG_CATEGORY_AS, "[as] skipping unavailable socket iface=%s ifidx=%u family=%s protocol=%s; retry on next scan",
ifname, ifindex, v6 ? "IPv6" : "IPv4", tcp ? "TCP" : "UDP");
}
}
}
@@ -1112,7 +1109,7 @@ static int reconcile_iface(struct AUTO_SOCKET* as, uint32_t ifindex, const char*
ifa->v4_type = v4_type; ifa->v6_type = v6_type;
reconcile_prune_ifa(as, ifa, ifindex, ifname);
if (changed) auto_socket_post_sockets_changed(as);
return result;
return 0;
}
/* ═══════════ Platform monitoring ═══════════ */
@@ -1359,35 +1356,32 @@ int auto_socket_init(struct UTUN_INSTANCE* inst) {
as->protocols = (uint8_t)inst->config->global.auto_socket_protocols;
inst->auto_socket_state = as;
/* Android-режим: источник сокетов — [server] секции конфига (генерирует Kotlin).
* Системный мониторинг и таблица портов в БД не нужны. */
/* Android получает адреса из конфига; системный режим — от монитора интерфейсов. */
if (as->mode == CFG_AUTO_SOCKETS_ANDROID) {
if (auto_socket_reconcile_config(as) < 0) { auto_socket_destroy(inst); return -1; }
DEBUG_INFO(DEBUG_CATEGORY_AS, "[as] initialized (android mode)");
return 0;
}
if (inst->topo_sqlite_db) {
int rc = sqlite3_exec(inst->topo_sqlite_db,
"CREATE TABLE IF NOT EXISTS auto_socket_ports ("
" if_name TEXT NOT NULL,"
" family INTEGER NOT NULL,"
" protocol INTEGER NOT NULL,"
" port INTEGER NOT NULL,"
" updated_at INTEGER DEFAULT (unixepoch()),"
" UNIQUE(if_name, family, protocol)"
");", NULL, NULL, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_AS, "[as] cannot create port table rc=%d: %s", rc, sqlite3_errmsg(inst->topo_sqlite_db));
auto_socket_destroy(inst); return -1;
} else {
if (inst->topo_sqlite_db) {
int rc = sqlite3_exec(inst->topo_sqlite_db,
"CREATE TABLE IF NOT EXISTS auto_socket_ports ("
" if_name TEXT NOT NULL,"
" family INTEGER NOT NULL,"
" protocol INTEGER NOT NULL,"
" port INTEGER NOT NULL,"
" updated_at INTEGER DEFAULT (unixepoch()),"
" UNIQUE(if_name, family, protocol)"
");", NULL, NULL, NULL);
if (rc != SQLITE_OK) {
DEBUG_ERROR(DEBUG_CATEGORY_AS, "[as] cannot create port table rc=%d: %s", rc, sqlite3_errmsg(inst->topo_sqlite_db));
auto_socket_destroy(inst); return -1;
}
}
if (auto_socket_init_monitor(as) < 0) { auto_socket_destroy(inst); return -1; }
if (auto_socket_scan_all(as) < 0) { auto_socket_destroy(inst); return -1; }
}
if (auto_socket_init_monitor(as) < 0) { auto_socket_destroy(inst); return -1; }
if (auto_socket_scan_all(as) < 0) { auto_socket_destroy(inst); return -1; }
DEBUG_INFO(DEBUG_CATEGORY_AS, "[as] initialized protocols=%s",
int v4 = as_check_usable(as, AF_INET), v6 = as_check_usable(as, AF_INET6);
if (!v4 && !v6) DEBUG_WARN(DEBUG_CATEGORY_AS, "[as] initialized without usable sockets; waiting for network/config changes");
DEBUG_INFO(DEBUG_CATEGORY_AS, "[as] initialized mode=%s usable_v4=%d usable_v6=%d protocols=%s",
as->mode == CFG_AUTO_SOCKETS_ANDROID ? "android" : "system", v4, v6,
as->protocols == CFG_AUTO_SOCKET_BOTH ? "tcp+udp" : as->protocols == CFG_AUTO_SOCKET_TCP ? "tcp" : "udp");
return 0;
}
@@ -1477,7 +1471,6 @@ static void remove_config_socket(struct AUTO_SOCKET* as, struct ETCP_SOCKET* soc
static int auto_socket_reconcile_config(struct AUTO_SOCKET* as) {
struct UTUN_INSTANCE* inst = as->instance;
if (!inst || !inst->config) { DEBUG_ERROR(DEBUG_CATEGORY_AS, "[as] missing config for reconcile"); return -1; }
int result = 0;
/* Тип сокета определяем по фактическому адресу (не доверяем type из конфига):
* публичный адрес → PUBLIC, приватный/link-local → NAT. */
@@ -1527,12 +1520,13 @@ static int auto_socket_reconcile_config(struct AUTO_SOCKET* as) {
s->name, s->transport ? "tcp" : "udp", sockaddr_storage_to_str(&s->ip).str);
ncd_add_socket_links(inst, created);
} else {
DEBUG_ERROR(DEBUG_CATEGORY_AS, "[as] configured endpoint creation failed name=%s", s->name); result = -1;
DEBUG_WARN(DEBUG_CATEGORY_AS, "[as] skipping unavailable configured socket name=%s ifidx=%u protocol=%s addr=%s; retry on next reload",
s->name, s->netif_index, s->transport ? "TCP" : "UDP", sockaddr_storage_to_str(&s->ip).str);
}
}
auto_socket_post_sockets_changed(as);
return result;
return 0;
}
void auto_socket_reload_config(struct UTUN_INSTANCE* inst, struct utun_config* new_cfg) {
+6 -2
View File
@@ -2,6 +2,8 @@
* При auto_sockets=yes init сканирует интерфейсы и включает мониторинг Linux/BSD/Windows;
* auto_socket_protocols выбирает TCP, UDP или TCP+UDP (по умолчанию); адреса и сокеты обновляются автоматически.
* Порты сохраняются в SQLite для повторного использования.
* Недоступные сокеты пропускаются с предупреждением; рабочие сохраняются даже при частичном отказе.
* Отсутствующие сокеты повторно создаются при сканировании сети, в Android — при обновлении конфига.
* Destroy отключает мониторинг и закрывает созданные системные endpoint; сохранённые порты остаются в БД.
* В режиме android сокеты приходят из конфигурации frontend и принадлежат ядру, а не списку системных интерфейсов.
* Основной API — uasync-поток. Из другого потока вызывать только on_network_change;
@@ -30,7 +32,9 @@ struct AUTO_SOCKET;
/* Инициализирует модуль: создаёт таблицу портов в БД, запускает платформенный
* мониторинг интерфейсов, сканирует все текущие UP-интерфейсы и создаёт сокеты.
* instance/ua обязательны. Если нет конфига или auto_sockets=no — ничего не делает, возвращает 0.
* Успех — 0; ошибка — -1 с откатом собственных endpoint/монитора. Повторный активный init — ошибка.
* Успех — 0, в том числе если отдельные или все сокеты недоступны; мониторинг/состояние сохраняются.
* Ошибка модуля (БД, мониторинг, сканирование, внутреннее состояние) — -1 с откатом собственных endpoint/монитора.
* Повторный активный init — ошибка.
* Системное полное сканирование ограничено 64 UP-интерфейсами; превышение — ошибка. */
int auto_socket_init(struct UTUN_INSTANCE* inst);
@@ -49,7 +53,7 @@ void auto_socket_on_network_change(struct UTUN_INSTANCE* inst);
* конфига. new_cfg — уже распарсенный конфиг; его список servers переносится в
* inst->config, остальное освобождается, в том числе при отказе режима/аргументов.
* new_cfg должен быть новым отдельным объектом; переданный текущий inst->config отклоняется без free.
* Обновление не атомарно: при частичном отказе новый список сохраняется, отсутствующие endpoint логируются.
* Обновление не атомарно: недоступные сокеты пропускаются с предупреждением, новый список и рабочие сокеты сохраняются.
* Неизменные endpoint, включая port=0, сохраняются; смена параметров bind пересоздаёт endpoint.
* Только uasync-поток, вне callbacks сверки. */
void auto_socket_reload_config(struct UTUN_INSTANCE* inst, struct utun_config* new_cfg);
+13 -16
View File
@@ -837,24 +837,21 @@ struct ETCP_SOCKET* etcp_socket_add(struct UTUN_INSTANCE* instance, struct CFG_S
memcpy(&e_sock->local_addr, bind_addr, sizeof(struct sockaddr_storage));
socklen_t addr_len = (bind_addr->ss_family == AF_INET) ? sizeof(struct sockaddr_in) : sizeof(struct sockaddr_in6);
if (bind(e_sock->fd, (struct sockaddr*)bind_addr, addr_len) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "[ETCP] Failed to bind socket to address family %d: %s",
bind_addr->ss_family, socket_strerror(socket_get_error()));
if (bind_addr->ss_family == AF_INET) {
struct sockaddr_in* sin = (struct sockaddr_in*)bind_addr;
DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "[ETCP] Failed to bind to %s:%d", ip_to_str(&sin->sin_addr, AF_INET).str, ntohs(sin->sin_port));
} else if (bind_addr->ss_family == AF_INET6) {
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)bind_addr;
DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "[ETCP] Failed to bind to %s:%d", ip_to_str(&sin6->sin6_addr, AF_INET6).str, ntohs(sin6->sin6_port));
}
int error = socket_get_error();
uint32_t scope = family == AF_INET6 ? ((struct sockaddr_in6*)bind_addr)->sin6_scope_id : 0;
DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "[ETCP] UDP bind failed name=%s ifidx=%u addr=%s scope=%u err=%d: %s",
name, netif_index, sockaddr_storage_to_str(bind_addr).str, scope, error, socket_strerror(error));
socket_close_wrapper(e_sock->fd);
u_free(e_sock);
socket_set_error(error);
return NULL;
}
socklen_t bound_len = sizeof(e_sock->local_addr);
if (getsockname(e_sock->fd, (struct sockaddr*)&e_sock->local_addr, &bound_len) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_SOCKET, "socket %s: cannot read bound address: %s",
name, socket_strerror(socket_get_error()));
socket_close_wrapper(e_sock->fd); u_free(e_sock); return NULL;
int error = socket_get_error();
DEBUG_ERROR(DEBUG_CATEGORY_SOCKET, "socket %s: cannot read bound address err=%d: %s",
name, error, socket_strerror(error));
socket_close_wrapper(e_sock->fd); u_free(e_sock); socket_set_error(error); return NULL;
}
// Порт 0 выбирает ОС; NODEINFO и подбор сокетов должны видеть фактический адрес.
e_sock->interface_addr = e_sock->local_addr;
@@ -1120,10 +1117,10 @@ static struct ETCP_LINK* link_create(struct ETCP_CONN* etcp, struct ETCP_SOCKET*
link->etcp = etcp;
link->is_server = is_server;
link->is_tcp = (uint8_t)is_tcp;
int mtu;
mtu = conn ? conn->mtu : STCP_MAX_MSG_SIZE;
if (is_tcp) mtu = STCP_MAX_MSG_SIZE;
else { if (mtu == 0) mtu = 1280; if (mtu > PACKET_DATA_MAX_MTU) mtu = PACKET_DATA_MAX_MTU; }
// Предел кадра STCP не задаёт MTU ETCP: фрагмент со служебными секциями должен вмещаться в 1500 байт.
int mtu = is_tcp || !conn ? PACKET_DATA_MAX_MTU : conn->mtu;
if (mtu == 0) mtu = 1280;
if (mtu > PACKET_DATA_MAX_MTU) mtu = PACKET_DATA_MAX_MTU;
if (driver) {
size_t maximum = driver->get_state(driver->ctx).max_packet;
mtu = maximum < PACKET_DATA_MAX_MTU ? (int)maximum : PACKET_DATA_MAX_MTU;
+1 -1
View File
@@ -188,7 +188,7 @@ struct stcp_server *stcp_server_listen(struct stcp_link_config *cfg, uint16_t po
ss->tcp_sock = tcp_sock;
ss->srv = stcp_server_create(cfg->inst->ua, port, &cfg->inst->my_keys, cfg->inst->my_ed25519_pubkey,
cfg->inst, server_accept_cb, ss, NULL, NULL, cfg->listen_family);
if (!ss->srv) { u_free(ss); return NULL; }
if (!ss->srv) { int error = socket_get_error(); u_free(ss); socket_set_error(error); return NULL; }
uint16_t bound_port = stcp_server_get_port(ss->srv);
if (!bound_port) { stcp_server_destroy(ss->srv); u_free(ss); return NULL; }
if (tcp_sock) {
+16 -7
View File
@@ -302,7 +302,12 @@ struct stcp_server *stcp_server_create(struct UASYNC *ua, uint16_t port,
int use_family = (family == AF_INET6) ? AF_INET6 : AF_INET;
srv->listen_sock = socket(use_family, SOCK_STREAM, 0);
if (srv->listen_sock == SOCKET_INVALID) { DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "socket failed"); u_free(srv); return NULL; }
if (srv->listen_sock == SOCKET_INVALID) {
int error = socket_get_error();
DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "TCP socket failed family=%d port=%u err=%d: %s",
use_family, port, error, socket_strerror(error));
u_free(srv); socket_set_error(error); return NULL;
}
socket_set_nonblocking(srv->listen_sock);
int reuse = 1;
setsockopt(srv->listen_sock, SOL_SOCKET, SO_REUSEADDR, (const char *)&reuse, sizeof(reuse));
@@ -317,8 +322,9 @@ struct stcp_server *stcp_server_create(struct UASYNC *ua, uint16_t port,
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(port);
if (bind(srv->listen_sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "bind port=%u (v4) failed err=%d", port, socket_get_error());
socket_close_wrapper(srv->listen_sock); u_free(srv); return NULL;
int error = socket_get_error();
DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "TCP bind addr=0.0.0.0:%u failed err=%d: %s", port, error, socket_strerror(error));
socket_close_wrapper(srv->listen_sock); u_free(srv); socket_set_error(error); return NULL;
}
} else {
struct sockaddr_in6 addr;
@@ -327,13 +333,16 @@ struct stcp_server *stcp_server_create(struct UASYNC *ua, uint16_t port,
addr.sin6_addr = in6addr_any;
addr.sin6_port = htons(port);
if (bind(srv->listen_sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "bind port=%u (v6) failed err=%d", port, socket_get_error());
socket_close_wrapper(srv->listen_sock); u_free(srv); return NULL;
int error = socket_get_error();
DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "TCP bind addr=[::]:%u failed err=%d: %s", port, error, socket_strerror(error));
socket_close_wrapper(srv->listen_sock); u_free(srv); socket_set_error(error); return NULL;
}
}
if (listen(srv->listen_sock, 16) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "listen failed err=%d", socket_get_error());
socket_close_wrapper(srv->listen_sock); u_free(srv); return NULL;
int error = socket_get_error();
DEBUG_ERROR(DEBUG_CATEGORY_ETCP, "TCP listen failed family=%d port=%u err=%d: %s",
use_family, port, error, socket_strerror(error));
socket_close_wrapper(srv->listen_sock); u_free(srv); socket_set_error(error); return NULL;
}
uint16_t bound_port = stcp_server_get_port(srv);
if (!bound_port) { socket_close_wrapper(srv->listen_sock); u_free(srv); return NULL; }
+17
View File
@@ -45,10 +45,23 @@ int video_transcode_is_active(void) { return vc_active_get(); }
/* Пробы не меняют глобальный уровень: параллельные звонки сохраняют рабочие ошибки. */
#ifdef _MSC_VER
static __declspec(thread) struct { unsigned depth, repeated; char last[1024]; } g_codec_probe;
static __declspec(thread) struct video_ffmpeg_errors* g_ffmpeg_errors;
#else
static __thread struct { unsigned depth, repeated; char last[1024]; } g_codec_probe;
static __thread struct video_ffmpeg_errors* g_ffmpeg_errors;
#endif
/* Сборщик действует только в потоке вызывающего и не заменяет общий FFmpeg callback. */
void video_ffmpeg_errors_begin(struct video_ffmpeg_errors* errors) {
video_ffmpeg_init();
errors->text[0] = '\0'; errors->previous = g_ffmpeg_errors; g_ffmpeg_errors = errors;
}
void video_ffmpeg_errors_end(struct video_ffmpeg_errors* errors) {
if (g_ffmpeg_errors != errors) { DEBUG_ERROR(DEBUG_CATEGORY_VIDEO, "FFmpeg error capture ended out of order"); return; }
g_ffmpeg_errors = errors->previous;
}
static void video_codec_probe_flush(void) {
if (g_codec_probe.repeated)
DEBUG_TRACE(DEBUG_CATEGORY_VIDEO, "ffmpeg probe: repeated=%u message=%s", g_codec_probe.repeated, g_codec_probe.last);
@@ -125,6 +138,10 @@ static void video_av_log_cb(void* avcl, int level, const char* fmt, va_list vl)
size_t len = strlen(buf);
while (len > 0 && (buf[len - 1] == '\n' || buf[len - 1] == '\r')) buf[--len] = '\0';
if (!len) return;
if (g_ffmpeg_errors && level <= AV_LOG_ERROR) {
size_t used = strlen(g_ffmpeg_errors->text);
snprintf(g_ffmpeg_errors->text + used, sizeof(g_ffmpeg_errors->text) - used, "%s%s", used ? "\n" : "", buf);
}
if (g_codec_probe.depth && level <= AV_LOG_WARNING) {
if (!strcmp(g_codec_probe.last, buf)) { ++g_codec_probe.repeated; return; }
video_codec_probe_flush(); snprintf(g_codec_probe.last, sizeof(g_codec_probe.last), "%s", buf);
+11
View File
@@ -20,6 +20,17 @@ struct UASYNC;
* trace категории debug включает AMF и заголовки HEVC. В сборке без FFmpeg ничего не делает. */
void video_ffmpeg_init(void);
/* Сбор ошибок FFmpeg текущего потока для отображения причины отказа codec.
* begin/end парные, допускают вложенность; сообщения получает только внутренний сборщик.
* Уровни и обычный вывод логов не меняются; без FFmpeg текст остаётся пустым. */
struct video_ffmpeg_errors {
char text[4096]; /* Первые сообщения уровня error/fatal, разделённые переводами строк. */
struct video_ffmpeg_errors* previous; /* Предыдущий сборщик текущего потока; управляется begin/end. */
};
/* errors обязателен и живёт до end; begin очищает text, end оставляет его доступным вызывающему. */
void video_ffmpeg_errors_begin(struct video_ffmpeg_errors* errors);
void video_ffmpeg_errors_end(struct video_ffmpeg_errors* errors);
/* Проба доступности codec: begin/end парные, вложенные, только для текущего потока.
* Ожидаемые отказы FFmpeg и сводка повторов выводятся на trace категории video;
* рабочие потоки сохраняют error/warn. begin/end должны выполняться в одном потоке. */
+2 -1
View File
@@ -398,7 +398,8 @@ check_PROGRAMS += test_auto_socket_faults
test_auto_socket_faults_SOURCES = test_auto_socket_faults.c
test_auto_socket_faults_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_auto_socket_faults_LDFLAGS = -Wl,--wrap=getifaddrs -Wl,--wrap=freeifaddrs \
-Wl,--wrap=etcp_socket_add -Wl,--wrap=stcp_server_listen -Wl,--wrap=random_bytes
-Wl,--wrap=etcp_socket_add -Wl,--wrap=stcp_server_listen -Wl,--wrap=random_bytes \
-Wl,--wrap=bind -Wl,--wrap=socket_close_wrapper
test_local_transport_SOURCES = test_local_transport.c $(test_clock_sources)
test_local_transport_LDADD = $(LIBUTUN) $(COMMON_LIBS)
test_local_transport_LDFLAGS = $(test_clock_ldflags) -Wl,--wrap=local_interfaces -Wl,--wrap=socket_sendto
+123 -12
View File
@@ -1,4 +1,4 @@
/* Отказы пересоздания, портовая SQLite и отмена pending scan на настоящем ядре. */
/* Пропуск недоступных автосокетов, восстановление, ошибки ОС и владение ресурсами на настоящем ядре. */
#include "nat_fixture.h"
#include "../src/transport_layer/auto_socket.h"
#include "../src/transport_layer/stcp_link.h"
@@ -7,36 +7,71 @@
#include <net/if.h>
static int fake_network, present = 1, fail_udp, fail_tcp, fail_random, deny_save, creates;
static int fail_scan, fail_bind_type, fail_bind_family, bind_error, bind_failures, bind_remaining, clobber_close, fake_ipv6;
static uint32_t address = 0x7f000001;
static struct sockaddr_in fake_address;
static struct ifaddrs fake_iface;
static struct sockaddr_in6 fake_address_v6;
static struct ifaddrs fake_iface, fake_iface_v6;
extern int __real_getifaddrs(struct ifaddrs**);
extern void __real_freeifaddrs(struct ifaddrs*);
int __wrap_getifaddrs(struct ifaddrs** out) {
if (!fake_network) return __real_getifaddrs(out);
if (fail_scan) { errno = EIO; return -1; }
memset(&fake_address, 0, sizeof(fake_address));
fake_address.sin_family = AF_INET; fake_address.sin_addr.s_addr = htonl(address);
fake_iface = (struct ifaddrs){.ifa_name = "lo", .ifa_flags = present ? IFF_UP : 0,
.ifa_addr = (struct sockaddr*)&fake_address};
if (fake_ipv6) {
memset(&fake_address_v6, 0, sizeof(fake_address_v6)); fake_address_v6.sin6_family = AF_INET6;
assert(inet_pton(AF_INET6, "fd42::1", &fake_address_v6.sin6_addr) == 1);
fake_iface_v6 = (struct ifaddrs){.ifa_name = "lo", .ifa_flags = fake_iface.ifa_flags,
.ifa_addr = (struct sockaddr*)&fake_address_v6};
fake_iface.ifa_next = &fake_iface_v6;
}
*out = &fake_iface; return 0;
}
void __wrap_freeifaddrs(struct ifaddrs* list) { if (list != &fake_iface) __real_freeifaddrs(list); }
/* Отказ настоящего bind; cleanup намеренно стирает errno, как успешный closesocket в Winsock. */
extern int __real_bind(int, const struct sockaddr*, socklen_t);
int __wrap_bind(int fd, const struct sockaddr* addr, socklen_t len) {
if (fail_bind_type && bind_remaining) {
int type; socklen_t size = sizeof(type);
assert(getsockopt(fd, SOL_SOCKET, SO_TYPE, &type, &size) == 0);
if (type == fail_bind_type && (!fail_bind_family || addr->sa_family == fail_bind_family)) {
bind_failures++;
if (bind_remaining > 0) bind_remaining--;
errno = bind_error; return -1;
}
}
return __real_bind(fd, addr, len);
}
extern int __real_socket_close_wrapper(socket_t);
int __wrap_socket_close_wrapper(socket_t fd) {
int result = __real_socket_close_wrapper(fd);
if (clobber_close) errno = 0;
return result;
}
extern struct ETCP_SOCKET* __real_etcp_socket_add(struct UTUN_INSTANCE*, struct CFG_SERVER*);
struct ETCP_SOCKET* __wrap_etcp_socket_add(struct UTUN_INSTANCE* instance, struct CFG_SERVER* server) {
if (!strncmp(server->name, "as_", 3)) {
creates++;
if (fail_udp) { errno = ENOMEM; return NULL; }
}
return __real_etcp_socket_add(instance, server);
struct ETCP_SOCKET* result = __real_etcp_socket_add(instance, server);
if (!result && fail_bind_type == SOCK_DGRAM) assert(socket_get_error() == bind_error);
return result;
}
extern struct stcp_server* __real_stcp_server_listen(struct stcp_link_config*, uint16_t,
stcp_server_on_link_cb, struct ETCP_SOCKET*);
struct stcp_server* __wrap_stcp_server_listen(struct stcp_link_config* config, uint16_t port,
stcp_server_on_link_cb callback, struct ETCP_SOCKET* socket) {
if (fail_tcp) { errno = ENOMEM; return NULL; }
return __real_stcp_server_listen(config, port, callback, socket);
struct stcp_server* result = __real_stcp_server_listen(config, port, callback, socket);
if (!result && fail_bind_type == SOCK_STREAM) assert(socket_get_error() == bind_error);
return result;
}
extern int __real_random_bytes(uint8_t*, size_t);
int __wrap_random_bytes(uint8_t* out, size_t size) {
@@ -47,7 +82,7 @@ int __wrap_random_bytes(uint8_t* out, size_t size) {
static struct ETCP_SOCKET* owned_socket(struct UTUN_INSTANCE* instance, int tcp) {
struct ETCP_SOCKET* result = NULL;
for (struct ETCP_SOCKET* es = instance->etcp_sockets; es; es = es->next) {
if (strncmp(es->name, "as_lo_", 6) || es->is_tcp != tcp) continue;
if (strncmp(es->name, "as_lo_v4_", 9) || es->is_tcp != tcp) continue;
assert(!result); result = es;
}
return result;
@@ -120,22 +155,80 @@ int main(void) {
DEBUG_INFO(DEBUG_CATEGORY_SOCKET, "PASS failed port save does not leave a SQLite transaction open");
fail_random = 1;
assert(auto_socket_init(instance) == -1 && !instance->auto_socket_state && !owned_socket(instance, 0));
assert(auto_socket_init(instance) == 0 && instance->auto_socket_state && !owned_socket(instance, 0));
fail_random = 0;
rescan(&fixture, instance); assert(owned_socket(instance, 0));
auto_socket_destroy(instance);
DEBUG_INFO(DEBUG_CATEGORY_SOCKET, "PASS port generation failure skips socket; next scan recovers");
instance->config->global.auto_socket_protocols = CFG_AUTO_SOCKET_BOTH;
fail_tcp = 1;
assert(auto_socket_init(instance) == -1 && !instance->auto_socket_state && !owned_socket(instance, 0) && !owned_socket(instance, 1));
fail_tcp = 0;
assert(auto_socket_init(instance) == 0 && owned_socket(instance, 0) && owned_socket(instance, 1));
uint64_t tcp_generation = owned_socket(instance, 1)->generation;
assert(auto_socket_init(instance) == 0 && instance->auto_socket_state && owned_socket(instance, 0) && !owned_socket(instance, 1));
uint64_t udp_generation = owned_socket(instance, 0)->generation;
fail_tcp = 0;
rescan(&fixture, instance);
assert(owned_socket(instance, 1) && owned_socket(instance, 0)->generation == udp_generation);
uint64_t tcp_generation = owned_socket(instance, 1)->generation;
rescan(&fixture, instance);
assert(owned_socket(instance, 1)->generation == tcp_generation && owned_socket(instance, 0)->generation == udp_generation);
address = 0x7f000003; rescan(&fixture, instance);
assert(owned_socket(instance, 1)->generation != tcp_generation && owned_socket(instance, 0)->generation != udp_generation);
auto_socket_on_network_change(instance); auto_socket_destroy(instance); uasync_poll(fixture.ua, 0);
assert(instance->etcp_sockets == fixed && !stcp_server_list_count(instance));
DEBUG_INFO(DEBUG_CATEGORY_SOCKET, "PASS entropy failure and partial TCP/UDP init rollback, no resource leaks");
DEBUG_INFO(DEBUG_CATEGORY_SOCKET, "PASS TCP failure preserves UDP; recovery and unchanged scans preserve working endpoints");
fail_udp = 1;
assert(auto_socket_init(instance) == 0 && !owned_socket(instance, 0) && owned_socket(instance, 1));
tcp_generation = owned_socket(instance, 1)->generation;
fail_udp = 0; rescan(&fixture, instance);
assert(owned_socket(instance, 0) && owned_socket(instance, 1)->generation == tcp_generation);
auto_socket_destroy(instance);
DEBUG_INFO(DEBUG_CATEGORY_SOCKET, "PASS UDP failure preserves TCP; next scan adds only missing UDP");
fail_udp = fail_tcp = 1;
struct UTUN_INSTANCE* offline = nat_fixture_node(&fixture, "127.0.0.10", NULL,
"[global]\nauto_sockets=yes\nauto_socket_protocols=tcp+udp\nauto_socket_skip_no_default_route=no\n");
assert(offline->auto_socket_state && !offline->etcp_sockets && !stcp_server_list_count(offline));
fail_udp = fail_tcp = 0;
rescan(&fixture, offline); assert(owned_socket(offline, 0) && owned_socket(offline, 1));
auto_socket_destroy(offline);
DEBUG_INFO(DEBUG_CATEGORY_SOCKET, "PASS core starts without any usable automatic sockets and recovers on network change");
fail_scan = 1;
assert(auto_socket_init(instance) == -1 && !instance->auto_socket_state && instance->etcp_sockets == fixed);
fail_scan = 0;
DEBUG_INFO(DEBUG_CATEGORY_SOCKET, "PASS interface scan failure remains fatal and preserves unrelated sockets");
clobber_close = 1;
for (int tcp = 0; tcp < 2; tcp++) {
fail_bind_type = tcp ? SOCK_STREAM : SOCK_DGRAM;
bind_error = EADDRNOTAVAIL; bind_remaining = -1; bind_failures = 0;
assert(auto_socket_init(instance) == 0 && bind_failures == 1);
assert(!owned_socket(instance, tcp) && owned_socket(instance, !tcp));
fail_bind_type = 0; rescan(&fixture, instance);
assert(owned_socket(instance, 0) && owned_socket(instance, 1));
auto_socket_destroy(instance);
fail_bind_type = tcp ? SOCK_STREAM : SOCK_DGRAM;
bind_error = EADDRINUSE; bind_remaining = 1; bind_failures = 0;
assert(auto_socket_init(instance) == 0 && bind_failures == 1);
assert(owned_socket(instance, 0) && owned_socket(instance, 1));
auto_socket_destroy(instance); fail_bind_type = 0;
}
DEBUG_INFO(DEBUG_CATEGORY_SOCKET, "PASS UDP/TCP bind errors survive cleanup; unavailable address skips, busy port retries");
fake_ipv6 = 1; fail_bind_family = AF_INET6; fail_bind_type = SOCK_DGRAM;
bind_error = EADDRNOTAVAIL; bind_remaining = -1; bind_failures = 0;
assert(auto_socket_init(instance) == 0 && bind_failures == 1);
udp_generation = owned_socket(instance, 0)->generation; tcp_generation = owned_socket(instance, 1)->generation;
uint64_t tcp6_generation = test_etcp_bound_socket(instance, "as_lo_v6_tcp")->generation;
for (struct ETCP_SOCKET* es = instance->etcp_sockets; es; es = es->next) assert(strcmp(es->name, "as_lo_v6_udp"));
fail_bind_type = 0; rescan(&fixture, instance);
assert(test_etcp_bound_socket(instance, "as_lo_v6_udp"));
assert(owned_socket(instance, 0)->generation == udp_generation && owned_socket(instance, 1)->generation == tcp_generation);
assert(test_etcp_bound_socket(instance, "as_lo_v6_tcp")->generation == tcp6_generation);
auto_socket_destroy(instance); fake_ipv6 = fail_bind_family = clobber_close = 0;
DEBUG_INFO(DEBUG_CATEGORY_SOCKET, "PASS IPv6 UDP bind failure preserves IPv4 UDP/TCP and IPv6 TCP; rescan adds only missing socket");
fake_network = 0; instance->config->global.auto_sockets = CFG_AUTO_SOCKETS_ANDROID;
assert(auto_socket_init(instance) == 0);
@@ -156,7 +249,25 @@ int main(void) {
assert(test_etcp_bound_socket(instance, "udp")->generation != original && test_etcp_bound_socket(instance, "udp")->mtu == 1150);
assert(link->local_srv == instance->config->servers);
auto_socket_reload_config(instance, instance->config);
auto_socket_destroy(instance); nat_fixture_cleanup(&fixture);
auto_socket_destroy(instance);
DEBUG_INFO(DEBUG_CATEGORY_SOCKET, "PASS unchanged ephemeral bind, changed options and config client reference rebinding");
fail_bind_type = SOCK_DGRAM; bind_error = EADDRNOTAVAIL; bind_remaining = -1;
struct UTUN_INSTANCE* android_offline = nat_fixture_node(&fixture, "127.0.0.11", NULL,
"[global]\nauto_sockets=android\n");
assert(android_offline->auto_socket_state && !android_offline->etcp_sockets);
const char* partial = "[server:udp]\naddr=127.0.0.11:0\n"
"[server:tcp]\naddr=127.0.0.11:0\ntransport=tcp\n";
updated = parse_config_from_buf(partial, strlen(partial), "android-partial"); assert(updated);
auto_socket_reload_config(android_offline, updated);
struct ETCP_SOCKET* retained_tcp = test_etcp_bound_socket(android_offline, "tcp");
tcp_generation = retained_tcp->generation;
assert(android_offline->etcp_sockets == retained_tcp && !retained_tcp->next);
fail_bind_type = 0;
updated = parse_config_from_buf(partial, strlen(partial), "android-recovered"); assert(updated);
auto_socket_reload_config(android_offline, updated);
assert(test_etcp_bound_socket(android_offline, "udp") && test_etcp_bound_socket(android_offline, "tcp")->generation == tcp_generation);
nat_fixture_cleanup(&fixture);
DEBUG_INFO(DEBUG_CATEGORY_SOCKET, "PASS Android offline startup, partial reload and recovery preserve working TCP; no resource leaks");
return 0;
}
+30 -1
View File
@@ -5,6 +5,7 @@
#include "etcp.h"
#include "etcp_api.h"
#include "etcp_connections.h"
#include "etcp_loadbalancer.h"
#include "etcp_session.h"
#include "etcp_bbr.h"
#include "stcp_link.h"
@@ -210,6 +211,34 @@ static void exchange_messages(void) {
for (int i = 0; i < 2; i++) CHECK(!endpoints[i].conn->input_wait_ack->count && !endpoints[i].conn->input_send_q->count);
}
// Накопленные ACK должны разбиваться на пакеты до 1500 байт и успешно уходить через настоящий STCP.
static void check_tcp_ack_mtu(void) {
for (int i = 0; i < 2; i++) {
struct ETCP_CONN* conn = endpoints[i].conn;
for (struct ETCP_LINK* l = conn->links; l; l = l->next)
CHECK(l->is_tcp && l->mtu == PACKET_DATA_MAX_MTU && l->mtu_local == PACKET_DATA_MAX_MTU);
struct ETCP_LINK* link = conn->links; CHECK(link && link->initialized && link->link_status);
for (unsigned attempt = 1; attempt <= 202; attempt++)
CHECK(etcp_tx_queue_ack(link, conn->last_delivered_id, etcp_tx_attempt_id(conn->last_delivered_id, attempt)) == 0);
unsigned packets = 0, max_size = 0;
while (link->ack_q->count) {
unsigned before = link->ack_q->count;
link->ack_reply_pending = 1;
struct ETCP_DGRAM* packet = etcp_request_pkt(conn); CHECK(packet && packet->link == link && packet->ack_count);
unsigned size = packet->data_len + ETCP_ENCRYPTED_HDR_SIZE + ETCP_SESSION_HEADER_SIZE;
CHECK(size <= link->mtu && size <= PACKET_DATA_MAX_MTU);
if (size > max_size) max_size = size;
unsigned count = packet->ack_count;
CHECK(etcp_loadbalancer_send(packet) >= 0);
CHECK(link->ack_q->count == (int)(before - count));
packets++;
}
CHECK(packets >= 2);
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "PASS TCP ACK MTU: side=%d mtu=%u packets=%u max_packet=%u ACK_queue=0",
i, link->mtu, packets, max_size);
}
}
static void run_case(int reality, int simultaneous, int restarted) {
DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "Transport case: reality=%d simultaneous=%d restart=%d", reality, simultaneous, restarted);
memset(endpoints, 0, sizeof(endpoints)); phase = 0; failed = 0; stream_packets = session_packets = 0;
@@ -229,7 +258,7 @@ static void run_case(int reality, int simultaneous, int restarted) {
CHECK(node_conn_direct_update_node(endpoints[0].inst, peers[0]->node_id) == 0);
unsigned updated = 0; for (struct ETCP_LINK* l = endpoints[0].conn->links; l; l = l->next) updated++;
CHECK(updated == links); node_conn_direct_close(extra);
exchange_messages();
exchange_messages(); check_tcp_ack_mtu();
if (restarted < 2) {
int survivor = 1 - restarted;
+20 -4
View File
@@ -159,7 +159,7 @@ static void check_mock(void) {
destroy(inst, ua);
}
/* Ошибка второго listen-сокета отклоняет весь запуск, включая начальный auto_sockets=android. */
/* Занятый ручной listener отклоняет запуск; автоматический пропускается и восстанавливается при reload. */
static void check_socket_failure(int automatic) {
int stdin_flags = fcntl(STDIN_FILENO, F_GETFD); assert(stdin_flags >= 0);
int blocker = socket(AF_INET, SOCK_STREAM, 0); assert(blocker >= 0);
@@ -172,10 +172,26 @@ static void check_socket_failure(int automatic) {
"[server:blocked]\naddr=127.0.0.1:%u\ntransport=tcp\ntype=private\n", automatic ? "android" : "no",
ntohs(address.sin_port));
struct UASYNC* ua = uasync_create(); assert(ua);
assert(!utun_instance_create_from_str(ua, config));
uasync_drain_immediate(ua); uasync_destroy(ua, 0); close(blocker);
struct UTUN_INSTANCE* inst = utun_instance_create_from_str(ua, config);
if (automatic) {
assert(inst && inst->auto_socket_state && inst->etcp_sockets && !inst->etcp_sockets->next);
struct ETCP_SOCKET* first = inst->etcp_sockets;
assert(!strcmp(first->name, "first") && !first->is_tcp && utun_core_start(inst) == 0);
uint64_t generation = first->generation;
close(blocker); blocker = -1;
struct utun_config* updated = parse_config_from_buf(config, strlen(config), "automatic-listener-retry"); assert(updated);
auto_socket_reload_config(inst, updated);
assert(inst->core_started && inst->etcp_sockets->is_tcp && !strcmp(inst->etcp_sockets->name, "blocked"));
assert(inst->etcp_sockets->next == first && !first->next && first->generation == generation);
destroy(inst, ua);
DEBUG_INFO(DEBUG_CATEGORY_TUN, "PASS busy automatic TCP listener skips startup failure; reload recovers and preserves UDP");
} else {
assert(!inst);
uasync_drain_immediate(ua); uasync_destroy(ua, 0);
DEBUG_INFO(DEBUG_CATEGORY_TUN, "PASS busy manual TCP listener rejects startup and cleans opened sockets");
}
if (blocker >= 0) close(blocker);
assert(fcntl(STDIN_FILENO, F_GETFD) == stdin_flags);
DEBUG_INFO(DEBUG_CATEGORY_TUN, "PASS second socket failure rejects startup and cleans opened sockets automatic=%d", automatic);
}
/* Android/JNI backend запускается без разрешений netlink и принимает свежие адреса без рестарта. */
+81 -22
View File
@@ -1,6 +1,7 @@
#include <QCoreApplication>
#include "channellist.h"
#include "channeldelegate.h"
#include "outline_icon.h"
#include "avatars/groupavatarmanager.h"
#include "../db/db_manager.h"
#include <QVBoxLayout>
@@ -9,6 +10,7 @@
#include <QPushButton>
#include <QPainter>
#include <QPainterPath>
#include <QTransform>
#include <QPixmap>
#include <QMouseEvent>
#include <QMenu>
@@ -17,6 +19,7 @@
#include <QMimeData>
#include <QUrl>
#include <QSignalBlocker>
#include <QToolTip>
extern "C" {
#include "../../lib/debug_config.h"
@@ -103,6 +106,44 @@ static QIcon makeChannelIcon(const QColor &color, const QString &letter) {
return QIcon(pix);
}
/* Создание группы: два человека и плюс; присоединение: стрелка входа в открытую дверь. */
static QIcon channelActionIcon(bool create, const QColor& color) {
QPainterPath path;
if (create) {
path.addEllipse(QRectF(4.5, 3.5, 6, 6)); path.addEllipse(QRectF(14, 4.5, 5, 5));
path.moveTo(2, 20); path.lineTo(2, 17); path.cubicTo(2, 14, 4.5, 12, 7.5, 12);
path.cubicTo(10.5, 12, 13, 14, 13, 17); path.lineTo(13, 20); path.closeSubpath();
path.moveTo(14.5, 12); path.cubicTo(18, 11.5, 21, 13, 21, 16);
path.moveTo(19, 17); path.lineTo(19, 22); path.moveTo(16.5, 19.5); path.lineTo(21.5, 19.5);
} else {
path.moveTo(12.5, 3); path.lineTo(21, 3); path.lineTo(21, 21); path.lineTo(12.5, 21);
path.moveTo(3, 12); path.lineTo(16, 12); path.moveTo(11, 7); path.lineTo(16, 12); path.lineTo(11, 17);
}
return outlineIcon(path, color, 28);
}
/* Шестерёнка с восемью зубцами и центральным отверстием в общем контурном стиле. */
static QIcon settingsIcon(const QColor& color) {
QPainterPath path;
path.moveTo(10.1, 4.2);
for (int tooth = 0; tooth < 8; tooth++) {
QTransform rotation; rotation.translate(12, 12); rotation.rotate(tooth * 45); rotation.translate(-12, -12);
for (const QPointF& point : {QPointF(10.1, 4.2), QPointF(10.5, 2), QPointF(13.5, 2), QPointF(13.9, 4.2)}) {
path.lineTo(rotation.map(point));
}
}
path.closeSubpath(); path.addEllipse(QRectF(9, 9, 6, 6));
return outlineIcon(path, color, 24);
}
/* График с осями повторяет смысл кнопки Diagnostics в окне звонка. */
static QIcon connectionMonitorIcon(const QColor& color) {
QPainterPath path;
path.moveTo(3, 3); path.lineTo(3, 21); path.lineTo(21, 21);
path.moveTo(5, 12); path.lineTo(9, 12); path.lineTo(12, 6); path.lineTo(15, 18); path.lineTo(20, 8);
return outlineIcon(path, color, 24);
}
ChannelList::ChannelList(DbManager* db, QWidget *parent)
: QWidget(parent)
, m_listView(new ChannelListView(this))
@@ -171,22 +212,28 @@ ChannelList::ChannelList(DbManager* db, QWidget *parent)
fl->setContentsMargins(6, 4, 6, 4);
fl->setSpacing(6);
auto *gearBtn = new QPushButton(QString::fromUtf8("\xE2\x9A\x99"), footer);
gearBtn->setFixedSize(28, 28);
const QString actionStyle = QStringLiteral(
"QPushButton { border: none; border-radius: 8px; background: transparent; }"
"QPushButton:hover { background: palette(light); }");
auto *gearBtn = new QPushButton(footer);
gearBtn->setObjectName(QStringLiteral("settingsButton"));
gearBtn->setIcon(settingsIcon(palette().color(QPalette::ButtonText))); gearBtn->setIconSize(QSize(24, 24));
gearBtn->setFixedSize(34, 34); gearBtn->setCursor(Qt::PointingHandCursor); gearBtn->setProperty("instantTooltip", true);
gearBtn->setToolTip(QCoreApplication::translate("ChatGui", "Settings"));
gearBtn->setStyleSheet(
"QPushButton { border: none; font-size: 18px; background: transparent; color: #666; }"
"QPushButton:hover { color: #3390EC; background: rgba(51,144,236,20); border-radius: 14px; }");
gearBtn->setAccessibleName(gearBtn->toolTip());
gearBtn->setStyleSheet(actionStyle);
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "settings button initialized icon=gear teeth=8 icon_px=24 button=34x34 tooltip_delay_ms=0");
fl->addWidget(gearBtn);
connect(gearBtn, &QPushButton::clicked, this, [this] { emit settingsRequested(); });
auto *monitorBtn = new QPushButton(QString::fromUtf8("\xF0\x9F\x94\x8D"), footer); /* 🔍 */
monitorBtn->setFixedSize(28, 28);
auto *monitorBtn = new QPushButton(footer);
monitorBtn->setObjectName(QStringLiteral("connectionMonitorButton"));
monitorBtn->setIcon(connectionMonitorIcon(palette().color(QPalette::ButtonText))); monitorBtn->setIconSize(QSize(24, 24));
monitorBtn->setFixedSize(34, 34); monitorBtn->setCursor(Qt::PointingHandCursor); monitorBtn->setProperty("instantTooltip", true);
monitorBtn->setToolTip(QCoreApplication::translate("ChatGui", "Connection Monitor"));
monitorBtn->setStyleSheet(
"QPushButton { border: none; font-size: 16px; background: transparent; color: #666; }"
"QPushButton:hover { color: #3390EC; background: rgba(51,144,236,20); border-radius: 14px; }");
monitorBtn->setAccessibleName(monitorBtn->toolTip());
monitorBtn->setStyleSheet(actionStyle);
fl->addWidget(monitorBtn);
connect(monitorBtn, &QPushButton::clicked, this, [this] { emit connMonitorRequested(); });
@@ -196,25 +243,28 @@ ChannelList::ChannelList(DbManager* db, QWidget *parent)
versionLabel->setStyleSheet("font-size: 10px; color: palette(mid);");
fl->addWidget(versionLabel);
auto *createBtn = new QPushButton(QCoreApplication::translate("ChatGui", "GRP"), footer);
createBtn->setFixedSize(36, 28);
auto *createBtn = new QPushButton(footer);
createBtn->setObjectName(QStringLiteral("createGroupButton"));
createBtn->setIcon(channelActionIcon(true, palette().color(QPalette::ButtonText))); createBtn->setIconSize(QSize(28, 28));
createBtn->setFixedSize(40, 38); createBtn->setCursor(Qt::PointingHandCursor); createBtn->setProperty("instantTooltip", true);
createBtn->setToolTip(QCoreApplication::translate("ChatGui", "Create group"));
createBtn->setStyleSheet(
"QPushButton { border: none; border-radius: 6px; font-size: 11px; font-weight: bold;"
" color: white; background: #27AE60; }"
"QPushButton:hover { background: #219A52; }");
createBtn->setAccessibleName(createBtn->toolTip());
createBtn->setStyleSheet(actionStyle);
connect(createBtn, &QPushButton::clicked, this, [this] { emit createGroupRequested(); });
fl->addWidget(createBtn);
auto *plusBtn = new QPushButton("+", footer);
plusBtn->setFixedSize(28, 28);
auto *plusBtn = new QPushButton(footer);
plusBtn->setObjectName(QStringLiteral("joinGroupButton"));
plusBtn->setIcon(channelActionIcon(false, palette().color(QPalette::ButtonText))); plusBtn->setIconSize(QSize(28, 28));
plusBtn->setFixedSize(40, 38); plusBtn->setCursor(Qt::PointingHandCursor); plusBtn->setProperty("instantTooltip", true);
plusBtn->setToolTip(QCoreApplication::translate("ChatGui", "Join"));
plusBtn->setStyleSheet(
"QPushButton { border: none; border-radius: 14px; font-size: 18px; font-weight: bold;"
" color: white; background: #3390EC; }"
"QPushButton:hover { background: #2B7EC8; }");
plusBtn->setAccessibleName(plusBtn->toolTip());
plusBtn->setStyleSheet(actionStyle);
connect(plusBtn, &QPushButton::clicked, this, [this] { emit joinRequested(); });
fl->addWidget(plusBtn);
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT,
"channel actions initialized icons=group-plus/enter icon_px=28 button=40x38 background=transparent tooltip_delay_ms=0");
DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "connection monitor button initialized icon=diagnostics icon_px=24 button=34x34 tooltip_delay_ms=0");
layout->addWidget(footer);
auto targets = footer->findChildren<QWidget*>(); targets.append(footer);
@@ -263,6 +313,15 @@ ChannelList::ChannelList(DbManager* db, QWidget *parent)
}
bool ChannelList::eventFilter(QObject* target, QEvent* event) {
if (target->property("instantTooltip").toBool()) {
auto* button = static_cast<QWidget*>(target);
if (event->type() == QEvent::Enter && button->isEnabled()) {
QToolTip::showText(button->mapToGlobal(QPoint(button->width() / 2, button->height())), button->toolTip(), button);
DEBUG_TRACE(DEBUG_CATEGORY_CHAT, "channel action tooltip shown button=%s", qPrintable(button->objectName()));
} else if (event->type() == QEvent::Leave || event->type() == QEvent::MouseButtonPress || event->type() == QEvent::Hide) {
QToolTip::hideText();
}
}
if (event->type() == QEvent::DragLeave) { m_dropHint->hide(); return true; }
if (event->type() != QEvent::DragEnter && event->type() != QEvent::DragMove && event->type() != QEvent::Drop)
return QWidget::eventFilter(target, event);
+344 -15
View File
@@ -4,6 +4,7 @@
#include "audiorecorder.h"
#include "voicemessageencoder.h"
#include "debug_ui.h"
#include "outline_icon.h"
#include <QDebug>
#include <QTextEdit>
#include <QPushButton>
@@ -32,7 +33,19 @@
#include <QImageIOHandler>
#include <QUrl>
#include <QToolTip>
#include <QBuffer>
#include <QFile>
#include <QImageWriter>
#include <QPixmap>
#include <QPainter>
#include <QPainterPath>
#include <QTransform>
#include <QRadioButton>
#include <QRunnable>
#include <QTemporaryFile>
#include <cmath>
#include <cstring>
#include <functional>
extern "C" {
#include "video/video.h"
@@ -42,6 +55,143 @@ extern "C" {
quint64 InputBar::s_submissionSequence = 0;
namespace {
constexpr qint64 clipboardMaxBytes = 128 * 1024 * 1024;
constexpr qint64 clipboardMaxPixels = 32 * 1024 * 1024;
const QList<QByteArray> clipboardImageFormats = {"image/png", "image/jpeg", "image/bmp", "image/webp"};
bool hasClipboardImage(const QMimeData* source) {
if (!source) return false;
if (source->hasImage()) return true;
for (const auto& format : clipboardImageFormats) if (source->hasFormat(QString::fromLatin1(format))) return true;
return false;
}
bool validClipboardSize(const QSize& size) {
return size.width() > 0 && size.height() > 0 && size.width() <= 16384 && size.height() <= 16384 &&
qint64(size.width()) * size.height() <= clipboardMaxPixels;
}
/* Общая точка вставки для клавиатуры и контекстного меню; обычный текст обрабатывает QTextEdit. */
class ComposerTextEdit final : public QTextEdit {
public:
ComposerTextEdit(QWidget* parent, std::function<void(const QMimeData*)> pasteImage)
: QTextEdit(parent), m_pasteImage(std::move(pasteImage)) {}
protected:
bool canInsertFromMimeData(const QMimeData* source) const override {
return hasClipboardImage(source) || QTextEdit::canInsertFromMimeData(source);
}
void insertFromMimeData(const QMimeData* source) override {
if (hasClipboardImage(source)) {
if (isReadOnly()) { GUI_WARN("clipboard image: insertion rejected in read-only composer"); return; }
m_pasteImage(source);
} else QTextEdit::insertFromMimeData(source);
}
private:
std::function<void(const QMimeData*)> m_pasteImage;
};
}
/* Не QObject: результат worker и GUI совместно держат путь, последний владелец удаляет только своё изображение. */
struct ClipboardImage {
QString path, name;
QImage thumbnail;
QSize dimensions;
qint64 bytes = 0;
~ClipboardImage() {
if (path.isEmpty()) return;
QFile file(path);
if (!file.remove()) GUI_WARN("clipboard image: cleanup failed path=%s error=%s", qPrintable(path), qPrintable(file.errorString()));
else GUI_DEBUG("clipboard image: temporary image removed path=%s", qPrintable(path));
}
};
/* Оба файла принадлежат черновику до очистки; worker заранее выбирает вариант по правилу размеров. */
struct ClipboardImages {
std::shared_ptr<ClipboardImage> png, jpeg;
bool preferJpeg = false;
};
/* Возврат происходит после destructor QTemporaryFile: его native handle уже закрыт, в том числе в Windows. */
static std::shared_ptr<ClipboardImage> writeClipboardImage(const QImage& image, const char* format, int quality,
QString& error) {
auto result = std::make_shared<ClipboardImage>();
const QString suffix = !std::strcmp(format, "jpeg") ? "jpg" : "png";
QTemporaryFile file(QDir::tempPath() + "/vibechat-clipboard-XXXXXX." + suffix);
if (!file.open()) { error = file.errorString(); return {}; }
result->path = file.fileName(); file.setAutoRemove(false);
QImageWriter writer(&file, format);
if (quality >= 0) writer.setQuality(quality);
if (!writer.write(image)) { error = writer.errorString(); return {}; }
if (!file.flush()) { error = file.errorString(); return {}; }
result->bytes = file.size();
return result;
}
/* Оба кодека работают в worker; JPEG (85) выбирается только при выигрыше минимум вдвое. */
static std::shared_ptr<ClipboardImages> encodeClipboardImage(QImage image, const QByteArray& encoded,
const QString& name, QString& error) {
if (image.isNull()) {
QBuffer buffer; buffer.setData(encoded);
if (!buffer.open(QIODevice::ReadOnly)) { error = buffer.errorString(); return {}; }
QImageReader reader(&buffer); reader.setAutoTransform(true);
if (!reader.canRead()) error = reader.errorString();
else if (!validClipboardSize(reader.size())) error = QStringLiteral("invalid dimensions or more than 32 megapixels");
else { image = reader.read(); if (image.isNull()) error = reader.errorString(); }
}
if (error.isEmpty() && (!validClipboardSize(image.size()) || image.sizeInBytes() > clipboardMaxBytes))
error = QStringLiteral("image exceeds 16384 pixels per side, 32 megapixels or 128 MiB");
if (!error.isEmpty()) return {};
image.setDevicePixelRatio(1);
QString pngError, jpegError;
const auto png = writeClipboardImage(image, "png", -1, pngError);
QImage jpegImage = image;
if (image.hasAlphaChannel()) {
jpegImage = QImage(image.size(), QImage::Format_RGB32);
if (jpegImage.isNull()) jpegError = QStringLiteral("cannot allocate JPEG background");
else { jpegImage.fill(Qt::white); QPainter painter(&jpegImage); painter.drawImage(0, 0, image); }
}
const auto jpeg = jpegError.isEmpty() ? writeClipboardImage(jpegImage, "jpeg", 85, jpegError) : nullptr;
if (!png) GUI_WARN("clipboard image: PNG encoding failed name=%s reason=%s", qPrintable(name), qPrintable(pngError));
if (!jpeg) GUI_WARN("clipboard image: JPEG encoding failed name=%s reason=%s", qPrintable(name), qPrintable(jpegError));
if (!png && !jpeg) { error = QString("PNG: %1; JPEG: %2").arg(pngError, jpegError); return {}; }
auto result = std::make_shared<ClipboardImages>(); result->png = png; result->jpeg = jpeg;
result->preferJpeg = jpeg && (!png || png->bytes >= 2 * jpeg->bytes);
if (png) {
png->name = name + ".png"; png->dimensions = image.size();
png->thumbnail = image.scaled(80, 60, Qt::KeepAspectRatio, Qt::SmoothTransformation);
}
if (jpeg) {
jpeg->name = name + ".jpg"; jpeg->dimensions = image.size();
jpeg->thumbnail = jpegImage.scaled(80, 60, Qt::KeepAspectRatio, Qt::SmoothTransformation);
}
GUI_INFO("clipboard image: compression name=%s png_bytes=%lld jpeg_bytes=%lld jpeg_quality=85 selected=%s alpha_background=white",
qPrintable(name), (long long)(png ? png->bytes : -1), (long long)(jpeg ? jpeg->bytes : -1), result->preferJpeg ? "jpeg" : "png");
return result;
}
/* Независимый source для worker: удаление/замена черновика не влияет на запрос, а отказ не мешает повтору. */
static bool copyClipboardImage(const QString& source, struct attachment_send_req* request, QString& error) {
ClipboardImage temporary;
QTemporaryFile output(QDir::tempPath() + "/vibechat-clipboard-send-XXXXXX." + QFileInfo(source).suffix());
if (!output.open()) { error = output.errorString(); return false; }
temporary.path = output.fileName(); output.setAutoRemove(false);
const QByteArray path = temporary.path.toUtf8();
if (path.size() >= int(sizeof(request->source))) { error = QStringLiteral("temporary source path is too long"); return false; }
QFile input(source);
if (!input.open(QIODevice::ReadOnly)) { error = input.errorString(); return false; }
while (!input.atEnd()) {
const QByteArray bytes = input.read(65536);
if (input.error() != QFileDevice::NoError) { error = input.errorString(); return false; }
if (output.write(bytes) != bytes.size()) { error = output.errorString(); return false; }
}
if (!output.flush()) { error = output.errorString(); return false; }
snprintf(request->source, sizeof(request->source), "%s", path.constData());
request->temporary_source = 1;
temporary.path.clear();
return true;
}
static bool isImageFile(const QString& suffix) {
QString s = suffix.toLower();
return s == "jpg" || s == "jpeg" || s == "png" || s == "gif" ||
@@ -52,6 +202,38 @@ static bool isVideoFile(const QString& suffix) {
return QStringList{"mp4", "mov", "mkv", "avi", "webm", "m4v"}.contains(suffix.toLower());
}
/* Наклонная скрепка без зависимости от системного emoji-шрифта. */
static QIcon attachmentIcon(const QPalette& palette) {
QPainterPath path;
path.moveTo(16.5, 6.5); path.lineTo(16.5, 17.5);
path.cubicTo(16.5, 20, 14.5, 22, 12, 22); path.cubicTo(9.5, 22, 7.5, 20, 7.5, 17.5);
path.lineTo(7.5, 5); path.cubicTo(7.5, 3.35, 8.85, 2, 10.5, 2); path.cubicTo(12.15, 2, 13.5, 3.35, 13.5, 5);
path.lineTo(13.5, 17); path.cubicTo(13.5, 17.83, 12.83, 18.5, 12, 18.5);
path.cubicTo(11.17, 18.5, 10.5, 17.83, 10.5, 17); path.lineTo(10.5, 6.5);
QTransform rotation; rotation.translate(12, 12); rotation.rotate(35); rotation.translate(-12, -12);
return outlineIcon(rotation.map(path), palette.color(QPalette::ButtonText));
}
/* Микрофон с капсюлем, держателем и подставкой в том же контурном стиле, что и скрепка. */
static QIcon microphoneIcon(const QPalette& palette) {
QPainterPath path;
path.addRoundedRect(QRectF(9, 2, 6, 12), 3, 3);
path.moveTo(5, 10); path.lineTo(5, 12);
path.cubicTo(5, 15.866, 8.134, 19, 12, 19); path.cubicTo(15.866, 19, 19, 15.866, 19, 12); path.lineTo(19, 10);
path.moveTo(12, 19); path.lineTo(12, 22); path.moveTo(8, 22); path.lineTo(16, 22);
return outlineIcon(path, palette.color(QPalette::ButtonText));
}
/* Смайлик с короткой улыбкой и свободным отступом от внешнего круга. */
static QIcon smileIcon(const QPalette& palette) {
QPainterPath path;
path.addEllipse(QRectF(2, 2, 20, 20));
path.moveTo(8.5, 9); path.lineTo(8.5, 9.15);
path.moveTo(15.5, 9); path.lineTo(15.5, 9.15);
path.moveTo(8, 14); path.cubicTo(10, 17.2, 14, 17.2, 16, 14);
return outlineIcon(path, palette.color(QPalette::ButtonText));
}
static QPushButton *makeIconBtn(const QIcon& icon, const QString &tooltip, QWidget *parent) {
auto *btn = new QPushButton(parent);
btn->setIcon(icon);
@@ -88,12 +270,13 @@ QString InputBar::formatFileSize(qint64 bytes) {
InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *parent)
: QWidget(parent)
, m_input(new QTextEdit(this))
, m_input(new ComposerTextEdit(this, [this](const QMimeData* source) { pasteImage(source); }))
, m_emojiBtn(nullptr)
, m_pttBtn(nullptr)
, m_emojiPanel(emojiPanel)
, m_recorder(recorder)
{
m_clipboardPool.setMaxThreadCount(1);
auto *mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(8, 4, 8, 0);
mainLayout->setSpacing(2);
@@ -114,9 +297,25 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par
auto *prevLay = new QHBoxLayout(m_attachPreview);
prevLay->setContentsMargins(0, 2, 0, 2);
prevLay->setSpacing(4);
m_attachThumbnail = new QLabel(m_attachPreview);
m_attachThumbnail->setObjectName("clipboardImageThumbnail");
m_attachThumbnail->hide();
prevLay->addWidget(m_attachThumbnail);
m_attachPreviewLabel = new QLabel(m_attachPreview);
m_attachPreviewLabel->setTextFormat(Qt::PlainText);
m_attachPreviewLabel->setMinimumWidth(0); m_attachPreviewLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
m_attachPreviewLabel->setStyleSheet("background: palette(light); border-radius: 6px; padding: 2px 8px;");
prevLay->addWidget(m_attachPreviewLabel, 1);
m_clipboardFormats = new QWidget(m_attachPreview); m_clipboardFormats->hide();
auto* formatLayout = new QHBoxLayout(m_clipboardFormats); formatLayout->setContentsMargins(0, 0, 0, 0);
m_clipboardJpeg = new QRadioButton(m_clipboardFormats); m_clipboardJpeg->setObjectName("clipboardJpegChoice");
m_clipboardPng = new QRadioButton(m_clipboardFormats); m_clipboardPng->setObjectName("clipboardPngChoice");
m_clipboardJpeg->setToolTip(QCoreApplication::translate("ChatGui", "JPEG quality: 85. Transparent areas use a white background."));
m_clipboardPng->setToolTip(QCoreApplication::translate("ChatGui", "PNG preserves the original pixels and transparency."));
for (auto* button : {m_clipboardJpeg, m_clipboardPng}) { formatLayout->addWidget(button); button->installEventFilter(this); }
connect(m_clipboardJpeg, &QRadioButton::clicked, this, [this] { selectClipboardFormat(true); });
connect(m_clipboardPng, &QRadioButton::clicked, this, [this] { selectClipboardFormat(false); });
prevLay->addWidget(m_clipboardFormats);
m_encryptCheck = new QCheckBox(QCoreApplication::translate("ChatGui", "Encrypt attachment"), m_attachPreview);
m_encryptCheck->setObjectName("encryptAttachmentCheckBox");
m_encryptCheck->setToolTip(QCoreApplication::translate("ChatGui", "Encrypt attachment; approve each recipient's key request"));
@@ -137,13 +336,14 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par
mainLayout->addWidget(m_attachPreview);
m_preparationLabel = new QLabel(this);
m_preparationLabel->setObjectName("mediaPreparationStatus");
m_preparationLabel->hide();
mainLayout->addWidget(m_preparationLabel);
auto *layout = new QHBoxLayout();
layout->setSpacing(4);
m_attachBtn = makeIconBtn(QIcon(":/icons/attach.png"),
m_attachBtn = makeIconBtn(attachmentIcon(palette()),
QCoreApplication::translate("ChatGui", "Attach"), this);
connect(m_attachBtn, &QPushButton::clicked, this, &InputBar::onAttachClicked);
layout->addWidget(wrapBottomBtn(m_attachBtn, 3), 0, Qt::AlignBottom);
@@ -162,12 +362,13 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par
m_input->setFixedHeight(fm.lineSpacing() + 12);
layout->addWidget(m_input, 1);
m_emojiBtn = makeIconBtn(QIcon(":/icons/emoji.png"),
m_emojiBtn = makeIconBtn(smileIcon(palette()),
QCoreApplication::translate("ChatGui", "Emoji"), this);
layout->addWidget(wrapBottomBtn(m_emojiBtn, 3), 0, Qt::AlignBottom);
m_pttBtn = makeIconBtn(QIcon(":/icons/mic.png"),
m_pttBtn = makeIconBtn(microphoneIcon(palette()),
QCoreApplication::translate("ChatGui", "Voice message (hold)"), this);
GUI_DEBUG("composer: attachment, emoji and voice buttons initialized icons=vector icon_px=20 stroke=1.8 HiDPI=1x..4x");
layout->addWidget(wrapBottomBtn(m_pttBtn, 3), 0, Qt::AlignBottom);
auto *sendBtn = makeIconBtn(QIcon(":/icons/send.png"),
@@ -244,6 +445,10 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par
/* QWidget уничтожает дочерние viewport после полей InputBar: снимаем callbacks до их уничтожения. */
InputBar::~InputBar() {
cancelClipboardPreparation();
m_clipboardPool.waitForDone();
m_clipboardDraft.reset();
m_clipboardChoices.reset();
GUI_DEBUG("InputBar: removing file drop targets count=%d", int(m_dropTargets.size()));
for (auto* target : m_dropTargets) {
target->removeEventFilter(this);
@@ -281,7 +486,7 @@ bool InputBar::submissionPending() const {
/* Канальный текст и каждая часть ответа имеют собственное подтверждение и снимок черновика. */
quint64 InputBar::beginSubmission(const QByteArray& reply, quint64 generation, const QString& target,
const QString& text, const QString& source) {
if (reply.isEmpty() && text.isEmpty() && (source.isEmpty() || m_dmMode)) return 0;
if (reply.isEmpty() && text.isEmpty() && (source.isEmpty() || (m_dmMode && !m_clipboardDraft))) return 0;
const quint64 request = ++s_submissionSequence;
m_pendingSubmissions.insert(request, {generation, target, text, source, m_input->document()->revision(), m_attachmentRevision});
GUI_DEBUG("send: pending ch=%s reply=%s request=%llu", qPrintable(target), reply.toHex().constData(), (unsigned long long)request);
@@ -319,7 +524,8 @@ void InputBar::submissionResult(quint64 request, bool success) {
}
if (!pending.text.isEmpty() && pending.textRevision == m_input->document()->revision()) m_input->clear();
if (!pending.source.isEmpty() && pending.attachmentRevision == m_attachmentRevision) clearPendingAttachment();
if (!submissionPending() && !m_submissionFailed && m_input->toPlainText().isEmpty() && m_pendingAttachSrc.isEmpty()) clearReply();
if (!submissionPending() && !m_submissionFailed && !m_clipboardPreparing &&
m_input->toPlainText().isEmpty() && m_pendingAttachSrc.isEmpty()) clearReply();
}
/* Черновик и выбранный ответ восстанавливаются после сохранения/отмены. Вложения остаются в composer. */
@@ -327,6 +533,7 @@ void InputBar::editMessage(const QByteArray& id, quint64 version, const QString&
if (m_dmMode || id.size() != 32 || !version || m_editRequest || submissionPending() || m_pttActive) {
GUI_WARN("edit: composer busy/invalid target=%s", qPrintable(m_channelIdForRecord)); return;
}
cancelClipboardPreparation();
if (!m_editId.isEmpty()) cancelEdit();
m_savedText = m_input->toPlainText(); m_savedReply = m_replyTo; m_savedReplyLabel = m_replyLabel->text();
clearReply(); m_editId = id; m_editVersion = version;
@@ -346,6 +553,7 @@ void InputBar::cancelEdit() {
m_replyPreview->setVisible(!m_replyTo.isEmpty()); m_attachPreview->setVisible(!m_pendingAttachSrc.isEmpty());
m_attachBtn->setEnabled(m_mediaEnabled); m_pttBtn->setEnabled(m_mediaEnabled);
m_sendBtn->setToolTip(QCoreApplication::translate("ChatGui", "Send"));
showPreparation();
}
/* Qt доставляет drop в viewport, а не в QTextEdit/QListView. Родитель composer принимает drop на кнопках. */
@@ -369,6 +577,98 @@ void InputBar::rejectAttachment(const QString& reason, const QString& source) {
emit attachmentRejected(reason);
}
void InputBar::cancelClipboardPreparation() {
++m_clipboardGeneration;
m_clipboardPool.clear();
m_clipboardPreparing = false;
showPreparation();
}
void InputBar::pasteImage(const QMimeData* source) {
if (!canAttachFile()) {
rejectAttachment(QCoreApplication::translate("ChatGui", "Images cannot be pasted in the current composer state.")); return;
}
const quint64 context = m_attachmentContext, generation = ++m_clipboardGeneration;
const QString target = m_channelIdForRecord;
m_clipboardPool.clear(); m_clipboardPreparing = true; showPreparation();
GUI_DEBUG("clipboard image: snapshot target=%s generation=%llu formats=%s", qPrintable(target),
(unsigned long long)generation, qPrintable(source->formats().join(",")));
QImage image;
QByteArray representation = "Qt image";
if (source->hasImage()) {
const QVariant data = source->imageData();
image = qvariant_cast<QImage>(data);
if (image.isNull() && data.canConvert<QPixmap>()) image = qvariant_cast<QPixmap>(data).toImage();
}
QByteArray encoded;
if (image.isNull()) for (const auto& format : clipboardImageFormats) {
if (!source->hasFormat(QString::fromLatin1(format))) continue;
encoded = source->data(QString::fromLatin1(format));
if (!encoded.isEmpty()) { representation = format; break; }
}
if (context != m_attachmentContext || generation != m_clipboardGeneration) {
GUI_DEBUG("clipboard image: snapshot superseded target=%s generation=%llu", qPrintable(target),
(unsigned long long)generation); return;
}
if ((!image.isNull() && (!validClipboardSize(image.size()) || image.sizeInBytes() > clipboardMaxBytes)) ||
encoded.size() > clipboardMaxBytes || (image.isNull() && encoded.isEmpty())) {
m_clipboardPreparing = false; showPreparation();
GUI_WARN("clipboard image: invalid snapshot target=%s dimensions=%dx%d image_bytes=%lld encoded_bytes=%lld",
qPrintable(target), image.width(), image.height(), (long long)image.sizeInBytes(), (long long)encoded.size());
rejectAttachment(QCoreApplication::translate("ChatGui", "Cannot paste image: invalid data or more than 32 megapixels / 128 MiB."));
return;
}
const QString name = "clipboard-" + QDateTime::currentDateTime().toString("yyyyMMdd-HHmmss-zzz");
++m_attachmentRevision;
GUI_INFO("clipboard image: preparing target=%s generation=%llu representation=%s dimensions=%dx%d encoded_bytes=%lld",
qPrintable(target), (unsigned long long)generation, representation.constData(), image.width(), image.height(),
(long long)encoded.size());
m_clipboardPool.start(QRunnable::create([this, image, encoded, name, target, context, generation] {
QString error;
const auto result = encodeClipboardImage(image, encoded, name, error);
if (!result) GUI_ERROR("clipboard image: preparation failed target=%s reason=%s", qPrintable(target), qPrintable(error));
if (!QMetaObject::invokeMethod(this, [this, result, error, target, context, generation] {
if (context != m_attachmentContext || generation != m_clipboardGeneration) {
GUI_DEBUG("clipboard image: stale image discarded target=%s generation=%llu", qPrintable(target),
(unsigned long long)generation); return;
}
m_clipboardPreparing = false; showPreparation();
if (!result) {
rejectAttachment(QCoreApplication::translate("ChatGui", "Could not prepare clipboard image: %1").arg(error)); return;
}
const auto selected = result->preferJpeg ? result->jpeg : result->png;
if (!prepareAttachment(selected->path, context)) return;
m_clipboardChoices = result;
m_clipboardJpeg->setText(QString("JPEG (%1)").arg(result->jpeg ? formatFileSize(result->jpeg->bytes) : QString::fromUtf8("—")));
m_clipboardPng->setText(QString("PNG (%1)").arg(result->png ? formatFileSize(result->png->bytes) : QString::fromUtf8("—")));
m_clipboardJpeg->setEnabled(bool(result->jpeg)); m_clipboardPng->setEnabled(bool(result->png));
m_clipboardFormats->show(); selectClipboardFormat(result->preferJpeg);
(result->preferJpeg ? m_clipboardJpeg : m_clipboardPng)->setFocus(Qt::OtherFocusReason);
}, Qt::QueuedConnection)) GUI_ERROR("clipboard image: cannot queue result target=%s", qPrintable(target));
}));
}
void InputBar::selectClipboardFormat(bool jpeg) {
const auto selected = m_clipboardChoices ? (jpeg ? m_clipboardChoices->jpeg : m_clipboardChoices->png) : nullptr;
if (!selected || !canAttachFile() || m_clipboardPreparing) {
GUI_WARN("clipboard image: format selection unavailable target=%s jpeg=%d", qPrintable(m_channelIdForRecord), jpeg);
m_clipboardJpeg->setChecked(m_clipboardChoices && m_clipboardDraft == m_clipboardChoices->jpeg);
m_clipboardPng->setChecked(m_clipboardChoices && m_clipboardDraft == m_clipboardChoices->png);
return;
}
if (selected != m_clipboardDraft) ++m_attachmentRevision;
m_clipboardDraft = selected;
m_pendingAttachSrc = selected->path; m_pendingAttachDest = selected->path;
m_pendingAttachName = selected->name; m_pendingAttachSize = selected->bytes;
m_clipboardJpeg->setChecked(jpeg); m_clipboardPng->setChecked(!jpeg);
m_attachPreviewLabel->setText(QString("%1 (%2, %3×%4)").arg(selected->name, formatFileSize(selected->bytes))
.arg(selected->dimensions.width()).arg(selected->dimensions.height()));
m_attachThumbnail->setPixmap(QPixmap::fromImage(selected->thumbnail)); m_attachThumbnail->show();
GUI_INFO("clipboard image: format selected target=%s format=%s source=%s dimensions=%dx%d bytes=%lld",
qPrintable(m_channelIdForRecord), jpeg ? "jpeg" : "png", qPrintable(selected->path),
selected->dimensions.width(), selected->dimensions.height(), (long long)selected->bytes);
}
bool InputBar::eventFilter(QObject *obj, QEvent *event) {
if (m_dropTargets.contains(obj)) {
if (event->type() == QEvent::DragLeave) {
@@ -416,12 +716,13 @@ bool InputBar::eventFilter(QObject *obj, QEvent *event) {
return true;
}
}
if (obj == m_input && event->type() == QEvent::KeyPress) {
if ((obj == m_input || obj == m_clipboardJpeg || obj == m_clipboardPng) && event->type() == QEvent::KeyPress) {
auto *ke = static_cast<QKeyEvent *>(event);
if (ke->key() == Qt::Key_Escape && !m_editId.isEmpty()) { cancelEdit(); return true; }
if (ke->key() == Qt::Key_Escape && !m_replyTo.isEmpty()) { clearReply(); return true; }
if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) {
if (ke->modifiers() & Qt::ControlModifier) {
m_input->setFocus();
m_input->insertPlainText("\n");
return true;
} else {
@@ -464,6 +765,9 @@ void InputBar::onSendClicked() {
m_editRequest = ++s_submissionSequence; m_input->setReadOnly(true);
emit saveEdit(m_channelIdForRecord, m_editId, m_editVersion, text, m_editRequest); return;
}
if (m_clipboardPreparing) {
GUI_WARN("send: clipboard image still preparing target=%s", qPrintable(m_channelIdForRecord)); return;
}
if (submissionPending()) {
GUI_WARN("send: submission still pending ch=%s", qPrintable(m_channelIdForRecord)); return;
}
@@ -486,15 +790,17 @@ void InputBar::onSendClicked() {
const bool typedImage = m_pendingAttachAsImage &&
QStringList{"jpg", "jpeg", "png"}.contains(QFileInfo(attachmentSource).suffix().toLower());
if (typedImage) {
QImageReader reader(attachmentSource); reader.setAutoTransform(true);
const QByteArray format = reader.format().toLower();
QSize size = reader.size();
if (reader.transformation() & QImageIOHandler::TransformationRotate90) size.transpose();
QByteArray format; QSize size; QString readError; bool readable;
{ // Reader должен закрыть исходник до синхронного submissionResult, который может удалить черновик изображения.
QImageReader reader(attachmentSource); reader.setAutoTransform(true);
format = reader.format().toLower(); size = reader.size(); readable = reader.canRead(); readError = reader.errorString();
if (reader.transformation() & QImageIOHandler::TransformationRotate90) size.transpose();
}
const QByteArray source = attachmentSource.toUtf8(), name = attachmentName.toUtf8();
if (!reader.canRead() || (format != "jpeg" && format != "png") || size.width() <= 0 || size.height() <= 0 ||
if (!readable || (format != "jpeg" && format != "png") || size.width() <= 0 || size.height() <= 0 ||
size.width() > 16384 || size.height() > 16384 || source.size() >= 1024 || name.size() >= 256) {
GUI_ERROR("image rejected: source=%s format=%s dimensions=%dx%d error=%s", qPrintable(attachmentSource),
format.constData(), size.width(), size.height(), qPrintable(reader.errorString()));
format.constData(), size.width(), size.height(), qPrintable(readError));
submissionResult(requestId, false);
rejectAttachment(QCoreApplication::translate("ChatGui", "Cannot read the image or its dimensions are too large."), attachmentSource);
return;
@@ -508,6 +814,15 @@ void InputBar::onSendClicked() {
snprintf(request->info.name, sizeof(request->info.name), "%s", name.constData());
if (reply.size() == 32) memcpy(request->reply_to, reply.constData(), 32);
request->request_id = requestId;
if (m_clipboardDraft) {
QString error;
if (!copyClipboardImage(attachmentSource, request, error)) {
GUI_ERROR("clipboard image: send snapshot failed source=%s reason=%s", qPrintable(attachmentSource), qPrintable(error));
attachment_send_discard(request); submissionResult(requestId, false); return;
}
GUI_DEBUG("clipboard image: send owns snapshot target=%s draft=%s source=%s request=%llu", qPrintable(target),
qPrintable(attachmentSource), request->source, (unsigned long long)requestId);
}
GUI_INFO("image queued: target=%s format=%s dimensions=%dx%d encrypted=%d", qPrintable(target),
format.constData(), size.width(), size.height(), encrypted);
emit submitAttachment(target, request);
@@ -615,13 +930,15 @@ void InputBar::setMediaEnabled(bool enabled) {
if (m_encryptCheck) m_encryptCheck->setEnabled(enabled);
if (m_pttBtn) m_pttBtn->setEnabled(enabled && m_editId.isEmpty());
if (m_attachBtn) m_attachBtn->setEnabled(enabled && m_editId.isEmpty());
if (!enabled) cancelClipboardPreparation();
else showPreparation();
if (!enabled && m_dropHint) m_dropHint->hide();
}
void InputBar::onPttPressed() {
if (!m_editId.isEmpty()) { GUI_WARN("voice: blocked while editing"); return; }
if (!isEnabled() || m_channelIdForRecord.isEmpty()) { GUI_WARN("voice: composer unavailable"); return; }
if (submissionPending()) { GUI_WARN("send: recording blocked while submission pending"); return; }
if (submissionPending() || m_clipboardPreparing) { GUI_WARN("send: recording blocked while submission/image pending"); return; }
if (!m_recorder || !m_recorder->isInitialized()) return;
m_recorder->startRecording();
m_pttActive = m_recorder->isRecording();
@@ -724,6 +1041,10 @@ bool InputBar::prepareAttachment(const QString& srcPath, quint64 context) {
}
m_encryptCheck->setChecked(false);
cancelClipboardPreparation();
m_clipboardDraft.reset();
m_clipboardChoices.reset(); m_clipboardFormats->hide();
m_attachThumbnail->clear(); m_attachThumbnail->hide();
m_pendingAttachSrc = srcPath;
++m_attachmentRevision;
m_pendingAttachDest = destPath;
@@ -744,6 +1065,12 @@ bool InputBar::prepareAttachment(const QString& srcPath, quint64 context) {
}
void InputBar::clearPendingAttachment() {
cancelClipboardPreparation();
m_clipboardDraft.reset();
m_clipboardChoices.reset();
if (m_clipboardFormats->isAncestorOf(QApplication::focusWidget())) m_input->setFocus();
m_clipboardFormats->hide();
m_attachThumbnail->clear(); m_attachThumbnail->hide();
m_encryptCheck->setChecked(false);
++m_attachmentRevision;
m_pendingAttachSrc.clear();
@@ -772,7 +1099,9 @@ void InputBar::updatePreparation(const QString& target, const QByteArray& job, i
void InputBar::showPreparation() {
QString status;
if (!m_preparations.value(m_channelIdForRecord).isEmpty()) status = QCoreApplication::translate("ChatGui", "Preparing media…");
m_clipboardFormats->setEnabled(!m_clipboardPreparing && canAttachFile());
if (m_clipboardPreparing || !m_preparations.value(m_channelIdForRecord).isEmpty())
status = QCoreApplication::translate("ChatGui", "Preparing media…");
else if (m_preparationErrors.contains(m_channelIdForRecord)) status = QCoreApplication::translate("ChatGui", "Failed to prepare attachment");
m_preparationLabel->setText(status);
m_preparationLabel->setVisible(!status.isEmpty());
+26 -9
View File
@@ -1,7 +1,7 @@
/* GUI-composer канала/PM: текст, emoji, ответ/редактирование, голосовая запись и preview одного вложения.
* Сам не отправляет данные в сеть: владелец обрабатывает сигналы в GUI и передаёт работу ядру.
* Для submitAttachment нужен ровно один обработчик, принимающий request вместе с PCM/compressor даже при отказе.
* Канальный текст/файлы/ответы/правки ждут submissionResult; PM-вложения очищаются сразу после сигнала отправки.
* Канальный текст/файлы/ответы/правки и изображения из буфера в PM ждут submissionResult; остальные PM-вложения очищаются сразу.
* emojiPanel/recorder заимствованы, могут быть nullptr и должны жить во время использования; остальные виджеты — дочерние. */
#pragma once
@@ -9,6 +9,8 @@
#include <QHash>
#include <QSet>
#include <QPointer>
#include <QThreadPool>
#include <memory>
class QTextEdit;
class QPushButton;
@@ -18,7 +20,11 @@ class QProgressBar;
class QTimer;
class EmojiPanel;
class AudioRecorder;
class QMimeData;
class QRadioButton;
struct attachment_send_req;
struct ClipboardImage;
struct ClipboardImages;
class InputBar : public QWidget {
Q_OBJECT
@@ -27,10 +33,10 @@ public:
* Для PTT recorder должен быть init; его жизненным циклом и остановкой при закрытии владеет вызывающий. */
explicit InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder,
QWidget *parent = nullptr);
~InputBar() override; // Снимает drop-фильтры/callbacks, удаляет drag-подсказку; не уничтожает заимствованные объекты.
~InputBar() override; // Ждёт image-worker, удаляет свой временный файл, снимает drop-фильтры; заимствованные объекты не уничтожает.
/* mediaDirBase — каталог media; channelId — канал либо dm:<conv_id>, dmGeneration — поколение PM (для канала 0).
* Изменение контекста отменяет edit/reply/preview и отложенный drop; обычный текст сохраняется.
* Изменение контекста отменяет edit/reply/preview, image-worker и отложенный drop; обычный текст сохраняется.
* Запись уже фиксирует прежний target/generation; удаление текущего поколения PM отдельно очищает его черновик. */
void setRecordingContext(const QString& mediaDirBase, const QString& channelId, quint64 dmGeneration = 0);
/* Очистить текст/reply/preview и остановить запись только при совпадении target/generation.
@@ -52,7 +58,7 @@ public:
* PM/пустая цель/неверный ключ отклоняются; edit сначала отменяется. Новый ответ имеет собственное поколение. */
void setReply(const QByteArray& messageId, const QString& author, const QString& preview);
void clearReply(); // Снять ответ и сменить поколение; текст/preview вложения не очищаются, старые результаты не применяются.
/* Подтвердить канальный текст/файл, правку либо одну часть ответа по request; 0/неизвестный ID игнорируется.
/* Подтвердить канальный текст/файл, изображение из буфера в PM, правку либо одну часть ответа по request.
* Ошибка сохраняет черновик и показывает отказ. Успех очищает лишь неизменённую ревизию текста/вложения;
* ответ снимается после успеха всех частей и очистки черновика. Старое поколение/другая цель не затрагиваются. */
void submissionResult(quint64 request, bool success);
@@ -90,7 +96,7 @@ private slots:
void onPttPressed(); // Запускает recorder и фиксирует target/generation/reply только при успешном старте.
void onPttReleased(); // Stop; запись от 500 мс передаётся через takeRecording/submitAttachment, меньшая отбрасывается.
void onAttachClicked(); // QFileDialog → та же подготовка preview, что у drop; контекст проверяется после модального выбора.
void clearPendingAttachment(); // Снять preview/encrypt, увеличить ревизию; исходный файл не удаляется.
void clearPendingAttachment(); // Снять preview/encrypt и подготовку изображения; удалить только собственный временный исходник.
void updateRecordingUI(int ms); // Длительность и RMS recorder (-70..0 dB → 0..100), чтение peakLevel потребляет метрику.
void showRecordOverlay(); // Показать overlay и начальную длительность/уровень по recordingStarted.
void hideRecordOverlay(); // Скрыть по recordingStopped; не выполняет отправку записи.
@@ -104,7 +110,7 @@ private:
quint64 attachmentRevision; // Ревизия preview для такой же защиты выбранного файла.
};
/* Запомнить канальный текст/файл либо часть ответа; ID общий для всех composer, чтобы late result не попал новой форме.
* Вложение без source/reply и файл PM не требуют подтверждения: вернуть 0 и ничего не запоминать. */
* Вложение без source/reply и обычный файл PM не требуют подтверждения: вернуть 0 и ничего не запоминать. */
quint64 beginSubmission(const QByteArray& reply, quint64 generation, const QString& target,
const QString& text = {}, const QString& source = {});
bool submissionPending() const; // Есть ожидающая часть текущего контекста/выбора ответа.
@@ -133,7 +139,10 @@ private:
QPushButton *m_cancelRecBtn = nullptr; // Stop без отправки: снимает m_pttActive до остановки recorder.
QProgressBar *m_levelBar = nullptr; // RMS последнего обработанного блока recorder, 0..100.
QWidget *m_attachPreview = nullptr; // Preview выбранного файла, скрыт до успешной подготовки выбора.
QLabel *m_attachPreviewLabel = nullptr; // Имя/размер файла; его содержимое здесь не читается.
QLabel *m_attachPreviewLabel = nullptr; // Plain text: имя/размер файла, для изображения из буфера также размеры в пикселях.
QLabel *m_attachThumbnail = nullptr; // Миниатюра изображения из буфера; скрыта для обычного файла.
QWidget *m_clipboardFormats = nullptr; // Выбор JPEG/PNG с размерами; скрыт для обычного файла.
QRadioButton *m_clipboardJpeg = nullptr, *m_clipboardPng = nullptr; // Взаимоисключающие варианты; выбранный получает фокус.
QPushButton *m_attachClearBtn = nullptr; // Снять preview без удаления исходника.
bool m_pttActive = false; // Composer начал запись, которую release ещё может отправить.
bool m_dmMode = false; // PM: общий формат JPEG/PNG/voice/video, групповое шифрование скрыто.
@@ -142,17 +151,25 @@ private:
QString m_mediaDirBase; // База планируемых путей вложений; файлы PM без transcode остаются по исходному пути.
QString m_channelIdForRecord; // Текущий target composer: канал либо dm:<id>.
QString m_recordTarget; // Target, зафиксированный при старте PTT, независимо от дальнейшего выбора канала.
QString m_pendingAttachSrc; // Локальный source preview, не копируется/удаляется composer.
QString m_pendingAttachSrc; // Локальный source preview; только изображение из буфера принадлежит composer.
QString m_pendingAttachDest; // Планируемый mediaDirBase/target/имя либо исходный путь PM.
QString m_pendingAttachName; // Исходное имя для отображения/метаданных; видео при отправке получает суффикс mp4.
qint64 m_pendingAttachSize = 0; // Размер на момент выбора, bytes.
bool m_pendingAttachAsVideo = false; // Пользователь выбрал подготовку/transcode видео.
bool m_pendingAttachAsImage = false; // Расширение картинки в канале; PM не использует этот режим.
bool m_pendingAttachAsImage = false; // Картинка в канале; в PM только JPEG/PNG.
std::shared_ptr<ClipboardImage> m_clipboardDraft; // Собственное изображение черновика; каждой отправке принадлежит отдельная копия.
std::shared_ptr<ClipboardImages> m_clipboardChoices; // Оба готовых файла; переключение не запускает повторное кодирование.
QThreadPool m_clipboardPool; // Один image-worker; destructor ждёт завершения, queued-результат владеет своим файлом.
quint64 m_clipboardGeneration = 0; // Номер вставки/отмены; устаревший результат не меняет preview.
bool m_clipboardPreparing = false; // Пока последнее изображение кодируется, отправка заблокирована, старый preview сохраняется.
QLabel* m_preparationLabel = nullptr; // Статус подготовки текущего target.
QHash<QString, QSet<QByteArray>> m_preparations; // Target → UUID активных задач ядра.
QSet<QString> m_preparationErrors; // Target с последней ошибкой, снимается при новом start или forget.
QSet<QByteArray> m_discardedPreparations; /* Завершения задач удалённой истории, ещё ожидаемые из ядра. */
void showPreparation(); // Обновить индикатор для текущей цели по кешу задач/ошибок.
void pasteImage(const QMimeData* source); // Забрать изображение/байты буфера, проверить лимиты, запустить сравнение PNG/JPEG в worker.
void cancelClipboardPreparation(); // Сменить поколение, снять очередь и индикатор; активный worker закончит без применения.
void selectClipboardFormat(bool jpeg); // Выбрать готовый вариант, обновить preview/ревизию; сохранить текст и выбор шифрования.
bool canAttachFile() const; // Enabled, выбран target, скрепка разрешена, нет edit/PTT/записи recorder.
/* Проверить source/context, выбрать режим видео и подготовить preview; true — выбор принят, false — отмена/отказ.
* Старый preview заменяется лишь после успеха; source остаётся неизменным, для групп/видео создаётся каталог назначения. */
+10
View File
@@ -2,6 +2,7 @@
#include "video_codec_log.h"
#include <QElapsedTimer>
#include <QPainter>
#include <QStringList>
#include <algorithm>
#include <cstdlib>
#include <cstring>
@@ -151,11 +152,13 @@ bool LiveVideoEncoder::open(const LiveVideoConfig& config, Backend backend) {
}
#ifdef HAVE_FFMPEG
video_ffmpeg_init();
QStringList failures;
for (const auto& name : encoders(config.codec)) {
if (!matchesBackend(name, backend)) continue;
if (!config.backend.isEmpty() && name != config.backend.toLatin1()) continue;
const AVCodec* encoder = avcodec_find_encoder_by_name(name.constData());
if (!encoder) continue;
VideoCodecErrors diagnostics;
// Проба не оставляет references/PTS в live-контексте: после двух KEY encoder открывается заново.
for (int pass = 0; pass < 2; pass++) {
close(); m->config = config; m->name = QString::fromLatin1(name);
@@ -365,7 +368,14 @@ bool LiveVideoEncoder::open(const LiveVideoConfig& config, Backend backend) {
c->thread_count, c->active_thread_type);
return true;
}
QString reason = m->error;
const QString details = QString::fromUtf8(diagnostics.text()).trimmed();
if (!details.isEmpty() && details != reason) reason += '\n' + details;
failures.append(QString::fromLatin1(name) + ": " + reason);
CODEC_LOG(DEBUG_LEVEL_WARN, DEBUG_CATEGORY_VIDEO, "desktop encoder attempt failed name=%s reason=%s",
name.constData(), qPrintable(reason));
}
if (!failures.isEmpty()) m->error = failures.join("\n\n");
if (m->error.isEmpty()) m->error = "No working encoder for " + codecName(config.codec);
#else
m->error = "FFmpeg is unavailable";
+2 -1
View File
@@ -59,7 +59,8 @@ public:
bool encode(const QImage& image, uint64_t pts, bool key, QList<LiveEncodedFrame>& output, bool finish = false);
/* Число принятых, но ещё не выданных входов профиля звонка; Automatic не ведёт эту таблицу и возвращает 0. */
unsigned pendingFrames() const;
/* Последняя записанная ошибка; успешный encode/setMaxQp не обязан очищать её. */
/* Последняя ошибка; при отказе open — причины всех попыток с именами encoder и сообщениями FFmpeg.
* Успешный encode/setMaxQp не обязан очищать её. */
QString error() const;
/* Имя FFmpeg encoder текущей/последней пробы; после close пусто, само по себе не означает успешный open. */
QString name() const;
+20
View File
@@ -1361,6 +1361,26 @@ void MainWindow::showIncomingMessageNotification(const QString& target, quint64
m_messageNotification->showMessage(target, m_db->getDisplayName(author), channel, m_trayIcon->geometry(), screen());
}
void MainWindow::changeEvent(QEvent* event) {
QMainWindow::changeEvent(event);
if (event->type() != QEvent::ActivationChange || !isActiveWindow()) return;
QTimer::singleShot(0, this, [this] {
auto* input = m_messageList ? m_messageList->focusProxy() : nullptr;
const bool ready = input && input->isVisible() && input->isEnabled();
if (QApplication::activeWindow() != this || !isVisible() || isMinimized() ||
QApplication::activeModalWidget() || QApplication::activePopupWidget() || !ready) {
GUI_DEBUG("window activation: input focus skipped active=%d visible=%d minimized=%d modal=%d popup=%d input_ready=%d",
QApplication::activeWindow() == this, isVisible(), isMinimized(), bool(QApplication::activeModalWidget()),
bool(QApplication::activePopupWidget()), ready);
return;
}
m_messageList->setFocus(Qt::ActiveWindowFocusReason);
auto* focused = QApplication::focusWidget();
GUI_DEBUG("window activation: message input focused target=%s focus_widget=%s", qPrintable(m_currentChannelId),
focused ? focused->metaObject()->className() : "none");
});
}
void MainWindow::closeEvent(QCloseEvent *event) {
DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "MainWindow: closeEvent — hiding to tray");
saveWindowGeometry();
+2
View File
@@ -53,6 +53,8 @@ public:
~MainWindow() override;
protected:
// После активации окна передать фокус composer; Qt сначала завершает восстановление фокуса дочерних виджетов.
void changeEvent(QEvent* event) override;
// Сохранить геометрию и скрыть в tray; для выхода служит requestShutdown.
void closeEvent(QCloseEvent *event) override;
+1
View File
@@ -658,6 +658,7 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
layout->addWidget(m_emojiPanel);
m_inputBar = new InputBar(m_emojiPanel, recorder, this);
setFocusProxy(m_inputBar);
layout->addWidget(m_inputBar);
m_inputBar->installFileDropTarget(m_view->viewport());
m_inputBar->focusProxy()->installEventFilter(m_view);
+1
View File
@@ -1,4 +1,5 @@
/* История и composer выбранного канала/PM, read/scroll, загрузки, feedback, рация и каталог streams.
* setFocus передаёт фокус полю сообщения через focusProxy composer.
* Все методы/callbacks — GUI. Сетевые операции передаются мосту с владением запросом и привязкой instance.
* Модель держит самостоятельные Qt-значения; DbManager/AudioRecorder заимствованы.
* PM снимки проверяются по conv/generation, канальные строки — по message_id/version.
+22
View File
@@ -0,0 +1,22 @@
/* Общий контурный стиль кнопок: геометрия в координатах 24×24, круглые концы линий и размеры для HiDPI.
* Вызывать в GUI-потоке; path задаёт контур, color — цвет, logicalSize > 0 — размер значка в логических пикселях.
* Возвращает самостоятельный QIcon с растровыми вариантами 1x..4x; отсутствует зависимость от шрифтов ОС. */
#pragma once
#include <QIcon>
#include <QPainter>
#include <QPainterPath>
#include <QPixmap>
inline QIcon outlineIcon(const QPainterPath& path, const QColor& color, int logicalSize = 20) {
QIcon icon;
for (int scale : {1, 2, 3, 4}) {
const int size = logicalSize * scale;
QPixmap pixmap(size, size); pixmap.fill(Qt::transparent);
QPainter painter(&pixmap); painter.setRenderHint(QPainter::Antialiasing);
painter.scale(size / 24.0, size / 24.0);
painter.setPen(QPen(color, 1.8, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
painter.drawPath(path); painter.end(); icon.addPixmap(pixmap);
}
return icon;
}
@@ -211,6 +211,8 @@ StreamPublishWindow::StreamPublishWindow(StreamPublishEngine* engine, uint64_t g
"Use CPU encoding instead? This may cause high CPU usage and slow down the computer.").arg(codec),
QMessageBox::Yes | QMessageBox::No, this);
question->setObjectName(QStringLiteral("streamCpuConfirmation"));
question->setTextFormat(Qt::PlainText);
question->setInformativeText(details.section("\n\n", 0, 0));
question->setDefaultButton(QMessageBox::No); question->setEscapeButton(QMessageBox::No);
question->setDetailedText(details); question->setAttribute(Qt::WA_DeleteOnClose);
auto* engine = m_engine;
+13
View File
@@ -16,6 +16,19 @@ public:
VideoCodecProbe& operator=(const VideoCodecProbe&) = delete;
};
/* Сохраняет сообщения FFmpeg error/fatal текущей попытки независимо от фильтров логирования.
* Создать/уничтожить в одном потоке; text заимствован до уничтожения объекта. */
class VideoCodecErrors {
public:
VideoCodecErrors() { video_ffmpeg_errors_begin(&m_errors); }
~VideoCodecErrors() { video_ffmpeg_errors_end(&m_errors); }
VideoCodecErrors(const VideoCodecErrors&) = delete;
VideoCodecErrors& operator=(const VideoCodecErrors&) = delete;
const char* text() const { return m_errors.text; }
private:
video_ffmpeg_errors m_errors;
};
// level/category — DEBUG_LEVEL_*/DEBUG_CATEGORY_*, далее printf-формат и аргументы;
// аргументы форматирования вычисляются только после фильтрации по итоговому уровню.
#define CODEC_LOG(level, category, ...) do { \
+16
View File
@@ -0,0 +1,16 @@
/* Изображение с градиентом и воспроизводимым шумом для сравнения PNG/JPEG в GUI и настоящем ядре. */
#pragma once
#include <QImage>
// density: 0..1024, больше — хуже сжатие PNG; alpha добавляет прозрачность и полупрозрачность.
inline QImage clipboardCodecFixture(int density, bool alpha = false) {
QImage image(320, 240, alpha ? QImage::Format_ARGB32 : QImage::Format_RGB32);
unsigned state = 1;
for (int y = 0; y < image.height(); ++y) for (int x = 0; x < image.width(); ++x) {
state = state * 1664525u + 1013904223u;
const int noise = int((state >> 16) & 1023) < density ? int(state >> 31) : 0;
image.setPixel(x, y, qRgba((x / 2 + noise) & 255, (y / 2 + noise) & 255, ((x + y) / 4 + noise) & 255,
alpha ? (x < 16 && y < 16 ? 0 : 127) : 255));
}
return image;
}
+22
View File
@@ -7,6 +7,9 @@
#include <QMenu>
#include <QPointer>
#include <QScopeGuard>
#include <QPushButton>
#include <QToolTip>
#include <QEnterEvent>
extern "C" {
#include "utun_instance.h"
#include "chat/chat_core.h"
@@ -49,6 +52,25 @@ class ChannelListTest : public QObject {
private slots:
void initTestCase() { debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); }
void actionTooltipsAppearImmediatelyAndClicksKeepTheirMeaning() {
ChannelList list; list.resize(220, 180); list.show(); QApplication::processEvents();
QSignalSpy created(&list, &ChannelList::createGroupRequested), joined(&list, &ChannelList::joinRequested);
QSignalSpy monitored(&list, &ChannelList::connMonitorRequested);
for (const char* name : {"createGroupButton", "joinGroupButton", "connectionMonitorButton"}) {
auto* button = list.findChild<QPushButton*>(name); QVERIFY(button);
const QPoint center = button->rect().center();
QEnterEvent enter(center, button->mapTo(&list, center), button->mapToGlobal(center));
QApplication::sendEvent(button, &enter);
// Проверяем синхронно, без ожидания стандартного tooltip timeout.
QCOMPARE(QToolTip::text(), button->toolTip()); QVERIFY(QToolTip::isVisible());
QTest::mouseClick(button, Qt::LeftButton);
QCOMPARE(created.count(), 1); QCOMPARE(joined.count(), name == QByteArray("createGroupButton") ? 0 : 1);
QCOMPARE(monitored.count(), name == QByteArray("connectionMonitorButton") ? 1 : 0);
QEvent leave(QEvent::Leave); QApplication::sendEvent(button, &leave);
QTRY_VERIFY(!QToolTip::isVisible());
}
}
void reloadKeepsConnectionState() {
ListCore core; QVERIFY(core.start());
ChannelList list(&core.db); list.loadChannels();
@@ -17,6 +17,11 @@
#include <QTemporaryDir>
#include <QFile>
#include <QTextEdit>
#include <QLineEdit>
#include <QRadioButton>
#include <QClipboard>
#include <QDialog>
#include <QMenu>
#include <QListView>
#include <QStandardItemModel>
#include <QPainter>
@@ -192,6 +197,63 @@ private slots:
QCOMPARE(outstanding.size(), size_t(0));
#endif
}
void chatWindowReactivationFocus_data() {
QTest::addColumn<bool>("imageChoice");
QTest::newRow("history") << false; QTest::newRow("image-format") << true;
}
void chatWindowReactivationFocus() {
QFETCH(bool, imageChoice); QTemporaryDir directory; QVERIFY(directory.isValid());
MainWindow window(nullptr, nullptr, directory.filePath("focus.cfg"));
auto* history = window.findChild<MessageList*>(); QVERIFY(history); history->loadChannel("123");
auto* composer = history->findChild<InputBar*>(); QVERIFY(composer); composer->setRecordingContext(directory.path(), "123");
auto* editor = composer->findChild<QTextEdit*>(); QVERIFY(editor); QCOMPARE(history->focusProxy(), composer);
window.show(); window.activateWindow(); QTRY_VERIFY(window.isActiveWindow()); QTRY_VERIFY(editor->hasFocus());
const QString draft = "first line\nsecond line"; editor->setPlainText(draft);
auto cursor = editor->textCursor(); cursor.setPosition(2); cursor.setPosition(7, QTextCursor::KeepAnchor); editor->setTextCursor(cursor);
QRadioButton* jpeg = nullptr;
const auto clearClipboard = qScopeGuard([&] { if (imageChoice) QApplication::clipboard()->clear(); });
if (imageChoice) {
QImage image(32, 24, QImage::Format_RGB32); image.fill(Qt::green); QApplication::clipboard()->setImage(image); editor->paste();
jpeg = composer->findChild<QRadioButton*>("clipboardJpegChoice"); QVERIFY(jpeg);
QTRY_VERIFY(jpeg->isVisible()); QTest::mouseClick(jpeg, Qt::LeftButton); QVERIFY(jpeg->isChecked()); jpeg->setFocus();
QTRY_VERIFY(jpeg->hasFocus());
} else {
auto* view = history->findChild<QListView*>(); QVERIFY(view); view->setFocus(); QTRY_VERIFY(view->hasFocus());
}
QLineEdit other; other.show(); other.activateWindow(); other.setFocus();
QTRY_VERIFY(other.isActiveWindow()); QTRY_VERIFY(other.hasFocus());
QTest::keyClicks(&other, "other window"); QCOMPARE(other.text(), QString("other window"));
window.activateWindow(); QTRY_VERIFY(window.isActiveWindow()); QTRY_VERIFY(editor->hasFocus());
QCOMPARE(editor->toPlainText(), draft); QCOMPARE(editor->textCursor().position(), 7); QCOMPARE(editor->textCursor().anchor(), 2);
if (imageChoice) QVERIFY(jpeg->isChecked());
QTest::keyClicks(QApplication::focusWidget(), "x"); QCOMPARE(editor->toPlainText(), QString("fixine\nsecond line"));
}
void chatActivationRespectsCurrentWindow_data() {
QTest::addColumn<QString>("state");
for (const char* state : {"other-window", "disabled", "hidden", "dialog", "popup"}) QTest::newRow(state) << QString(state);
}
void chatActivationRespectsCurrentWindow() {
QFETCH(QString, state); QTemporaryDir directory; QVERIFY(directory.isValid());
MainWindow window(nullptr, nullptr, directory.filePath("focus.cfg"));
auto* history = window.findChild<MessageList*>(); QVERIFY(history); history->loadChannel("123");
auto* composer = history->findChild<InputBar*>(); QVERIFY(composer);
auto* editor = composer->findChild<QTextEdit*>(); QVERIFY(editor);
auto* view = history->findChild<QListView*>(); QVERIFY(view);
window.show(); window.activateWindow(); QTRY_VERIFY(window.isActiveWindow()); QTRY_VERIFY(editor->hasFocus());
view->setFocus(); QTRY_VERIFY(view->hasFocus());
QEvent activation(QEvent::ActivationChange); QApplication::sendEvent(&window, &activation);
QLineEdit other; QDialog dialog(&window); QLineEdit dialogInput(&dialog); QMenu popup(&window); popup.addAction("Menu item");
if (state == "other-window") { other.show(); other.activateWindow(); other.setFocus(); QTRY_VERIFY(other.isActiveWindow()); }
if (state == "disabled") composer->setEnabled(false);
if (state == "hidden") window.hide();
if (state == "dialog") { dialog.setModal(true); dialog.show(); dialog.activateWindow(); dialogInput.setFocus(); }
if (state == "popup") popup.popup(window.mapToGlobal(QPoint(100, 100)));
QCoreApplication::processEvents(); QVERIFY(!editor->hasFocus());
if (state == "other-window") { QVERIFY(other.isActiveWindow()); QVERIFY(other.hasFocus()); }
if (state == "dialog") { QCOMPARE(QApplication::activeModalWidget(), &dialog); QTRY_VERIFY(dialogInput.hasFocus()); }
if (state == "popup") QCOMPARE(QApplication::activePopupWidget(), &popup);
}
void imagePreviewPreparedOutsidePaint() {
QTemporaryDir directory; QVERIFY(directory.isValid());
const QString path = directory.filePath("image.png");
+321
View File
@@ -2,6 +2,7 @@
#include "../src/inputbar.h"
#include "../src/chatview.h"
#include "../src/emojipanel.h"
#include "clipboard_fixture.h"
#include "../../../lib/debug_config.h"
extern "C" {
#include "media_async/attachment_send.h"
@@ -32,6 +33,42 @@ extern "C" {
#include <QPointer>
#include <QScopeGuard>
#include <QImage>
#include <QClipboard>
#include <QBuffer>
#include <QMenu>
#include <QAction>
#include <QDir>
#include <QProcess>
#include <QStandardPaths>
#include <QRadioButton>
#include <QImageWriter>
#include <QImageReader>
#include <QPainter>
#include <QRegularExpression>
static QSet<QString> clipboardPaths() {
QSet<QString> paths;
const QDir directory(QDir::tempPath());
for (const auto& name : directory.entryList({"vibechat-clipboard-*.png", "vibechat-clipboard-*.jpg"}, QDir::Files))
paths.insert(directory.filePath(name));
return paths;
}
static QString clipboardPngPath(const QSet<QString>& paths) {
for (const auto& path : paths) if (path.endsWith(".png") && !path.contains("clipboard-send-")) return path;
return {};
}
static void copyImage(const QImage& image, bool encoded = false) {
auto* mime = new QMimeData;
if (encoded) {
QByteArray bytes; QBuffer buffer(&bytes); buffer.open(QIODevice::WriteOnly);
if (!image.save(&buffer, "PNG")) qFatal("Cannot create clipboard PNG fixture");
mime->setData("image/png", bytes);
} else mime->setImageData(image);
mime->setText("image alternate text"); mime->setHtml("<b>image alternate text</b>");
QApplication::clipboard()->setMimeData(mime);
}
static QString makeFile(const QTemporaryDir& dir, const QString& name, const QByteArray& contents = "test") {
const QString path = dir.filePath(name);
@@ -57,6 +94,290 @@ static bool dropUrls(QWidget* target, const QList<QUrl>& urls, bool process = tr
class FileDropTest : public QObject {
Q_OBJECT
private slots:
void cleanup() { QApplication::clipboard()->clear(); }
void clipboardFormatChoice_data() {
QTest::addColumn<int>("density"); QTest::addColumn<bool>("alpha"); QTest::addColumn<bool>("dm");
QTest::addColumn<bool>("encrypted"); QTest::addColumn<QString>("manual"); QTest::addColumn<bool>("automaticJpeg");
QTest::newRow("png-smaller") << 0 << false << false << false << QString() << false;
QTest::newRow("png-bigger-but-below-twofold") << 16 << false << false << false << QString() << false;
QTest::newRow("jpeg-group") << 64 << false << false << false << QString() << true;
QTest::newRow("jpeg-pm") << 64 << false << true << false << QString() << true;
QTest::newRow("jpeg-encrypted") << 64 << false << false << true << QString() << true;
QTest::newRow("manual-jpeg") << 0 << false << false << false << QString("jpeg") << false;
QTest::newRow("manual-png-encrypted") << 64 << false << false << true << QString("png") << true;
QTest::newRow("transparent-manual-png") << 1024 << true << true << false << QString("png") << true;
QTest::newRow("transparent-manual-jpeg") << 0 << true << false << false << QString("jpeg") << false;
}
void clipboardFormatChoice() {
QFETCH(int, density); QFETCH(bool, alpha); QFETCH(bool, dm); QFETCH(bool, encrypted);
QFETCH(QString, manual); QFETCH(bool, automaticJpeg);
const QImage image = clipboardCodecFixture(density, alpha);
QImage jpegImage = image;
if (alpha) {
jpegImage = QImage(image.size(), QImage::Format_RGB32); jpegImage.fill(Qt::white);
QPainter painter(&jpegImage); painter.drawImage(0, 0, image);
}
QByteArray pngBytes, jpegBytes; QBuffer pngBuffer(&pngBytes), jpegBuffer(&jpegBytes);
QVERIFY(pngBuffer.open(QIODevice::WriteOnly)); QVERIFY(jpegBuffer.open(QIODevice::WriteOnly));
QVERIFY(image.save(&pngBuffer, "PNG")); QVERIFY(jpegImage.save(&jpegBuffer, "JPEG", 85));
if (automaticJpeg) QVERIFY(pngBytes.size() >= 2 * jpegBytes.size());
else {
QVERIFY(pngBytes.size() < 2 * jpegBytes.size());
if (density == 16) QVERIFY(pngBytes.size() > jpegBytes.size());
}
const auto before = clipboardPaths(); QTemporaryDir directory;
InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), dm ? "dm:456" : "123", dm ? 7 : 0);
bar.setDmMode(dm); bar.show();
auto* editor = bar.findChild<QTextEdit*>(); auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail");
auto* jpeg = bar.findChild<QRadioButton*>("clipboardJpegChoice"); auto* png = bar.findChild<QRadioButton*>("clipboardPngChoice");
QVERIFY(editor); QVERIFY(thumbnail); QVERIFY(jpeg); QVERIFY(png);
copyImage(image); editor->paste(); QTRY_VERIFY(!thumbnail->isHidden());
QVERIFY(jpeg->isVisible() && png->isVisible()); QVERIFY(jpeg->isEnabled() && png->isEnabled());
QCOMPARE(jpeg->isChecked(), automaticJpeg); QCOMPARE(png->isChecked(), !automaticJpeg);
QCOMPARE(bar.focusWidget(), static_cast<QWidget*>(automaticJpeg ? jpeg : png));
QVERIFY(jpeg->text().startsWith("JPEG (")); QVERIFY(png->text().startsWith("PNG ("));
QVERIFY(jpeg->text().contains(QRegularExpression("[0-9]+(\\.[0-9]+)?(B|KB|MB)")));
QVERIFY(png->text().contains(QRegularExpression("[0-9]+(\\.[0-9]+)?(B|KB|MB)")));
QCOMPARE(clipboardPaths().size(), before.size() + 2);
auto* encrypt = bar.findChild<QCheckBox*>(); QVERIFY(encrypt); if (encrypted) encrypt->setChecked(true);
const bool useJpeg = manual.isEmpty() ? automaticJpeg : manual == "jpeg";
if (!manual.isEmpty()) {
auto* current = automaticJpeg ? jpeg : png;
QTest::keyClick(current, automaticJpeg ? Qt::Key_Right : Qt::Key_Left);
QCOMPARE(jpeg->isChecked(), useJpeg); QCOMPARE(png->isChecked(), !useJpeg);
QCOMPARE(bar.focusWidget(), static_cast<QWidget*>(useJpeg ? jpeg : png));
}
attachment_send_req* pending = nullptr;
const auto discard = qScopeGuard([&] { attachment_send_discard(pending); });
connect(&bar, &InputBar::submitAttachment, &bar, [&](const QString&, attachment_send_req* request) { pending = request; });
QTest::keyClick(useJpeg ? jpeg : png, Qt::Key_Return); QVERIFY(pending);
QCOMPARE(pending->info.kind, useJpeg ? ATTACHMENT_JPEG : ATTACHMENT_PNG); QCOMPARE(pending->encrypted, int(encrypted));
QCOMPARE(pending->info.width, image.width()); QCOMPARE(pending->info.height, image.height());
QVERIFY(QString::fromUtf8(pending->info.name).endsWith(useJpeg ? ".jpg" : ".png"));
const QString source = QString::fromUtf8(pending->source); QVERIFY(source.endsWith(useJpeg ? ".jpg" : ".png"));
QFile payload(source); QVERIFY(payload.open(QIODevice::ReadOnly)); QCOMPARE(payload.readAll(), useJpeg ? jpegBytes : pngBytes); payload.close();
const QImage decoded(source); QCOMPARE(decoded.size(), image.size());
if (useJpeg) QVERIFY(!decoded.hasAlphaChannel()); else QCOMPARE(decoded.pixelColor(0, 0), image.pixelColor(0, 0));
bar.submissionResult(pending->request_id, true); QVERIFY(jpeg->isHidden() || !jpeg->isVisible());
attachment_send_discard(pending); pending = nullptr; QCOMPARE(clipboardPaths(), before);
}
void clipboardManualChoiceOutlivesPendingResult() {
const auto before = clipboardPaths(); QTemporaryDir directory;
InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), "123"); bar.show();
auto* editor = bar.findChild<QTextEdit*>(); auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail");
auto* jpeg = bar.findChild<QRadioButton*>("clipboardJpegChoice"); auto* png = bar.findChild<QRadioButton*>("clipboardPngChoice");
QVERIFY(editor); QVERIFY(thumbnail); QVERIFY(jpeg); QVERIFY(png);
copyImage(clipboardCodecFixture(0)); editor->paste(); QTRY_VERIFY(!thumbnail->isHidden()); QVERIFY(png->isChecked());
attachment_send_req* pending = nullptr;
const auto discard = qScopeGuard([&] { attachment_send_discard(pending); });
connect(&bar, &InputBar::submitAttachment, &bar, [&](const QString&, attachment_send_req* request) { pending = request; });
QTest::keyClick(png, Qt::Key_Return, Qt::ControlModifier); QVERIFY(!pending);
QCOMPARE(editor->toPlainText(), QString("\n")); QCOMPARE(bar.focusWidget(), static_cast<QWidget*>(editor)); editor->clear();
QTest::keyClick(png, Qt::Key_Return); QVERIFY(pending); QCOMPARE(pending->info.kind, ATTACHMENT_PNG);
QTest::mouseClick(jpeg, Qt::LeftButton); QVERIFY(jpeg->isChecked());
bar.submissionResult(pending->request_id, true); QVERIFY(jpeg->isVisible());
attachment_send_discard(pending); pending = nullptr;
QTest::keyClick(jpeg, Qt::Key_Return); QVERIFY(pending); QCOMPARE(pending->info.kind, ATTACHMENT_JPEG);
const auto request = pending->request_id;
attachment_send_discard(pending); pending = nullptr; bar.submissionResult(request, false);
QVERIFY(jpeg->isChecked()); QVERIFY(jpeg->isVisible()); // Ошибка сохраняет ручной выбор и оба варианта.
bar.setRecordingContext(directory.path(), "456"); QCOMPARE(clipboardPaths(), before);
}
void clipboardImageUsesPngPipeline_data() {
QTest::addColumn<bool>("dm"); QTest::addColumn<bool>("encrypted"); QTest::addColumn<QString>("method");
QTest::newRow("group-ctrl-v") << false << false << QString("ctrl");
QTest::newRow("pm-ctrl-v") << true << false << QString("ctrl");
QTest::newRow("encrypted-shift-insert") << false << true << QString("shift");
QTest::newRow("group-context-menu-png") << false << false << QString("menu");
}
void clipboardImageUsesPngPipeline() {
QFETCH(bool, dm); QFETCH(bool, encrypted); QFETCH(QString, method);
const auto before = clipboardPaths(); QTemporaryDir directory; QVERIFY(directory.isValid());
InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), dm ? "dm:456" : "123", dm ? 7 : 0);
bar.setDmMode(dm); bar.show();
auto* editor = bar.findChild<QTextEdit*>(); QVERIFY(editor);
auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail"); QVERIFY(thumbnail);
QSignalSpy errors(&bar, &InputBar::attachmentRejected), texts(&bar, &InputBar::sendMessage);
attachment_send_req* pending = nullptr;
const auto discard = qScopeGuard([&] { attachment_send_discard(pending); });
connect(&bar, &InputBar::submitAttachment, &bar, [&](const QString& target, attachment_send_req* request) {
QCOMPARE(target, dm ? QString("dm:456") : QString("123")); QVERIFY(!pending); pending = request;
});
QImage original(120, 80, QImage::Format_ARGB32); original.fill(qRgba(17, 61, 95, 127));
editor->setPlainText("existing draft"); copyImage(original, method == "menu");
if (method == "ctrl") QTest::keyClick(editor, Qt::Key_V, Qt::ControlModifier);
else if (method == "shift") QTest::keyClick(editor, Qt::Key_Insert, Qt::ShiftModifier);
else {
std::unique_ptr<QMenu> menu(editor->createStandardContextMenu()); QAction* paste = nullptr;
for (auto* action : menu->actions()) if (action->text().remove('&').startsWith("Paste")) paste = action;
QVERIFY(paste); QVERIFY(paste->isEnabled()); paste->trigger();
}
QCOMPARE(editor->toPlainText(), QString("existing draft")); QVERIFY(!pending); QCOMPARE(texts.size(), 0);
QTest::keyClick(editor, Qt::Key_Return); QVERIFY(!pending); QCOMPARE(texts.size(), 0); // PNG ещё кодируется.
QApplication::clipboard()->setText("clipboard has changed");
QTRY_VERIFY(!thumbnail->isHidden());
QCOMPARE(errors.size(), 0); QCOMPARE(editor->toPlainText(), QString("existing draft"));
const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 2); const QString draft = clipboardPngPath(drafts);
QImage decoded(draft); QCOMPARE(decoded.size(), original.size()); QCOMPARE(decoded.pixelColor(0, 0), original.pixelColor(0, 0));
editor->clear(); const QByteArray reply(32, 'r'); if (!dm) bar.setReply(reply, "Member", "Original");
if (encrypted) bar.findChild<QCheckBox*>()->setChecked(true);
QTest::keyClick(editor, Qt::Key_Return); QVERIFY(pending); QVERIFY(pending->request_id); QVERIFY(pending->temporary_source);
QCOMPARE(pending->info.kind, ATTACHMENT_PNG); QCOMPARE(pending->info.width, 120); QCOMPARE(pending->info.height, 80);
QCOMPARE(pending->encrypted, int(encrypted)); QCOMPARE(pending->dm_generation, dm ? UINT64_C(7) : UINT64_C(0));
QVERIFY(QString::fromUtf8(pending->info.name).startsWith("clipboard-"));
QVERIFY(QString::fromUtf8(pending->info.name).endsWith(".png"));
if (!dm) QCOMPARE(QByteArray(reinterpret_cast<const char*>(pending->reply_to), 32), reply);
const QString source = QString::fromUtf8(pending->source); QVERIFY(source != draft);
QCOMPARE(QImage(source).pixelColor(0, 0), original.pixelColor(0, 0));
bar.submissionResult(pending->request_id, true); QVERIFY(thumbnail->isHidden()); QVERIFY(!QFile::exists(draft));
QVERIFY(QFile::exists(source)); // Worker владеет отдельным source после очистки preview.
attachment_send_discard(pending); pending = nullptr; QVERIFY(!QFile::exists(source));
QCOMPARE(clipboardPaths(), before);
}
void clipboardFailureKeepsDraftAndRetries_data() {
QTest::addColumn<bool>("dm"); QTest::newRow("group") << false; QTest::newRow("pm") << true;
}
void clipboardFailureKeepsDraftAndRetries() {
QFETCH(bool, dm); const auto before = clipboardPaths(); QTemporaryDir directory;
InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), dm ? "dm:456" : "123", dm ? 7 : 0);
bar.setDmMode(dm); bar.show(); auto* editor = bar.findChild<QTextEdit*>(); QVERIFY(editor);
auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail"); QVERIFY(thumbnail);
attachment_send_req* pending = nullptr;
const auto discard = qScopeGuard([&] { attachment_send_discard(pending); });
connect(&bar, &InputBar::submitAttachment, &bar, [&](const QString&, attachment_send_req* request) { pending = request; });
QImage image(32, 24, QImage::Format_RGB32); image.fill(Qt::green); copyImage(image); editor->paste();
QTRY_VERIFY(!thumbnail->isHidden());
const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 2); const QString draft = clipboardPngPath(drafts);
QTest::keyClick(editor, Qt::Key_Return); QVERIFY(pending); const auto first = pending->request_id; QVERIFY(first);
QTest::keyClick(editor, Qt::Key_Return); QCOMPARE(pending->request_id, first);
attachment_send_discard(pending); pending = nullptr;
QSignalSpy errors(&bar, &InputBar::attachmentRejected); bar.submissionResult(first, false);
QCOMPARE(errors.size(), 1); QVERIFY(!thumbnail->isHidden()); QVERIFY(QFile::exists(draft));
QTest::keyClick(editor, Qt::Key_Return); QVERIFY(pending); QVERIFY(pending->request_id != first);
bar.submissionResult(pending->request_id, true); QVERIFY(thumbnail->isHidden()); QVERIFY(!QFile::exists(draft));
attachment_send_discard(pending); pending = nullptr; QCOMPARE(clipboardPaths(), before);
}
void clipboardOldSuccessPreservesNewPaste() {
const auto before = clipboardPaths(); QTemporaryDir directory;
InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), "123"); bar.show();
auto* editor = bar.findChild<QTextEdit*>(); auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail");
QVERIFY(editor); QVERIFY(thumbnail);
attachment_send_req* pending = nullptr;
const auto discard = qScopeGuard([&] { attachment_send_discard(pending); });
connect(&bar, &InputBar::submitAttachment, &bar, [&](const QString&, attachment_send_req* request) { pending = request; });
QImage image(32, 24, QImage::Format_RGB32); image.fill(Qt::red); copyImage(image); editor->paste();
QTRY_VERIFY(!thumbnail->isHidden()); QTest::keyClick(editor, Qt::Key_Return); QVERIFY(pending);
const auto oldDrafts = clipboardPaths() - before; QCOMPARE(oldDrafts.size(), 3);
image.fill(Qt::blue); copyImage(image); editor->paste();
bar.submissionResult(pending->request_id, true); // Успех A не должен отменить кодирование B.
const QString sendSource = QString::fromUtf8(pending->source);
auto* status = bar.findChild<QLabel*>("mediaPreparationStatus"); QVERIFY(status); QTRY_VERIFY(status->isHidden());
const auto newDrafts = clipboardPaths() - before - QSet<QString>{sendSource}; QCOMPARE(newDrafts.size(), 2);
QCOMPARE(QImage(clipboardPngPath(newDrafts)).pixelColor(0, 0), QColor(Qt::blue));
QVERIFY(!thumbnail->isHidden()); QVERIFY(QFile::exists(sendSource));
attachment_send_discard(pending); pending = nullptr;
bar.setRecordingContext(directory.path(), "456"); QCOMPARE(clipboardPaths(), before);
}
void clipboardWorkIsSupersededAndCleaned_data() {
QTest::addColumn<QString>("action");
for (const char* action : {"replace", "change-chat", "clear", "destroy", "edit", "disable", "file"})
QTest::newRow(action) << QString(action);
}
void clipboardWorkIsSupersededAndCleaned() {
QFETCH(QString, action); const auto before = clipboardPaths(); QTemporaryDir directory;
{
InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), "123"); bar.show();
auto* editor = bar.findChild<QTextEdit*>(); QVERIFY(editor);
QImage image(120, 80, QImage::Format_RGB32); image.fill(Qt::red); copyImage(image); editor->paste();
if (action != "destroy") {
if (action == "change-chat") bar.setRecordingContext(directory.path(), "456");
if (action == "clear") QVERIFY(QMetaObject::invokeMethod(&bar, "clearPendingAttachment", Qt::DirectConnection));
if (action == "edit") { bar.editMessage(QByteArray(32, 'e'), 1, "edit"); bar.cancelEdit(); }
if (action == "disable") { bar.setMediaEnabled(false); bar.setMediaEnabled(true); }
if (action == "file") QVERIFY(dropUrls(editor->viewport(), {QUrl::fromLocalFile(makeFile(directory, "file.txt"))}));
image = QImage(60, 40, QImage::Format_RGB32); image.fill(Qt::blue); copyImage(image); editor->paste();
auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail"); QVERIFY(thumbnail); QTRY_VERIFY(!thumbnail->isHidden());
const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 2);
const QImage decoded(clipboardPngPath(drafts));
QCOMPARE(decoded.size(), image.size()); QCOMPARE(decoded.pixelColor(0, 0), QColor(Qt::blue));
}
}
QCOMPARE(clipboardPaths(), before);
}
void clipboardInvalidImagePreservesDraft_data() {
QTest::addColumn<QString>("kind");
for (const char* kind : {"corrupt-png", "too-wide", "too-many-pixels"}) QTest::newRow(kind) << QString(kind);
}
void clipboardInvalidImagePreservesDraft() {
QFETCH(QString, kind); const auto before = clipboardPaths(); QTemporaryDir directory;
InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), "123"); bar.show();
auto* editor = bar.findChild<QTextEdit*>(); auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail");
QVERIFY(editor); QVERIFY(thumbnail);
QImage original(32, 24, QImage::Format_RGB32); original.fill(Qt::green); copyImage(original); editor->paste();
QTRY_VERIFY(!thumbnail->isHidden()); const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 2);
QSignalSpy errors(&bar, &InputBar::attachmentRejected);
if (kind == "corrupt-png") {
auto* mime = new QMimeData; mime->setData("image/png", "invalid PNG"); QApplication::clipboard()->setMimeData(mime);
} else copyImage(QImage(kind == "too-wide" ? 16385 : 8192, kind == "too-wide" ? 1 : 4097, QImage::Format_Mono));
editor->paste(); QTRY_COMPARE(errors.size(), 1);
QCOMPARE(clipboardPaths() - before, drafts); QVERIFY(!thumbnail->isHidden());
QCOMPARE(QImage(clipboardPngPath(drafts)).pixelColor(0, 0), QColor(Qt::green));
bar.setRecordingContext(directory.path(), "456"); QCOMPARE(clipboardPaths(), before);
}
void clipboardTextAndUnavailableComposer_data() {
QTest::addColumn<QString>("mode");
for (const char* mode : {"text", "disabled-media", "no-target", "edit"}) QTest::newRow(mode) << QString(mode);
}
void clipboardTextAndUnavailableComposer() {
QFETCH(QString, mode); const auto before = clipboardPaths(); QTemporaryDir directory;
InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), "123");
auto* editor = bar.findChild<QTextEdit*>(); QVERIFY(editor); QSignalSpy errors(&bar, &InputBar::attachmentRejected);
if (mode == "text") {
QApplication::clipboard()->setText("обычный текст\nnext line"); editor->paste();
QCOMPARE(editor->toPlainText(), QString("обычный текст\nnext line")); QCOMPARE(errors.size(), 0);
} else {
if (mode == "disabled-media") bar.setMediaEnabled(false);
if (mode == "no-target") bar.setRecordingContext(directory.path(), "");
if (mode == "edit") bar.editMessage(QByteArray(32, 'e'), 1, "edit");
const QString text = editor->toPlainText();
QImage image(32, 24, QImage::Format_RGB32); image.fill(Qt::red); copyImage(image); editor->paste();
QCOMPARE(errors.size(), 1); QCOMPARE(editor->toPlainText(), text);
}
QCOMPARE(clipboardPaths(), before);
}
void externalClipboardImage() {
const QString platform = QGuiApplication::platformName();
const bool wayland = platform.startsWith("wayland");
if (!wayland && platform != "xcb") QSKIP("Run with QT_QPA_PLATFORM=xcb or wayland to test an external clipboard owner.");
const QString program = QStandardPaths::findExecutable(wayland ? "wl-copy" : "xclip");
if (program.isEmpty()) QSKIP("Install wl-copy/xclip to test an external PNG clipboard owner.");
const auto before = clipboardPaths(); QTemporaryDir directory;
InputBar bar(nullptr, nullptr); bar.setRecordingContext(directory.path(), "123"); bar.show(); bar.activateWindow();
QVERIFY(QTest::qWaitForWindowActive(&bar));
QImage original(120, 80, QImage::Format_ARGB32); original.fill(qRgba(41, 81, 121, 127));
QByteArray bytes; QBuffer buffer(&bytes); QVERIFY(buffer.open(QIODevice::WriteOnly)); QVERIFY(original.save(&buffer, "PNG"));
QProcess owner;
const auto stop = qScopeGuard([&] { owner.kill(); owner.waitForFinished(1000); });
owner.start(program, wayland ? QStringList{"--foreground", "--type", "image/png"}
: QStringList{"-selection", "clipboard", "-target", "image/png", "-in", "-quiet"});
QVERIFY(owner.waitForStarted(1000)); QCOMPARE(owner.write(bytes), qint64(bytes.size())); owner.closeWriteChannel();
QTRY_VERIFY(QApplication::clipboard()->mimeData()->hasFormat("image/png"));
auto* editor = bar.findChild<QTextEdit*>(); QVERIFY(editor); editor->paste();
auto* thumbnail = bar.findChild<QLabel*>("clipboardImageThumbnail"); QVERIFY(thumbnail); QTRY_VERIFY(!thumbnail->isHidden());
const auto drafts = clipboardPaths() - before; QCOMPARE(drafts.size(), 2);
QCOMPARE(QImage(clipboardPngPath(drafts)).pixelColor(0, 0), original.pixelColor(0, 0));
bar.setRecordingContext(directory.path(), "456"); QCOMPARE(clipboardPaths(), before);
}
void ordinaryTextWaitsForResultAndCanRetry() {
InputBar bar(nullptr, nullptr); bar.setRecordingContext("/tmp", "123");
auto* editor = bar.findChild<QTextEdit*>(); QVERIFY(editor);
+76 -5
View File
@@ -10,6 +10,7 @@
#include "../db/db_manager.h"
#include "../transport/utun_node.h"
#include "../transport/gui_bridge.h"
#include "clipboard_fixture.h"
#include <QtTest>
#include <QTemporaryDir>
#include <QFile>
@@ -19,6 +20,11 @@
#include <QCheckBox>
#include <QScopeGuard>
#include <QPointer>
#include <QClipboard>
#include <QImage>
#include <QLabel>
#include <QDir>
#include <QRadioButton>
#include <future>
#include <memory>
#include <atomic>
@@ -26,6 +32,8 @@ extern "C" {
#include "utun_instance.h"
#include "chat/chat_core.h"
#include "chat/chat_event.h"
#include "dm/dm_core.h"
#include "dm/dm_crypto.h"
#include "debug_config.h"
#include "mem.h"
}
@@ -78,18 +86,19 @@ struct SendCore {
void openHistory() {
history = new MessageList(&db); sendHistory = history; history->loadChannel(channel);
}
bool start(bool reply = false) {
bool start(bool reply = false, const QString& dbPath = {}) {
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN);
debug_set_category_level(DEBUG_CATEGORY_CHAT, DEBUG_LEVEL_DEBUG);
debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_DEBUG); gui_bridge_init();
instance = ua ? utun_instance_create_from_str(ua,
"[global]\nmy_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n"
const QByteArray config = "[global]\ndb_path=" + dbPath.toUtf8() +
"\nmy_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n"
"my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n"
"tun_enabled=no\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\nmember_files_enabled=0\n"
"local_invites_enabled=0\nlocal_broadcasts_enabled=0\n[ntp]\nenabled=no\n") : nullptr;
"local_invites_enabled=0\nlocal_broadcasts_enabled=0\n[ntp]\nenabled=no\n";
instance = ua ? utun_instance_create_from_str(ua, config.constData()) : nullptr;
if (!instance || utun_core_start(instance) || chat_service_start(instance)) return false;
const auto id = chat_core_create_channel_auto(instance, "Send tests"); if (!id) return false;
channel = QString::number(id); db.setDb(instance->topo_sqlite_db); db.setMyNodeId(instance->node_id);
channel = QString::number(id); db.setDb(instance->topo_sqlite_db); db.setMyNodeId(instance->node_id); db.setDbPath(dbPath);
if (reply) {
chat_msg_submit request{}; request.inst = instance; strcpy(request.content_type, "text/plain");
snprintf(request.channel_id, sizeof(request.channel_id), "%s", qPrintable(channel));
@@ -119,6 +128,68 @@ static int inCore(std::function<int(UTUN_INSTANCE*)> operation) {
class GroupEditTest : public QObject {
Q_OBJECT
private slots:
void clipboardImageCommitsThroughCore_data() {
QTest::addColumn<bool>("dm"); QTest::addColumn<bool>("jpeg"); QTest::addColumn<bool>("manual");
QTest::newRow("png-group-reply") << false << false << false;
QTest::newRow("png-pm") << true << false << false;
QTest::newRow("jpeg-group-reply") << false << true << false;
QTest::newRow("jpeg-pm") << true << true << false;
QTest::newRow("manual-jpeg-group") << false << true << true;
QTest::newRow("manual-png-pm") << true << false << true;
}
void clipboardImageCommitsThroughCore() {
QFETCH(bool, dm); QFETCH(bool, jpeg); QFETCH(bool, manual);
QTemporaryDir directory; QVERIFY(directory.isValid()); SendCore core;
QVERIFY(core.start(!dm, directory.path())); QString conversation;
if (dm) {
SC_MYKEYS peer{}; uint8_t ed[32]; QCOMPARE(sc_generate_keypair(&peer), 0);
QCOMPARE(sc_derive_ed25519_pubkey(peer.private_key, ed), 0);
const auto peerId = sc_derive_node_id_from_pubkey(peer.public_key);
QCOMPARE(dm_start(core.instance, peerId, peer.public_key, ed, "Clipboard peer", nullptr), 0);
conversation = QString::number(dm_derive_conv_id(core.instance->node_id, peerId));
core.history->loadChannel("dm:" + conversation);
}
core.history->show();
auto* thumbnail = core.input()->findChild<QLabel*>("clipboardImageThumbnail"); QVERIFY(thumbnail);
QSignalSpy errors(core.input(), &InputBar::attachmentRejected);
const QStringList patterns{"vibechat-clipboard-*.png", "vibechat-clipboard-*.jpg"};
const auto before = QDir(QDir::tempPath()).entryList(patterns, QDir::Files);
const auto clear = qScopeGuard([] { QApplication::clipboard()->clear(); });
const QImage image = clipboardCodecFixture(jpeg != manual ? 64 : 0);
QApplication::clipboard()->setImage(image); core.editor()->paste(); QTRY_VERIFY(!thumbnail->isHidden());
auto* choice = core.input()->findChild<QRadioButton*>(jpeg ? "clipboardJpegChoice" : "clipboardPngChoice"); QVERIFY(choice);
if (manual) QTest::mouseClick(choice, Qt::LeftButton);
QVERIFY(choice->isChecked());
gui_bridge_set_inst(nullptr); QVERIFY(core.send()); QCOMPARE(errors.size(), 1); QVERIFY(!thumbnail->isHidden());
core.attach(); QVERIFY(core.send());
QTRY_VERIFY_WITH_TIMEOUT((core.pump(), thumbnail->isHidden()), 5000);
QCOMPARE(errors.size(), 1);
QCOMPARE(QDir(QDir::tempPath()).entryList(patterns, QDir::Files), before);
if (dm) {
char json[8192]; size_t length = 0;
QCOMPARE(dm_list_messages_json(core.instance, qPrintable(conversation), 20, 0, json, sizeof(json), &length), 0);
const QByteArray messages(json, int(length));
QVERIFY(messages.contains(jpeg ? "\"content_type\":\"image/jpeg\"" : "\"content_type\":\"image/png\""));
QVERIFY(messages.contains("\"width\":320,\"height\":240")); QVERIFY(messages.contains("clipboard-"));
QVERIFY(messages.contains(jpeg ? ".jpg" : ".png"));
} else {
const auto messages = core.db.getMessages(core.channel, 10); QCOMPARE(messages.size(), 2);
bool found = false;
for (const auto& message : messages) if (message.contentType == (jpeg ? "image/jpeg" : "image/png")) {
found = true; QCOMPARE(message.replyTo, core.original);
const int separator = message.data.indexOf('|'); QVERIFY(separator > 1); QVERIFY(message.data.startsWith('*'));
const auto name = QByteArray::fromBase64(message.data.mid(1, separator - 1)); QVERIFY(name.startsWith("clipboard-"));
QVERIFY(name.endsWith(jpeg ? ".jpg" : ".png")); QVERIFY(message.data.endsWith("|w=320|h=240"));
}
QVERIFY(found);
const QDir media(directory.filePath("media/" + core.channel));
const auto files = media.entryList({jpeg ? "*.jpg" : "*.png"}, QDir::Files); QCOMPARE(files.size(), 1);
const QImage decoded(media.filePath(files.front()));
QCOMPARE(decoded.size(), image.size());
if (!jpeg) QCOMPARE(decoded.pixelColor(0, 0), image.pixelColor(0, 0));
}
}
void channelSend_data() {
QTest::addColumn<bool>("reply"); QTest::addColumn<QString>("failure");
QStringList failures{"none", "unavailable", "stopped", "cancelled", "sql"};
+32 -1
View File
@@ -91,7 +91,13 @@ extern "C" int __wrap_avcodec_open2(AVCodecContext* context, const AVCodec* code
}
QByteArray name(codec->name);
bool hardware = name.endsWith("_nvenc") || name.endsWith("_qsv") || name.endsWith("_amf") || name.endsWith("_vaapi");
if (hardware) { hardwareOpens++; if (failHardware) return AVERROR(ENODEV); }
if (hardware) {
hardwareOpens++;
if (failHardware) {
av_log(context, AV_LOG_ERROR, "Test driver failure for %s: required API 13.1, found 13.0\n", codec->name);
return AVERROR(ENODEV);
}
}
else softwareOpens++;
}
return __real_avcodec_open2(context, codec, options);
@@ -930,6 +936,27 @@ private slots:
QTRY_VERIFY_WITH_TIMEOUT(ended(id), 4000); // Core продолжает FIN drain после удаления desktop engine.
#else
QSKIP("Capture test requires FFmpeg and null audio wrappers");
#endif
}
void encoderFailuresPreserveDriverDetails() {
#if defined(TEST_NULL_AUDIO) && defined(HAVE_FFMPEG)
if (!avcodec_find_encoder_by_name("h264_nvenc") || !avcodec_find_encoder_by_name("h264_qsv"))
QSKIP("Test needs two registered hardware encoders");
failHardware = true; hardwareOpens = 0; softwareOpens = 0;
auto reset = qScopeGuard([] { failHardware = false; });
LiveVideoConfig config; config.width = 320; config.height = 180;
LiveVideoEncoder encoder;
QVERIFY(!encoder.open(config, LiveVideoEncoder::Backend::HardwareOnly));
const QString details = encoder.error();
QVERIFY2(details.contains("h264_nvenc:"), qPrintable(details));
QVERIFY2(details.contains("h264_qsv:"), qPrintable(details));
QVERIFY2(details.contains("Test driver failure for h264_nvenc: required API 13.1, found 13.0"), qPrintable(details));
QVERIFY2(details.contains("Test driver failure for h264_qsv: required API 13.1, found 13.0"), qPrintable(details));
QVERIFY(details.indexOf("h264_nvenc:") < details.indexOf("\n\nh264_qsv:"));
QVERIFY(hardwareOpens.load() >= 2); QCOMPARE(softwareOpens.load(), 0u);
QVERIFY(encoder.open(config, LiveVideoEncoder::Backend::SoftwareOnly)); QVERIFY(encoder.error().isEmpty());
#else
QSKIP("Encoder instrumentation unavailable");
#endif
}
void cpuConsent_data() {
@@ -952,6 +979,10 @@ private slots:
QVERIFY(engine.start(group, config)); QTRY_COMPARE(requests.size(), 1);
uint64_t generation = requests.front().front().toULongLong();
auto* question = controls.findChild<QMessageBox*>("streamCpuConfirmation"); QVERIFY(question);
QCOMPARE(question->detailedText(), requests.front().at(2).toString());
QCOMPARE(question->informativeText(), question->detailedText().section("\n\n", 0, 0));
QVERIFY(question->informativeText().contains("required API 13.1, found 13.0"));
QCOMPARE(question->textFormat(), Qt::PlainText);
QCOMPARE(question->defaultButton(), question->button(QMessageBox::No));
QTest::qWait(allow ? 8200 : 150); // Время ответа человека не является timeout первого кадра.
QCOMPARE(softwareOpens.load(), 0u); QCOMPARE(audioEncodes.load(), 0u); QVERIFY(errors.isEmpty());
@@ -29,6 +29,34 @@ private slots:
messages.clear(); debug_set_log_hook(capture); video_ffmpeg_init();
}
void cleanup() { debug_set_log_hook(nullptr); }
void capturesDriverErrorsWithLogsDisabled() {
debug_set_level(DEBUG_LEVEL_DISABLED);
VideoCodecErrors errors;
av_log(nullptr, AV_LOG_ERROR, "Driver does not support the required nvenc API version. Required: 13.1 Found: 13.0\n");
av_log(nullptr, AV_LOG_WARNING, "unrelated warning\n");
std::thread playback([] { av_log(nullptr, AV_LOG_ERROR, "unrelated playback error\n"); }); playback.join();
{
VideoCodecProbe probe;
av_log(nullptr, AV_LOG_ERROR, "encoder probe failed\n");
}
QCOMPARE(QString::fromUtf8(errors.text()), QStringLiteral(
"Driver does not support the required nvenc API version. Required: 13.1 Found: 13.0\nencoder probe failed"));
QVERIFY(messages.empty());
}
void nestedErrorCaptureAndBoundedOutput() {
VideoCodecErrors outer;
av_log(nullptr, AV_LOG_ERROR, "outer failure\n");
{
VideoCodecErrors inner;
av_log(nullptr, AV_LOG_FATAL, "inner failure\n");
QCOMPARE(QByteArray(inner.text()), QByteArray("inner failure"));
}
av_log(nullptr, AV_LOG_ERROR, "second outer failure\n");
QCOMPARE(QByteArray(outer.text()), QByteArray("outer failure\nsecond outer failure"));
for (int i = 0; i < 1000; i++) av_log(nullptr, AV_LOG_ERROR, "repeated driver failure %d\n", i);
const QByteArray details(outer.text());
QVERIFY(details.startsWith("outer failure\nsecond outer failure\n")); QCOMPARE(details.size(), 4095);
}
void probeLevelsRepeatsAndConcurrentErrors() {
debug_set_category_level(DEBUG_CATEGORY_VIDEO, DEBUG_LEVEL_TRACE);
{
+20
View File
@@ -2815,5 +2815,25 @@ See log:
<source>Camera connection error: %1</source>
<translation>Camera connection error: %1</translation>
</message>
<message>
<source>Images cannot be pasted in the current composer state.</source>
<translation>Images cannot be pasted in the current composer state.</translation>
</message>
<message>
<source>Cannot paste image: invalid data or more than 32 megapixels / 128 MiB.</source>
<translation>Cannot paste image: invalid data or more than 32 megapixels / 128 MiB.</translation>
</message>
<message>
<source>Could not prepare clipboard image: %1</source>
<translation>Could not prepare clipboard image: %1</translation>
</message>
<message>
<source>JPEG quality: 85. Transparent areas use a white background.</source>
<translation>JPEG quality: 85. Transparent areas use a white background.</translation>
</message>
<message>
<source>PNG preserves the original pixels and transparency.</source>
<translation>PNG preserves the original pixels and transparency.</translation>
</message>
</context>
</TS>
+20
View File
@@ -2824,5 +2824,25 @@ See log:
<source>Camera connection error: %1</source>
<translation>Ошибка подключения к камере: %1</translation>
</message>
<message>
<source>Images cannot be pasted in the current composer state.</source>
<translation>Сейчас нельзя вставить изображение в сообщение.</translation>
</message>
<message>
<source>Cannot paste image: invalid data or more than 32 megapixels / 128 MiB.</source>
<translation>Не удалось вставить изображение: повреждённые данные либо превышен предел 32 Мп / 128 МиБ.</translation>
</message>
<message>
<source>Could not prepare clipboard image: %1</source>
<translation>Не удалось подготовить изображение из буфера: %1</translation>
</message>
<message>
<source>JPEG quality: 85. Transparent areas use a white background.</source>
<translation>Качество JPEG: 85. Прозрачные области заменяются белым фоном.</translation>
</message>
<message>
<source>PNG preserves the original pixels and transparency.</source>
<translation>PNG сохраняет исходные пиксели и прозрачность.</translation>
</message>
</context>
</TS>