Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dff681ff0 | ||
|
|
a77a41f068 | ||
|
|
46b3296a36 | ||
|
|
c889928e52 | ||
|
|
d8db018166 | ||
|
|
4e3c1182db | ||
|
|
4d30fa6bcc | ||
|
|
c8012d35f1 | ||
|
|
57168e4a16 | ||
|
|
c8388ba8ba | ||
|
|
84e26dcf00 | ||
|
|
60af36902c | ||
|
|
4f2ad13e39 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 и принимает свежие адреса без рестарта. */
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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 остаётся неизменным, для групп/видео создаётся каталог назначения. */
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -53,6 +53,8 @@ public:
|
||||
~MainWindow() override;
|
||||
|
||||
protected:
|
||||
// После активации окна передать фокус composer; Qt сначала завершает восстановление фокуса дочерних виджетов.
|
||||
void changeEvent(QEvent* event) override;
|
||||
// Сохранить геометрию и скрыть в tray; для выхода служит requestShutdown.
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
/* История и composer выбранного канала/PM, read/scroll, загрузки, feedback, рация и каталог streams.
|
||||
* setFocus передаёт фокус полю сообщения через focusProxy composer.
|
||||
* Все методы/callbacks — GUI. Сетевые операции передаются мосту с владением запросом и привязкой instance.
|
||||
* Модель держит самостоятельные Qt-значения; DbManager/AudioRecorder заимствованы.
|
||||
* PM снимки проверяются по conv/generation, канальные строки — по message_id/version.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 { \
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"};
|
||||
|
||||
@@ -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);
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user