Compare commits
10 Commits
ce1f23fe46
...
d8cbe2e589
| Author | SHA1 | Date |
|---|---|---|
|
|
d8cbe2e589 | 2 days ago |
|
|
e1946b208d | 3 days ago |
|
|
09bc8c35c5 | 3 days ago |
|
|
f19ff11b36 | 3 days ago |
|
|
268243feeb | 3 days ago |
|
|
4be1e62251 | 3 days ago |
|
|
10b66e2ad0 | 3 days ago |
|
|
7a41fcf512 | 3 days ago |
|
|
518df33b0e | 3 days ago |
|
|
fdec5b65c5 | 3 days ago |
53 changed files with 2432 additions and 630 deletions
@ -0,0 +1,33 @@ |
|||||||
|
Ниже — подтверждённые по коду проблемы; отдельно указал, что воспроизведено запуском. |
||||||
|
|
||||||
|
[P1] Exit смешивает TCP-потоки разных клиентов. Поиск соединения использует только stream_id, хотя каждый клиент начинает нумерацию заново. После подключения второго клиента с таким же ID данные первого могут уйти в соединение второго; FIN/CLOSE также закрывают чужой поток. Нужен ключ (src_node_id, stream_id) во всех обработчиках, включая проверку повторного CONNECT. src/proxy/tcp_proxy_server.c:342 |
||||||
|
|
||||||
|
[P1] Клиент не проверяет отправителя ответов. DATA/FIN/CLOSE принимаются по одному stream_id, без сравнения источника с via_node_id. Обработка CLOSE_ALL читает peer_id, но затем уничтожает вообще все клиентские соединения. Пакеты другого узла, доставленные этому сервису, могут повредить или закрыть существующие потоки. src/proxy/tcp_proxy_client.c:668 |
||||||
|
|
||||||
|
[P1, воспроизведено] UDP-ответы в TUN имеют неправильную checksum. Обнуляется только IP-заголовок; байты UDP checksum остаются заполненными аллокатором значением 0xAAAA. Проверка сформированного пакета дала сумму 0xC1E2 вместо 0xFFFF. Такие ответы будут отбрасываться принимающим стеком. Нужно рассчитывать checksum либо явно устанавливать ноль для IPv4. src/proxy/udp_proxy.c:188 |
||||||
|
|
||||||
|
[P1, воспроизведено] ICMP с нечётной длиной payload получает неверную checksum. Цикл читает последнее 16-битное слово целиком, захватывая байт за пределами выделенного пользовательского буфера. Ошибка есть и на exit, и при восстановлении ответа клиенту. Для payload длиной 2 проверочная сумма получилась FFFF, длиной 3 — FF10. Отправка (src/proxy/icmp_proxy.c:80), ответ в TUN (src/proxy/icmp_proxy.c:285) |
||||||
|
|
||||||
|
[P1] UDP/ICMP-контексты глобальные, а ядро поддерживает несколько instances. Следующий init() перезаписывает g_udp_ctx/g_icmp_ctx; обработчики старого экземпляра используют новый контекст, а destroy(inst) освобождает его без проверки владельца. Дополнительно UDP различает запрос и ответ только по глобальному is_exit: узел, одновременно работающий клиентом и exit, трактует входящий ответ как новый исходящий запрос. src/proxy/udp_proxy.c:137, инициализация (src/proxy/udp_proxy.c:237), src/proxy/icmp_proxy.c:321 |
||||||
|
|
||||||
|
[P1] Нет сквозного ограничения потока при медленном получателе. Принятые ETCP-данные без ограничения добавляются в write_queue сокета или to_lwip. Заполненность этих очередей не останавливает доставку и подтверждение данных маршрутизатором. Медленный destination или локальный клиент приводит к росту памяти; при отказе выделения TCP-данные просто теряются. src/proxy/tcp_proxy_server.c:424, src/proxy/tcp_proxy_client.c:584 |
||||||
|
|
||||||
|
[P1] TUN-клиент может уничтожить ещё не отправленные данные при FIN. Если локальный FIN уже получен, но tx_queue остаётся заблокированной, входящий FIN от exit вызывает conn_finish() без проверки этой очереди. Она освобождается вместе с данными. Завершать поток нужно после опустошения очередей обоих направлений. src/proxy/tcp_proxy_client.c:642 |
||||||
|
|
||||||
|
[P1] Exit может отправить CLOSE раньше остатка ответа. В on_fin_cb() проверка tc->fin_local имеет приоритет над pend_r. Если клиент уже закрыл свою половину, а ответ destination остался в tx_buf из-за backpressure, сервер отправляет CLOSE и освобождает остаток. Ветка on_flushed_cb() также не проверяет ожидающие отправки данные ответа. src/proxy/tcp_proxy_server.c:108 |
||||||
|
|
||||||
|
[P1] HTTP CONNECT зависит от границ TCP-чтений. Парсер запускает туннель после первой \r\n, не дожидаясь \r\n\r\n. Если заголовки приходят следующим чтением, они пересылаются destination как содержимое туннеля — например, перед TLS ClientHello. Если вместе с заголовками уже пришли данные туннеля, они уничтожаются обнулением buf_len. src/proxy/socks_proxy.c:195 |
||||||
|
|
||||||
|
[P1] HTTP POST во время DNS может потерять весь накопленный запрос. dns_pending накапливается до 65535 байт, затем отправляется одним DATA. Exit принимает максимум 8192 байта в одном сообщении и отбрасывает превышение. При переполнении самого dns_pending очередные данные также теряются без прекращения потока. Нужны ограниченная очередь и отправка частями. src/proxy/socks_proxy.c:339, накопление (src/proxy/socks_proxy.c:430), ограничение exit (src/proxy/tcp_proxy_server.c:438) |
||||||
|
|
||||||
|
[P2] FIN теряется в двух штатных сценариях. SOCKS/HTTP при непустой очереди ответа вызывает tcp_conn_set_flushed(tc, NULL) — продолжение, которое должно переслать FIN, отсутствует. Exit молча игнорирует FIN, пришедший до завершения TCP connect. Протоколы, ожидающие EOF перед ответом, могут зависнуть. src/proxy/socks_proxy.c:538, src/proxy/tcp_proxy_server.c:492 |
||||||
|
|
||||||
|
[P2] SOCKS5-парсер некорректно обрабатывает запросы. После greeting/CONNECT сбрасывается весь буфер, включая следующие байты; всегда выбирается метод NO AUTH, даже если клиент его не предлагал. IPv6 вместо отказа превращается в IPv4 из неправильного смещения buf + 12. Доменный CONNECT длиной менее 10 байт бесконечно ожидает продолжения. src/proxy/socks_proxy.c:132 |
||||||
|
|
||||||
|
[P2] SOCKS/HTTP сообщают об успешном соединении до подключения exit. Ответ SOCKS success или HTTP 200 формируется даже до send_connect(). Подтверждения успешного TCP connect от exit в протоколе нет. При отказе подключения клиент сначала получает успех, затем закрытие вместо корректной ошибки. src/proxy/socks_proxy.c:316 |
||||||
|
|
||||||
|
[P2] После RST TUN-соединение остаётся в списке. tcp_proxy_client_err_cb() обнуляет pcb и выставляет error, но не освобождает pc. Очистка предусмотрена в poll callback уже уничтоженного PCB, который больше не вызовется. src/proxy/tcp_proxy_client.c:446 |
||||||
|
|
||||||
|
[P2] Неправильно разбираются IP options и фрагменты в TUN. UDP/ICMP используют фиксированные смещения от 20-байтового IPv4-заголовка, игнорируя IHL и fragment offset. Пакет с options или последующий IP-фрагмент превращается в запрос с неверными портами/данными. src/proxy/tcp_proxy_client.c:158 |
||||||
|
|
||||||
|
[P2] ICMP-ответы разных клиентов могут перепутаться. Exit сохраняет исходные echo_id/echo_seq и ищет ответ только по этой паре, без уникального преобразования ID и проверки адреса отправителя. Совпадающие ping-запросы разных клиентов получают чужие ответы. src/proxy/icmp_proxy.c:56 |
||||||
@ -0,0 +1,61 @@ |
|||||||
|
# Proxy: потоки TCP и датаграммы |
||||||
|
|
||||||
|
Клиентские входы — TUN (lwIP), SOCKS5 CONNECT и HTTP proxy/CONNECT. Exit открывает |
||||||
|
обычный TCP-сокет к назначению. TCP-поток на exit определяется парой `(peer_node_id, |
||||||
|
stream_id)`. Клиент принимает ответы и уведомления restart только от настроенного |
||||||
|
exit в UTUN-группе. UDP/ICMP-контексты принадлежат конкретному `UTUN_INSTANCE`. |
||||||
|
|
||||||
|
## TCP |
||||||
|
|
||||||
|
Общие определения находятся в `src/proxy/proxy_protocol.h`. Передаваемый заголовок: |
||||||
|
`svc_id:1, command:1, stream_id:4`; поля маршрута добавляет ETCP-router. |
||||||
|
|
||||||
|
- CONNECT (1): IPv4:4 и порт:2 в сетевом порядке. |
||||||
|
- CONNECTED (2): exit подтвердил успешный TCP connect. Только после этого клиент |
||||||
|
сообщает SOCKS success / HTTP 200 и начинает передавать накопленные данные. |
||||||
|
- DATA (3): от 1 до 4096 байт. |
||||||
|
- CLOSE (4), ERROR (5): прекращение потока. |
||||||
|
- FIN (6): конец одного направления; все предшествующие DATA должны быть переданы. |
||||||
|
- WINDOW (7): uint32_t, число дополнительно разрешённых байт. |
||||||
|
|
||||||
|
У каждого направления начальное окно 65536 байт. DATA уменьшает окно отправителя |
||||||
|
и получателя. Получатель возвращает кредит после записи в локальный TCP-сокет |
||||||
|
либо принятия данных ограниченным send-buffer lwIP. Очередь ETCP сама по себе |
||||||
|
не является подтверждением потребления данных конечным TCP. |
||||||
|
|
||||||
|
FIN ждёт освобождения исходящей очереди, включая данные, остановленные окном |
||||||
|
или backpressure роутера. Закрытие одного направления не прекращает другое. |
||||||
|
Окончательное освобождение происходит после обоих FIN и передачи оставшихся данных. |
||||||
|
Непереданные управляющие сообщения повторяются таймером. Ошибки и переходы |
||||||
|
состояния диагностируются категорией `proxy`; состояние сокетов — `socket`. |
||||||
|
|
||||||
|
Парсеры сохраняют остаток после greeting/request/HTTP headers. HTTP CONNECT ждёт |
||||||
|
полного блока заголовков. Во время DNS/connect чтение ограничено очередью tcp_io; |
||||||
|
накопленные HTTP headers/body передаются частями размером DATA. |
||||||
|
|
||||||
|
## UDP и ICMP |
||||||
|
|
||||||
|
REQUEST и REPLY различаются явно. Ответ допускается только от настроенного exit. |
||||||
|
UDP-ответ восстанавливается с нулевой UDP checksum (допустимо для IPv4), IPv4 checksum |
||||||
|
пересчитывается. ICMP checksum учитывает нечётный последний байт без чтения за буфером. |
||||||
|
|
||||||
|
Exit заменяет ICMP id/sequence уникальной среди ожидающих запросов парой и сопоставляет |
||||||
|
ответ также с IP назначения. Исходные id/sequence восстанавливаются для клиента. |
||||||
|
Это исключает смешивание ping-запросов разных клиентов с одинаковыми исходными ID. |
||||||
|
|
||||||
|
## Проверки и ограничения |
||||||
|
|
||||||
|
`test_proxy_packets` проверяет IPv4/UDP/ICMP и payload длиной 0..1501. |
||||||
|
`test_tcp_io_flush` проверяет уведомление после последнего синхронного send. |
||||||
|
`test_proxy_regressions` использует реальные локальные сокеты и управляемую доставку |
||||||
|
ETCP: совместные/раздельные handshake, ранние данные, отложенный CONNECTED, HTTP 502, |
||||||
|
большой POST, одинаковые stream ID разных пиров, FIN при закрытом окне, RST и IPv4 options. |
||||||
|
|
||||||
|
На Linux полный `./check.sh`: 109 passed, 0 failed, 1 skipped; дополнительно прошли |
||||||
|
настоящий TUN, full-duplex burst и нагрузка 8 соединений / 128 МиБ. |
||||||
|
Windows/FreeBSD в этом прогоне не проверялись. |
||||||
|
|
||||||
|
SOCKS IPv6 явно отклоняется кодом address type not supported. IPv4-фрагменты |
||||||
|
на входе proxy TUN отклоняются с диагностикой; сборка фрагментов не реализована. |
||||||
|
Формат TCP дополнен CONNECTED/WINDOW, UDP-команды разделены: клиент и exit необходимо |
||||||
|
обновлять вместе; совместимость со старым proxy-протоколом не предусмотрена. |
||||||
@ -0,0 +1,157 @@ |
|||||||
|
// Общий TCP proxy протокол. DATA ограничен окном каждого потока; WINDOW возвращает
|
||||||
|
// кредит после потребления данных локальным TCP. FIN следует за всеми DATA своего
|
||||||
|
// направления. CONNECTED подтверждает реальный connect exit → destination.
|
||||||
|
#ifndef PROXY_PROTOCOL_H |
||||||
|
#define PROXY_PROTOCOL_H |
||||||
|
|
||||||
|
#include <stdint.h> |
||||||
|
#include <string.h> |
||||||
|
#include "../routing_layer/etcp_router.h" |
||||||
|
#include "../routing_layer/topo_node.h" |
||||||
|
#include "../../lib/u_async.h" |
||||||
|
#include "../../lib/ll_queue.h" |
||||||
|
#include "../../lib/mem.h" |
||||||
|
#include "../../lib/debug_config.h" |
||||||
|
|
||||||
|
#define TCP_PROXY_SUBCMD_CONNECT 0x01 |
||||||
|
#define TCP_PROXY_SUBCMD_CONNECTED 0x02 |
||||||
|
#define TCP_PROXY_SUBCMD_DATA 0x03 |
||||||
|
#define TCP_PROXY_SUBCMD_CLOSE 0x04 |
||||||
|
#define TCP_PROXY_SUBCMD_ERROR 0x05 |
||||||
|
#define TCP_PROXY_SUBCMD_FIN 0x06 |
||||||
|
#define TCP_PROXY_SUBCMD_WINDOW 0x07 |
||||||
|
#define TCP_PROXY_HDR_SIZE 6 |
||||||
|
#define TCP_PROXY_CONNECT_HDR_SIZE 12 |
||||||
|
#define TCP_PROXY_RECV_HDR_SIZE (ROUTER_SVC_PAYLOAD_OFF + 5) |
||||||
|
#define TCP_PROXY_CHUNK 4096 |
||||||
|
#define TCP_PROXY_WINDOW 65536u |
||||||
|
|
||||||
|
struct proxy_flow { |
||||||
|
struct UTUN_INSTANCE* inst; |
||||||
|
struct UASYNC* ua; |
||||||
|
uint64_t peer; |
||||||
|
uint32_t sid; |
||||||
|
uint8_t svc; |
||||||
|
uint8_t ready; |
||||||
|
uint8_t connected_pending; |
||||||
|
uint8_t fin_pending; |
||||||
|
uint8_t fin_sent; |
||||||
|
uint8_t fin_received; |
||||||
|
uint32_t tx_credit; |
||||||
|
uint32_t rx_credit; |
||||||
|
uint32_t consumed; |
||||||
|
void* retry; |
||||||
|
void (*wake)(void* arg); |
||||||
|
void* arg; |
||||||
|
}; |
||||||
|
|
||||||
|
// Сообщение целиком передаётся маршрутизатору, который освобождает entry при любом результате.
|
||||||
|
static inline int proxy_send(struct UTUN_INSTANCE* inst, uint64_t peer, uint8_t svc, uint8_t cmd, |
||||||
|
uint32_t sid, const uint8_t* data, size_t len, int force) { |
||||||
|
if (len > TCP_PROXY_CHUNK) { |
||||||
|
DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "proxy: oversized message sid=%08x cmd=%u len=%zu", sid, cmd, len); |
||||||
|
return -1; |
||||||
|
} |
||||||
|
struct ll_entry* e = queue_entry_new(0); |
||||||
|
if (!e) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "proxy: entry allocation failed sid=%08x", sid); return -1; } |
||||||
|
e->dgram = u_malloc(TCP_PROXY_HDR_SIZE + len); |
||||||
|
if (!e->dgram) { |
||||||
|
DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "proxy: payload allocation failed sid=%08x", sid); |
||||||
|
queue_entry_free(e); return -1; |
||||||
|
} |
||||||
|
e->dgram[0] = svc; e->dgram[1] = cmd; |
||||||
|
memcpy(e->dgram + 2, &sid, 4); |
||||||
|
if (len) memcpy(e->dgram + TCP_PROXY_HDR_SIZE, data, len); |
||||||
|
e->len = TCP_PROXY_HDR_SIZE + len; |
||||||
|
return etcp_route_send(inst, TOPO_GROUP_UTUN, peer, e, force, 0); |
||||||
|
} |
||||||
|
|
||||||
|
static inline void proxy_flow_flush(struct proxy_flow* f); |
||||||
|
|
||||||
|
// Повторяем только недоставленные control-сообщения; DATA остаётся у владельца потока.
|
||||||
|
static inline void proxy_flow_retry(void* arg) { |
||||||
|
struct proxy_flow* f = arg; |
||||||
|
f->retry = NULL; |
||||||
|
proxy_flow_flush(f); |
||||||
|
if (f->wake) f->wake(f->arg); |
||||||
|
} |
||||||
|
|
||||||
|
static inline void proxy_flow_init(struct proxy_flow* f, struct UTUN_INSTANCE* inst, struct UASYNC* ua, |
||||||
|
uint64_t peer, uint8_t svc, uint32_t sid, void (*wake)(void*), void* arg) { |
||||||
|
memset(f, 0, sizeof(*f)); |
||||||
|
f->inst = inst; f->ua = ua; f->peer = peer; f->svc = svc; f->sid = sid; |
||||||
|
f->tx_credit = TCP_PROXY_WINDOW; f->rx_credit = TCP_PROXY_WINDOW; |
||||||
|
f->wake = wake; f->arg = arg; |
||||||
|
} |
||||||
|
|
||||||
|
static inline void proxy_flow_destroy(struct proxy_flow* f) { |
||||||
|
if (f->retry) { uasync_cancel_timeout(f->ua, f->retry); f->retry = NULL; } |
||||||
|
} |
||||||
|
|
||||||
|
// Сначала CONNECTED, затем WINDOW и FIN. Force обходит лишь очередь router, не окно потока.
|
||||||
|
static inline void proxy_flow_flush(struct proxy_flow* f) { |
||||||
|
if (f->connected_pending) { |
||||||
|
if (proxy_send(f->inst, f->peer, f->svc, TCP_PROXY_SUBCMD_CONNECTED, f->sid, NULL, 0, 1) < 0) goto retry; |
||||||
|
f->connected_pending = 0; f->ready = 1; |
||||||
|
} |
||||||
|
if (f->consumed) { |
||||||
|
uint32_t credit = f->consumed; |
||||||
|
// Резервируем до синхронной loopback-доставки.
|
||||||
|
f->consumed = 0; f->rx_credit += credit; |
||||||
|
if (proxy_send(f->inst, f->peer, f->svc, TCP_PROXY_SUBCMD_WINDOW, f->sid, (uint8_t*)&credit, 4, 1) < 0) { |
||||||
|
f->consumed += credit; f->rx_credit -= credit; goto retry; |
||||||
|
} |
||||||
|
} |
||||||
|
if (f->fin_pending && f->ready) { |
||||||
|
if (proxy_send(f->inst, f->peer, f->svc, TCP_PROXY_SUBCMD_FIN, f->sid, NULL, 0, 1) < 0) goto retry; |
||||||
|
f->fin_pending = 0; f->fin_sent = 1; |
||||||
|
DEBUG_DEBUG(DEBUG_CATEGORY_PROXY, "proxy: FIN queued peer=%016llx sid=%08x", (unsigned long long)f->peer, f->sid); |
||||||
|
} |
||||||
|
return; |
||||||
|
retry: |
||||||
|
DEBUG_DEBUG(DEBUG_CATEGORY_PROXY, "proxy: control retry sid=%08x connected=%u credit=%u fin=%u", |
||||||
|
f->sid, f->connected_pending, f->consumed, f->fin_pending); |
||||||
|
if (!f->retry) f->retry = uasync_set_timeout(f->ua, 5000, f, proxy_flow_retry, "proxy_control"); |
||||||
|
} |
||||||
|
|
||||||
|
// 0 = отправлено, -2 = ждём окно/CONNECTED, -1 = router backpressure/ошибка выделения.
|
||||||
|
static inline int proxy_flow_send(struct proxy_flow* f, const uint8_t* data, size_t len, int force) { |
||||||
|
if (!f->ready || len > f->tx_credit) return -2; |
||||||
|
if (!len || len > TCP_PROXY_CHUNK || f->fin_sent) { |
||||||
|
DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "proxy: invalid DATA sid=%08x len=%zu fin=%u", f->sid, len, f->fin_sent); |
||||||
|
return -1; |
||||||
|
} |
||||||
|
f->tx_credit -= len; |
||||||
|
int ret = proxy_send(f->inst, f->peer, f->svc, TCP_PROXY_SUBCMD_DATA, f->sid, data, len, force); |
||||||
|
if (ret < 0) f->tx_credit += len; |
||||||
|
return ret; |
||||||
|
} |
||||||
|
|
||||||
|
static inline int proxy_flow_receive(struct proxy_flow* f, size_t len) { |
||||||
|
if (!len || len > TCP_PROXY_CHUNK || len > f->rx_credit || f->fin_received) { |
||||||
|
DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "proxy: invalid receive sid=%08x len=%zu credit=%u fin=%u", |
||||||
|
f->sid, len, f->rx_credit, f->fin_received); |
||||||
|
return -1; |
||||||
|
} |
||||||
|
f->rx_credit -= len; |
||||||
|
return 0; |
||||||
|
} |
||||||
|
|
||||||
|
static inline int proxy_flow_window(struct proxy_flow* f, const uint8_t* data, size_t len) { |
||||||
|
uint32_t credit = 0; |
||||||
|
if (len == 4) memcpy(&credit, data, 4); |
||||||
|
if (!credit || credit > TCP_PROXY_WINDOW - f->tx_credit) { |
||||||
|
DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "proxy: invalid WINDOW sid=%08x len=%zu credit=%u tx=%u", f->sid, len, credit, f->tx_credit); |
||||||
|
return -1; |
||||||
|
} |
||||||
|
f->tx_credit += credit; |
||||||
|
DEBUG_TRACE(DEBUG_CATEGORY_PROXY, "proxy: WINDOW sid=%08x add=%u available=%u", f->sid, credit, f->tx_credit); |
||||||
|
return 0; |
||||||
|
} |
||||||
|
|
||||||
|
// Вызывать только после передачи байтов локальному TCP, не после получения ETCP DATA.
|
||||||
|
static inline void proxy_flow_consume(struct proxy_flow* f, uint32_t len) { |
||||||
|
f->consumed += len; |
||||||
|
proxy_flow_flush(f); |
||||||
|
} |
||||||
|
#endif |
||||||
@ -0,0 +1,58 @@ |
|||||||
|
// Проверяем пакеты на границе proxy → TUN: длины, адреса, payload и checksum.
|
||||||
|
#include <stdio.h> |
||||||
|
#include <string.h> |
||||||
|
#include "utun_instance.h" |
||||||
|
#include "tun_if.h" |
||||||
|
#include "proxy/udp_proxy.h" |
||||||
|
#include "proxy/icmp_proxy.h" |
||||||
|
#include "debug_config.h" |
||||||
|
#include "mem.h" |
||||||
|
|
||||||
|
#define CHECK(x) do { if (!(x)) { fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #x); return 1; } } while (0) |
||||||
|
|
||||||
|
static unsigned checksum_sum(const uint8_t* p, size_t n) { |
||||||
|
unsigned sum = 0; |
||||||
|
while (n >= 2) { sum += ((unsigned)p[0] << 8) | p[1]; p += 2; n -= 2; } |
||||||
|
if (n) sum += (unsigned)p[0] << 8; |
||||||
|
while (sum >> 16) sum = (sum & 65535) + (sum >> 16); |
||||||
|
return sum; |
||||||
|
} |
||||||
|
|
||||||
|
int main(void) { |
||||||
|
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); |
||||||
|
struct UASYNC* ua = uasync_create(); |
||||||
|
struct UTUN_INSTANCE* inst = u_calloc(1, sizeof(*inst)); |
||||||
|
struct tcp_proxy_client client = {0}; |
||||||
|
struct tun_if tun = {0}; |
||||||
|
CHECK(ua && inst); |
||||||
|
inst->tcp_proxy_client = &client; client.tun = &tun; |
||||||
|
tun.input_queue = queue_new(ua, 0, 0, 0, "packet_test"); |
||||||
|
CHECK(tun.input_queue); |
||||||
|
uint8_t payload[1501]; |
||||||
|
for (size_t i = 0; i < sizeof(payload); i++) payload[i] = (uint8_t)(i * 17 + 3); |
||||||
|
const uint8_t src[4] = {192, 0, 2, 1}, dst[4] = {10, 0, 0, 2}; |
||||||
|
uint32_t src_ip, dst_ip; memcpy(&src_ip, src, 4); memcpy(&dst_ip, dst, 4); |
||||||
|
for (size_t len = 0; len <= sizeof(payload); len++) { |
||||||
|
CHECK(udp_proxy_deliver_reply(inst, src_ip, htons(53), dst_ip, htons(23456), payload, len) == 0); |
||||||
|
struct ll_entry* e = queue_data_get(tun.input_queue); |
||||||
|
CHECK(e && e->len == 29 + len); |
||||||
|
const uint8_t* ip = e->dgram + 1; |
||||||
|
CHECK(e->dgram[0] == 4 && ip[9] == 17 && checksum_sum(ip, 20) == 65535); |
||||||
|
CHECK(memcmp(ip + 12, src, 4) == 0 && memcmp(ip + 16, dst, 4) == 0); |
||||||
|
CHECK(ip[26] == 0 && ip[27] == 0); |
||||||
|
CHECK(memcmp(ip + 28, payload, len) == 0); |
||||||
|
queue_dgram_free(e); queue_entry_free(e); |
||||||
|
CHECK(icmp_proxy_deliver_reply(inst, dst_ip, src_ip, htons(123), htons(5), payload, len) == 0); |
||||||
|
e = queue_data_get(tun.input_queue); |
||||||
|
CHECK(e && e->len == 29 + len); |
||||||
|
ip = e->dgram + 1; |
||||||
|
CHECK(ip[9] == 1 && checksum_sum(ip, 20) == 65535 && checksum_sum(ip + 20, 8 + len) == 65535); |
||||||
|
CHECK(ip[20] == 0 && ip[24] == 0 && ip[25] == 123 && ip[27] == 5); |
||||||
|
CHECK(memcmp(ip + 28, payload, len) == 0); |
||||||
|
queue_dgram_free(e); queue_entry_free(e); |
||||||
|
} |
||||||
|
queue_free(tun.input_queue); u_free(inst); uasync_destroy(ua, 0); |
||||||
|
CHECK(u_get_allocated_count() == 0); |
||||||
|
puts("[PASS] proxy UDP/ICMP packets: payload lengths 0..1501"); |
||||||
|
return 0; |
||||||
|
} |
||||||
@ -0,0 +1,292 @@ |
|||||||
|
// Реальные локальные TCP-сокеты и управляемый транспорт: проверяем границы
|
||||||
|
// handshake, подтверждение connect, изоляцию потоков и порядок DATA/FIN.
|
||||||
|
#include <stdio.h> |
||||||
|
#include <stdlib.h> |
||||||
|
#include <string.h> |
||||||
|
#include "utun_instance.h" |
||||||
|
#include "etcp_connections.h" |
||||||
|
#include "etcp.h" |
||||||
|
#include "proxy/socks_proxy.h" |
||||||
|
#include "proxy/udp_proxy.h" |
||||||
|
#include "proxy/icmp_proxy.h" |
||||||
|
#include "lwip_tcp/lwip_tcp.h" |
||||||
|
#include "tun_if.h" |
||||||
|
#include "debug_config.h" |
||||||
|
#include "mem.h" |
||||||
|
#ifndef _WIN32 |
||||||
|
#include <unistd.h> |
||||||
|
#else |
||||||
|
#define SHUT_WR SD_SEND |
||||||
|
#endif |
||||||
|
|
||||||
|
#define CHECK(x) do { if (!(x)) { fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #x); exit(1); } } while (0) |
||||||
|
struct message { struct UTUN_INSTANCE* inst; uint64_t peer; uint8_t svc, cmd; uint32_t sid; size_t len; uint8_t data[4096]; }; |
||||||
|
static struct message messages[512]; |
||||||
|
static unsigned message_count; |
||||||
|
static struct UASYNC* ua; |
||||||
|
|
||||||
|
// Перехватывается только транспорт. Парсеры, tcp_io, очереди и lifecycle — настоящие.
|
||||||
|
int __wrap_etcp_route_send(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t peer, |
||||||
|
struct ll_entry* entry, int force, int mode) { |
||||||
|
(void)group; (void)force; (void)mode; |
||||||
|
CHECK(message_count < 512 && entry->len >= 6 && entry->len <= 4102); |
||||||
|
struct message* m = &messages[message_count++]; |
||||||
|
m->inst = inst; m->peer = peer; m->svc = entry->dgram[0]; m->cmd = entry->dgram[1]; |
||||||
|
memcpy(&m->sid, entry->dgram + 2, 4); m->len = entry->len - 6; |
||||||
|
memcpy(m->data, entry->dgram + 6, m->len); |
||||||
|
queue_dgram_free(entry); queue_entry_free(entry); |
||||||
|
return 0; |
||||||
|
} |
||||||
|
|
||||||
|
static void pump(void) { for (int i = 0; i < 12; i++) uasync_poll(ua, 1); } |
||||||
|
|
||||||
|
static socket_t listen_local(uint16_t* port) { |
||||||
|
socket_t fd = socket(AF_INET, SOCK_STREAM, 0); |
||||||
|
CHECK(fd != SOCKET_INVALID); |
||||||
|
struct sockaddr_in a = {.sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK)}; |
||||||
|
CHECK(bind(fd, (struct sockaddr*)&a, sizeof(a)) == 0 && listen(fd, 8) == 0); |
||||||
|
socklen_t len = sizeof(a); CHECK(getsockname(fd, (struct sockaddr*)&a, &len) == 0); |
||||||
|
*port = ntohs(a.sin_port); socket_set_nonblocking(fd); |
||||||
|
return fd; |
||||||
|
} |
||||||
|
|
||||||
|
static socket_t connect_local(uint16_t port) { |
||||||
|
socket_t fd = socket(AF_INET, SOCK_STREAM, 0); |
||||||
|
struct sockaddr_in a = {.sin_family = AF_INET, .sin_port = htons(port), .sin_addr.s_addr = htonl(INADDR_LOOPBACK)}; |
||||||
|
CHECK(connect(fd, (struct sockaddr*)&a, sizeof(a)) == 0); |
||||||
|
socket_set_nonblocking(fd); pump(); return fd; |
||||||
|
} |
||||||
|
|
||||||
|
static void send_bytes(socket_t fd, const void* bytes, size_t len) { |
||||||
|
CHECK(send(fd, bytes, len, 0) == (ssize_t)len); pump(); |
||||||
|
} |
||||||
|
|
||||||
|
static unsigned count_cmd(uint8_t cmd) { |
||||||
|
unsigned count = 0; |
||||||
|
for (unsigned i = 0; i < message_count; i++) if (messages[i].cmd == cmd) count++; |
||||||
|
return count; |
||||||
|
} |
||||||
|
|
||||||
|
static void deliver(struct UTUN_INSTANCE* inst, uint64_t peer, uint8_t svc, uint8_t cmd, |
||||||
|
uint32_t sid, const void* data, size_t len) { |
||||||
|
struct ll_entry* e = queue_entry_new(0); |
||||||
|
CHECK(e); |
||||||
|
e->len = cmd ? TCP_PROXY_RECV_HDR_SIZE + len : ROUTER_SVC_HDR_SIZE; |
||||||
|
e->dgram = u_calloc(1, e->len); CHECK(e->dgram); |
||||||
|
e->dgram[0] = svc; |
||||||
|
uint64_t group = TOPO_GROUP_UTUN; |
||||||
|
memcpy(e->dgram + ROUTER_SVC_SRC_OFF, &peer, 8); |
||||||
|
memcpy(e->dgram + ROUTER_SVC_GROUP_OFF, &group, 8); |
||||||
|
if (cmd) { |
||||||
|
e->dgram[ROUTER_SVC_PAYLOAD_OFF] = cmd; |
||||||
|
memcpy(e->dgram + ROUTER_SVC_PAYLOAD_OFF + 1, &sid, 4); |
||||||
|
if (len) memcpy(e->dgram + TCP_PROXY_RECV_HDR_SIZE, data, len); |
||||||
|
} |
||||||
|
struct ETCP_CONN conn = {0}; conn.instance = inst; |
||||||
|
if (svc == ETCP_RT_ID_TCP_PROXY_SERVER) tcp_proxy_server_recv_cb(&conn, e); |
||||||
|
else tcp_proxy_client_router_recv_cb(&conn, e); |
||||||
|
pump(); |
||||||
|
} |
||||||
|
|
||||||
|
static void parser_tests(struct UTUN_INSTANCE* inst) { |
||||||
|
uint16_t sp, hp; |
||||||
|
socket_t reserved = listen_local(&sp); socket_close_wrapper(reserved); |
||||||
|
reserved = listen_local(&hp); socket_close_wrapper(reserved); |
||||||
|
char socks[64], http[64]; |
||||||
|
snprintf(socks, sizeof(socks), "127.0.0.1:%u", sp); snprintf(http, sizeof(http), "127.0.0.1:%u", hp); |
||||||
|
struct tcp_proxy_client* p = tcp_proxy_client_create(inst, ua, NULL, NULL, 1500, 1, NULL, 0, 42, 1, socks, 1, http); |
||||||
|
CHECK(p); inst->tcp_proxy_client = p; |
||||||
|
socket_t fd = connect_local(sp); |
||||||
|
// Greeting, CONNECT и ранние данные намеренно одним write.
|
||||||
|
const uint8_t request[] = {5,1,0, 5,1,0,1,127,0,0,1,0,80, 'a','b','c'}; |
||||||
|
message_count = 0; send_bytes(fd, request, sizeof(request)); |
||||||
|
CHECK(count_cmd(TCP_PROXY_SUBCMD_CONNECT) == 1 && count_cmd(TCP_PROXY_SUBCMD_DATA) == 0); |
||||||
|
uint8_t reply[256]; CHECK(recv(fd, reply, sizeof(reply), 0) == 2 && reply[1] == 0); |
||||||
|
uint32_t sid = p->socks_conns->stream_id; |
||||||
|
deliver(inst, 99, ETCP_RT_ID_TCP_PROXY_CLIENT, TCP_PROXY_SUBCMD_CONNECTED, sid, NULL, 0); |
||||||
|
deliver(inst, 99, ETCP_RT_ID_TCP_PROXY_CLIENT, 0, 0, NULL, 0); |
||||||
|
CHECK(p->socks_conn_count == 1 && !p->socks_conns->flow.ready); |
||||||
|
deliver(inst, 42, ETCP_RT_ID_TCP_PROXY_CLIENT, TCP_PROXY_SUBCMD_CONNECTED, sid, NULL, 0); |
||||||
|
CHECK(recv(fd, reply, sizeof(reply), 0) == 10 && reply[1] == 0); |
||||||
|
CHECK(count_cmd(TCP_PROXY_SUBCMD_DATA) == 1 && messages[message_count-1].len == 3); |
||||||
|
CHECK(memcmp(messages[message_count-1].data, "abc", 3) == 0); |
||||||
|
// Закрытое окно: FIN обоих направлений не должен уничтожать ожидающую передачу.
|
||||||
|
p->socks_conns->flow.tx_credit = 0; |
||||||
|
message_count = 0; send_bytes(fd, "tail", 4); |
||||||
|
CHECK(shutdown(fd, SHUT_WR) == 0); pump(); |
||||||
|
CHECK(count_cmd(TCP_PROXY_SUBCMD_FIN) == 0); |
||||||
|
deliver(inst, 42, ETCP_RT_ID_TCP_PROXY_CLIENT, TCP_PROXY_SUBCMD_FIN, sid, NULL, 0); |
||||||
|
CHECK(p->socks_conn_count == 1); |
||||||
|
uint32_t credit = 4; |
||||||
|
deliver(inst, 42, ETCP_RT_ID_TCP_PROXY_CLIENT, TCP_PROXY_SUBCMD_WINDOW, sid, &credit, 4); |
||||||
|
CHECK(count_cmd(TCP_PROXY_SUBCMD_DATA) == 1 && count_cmd(TCP_PROXY_SUBCMD_FIN) == 1); |
||||||
|
CHECK(messages[0].cmd == TCP_PROXY_SUBCMD_DATA && memcmp(messages[0].data, "tail", 4) == 0); |
||||||
|
CHECK(p->socks_conn_count == 0); socket_close_wrapper(fd); |
||||||
|
|
||||||
|
fd = connect_local(hp); message_count = 0; |
||||||
|
const char first[] = "CONNECT 127.0.0.1:443 HTTP/1.1\r\n"; |
||||||
|
send_bytes(fd, first, sizeof(first)-1); CHECK(message_count == 0); |
||||||
|
CHECK(recv(fd, reply, sizeof(reply), 0) < 0); |
||||||
|
const char second[] = "Host: 127.0.0.1\r\n\r\nTLS"; |
||||||
|
send_bytes(fd, second, sizeof(second)-1); CHECK(count_cmd(TCP_PROXY_SUBCMD_CONNECT) == 1); |
||||||
|
sid = p->http_conns->stream_id; |
||||||
|
CHECK(recv(fd, reply, sizeof(reply), 0) < 0); |
||||||
|
deliver(inst, 42, ETCP_RT_ID_TCP_PROXY_CLIENT, TCP_PROXY_SUBCMD_CONNECTED, sid, NULL, 0); |
||||||
|
ssize_t n = recv(fd, reply, sizeof(reply), 0); CHECK(n > 12 && memcmp(reply, "HTTP/1.1 200", 12) == 0); |
||||||
|
CHECK(messages[message_count-1].len == 3 && memcmp(messages[message_count-1].data, "TLS", 3) == 0); |
||||||
|
deliver(inst, 42, ETCP_RT_ID_TCP_PROXY_CLIENT, TCP_PROXY_SUBCMD_ERROR, sid, NULL, 0); |
||||||
|
socket_close_wrapper(fd); |
||||||
|
|
||||||
|
fd = connect_local(hp); message_count = 0; |
||||||
|
send_bytes(fd, first, sizeof(first)-1); send_bytes(fd, second, sizeof(second)-1); |
||||||
|
sid = p->http_conns->stream_id; |
||||||
|
deliver(inst, 42, ETCP_RT_ID_TCP_PROXY_CLIENT, TCP_PROXY_SUBCMD_ERROR, sid, NULL, 0); |
||||||
|
n = recv(fd, reply, sizeof(reply), 0); CHECK(n > 12 && memcmp(reply, "HTTP/1.1 502", 12) == 0); |
||||||
|
socket_close_wrapper(fd); |
||||||
|
|
||||||
|
fd = connect_local(sp); message_count = 0; |
||||||
|
const uint8_t auth[] = {5,1,2}; send_bytes(fd, auth, sizeof(auth)); |
||||||
|
CHECK(recv(fd, reply, sizeof(reply), 0) == 2 && reply[1] == 255 && message_count == 0); |
||||||
|
socket_close_wrapper(fd); |
||||||
|
fd = connect_local(sp); message_count = 0; |
||||||
|
uint8_t v6[25] = {5,1,0,5,1,0,4}; v6[24] = 80; |
||||||
|
send_bytes(fd, v6, sizeof(v6)); |
||||||
|
n = recv(fd, reply, sizeof(reply), 0); CHECK(n == 12 && reply[3] == 8 && message_count == 0); |
||||||
|
socket_close_wrapper(fd); |
||||||
|
// Большие заголовки и body, поступающий до CONNECTED, должны сохраниться и разбиться на DATA.
|
||||||
|
fd = connect_local(hp); message_count = 0; |
||||||
|
char upload[32000], expected[32000]; |
||||||
|
int header = snprintf(upload, sizeof(upload), "POST http://127.0.0.1/upload HTTP/1.1\r\nHost: 127.0.0.1\r\nX-Pad: "); |
||||||
|
memset(upload + header, 'x', 9000); header += 9000; |
||||||
|
header += snprintf(upload + header, sizeof(upload) - header, "\r\nContent-Length: 20000\r\n\r\n"); |
||||||
|
memset(upload + header, 'B', 20000); |
||||||
|
for (int off = 0; off < header + 20000;) { |
||||||
|
int chunk = header + 20000 - off; if (chunk > 1024) chunk = 1024; |
||||||
|
send_bytes(fd, upload + off, chunk); off += chunk; |
||||||
|
} |
||||||
|
CHECK(count_cmd(TCP_PROXY_SUBCMD_CONNECT) == 1 && count_cmd(TCP_PROXY_SUBCMD_DATA) == 0); |
||||||
|
sid = p->http_conns->stream_id; |
||||||
|
CHECK(p->http_conns->tc->read_queue->count <= 8); |
||||||
|
deliver(inst, 42, ETCP_RT_ID_TCP_PROXY_CLIENT, TCP_PROXY_SUBCMD_CONNECTED, sid, NULL, 0); |
||||||
|
for (int i = 0; i < 10; i++) pump(); |
||||||
|
size_t total = 0; |
||||||
|
for (unsigned i = 0; i < message_count; i++) if (messages[i].cmd == TCP_PROXY_SUBCMD_DATA) { |
||||||
|
CHECK(messages[i].len <= TCP_PROXY_CHUNK && total + messages[i].len <= sizeof(expected)); |
||||||
|
memcpy(expected + total, messages[i].data, messages[i].len); total += messages[i].len; |
||||||
|
} |
||||||
|
const size_t prefix = strlen("http://127.0.0.1"); |
||||||
|
CHECK(total == header + 20000 - prefix); |
||||||
|
CHECK(memcmp(expected, "POST ", 5) == 0 && memcmp(expected + 5, upload + 5 + prefix, total - 5) == 0); |
||||||
|
deliver(inst, 42, ETCP_RT_ID_TCP_PROXY_CLIENT, TCP_PROXY_SUBCMD_ERROR, sid, NULL, 0); |
||||||
|
socket_close_wrapper(fd); |
||||||
|
tcp_proxy_client_destroy(p); inst->tcp_proxy_client = NULL; pump(); |
||||||
|
puts("[PASS] SOCKS/HTTP split/coalesced headers, CONNECTED/error, source identity and half-close"); |
||||||
|
} |
||||||
|
|
||||||
|
static void server_tests(struct UTUN_INSTANCE* inst) { |
||||||
|
uint16_t port; socket_t listener = listen_local(&port); |
||||||
|
inst->tcp_proxy_server.enabled = 1; inst->tcp_proxy_server.inst = inst; |
||||||
|
uint8_t connect_data[6] = {127,0,0,1}; uint16_t wire_port = htons(port); memcpy(connect_data+4, &wire_port, 2); |
||||||
|
deliver(inst, 11, ETCP_RT_ID_TCP_PROXY_SERVER, TCP_PROXY_SUBCMD_CONNECT, 2, connect_data, 6); |
||||||
|
socket_t a = accept(listener, NULL, NULL); CHECK(a != SOCKET_INVALID); socket_set_nonblocking(a); |
||||||
|
deliver(inst, 22, ETCP_RT_ID_TCP_PROXY_SERVER, TCP_PROXY_SUBCMD_CONNECT, 2, connect_data, 6); |
||||||
|
socket_t b = accept(listener, NULL, NULL); CHECK(b != SOCKET_INVALID); socket_set_nonblocking(b); |
||||||
|
CHECK(inst->tcp_proxy_server.conn_count == 2); |
||||||
|
deliver(inst, 11, ETCP_RT_ID_TCP_PROXY_SERVER, TCP_PROXY_SUBCMD_DATA, 2, "AAA", 3); |
||||||
|
deliver(inst, 22, ETCP_RT_ID_TCP_PROXY_SERVER, TCP_PROXY_SUBCMD_DATA, 2, "BBB", 3); |
||||||
|
uint8_t buf[16]; CHECK(recv(a, buf, sizeof(buf), 0) == 3 && memcmp(buf, "AAA", 3) == 0); |
||||||
|
CHECK(recv(b, buf, sizeof(buf), 0) == 3 && memcmp(buf, "BBB", 3) == 0); |
||||||
|
deliver(inst, 33, ETCP_RT_ID_TCP_PROXY_SERVER, TCP_PROXY_SUBCMD_CLOSE, 2, NULL, 0); |
||||||
|
CHECK(inst->tcp_proxy_server.conn_count == 2); |
||||||
|
deliver(inst, 11, ETCP_RT_ID_TCP_PROXY_SERVER, TCP_PROXY_SUBCMD_CONNECT, 2, connect_data, 6); |
||||||
|
CHECK(inst->tcp_proxy_server.conn_count == 2); |
||||||
|
deliver(inst, 11, ETCP_RT_ID_TCP_PROXY_SERVER, TCP_PROXY_SUBCMD_CLOSE, 2, NULL, 0); |
||||||
|
CHECK(inst->tcp_proxy_server.conn_count == 1); |
||||||
|
deliver(inst, 22, ETCP_RT_ID_TCP_PROXY_SERVER, TCP_PROXY_SUBCMD_CLOSE, 2, NULL, 0); |
||||||
|
CHECK(inst->tcp_proxy_server.conn_count == 0); |
||||||
|
socket_close_wrapper(a); socket_close_wrapper(b); socket_close_wrapper(listener); |
||||||
|
inst->tcp_proxy_server.enabled = 0; pump(); |
||||||
|
puts("[PASS] exit streams isolated by node + stream ID, duplicate CONNECT rejected"); |
||||||
|
} |
||||||
|
|
||||||
|
static void instance_tests(struct UTUN_INSTANCE* a) { |
||||||
|
struct UTUN_INSTANCE* b = u_calloc(1, sizeof(*b)); CHECK(b); b->ua = ua; |
||||||
|
CHECK(udp_proxy_init(a, ua) == 0 && udp_proxy_init(b, ua) == 0); |
||||||
|
CHECK(icmp_proxy_init(a, ua) == 0 && icmp_proxy_init(b, ua) == 0); |
||||||
|
CHECK(a->udp_proxy != b->udp_proxy && a->icmp_proxy != b->icmp_proxy); |
||||||
|
struct udp_proxy_ctx* udp = b->udp_proxy; struct icmp_proxy_ctx* icmp = b->icmp_proxy; |
||||||
|
udp_proxy_destroy(a); icmp_proxy_destroy(a); |
||||||
|
CHECK(b->udp_proxy == udp && b->icmp_proxy == icmp); |
||||||
|
CHECK(udp_proxy_init(b, ua) == 0 && b->udp_proxy == udp); |
||||||
|
udp_proxy_destroy(b); icmp_proxy_destroy(b); u_free(b); |
||||||
|
puts("[PASS] UDP/ICMP contexts and destruction are instance-local"); |
||||||
|
} |
||||||
|
|
||||||
|
// Принимающий callback lwIP запускаем с PCB установленного соединения; дальнейшие
|
||||||
|
// recv/abort/shutdown проходят через настоящие callbacks proxy и стек lwIP.
|
||||||
|
static struct tcp_pcb* accept_tun(struct tcp_proxy_client* p) { |
||||||
|
struct tcp_pcb* pcb = tcp_new(p->lwip); CHECK(pcb); |
||||||
|
pcb->state = ESTABLISHED; pcb->local_port = 80; pcb->remote_port = 12345; |
||||||
|
pcb->local_ip = htonl(0xc0000201); pcb->remote_ip = htonl(0x0a000002); |
||||||
|
pcb->next = p->lwip->active_pcbs; p->lwip->active_pcbs = pcb; |
||||||
|
struct tcp_pcb_listen* listener = (struct tcp_pcb_listen*)p->lwip->listen_pcbs; CHECK(listener && listener->accept); |
||||||
|
CHECK(listener->accept(p, pcb, LERR_OK) == LERR_OK); |
||||||
|
return pcb; |
||||||
|
} |
||||||
|
|
||||||
|
static void tun_tests(struct UTUN_INSTANCE* inst) { |
||||||
|
#ifndef _WIN32 |
||||||
|
struct tcp_proxy_client_mapping_config map = {.local_port=80, .remote_port=80, .remote_ip="192.0.2.1"}; |
||||||
|
struct tcp_proxy_client* p = tcp_proxy_client_create(inst, ua, "testproxy", "10.0.0.1", 1500, 1, &map, 1, 42, 0, NULL, 0, NULL); |
||||||
|
CHECK(p); inst->tcp_proxy_client = p; |
||||||
|
message_count = 0; |
||||||
|
struct tcp_pcb* pcb = accept_tun(p); CHECK(p->conn_count == 1); |
||||||
|
uint32_t sid = p->conns->stream_id; |
||||||
|
deliver(inst, 42, ETCP_RT_ID_TCP_PROXY_CLIENT, TCP_PROXY_SUBCMD_CONNECTED, sid, NULL, 0); |
||||||
|
p->conns->flow.tx_credit = 0; |
||||||
|
struct pbuf* pb = pbuf_alloc(PBUF_RAW, 4); CHECK(pb); pbuf_take(pb, "tail", 4); |
||||||
|
CHECK(pcb->recv(pcb->callback_arg, pcb, pb, LERR_OK) == LERR_OK); |
||||||
|
pcb->state = CLOSE_WAIT; |
||||||
|
CHECK(pcb->recv(pcb->callback_arg, pcb, NULL, LERR_OK) == LERR_OK); |
||||||
|
deliver(inst, 42, ETCP_RT_ID_TCP_PROXY_CLIENT, TCP_PROXY_SUBCMD_FIN, sid, NULL, 0); |
||||||
|
CHECK(p->conn_count == 1 && p->conns->tx_queue->count == 1); |
||||||
|
message_count = 0; uint32_t credit = 4; |
||||||
|
deliver(inst, 42, ETCP_RT_ID_TCP_PROXY_CLIENT, TCP_PROXY_SUBCMD_WINDOW, sid, &credit, 4); |
||||||
|
CHECK(p->conn_count == 0 && count_cmd(TCP_PROXY_SUBCMD_DATA) == 1 && count_cmd(TCP_PROXY_SUBCMD_FIN) == 1); |
||||||
|
CHECK(messages[0].cmd == TCP_PROXY_SUBCMD_DATA && memcmp(messages[0].data, "tail", 4) == 0); |
||||||
|
pcb = accept_tun(p); CHECK(p->conn_count == 1); |
||||||
|
tcp_abort(pcb); CHECK(p->conn_count == 0); |
||||||
|
|
||||||
|
// IPv4 options смещают UDP-заголовок; последующий фрагмент не является UDP-запросом.
|
||||||
|
uint8_t ip[36] = {0x46, 0, 0, 36, 0, 0, 0, 0, 64, 17}; |
||||||
|
uint32_t src = htonl(0x0a000002), dst = htonl(0xc0000201); |
||||||
|
memcpy(ip+12, &src, 4); memcpy(ip+16, &dst, 4); |
||||||
|
uint16_t sport=htons(12345), dport=htons(53), ulen=htons(12); |
||||||
|
memcpy(ip+24, &sport, 2); memcpy(ip+26, &dport, 2); memcpy(ip+28, &ulen, 2); memcpy(ip+32, "data", 4); |
||||||
|
message_count = 0; |
||||||
|
for (int fragment = 0; fragment < 2; fragment++) { |
||||||
|
struct ll_entry* e = queue_entry_new(0); CHECK(e); |
||||||
|
e->dgram = u_calloc(1, sizeof(ip)+1); CHECK(e->dgram); e->len = sizeof(ip)+1; |
||||||
|
ip[7] = fragment; memcpy(e->dgram+1, ip, sizeof(ip)); |
||||||
|
queue_data_put(p->tun->output_queue, e); pump(); |
||||||
|
} |
||||||
|
CHECK(message_count == 1 && messages[0].svc == ETCP_RT_ID_UDP_PROXY && messages[0].len == 12); |
||||||
|
CHECK(memcmp(messages[0].data+6, &dport, 2) == 0 && memcmp(messages[0].data+8, "data", 4) == 0); |
||||||
|
tcp_proxy_client_destroy(p); inst->tcp_proxy_client = NULL; pump(); |
||||||
|
puts("[PASS] TUN pending DATA survives FIN; aborted PCB freed; IPv4 options/fragment validation"); |
||||||
|
#endif |
||||||
|
} |
||||||
|
|
||||||
|
int main(void) { |
||||||
|
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); |
||||||
|
if (getenv("UTUN_TEST_DEBUG")) debug_set_category_level(DEBUG_CATEGORY_PROXY, DEBUG_LEVEL_DEBUG); |
||||||
|
CHECK(socket_platform_init() == 0); |
||||||
|
ua = uasync_create(); CHECK(ua); |
||||||
|
struct UTUN_INSTANCE* inst = u_calloc(1, sizeof(*inst)); CHECK(inst); |
||||||
|
struct utun_config config = {0}; inst->config = &config; inst->ua = ua; |
||||||
|
instance_tests(inst); parser_tests(inst); server_tests(inst); tun_tests(inst); |
||||||
|
u_free(inst); pump(); uasync_destroy(ua, 0); |
||||||
|
CHECK(u_get_allocated_count() == 0); |
||||||
|
socket_platform_cleanup(); |
||||||
|
return 0; |
||||||
|
} |
||||||
@ -0,0 +1,42 @@ |
|||||||
|
// on_flushed должен приходить после последнего send даже без активного POLLOUT.
|
||||||
|
#include <stdio.h> |
||||||
|
#include <stdlib.h> |
||||||
|
#include <string.h> |
||||||
|
#include "tcp_io.h" |
||||||
|
#include "debug_config.h" |
||||||
|
#include "mem.h" |
||||||
|
#define CHECK(x) do { if (!(x)) { fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #x); exit(1); } } while (0) |
||||||
|
static int flushed; |
||||||
|
static void on_flushed(struct tcp_conn* tc, void* arg) { (void)tc; (void)arg; flushed++; } |
||||||
|
static void on_error(struct tcp_conn* tc, int err, void* arg) { (void)tc; (void)arg; fprintf(stderr, "socket error %d\n", err); exit(1); } |
||||||
|
int main(void) { |
||||||
|
debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); |
||||||
|
socket_platform_init(); |
||||||
|
struct UASYNC* ua = uasync_create(); CHECK(ua); |
||||||
|
socket_t listener = socket(AF_INET, SOCK_STREAM, 0); CHECK(listener != SOCKET_INVALID); |
||||||
|
struct sockaddr_in addr = {.sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK)}; |
||||||
|
CHECK(bind(listener, (struct sockaddr*)&addr, sizeof(addr)) == 0 && listen(listener, 1) == 0); |
||||||
|
socklen_t len = sizeof(addr); CHECK(getsockname(listener, (struct sockaddr*)&addr, &len) == 0); |
||||||
|
socket_t peer = socket(AF_INET, SOCK_STREAM, 0); CHECK(peer != SOCKET_INVALID); |
||||||
|
CHECK(connect(peer, (struct sockaddr*)&addr, sizeof(addr)) == 0); |
||||||
|
socket_t fd = accept(listener, NULL, NULL); CHECK(fd != SOCKET_INVALID); |
||||||
|
socket_close_wrapper(listener); socket_set_nonblocking(fd); socket_set_nonblocking(peer); |
||||||
|
struct tcp_conn* tc = tcp_conn_create(ua, fd, 4096, 4096, 8, 4, 0, NULL, on_error, NULL); CHECK(tc); |
||||||
|
tc->connected = 1; |
||||||
|
for (int n = 0; n < 3; n++) { |
||||||
|
for (int i = 0; i < 12; i++) uasync_poll(ua, 1); |
||||||
|
CHECK(tc->write_monitor == 0); |
||||||
|
struct ll_entry* e = queue_entry_new_from_pool(tc->entry_pool); CHECK(e); |
||||||
|
e->dgram = memory_pool_alloc(tc->data_pool); CHECK(e->dgram); |
||||||
|
memcpy(e->dgram, "last", 4); e->len = 4; |
||||||
|
tcp_conn_set_flushed(tc, on_flushed); queue_data_put(tc->write_queue, e); |
||||||
|
for (int i = 0; i < 12; i++) uasync_poll(ua, 1); |
||||||
|
CHECK(flushed == n + 1); |
||||||
|
char bytes[4]; CHECK(recv(peer, bytes, sizeof(bytes), 0) == 4 && memcmp(bytes, "last", 4) == 0); |
||||||
|
} |
||||||
|
tcp_conn_destroy(tc); socket_close_wrapper(peer); |
||||||
|
for (int i = 0; i < 12; i++) uasync_poll(ua, 1); |
||||||
|
uasync_destroy(ua, 0); CHECK(u_get_allocated_count() == 0); |
||||||
|
puts("[PASS] tcp_io: final synchronous send notifies flushed exactly once"); |
||||||
|
return 0; |
||||||
|
} |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
build/ |
||||||
|
.gradle/ |
||||||
|
*.apk |
||||||
|
*.iml |
||||||
|
.idea/ |
||||||
|
local.properties |
||||||
|
.cxx/ |
||||||
@ -0,0 +1,49 @@ |
|||||||
|
plugins { |
||||||
|
id("com.android.application") |
||||||
|
id("org.jetbrains.kotlin.android") |
||||||
|
} |
||||||
|
|
||||||
|
android { |
||||||
|
namespace = "com.utun.lightsout" |
||||||
|
compileSdk = 36 |
||||||
|
|
||||||
|
compileOptions { |
||||||
|
sourceCompatibility = JavaVersion.VERSION_17 |
||||||
|
targetCompatibility = JavaVersion.VERSION_17 |
||||||
|
} |
||||||
|
|
||||||
|
kotlinOptions { |
||||||
|
jvmTarget = "17" |
||||||
|
} |
||||||
|
|
||||||
|
defaultConfig { |
||||||
|
applicationId = "com.utun.lightsout" |
||||||
|
minSdk = 26 |
||||||
|
targetSdk = 36 |
||||||
|
versionCode = 1 |
||||||
|
versionName = "0.1.0" |
||||||
|
} |
||||||
|
|
||||||
|
buildFeatures { |
||||||
|
compose = true |
||||||
|
} |
||||||
|
|
||||||
|
composeOptions { |
||||||
|
kotlinCompilerExtensionVersion = "1.5.5" |
||||||
|
} |
||||||
|
|
||||||
|
buildTypes { |
||||||
|
release { |
||||||
|
isMinifyEnabled = false |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
dependencies { |
||||||
|
implementation(platform("androidx.compose:compose-bom:2024.02.00")) |
||||||
|
implementation("androidx.compose.ui:ui") |
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview") |
||||||
|
implementation("androidx.compose.material3:material3") |
||||||
|
implementation("androidx.compose.foundation:foundation") |
||||||
|
implementation("androidx.activity:activity-compose:1.8.2") |
||||||
|
} |
||||||
@ -0,0 +1,20 @@ |
|||||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
||||||
|
|
||||||
|
<application |
||||||
|
android:allowBackup="true" |
||||||
|
android:label="@string/app_name" |
||||||
|
android:supportsRtl="true" |
||||||
|
android:theme="@style/Theme.LightsOut"> |
||||||
|
|
||||||
|
<activity |
||||||
|
android:name=".MainActivity" |
||||||
|
android:exported="true" |
||||||
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"> |
||||||
|
<intent-filter> |
||||||
|
<action android:name="android.intent.action.MAIN" /> |
||||||
|
<category android:name="android.intent.category.LAUNCHER" /> |
||||||
|
</intent-filter> |
||||||
|
</activity> |
||||||
|
</application> |
||||||
|
</manifest> |
||||||
@ -0,0 +1,77 @@ |
|||||||
|
package com.utun.lightsout |
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue |
||||||
|
import androidx.compose.runtime.mutableIntStateOf |
||||||
|
import androidx.compose.runtime.mutableStateListOf |
||||||
|
import androidx.compose.runtime.setValue |
||||||
|
import kotlin.random.Random |
||||||
|
|
||||||
|
// Логика игры «Выключить свет»: хранит истинные состояния клеток (real, 0/1), |
||||||
|
// отображаемое значение каждой клетки — XOR её самой с четырьмя соседями. |
||||||
|
// Поле хранится как Compose-наблюдаемый список, чтобы клики перерисовывали доску. |
||||||
|
class LightsOutGame(initialSize: Int = 5) { |
||||||
|
var size: Int = initialSize |
||||||
|
private set |
||||||
|
|
||||||
|
val real = mutableStateListOf<Int>() |
||||||
|
|
||||||
|
var moves by mutableIntStateOf(0) |
||||||
|
private set |
||||||
|
|
||||||
|
init { |
||||||
|
newGame(initialSize) |
||||||
|
} |
||||||
|
|
||||||
|
// Новая партия: сброс поля и случайная решаемая позиция. |
||||||
|
fun newGame(size: Int) { |
||||||
|
this.size = size |
||||||
|
real.clear() |
||||||
|
repeat(size * size) { real.add(0) } |
||||||
|
moves = 0 |
||||||
|
scramble() |
||||||
|
} |
||||||
|
|
||||||
|
// Переключает клетку, инкрементирует счётчик, возвращает true, если решено. |
||||||
|
fun toggle(row: Int, col: Int): Boolean { |
||||||
|
val idx = row * size + col |
||||||
|
real[idx] = real[idx] xor 1 |
||||||
|
moves++ |
||||||
|
return isSolved() |
||||||
|
} |
||||||
|
|
||||||
|
// Отображаемое значение: XOR клетки с четырьмя соседями (с учётом границ). |
||||||
|
fun display(row: Int, col: Int): Int { |
||||||
|
var v = real[row * size + col] |
||||||
|
if (row > 0) v = v xor real[(row - 1) * size + col] |
||||||
|
if (row < size - 1) v = v xor real[(row + 1) * size + col] |
||||||
|
if (col > 0) v = v xor real[row * size + (col - 1)] |
||||||
|
if (col < size - 1) v = v xor real[row * size + (col + 1)] |
||||||
|
return v |
||||||
|
} |
||||||
|
|
||||||
|
// Истинное состояние клетки (для подсказки). |
||||||
|
fun isReallyOff(row: Int, col: Int): Boolean = real[row * size + col] == 0 |
||||||
|
|
||||||
|
// Победа — все отображаемые клетки погашены. |
||||||
|
fun isSolved(): Boolean { |
||||||
|
for (r in 0 until size) { |
||||||
|
for (c in 0 until size) { |
||||||
|
if (display(r, c) != 0) return false |
||||||
|
} |
||||||
|
} |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
// Позиция строится случайными одиночными переключениями из нулевого состояния — |
||||||
|
// обратная последовательность ходов гарантированно решает её. |
||||||
|
private fun scramble() { |
||||||
|
do { |
||||||
|
for (i in real.indices) real[i] = 0 |
||||||
|
val scrambles = size * size * 3 |
||||||
|
repeat(scrambles) { |
||||||
|
val idx = Random.nextInt(size * size) |
||||||
|
real[idx] = real[idx] xor 1 |
||||||
|
} |
||||||
|
} while (isSolved()) |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,206 @@ |
|||||||
|
package com.utun.lightsout |
||||||
|
|
||||||
|
import android.os.Bundle |
||||||
|
import androidx.activity.ComponentActivity |
||||||
|
import androidx.activity.compose.setContent |
||||||
|
import androidx.compose.foundation.background |
||||||
|
import androidx.compose.foundation.border |
||||||
|
import androidx.compose.foundation.clickable |
||||||
|
import androidx.compose.foundation.layout.Arrangement |
||||||
|
import androidx.compose.foundation.layout.Box |
||||||
|
import androidx.compose.foundation.layout.Column |
||||||
|
import androidx.compose.foundation.layout.Row |
||||||
|
import androidx.compose.foundation.layout.Spacer |
||||||
|
import androidx.compose.foundation.layout.aspectRatio |
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight |
||||||
|
import androidx.compose.foundation.layout.fillMaxSize |
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth |
||||||
|
import androidx.compose.foundation.layout.height |
||||||
|
import androidx.compose.foundation.layout.padding |
||||||
|
import androidx.compose.foundation.layout.size |
||||||
|
import androidx.compose.foundation.shape.CircleShape |
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape |
||||||
|
import androidx.compose.material3.AlertDialog |
||||||
|
import androidx.compose.material3.Button |
||||||
|
import androidx.compose.material3.MaterialTheme |
||||||
|
import androidx.compose.material3.OutlinedButton |
||||||
|
import androidx.compose.material3.Slider |
||||||
|
import androidx.compose.material3.Text |
||||||
|
import androidx.compose.material3.TextButton |
||||||
|
import androidx.compose.runtime.Composable |
||||||
|
import androidx.compose.runtime.getValue |
||||||
|
import androidx.compose.runtime.mutableIntStateOf |
||||||
|
import androidx.compose.runtime.mutableStateOf |
||||||
|
import androidx.compose.runtime.remember |
||||||
|
import androidx.compose.runtime.setValue |
||||||
|
import androidx.compose.ui.Alignment |
||||||
|
import androidx.compose.ui.Modifier |
||||||
|
import androidx.compose.ui.draw.clip |
||||||
|
import androidx.compose.ui.graphics.Color |
||||||
|
import androidx.compose.ui.unit.dp |
||||||
|
import kotlin.math.roundToInt |
||||||
|
|
||||||
|
private val OnColor = Color(0xFFFFD65C) // «включена» — тёплый жёлтый |
||||||
|
private val OffColor = Color(0xFF2C3038) // «выключена» — тёмно-серый |
||||||
|
private val HintColor = Color(0xFF78DC78) // подсказка — зелёная точка |
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() { |
||||||
|
override fun onCreate(savedInstanceState: Bundle?) { |
||||||
|
super.onCreate(savedInstanceState) |
||||||
|
setContent { |
||||||
|
MaterialTheme { |
||||||
|
LightsOutApp() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
fun LightsOutApp() { |
||||||
|
var game by remember { mutableStateOf<LightsOutGame?>(null) } |
||||||
|
var hintEnabled by remember { mutableStateOf(false) } |
||||||
|
var showDialog by remember { mutableStateOf(true) } |
||||||
|
var solved by remember { mutableStateOf(false) } |
||||||
|
|
||||||
|
// Диалог выбора размера: при старте и по кнопке «Новая игра». |
||||||
|
if (showDialog || game == null) { |
||||||
|
val current = game?.size ?: 5 |
||||||
|
var selected by remember(current) { mutableIntStateOf(current) } |
||||||
|
val isInitial = game == null |
||||||
|
AlertDialog( |
||||||
|
onDismissRequest = { if (!isInitial) showDialog = false }, |
||||||
|
title = { Text(if (isInitial) "Выключить свет" else "Новая игра") }, |
||||||
|
text = { |
||||||
|
Column( |
||||||
|
modifier = Modifier.fillMaxWidth(), |
||||||
|
horizontalAlignment = Alignment.CenterHorizontally |
||||||
|
) { |
||||||
|
Text( |
||||||
|
"$selected × $selected", |
||||||
|
style = MaterialTheme.typography.headlineMedium |
||||||
|
) |
||||||
|
Slider( |
||||||
|
value = selected.toFloat(), |
||||||
|
onValueChange = { selected = it.roundToInt() }, |
||||||
|
valueRange = 3f..8f, |
||||||
|
steps = 4 |
||||||
|
) |
||||||
|
Text( |
||||||
|
"Размер поля", |
||||||
|
style = MaterialTheme.typography.bodyMedium |
||||||
|
) |
||||||
|
} |
||||||
|
}, |
||||||
|
confirmButton = { |
||||||
|
TextButton(onClick = { |
||||||
|
game = LightsOutGame(selected) |
||||||
|
hintEnabled = false |
||||||
|
solved = false |
||||||
|
showDialog = false |
||||||
|
}) { |
||||||
|
Text(if (isInitial) "Играть" else "Начать") |
||||||
|
} |
||||||
|
}, |
||||||
|
dismissButton = { |
||||||
|
if (!isInitial) { |
||||||
|
TextButton(onClick = { showDialog = false }) { Text("Отмена") } |
||||||
|
} |
||||||
|
} |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
game?.let { g -> |
||||||
|
Column( |
||||||
|
modifier = Modifier |
||||||
|
.fillMaxSize() |
||||||
|
.padding(16.dp), |
||||||
|
horizontalAlignment = Alignment.CenterHorizontally |
||||||
|
) { |
||||||
|
Text("Ходы: ${g.moves}", style = MaterialTheme.typography.titleLarge) |
||||||
|
Spacer(Modifier.height(16.dp)) |
||||||
|
|
||||||
|
Board( |
||||||
|
game = g, |
||||||
|
hintEnabled = hintEnabled, |
||||||
|
onCellTap = { r, c -> |
||||||
|
if (g.toggle(r, c)) { |
||||||
|
solved = true |
||||||
|
} |
||||||
|
} |
||||||
|
) |
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp)) |
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { |
||||||
|
OutlinedButton(onClick = { hintEnabled = !hintEnabled }) { |
||||||
|
Text(if (hintEnabled) "Скрыть подсказку" else "Подсказка") |
||||||
|
} |
||||||
|
Button(onClick = { showDialog = true }) { |
||||||
|
Text("Новая игра") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (solved) { |
||||||
|
val g = game ?: return@LightsOutApp |
||||||
|
AlertDialog( |
||||||
|
onDismissRequest = { solved = false }, |
||||||
|
title = { Text("Победа!") }, |
||||||
|
text = { Text("Свет выключен за ${g.moves} ходов.") }, |
||||||
|
confirmButton = { |
||||||
|
Button(onClick = { showDialog = true }) { Text("Новая игра") } |
||||||
|
}, |
||||||
|
dismissButton = { |
||||||
|
TextButton(onClick = { solved = false }) { Text("Ок") } |
||||||
|
} |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Composable |
||||||
|
private fun Board( |
||||||
|
game: LightsOutGame, |
||||||
|
hintEnabled: Boolean, |
||||||
|
onCellTap: (Int, Int) -> Unit |
||||||
|
) { |
||||||
|
val n = game.size |
||||||
|
Column( |
||||||
|
modifier = Modifier |
||||||
|
.fillMaxWidth() |
||||||
|
.aspectRatio(1f), |
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp) |
||||||
|
) { |
||||||
|
for (r in 0 until n) { |
||||||
|
Row( |
||||||
|
modifier = Modifier |
||||||
|
.weight(1f) |
||||||
|
.fillMaxWidth(), |
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp) |
||||||
|
) { |
||||||
|
for (c in 0 until n) { |
||||||
|
val on = game.display(r, c) != 0 |
||||||
|
val hint = hintEnabled && game.isReallyOff(r, c) |
||||||
|
Box( |
||||||
|
modifier = Modifier |
||||||
|
.weight(1f) |
||||||
|
.fillMaxHeight() |
||||||
|
.clip(RoundedCornerShape(6.dp)) |
||||||
|
.background(if (on) OnColor else OffColor) |
||||||
|
.border(1.dp, Color(0xFF14161A), RoundedCornerShape(6.dp)) |
||||||
|
.clickable { onCellTap(r, c) }, |
||||||
|
contentAlignment = Alignment.Center |
||||||
|
) { |
||||||
|
if (hint) { |
||||||
|
Box( |
||||||
|
modifier = Modifier |
||||||
|
.size(18.dp) |
||||||
|
.clip(CircleShape) |
||||||
|
.background(HintColor) |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,4 @@ |
|||||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||||
|
<resources> |
||||||
|
<string name="app_name">Выключить свет</string> |
||||||
|
</resources> |
||||||
@ -0,0 +1,4 @@ |
|||||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||||
|
<resources> |
||||||
|
<style name="Theme.LightsOut" parent="android:Theme.Material.Light.NoActionBar" /> |
||||||
|
</resources> |
||||||
@ -0,0 +1,4 @@ |
|||||||
|
plugins { |
||||||
|
id("com.android.application") version "8.2.0" apply false |
||||||
|
id("org.jetbrains.kotlin.android") version "1.9.20" apply false |
||||||
|
} |
||||||
@ -0,0 +1,49 @@ |
|||||||
|
#!/bin/bash |
||||||
|
# Сборка и прошивка игры «Выключить свет» (Android). |
||||||
|
# Использование: |
||||||
|
# ./build.sh # clean + сборка + установка на все подключённые устройства |
||||||
|
# ./build.sh noinstall # только сборка, без прошивки |
||||||
|
# ./build.sh <SN> # clean + сборка + прошивка на конкретный девайс |
||||||
|
set -eo pipefail |
||||||
|
cd "$(dirname "$0")" |
||||||
|
export ANDROID_HOME="${ANDROID_HOME:-/home/user/android}" |
||||||
|
APK=app/build/outputs/apk/debug/app-debug.apk |
||||||
|
|
||||||
|
echo "Building..." |
||||||
|
TMP=$(mktemp) |
||||||
|
./gradlew clean > /dev/null 2>&1 |
||||||
|
if ! ./gradlew assembleDebug > "$TMP" 2>&1; then |
||||||
|
cat "$TMP" |
||||||
|
rm -f "$TMP" |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
rm -f "$TMP" |
||||||
|
echo "Build OK" |
||||||
|
|
||||||
|
[ "$1" = "noinstall" ] && exit 0 |
||||||
|
|
||||||
|
if [ -n "$1" ]; then |
||||||
|
DEVICES=("$1") |
||||||
|
else |
||||||
|
mapfile -t DEVICES < <(adb devices | awk 'NR>1 && $2=="device"{print $1}') |
||||||
|
fi |
||||||
|
|
||||||
|
if [ ${#DEVICES[@]} -eq 0 ]; then |
||||||
|
echo "No devices connected" >&2 |
||||||
|
exit 1 |
||||||
|
fi |
||||||
|
|
||||||
|
echo "Devices (${#DEVICES[@]}):" |
||||||
|
i=1 |
||||||
|
for d in "${DEVICES[@]}"; do |
||||||
|
name=$(adb -s "$d" shell getprop ro.product.model 2>/dev/null | tr -d '\r') |
||||||
|
[ -z "$name" ] && name=$(adb -s "$d" shell getprop ro.product.marketname 2>/dev/null | tr -d '\r') |
||||||
|
echo " $i) $d $name" |
||||||
|
i=$((i+1)) |
||||||
|
done |
||||||
|
|
||||||
|
for d in "${DEVICES[@]}"; do |
||||||
|
echo "Installing on $d..." |
||||||
|
adb -s "$d" install -r "$APK" |
||||||
|
done |
||||||
|
echo "Install OK" |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
android.useAndroidX=true |
||||||
|
kotlin.code.style=official |
||||||
|
org.gradle.jvmargs=-Xmx2g |
||||||
Binary file not shown.
@ -0,0 +1,7 @@ |
|||||||
|
distributionBase=GRADLE_USER_HOME |
||||||
|
distributionPath=wrapper/dists |
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip |
||||||
|
networkTimeout=10000 |
||||||
|
validateDistributionUrl=true |
||||||
|
zipStoreBase=GRADLE_USER_HOME |
||||||
|
zipStorePath=wrapper/dists |
||||||
@ -0,0 +1,249 @@ |
|||||||
|
#!/bin/sh |
||||||
|
|
||||||
|
# |
||||||
|
# Copyright © 2015-2021 the original authors. |
||||||
|
# |
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
# you may not use this file except in compliance with the License. |
||||||
|
# You may obtain a copy of the License at |
||||||
|
# |
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
# |
||||||
|
# Unless required by applicable law or agreed to in writing, software |
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
# See the License for the specific language governing permissions and |
||||||
|
# limitations under the License. |
||||||
|
# |
||||||
|
|
||||||
|
############################################################################## |
||||||
|
# |
||||||
|
# Gradle start up script for POSIX generated by Gradle. |
||||||
|
# |
||||||
|
# Important for running: |
||||||
|
# |
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is |
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or |
||||||
|
# bash, then to run this script, type that shell name before the whole |
||||||
|
# command line, like: |
||||||
|
# |
||||||
|
# ksh Gradle |
||||||
|
# |
||||||
|
# Busybox and similar reduced shells will NOT work, because this script |
||||||
|
# requires all of these POSIX shell features: |
||||||
|
# * functions; |
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», |
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»; |
||||||
|
# * compound commands having a testable exit status, especially «case»; |
||||||
|
# * various built-in commands including «command», «set», and «ulimit». |
||||||
|
# |
||||||
|
# Important for patching: |
||||||
|
# |
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided |
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided. |
||||||
|
# |
||||||
|
# The "traditional" practice of packing multiple parameters into a |
||||||
|
# space-separated string is a well documented source of bugs and security |
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating |
||||||
|
# options in "$@", and eventually passing that to Java. |
||||||
|
# |
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, |
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; |
||||||
|
# see the in-line comments for details. |
||||||
|
# |
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin, |
||||||
|
# Darwin, MinGW, and NonStop. |
||||||
|
# |
||||||
|
# (3) This script is generated from the Groovy template |
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt |
||||||
|
# within the Gradle project. |
||||||
|
# |
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/. |
||||||
|
# |
||||||
|
############################################################################## |
||||||
|
|
||||||
|
# Attempt to set APP_HOME |
||||||
|
|
||||||
|
# Resolve links: $0 may be a link |
||||||
|
app_path=$0 |
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks. |
||||||
|
while |
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path |
||||||
|
[ -h "$app_path" ] |
||||||
|
do |
||||||
|
ls=$( ls -ld "$app_path" ) |
||||||
|
link=${ls#*' -> '} |
||||||
|
case $link in #( |
||||||
|
/*) app_path=$link ;; #( |
||||||
|
*) app_path=$APP_HOME$link ;; |
||||||
|
esac |
||||||
|
done |
||||||
|
|
||||||
|
# This is normally unused |
||||||
|
# shellcheck disable=SC2034 |
||||||
|
APP_BASE_NAME=${0##*/} |
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) |
||||||
|
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit |
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value. |
||||||
|
MAX_FD=maximum |
||||||
|
|
||||||
|
warn () { |
||||||
|
echo "$*" |
||||||
|
} >&2 |
||||||
|
|
||||||
|
die () { |
||||||
|
echo |
||||||
|
echo "$*" |
||||||
|
echo |
||||||
|
exit 1 |
||||||
|
} >&2 |
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false'). |
||||||
|
cygwin=false |
||||||
|
msys=false |
||||||
|
darwin=false |
||||||
|
nonstop=false |
||||||
|
case "$( uname )" in #( |
||||||
|
CYGWIN* ) cygwin=true ;; #( |
||||||
|
Darwin* ) darwin=true ;; #( |
||||||
|
MSYS* | MINGW* ) msys=true ;; #( |
||||||
|
NONSTOP* ) nonstop=true ;; |
||||||
|
esac |
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar |
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM. |
||||||
|
if [ -n "$JAVA_HOME" ] ; then |
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then |
||||||
|
# IBM's JDK on AIX uses strange locations for the executables |
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java |
||||||
|
else |
||||||
|
JAVACMD=$JAVA_HOME/bin/java |
||||||
|
fi |
||||||
|
if [ ! -x "$JAVACMD" ] ; then |
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME |
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the |
||||||
|
location of your Java installation." |
||||||
|
fi |
||||||
|
else |
||||||
|
JAVACMD=java |
||||||
|
if ! command -v java >/dev/null 2>&1 |
||||||
|
then |
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. |
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the |
||||||
|
location of your Java installation." |
||||||
|
fi |
||||||
|
fi |
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can. |
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then |
||||||
|
case $MAX_FD in #( |
||||||
|
max*) |
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. |
||||||
|
# shellcheck disable=SC2039,SC3045 |
||||||
|
MAX_FD=$( ulimit -H -n ) || |
||||||
|
warn "Could not query maximum file descriptor limit" |
||||||
|
esac |
||||||
|
case $MAX_FD in #( |
||||||
|
'' | soft) :;; #( |
||||||
|
*) |
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. |
||||||
|
# shellcheck disable=SC2039,SC3045 |
||||||
|
ulimit -n "$MAX_FD" || |
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD" |
||||||
|
esac |
||||||
|
fi |
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order: |
||||||
|
# * args from the command line |
||||||
|
# * the main class name |
||||||
|
# * -classpath |
||||||
|
# * -D...appname settings |
||||||
|
# * --module-path (only if needed) |
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. |
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java |
||||||
|
if "$cygwin" || "$msys" ; then |
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) |
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) |
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" ) |
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh |
||||||
|
for arg do |
||||||
|
if |
||||||
|
case $arg in #( |
||||||
|
-*) false ;; # don't mess with options #( |
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath |
||||||
|
[ -e "$t" ] ;; #( |
||||||
|
*) false ;; |
||||||
|
esac |
||||||
|
then |
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" ) |
||||||
|
fi |
||||||
|
# Roll the args list around exactly as many times as the number of |
||||||
|
# args, so each arg winds up back in the position where it started, but |
||||||
|
# possibly modified. |
||||||
|
# |
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so |
||||||
|
# changing the positional parameters here affects neither the number of |
||||||
|
# iterations, nor the values presented in `arg`. |
||||||
|
shift # remove old arg |
||||||
|
set -- "$@" "$arg" # push replacement arg |
||||||
|
done |
||||||
|
fi |
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. |
||||||
|
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' |
||||||
|
|
||||||
|
# Collect all arguments for the java command: |
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, |
||||||
|
# and any embedded shellness will be escaped. |
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be |
||||||
|
# treated as '${Hostname}' itself on the command line. |
||||||
|
|
||||||
|
set -- \ |
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \ |
||||||
|
-classpath "$CLASSPATH" \ |
||||||
|
org.gradle.wrapper.GradleWrapperMain \ |
||||||
|
"$@" |
||||||
|
|
||||||
|
# Stop when "xargs" is not available. |
||||||
|
if ! command -v xargs >/dev/null 2>&1 |
||||||
|
then |
||||||
|
die "xargs is not available" |
||||||
|
fi |
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args. |
||||||
|
# |
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed. |
||||||
|
# |
||||||
|
# In Bash we could simply go: |
||||||
|
# |
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) && |
||||||
|
# set -- "${ARGS[@]}" "$@" |
||||||
|
# |
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we |
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any |
||||||
|
# character that might be a shell metacharacter, then use eval to reverse |
||||||
|
# that process (while maintaining the separation between arguments), and wrap |
||||||
|
# the whole thing up as a single "set" statement. |
||||||
|
# |
||||||
|
# This will of course break if any of these variables contains a newline or |
||||||
|
# an unmatched quote. |
||||||
|
# |
||||||
|
|
||||||
|
eval "set -- $( |
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | |
||||||
|
xargs -n1 | |
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | |
||||||
|
tr '\n' ' ' |
||||||
|
)" '"$@"' |
||||||
|
|
||||||
|
exec "$JAVACMD" "$@" |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
pluginManagement { |
||||||
|
repositories { |
||||||
|
google() |
||||||
|
mavenCentral() |
||||||
|
gradlePluginPortal() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
dependencyResolutionManagement { |
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) |
||||||
|
repositories { |
||||||
|
google() |
||||||
|
mavenCentral() |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
rootProject.name = "lightsout-android" |
||||||
|
include(":app") |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
cmake_minimum_required(VERSION 3.16) |
||||||
|
project(lightsout VERSION 0.1.0 LANGUAGES CXX) |
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 17) |
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON) |
||||||
|
set(CMAKE_AUTOMOC ON) |
||||||
|
|
||||||
|
find_package(Qt6 COMPONENTS Widgets QUIET) |
||||||
|
if(NOT Qt6_FOUND) |
||||||
|
find_package(Qt5 COMPONENTS Widgets REQUIRED) |
||||||
|
set(QT_LIBS Qt5::Widgets) |
||||||
|
else() |
||||||
|
set(QT_LIBS Qt6::Widgets) |
||||||
|
endif() |
||||||
|
|
||||||
|
add_executable(lightsout |
||||||
|
src/main.cpp |
||||||
|
src/mainwindow.cpp |
||||||
|
src/boardwidget.cpp |
||||||
|
) |
||||||
|
|
||||||
|
target_link_libraries(lightsout PRIVATE ${QT_LIBS}) |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
#!/bin/sh |
||||||
|
# Сборка игры «Выключить свет» (Qt). |
||||||
|
set -e |
||||||
|
cd "$(dirname "$0")" |
||||||
|
|
||||||
|
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release |
||||||
|
cmake --build build -j"$(nproc 2>/dev/null || echo 4)" |
||||||
|
echo "Готово: ./build/lightsout" |
||||||
@ -0,0 +1,141 @@ |
|||||||
|
#include "boardwidget.h" |
||||||
|
|
||||||
|
#include <QMouseEvent> |
||||||
|
#include <QPainter> |
||||||
|
#include <QRandomGenerator> |
||||||
|
|
||||||
|
namespace { |
||||||
|
constexpr int kMargin = 8; // внешний отступ поля
|
||||||
|
constexpr int kGap = 4; // зазор между клетками
|
||||||
|
constexpr int kMinCell = 36; // минимальный размер клетки (пикс.)
|
||||||
|
constexpr int kMaxCell = 72; // максимальный размер клетки (пикс.)
|
||||||
|
} |
||||||
|
|
||||||
|
BoardWidget::BoardWidget(QWidget *parent) |
||||||
|
: QWidget(parent) { |
||||||
|
setMinimumSize(220, 220); |
||||||
|
} |
||||||
|
|
||||||
|
// Создаёт новую партию: сбрасывает поле, затем случайными одиночными
|
||||||
|
// переключениями формирует решаемую стартовую позицию.
|
||||||
|
void BoardWidget::newGame(int size) { |
||||||
|
m_size = size; |
||||||
|
m_moves = 0; |
||||||
|
m_real.fill(0, m_size * m_size); |
||||||
|
|
||||||
|
// Позиция строится переключением случайных клеток из нулевого состояния —
|
||||||
|
// обратная последовательность ходов гарантированно решает её.
|
||||||
|
const int scrambles = m_size * m_size * 3; |
||||||
|
for (int i = 0; i < scrambles; ++i) { |
||||||
|
const int idx = QRandomGenerator::global()->bounded(m_size * m_size); |
||||||
|
m_real[idx] ^= 1; |
||||||
|
} |
||||||
|
|
||||||
|
update(); |
||||||
|
emit stateChanged(isSolved()); |
||||||
|
} |
||||||
|
|
||||||
|
// Включает/выключает показ подсказки.
|
||||||
|
void BoardWidget::setHintEnabled(bool enabled) { |
||||||
|
if (m_hintEnabled == enabled) { |
||||||
|
return; |
||||||
|
} |
||||||
|
m_hintEnabled = enabled; |
||||||
|
update(); |
||||||
|
} |
||||||
|
|
||||||
|
// Отображаемое значение клетки — XOR её самой с четырьмя соседями.
|
||||||
|
int BoardWidget::displayValue(int row, int col) const { |
||||||
|
int v = m_real[row * m_size + col]; |
||||||
|
if (row > 0) v ^= m_real[(row - 1) * m_size + col]; |
||||||
|
if (row < m_size - 1) v ^= m_real[(row + 1) * m_size + col]; |
||||||
|
if (col > 0) v ^= m_real[row * m_size + (col - 1)]; |
||||||
|
if (col < m_size - 1) v ^= m_real[row * m_size + (col + 1)]; |
||||||
|
return v; |
||||||
|
} |
||||||
|
|
||||||
|
// Переключает клетку по клику и проверяет победу.
|
||||||
|
void BoardWidget::toggleCell(int row, int col) { |
||||||
|
m_real[row * m_size + col] ^= 1; |
||||||
|
++m_moves; |
||||||
|
update(); |
||||||
|
emit stateChanged(isSolved()); |
||||||
|
} |
||||||
|
|
||||||
|
// Победа — все отображаемые клетки погашены.
|
||||||
|
bool BoardWidget::isSolved() const { |
||||||
|
for (int r = 0; r < m_size; ++r) { |
||||||
|
for (int c = 0; c < m_size; ++c) { |
||||||
|
if (displayValue(r, c) != 0) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
void BoardWidget::mousePressEvent(QMouseEvent *event) { |
||||||
|
if (m_size <= 0 || event->button() != Qt::LeftButton) { |
||||||
|
QWidget::mousePressEvent(event); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const QRectF board = boardRect(); |
||||||
|
if (!board.contains(event->pos())) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const double cell = (board.width() - (m_size - 1) * kGap) / m_size; |
||||||
|
const int col = static_cast<int>((event->pos().x() - board.left()) / (cell + kGap)); |
||||||
|
const int row = static_cast<int>((event->pos().y() - board.top()) / (cell + kGap)); |
||||||
|
|
||||||
|
if (row >= 0 && row < m_size && col >= 0 && col < m_size) { |
||||||
|
toggleCell(row, col); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
void BoardWidget::paintEvent(QPaintEvent *) { |
||||||
|
QPainter painter(this); |
||||||
|
painter.setRenderHint(QPainter::Antialiasing); |
||||||
|
|
||||||
|
if (m_size <= 0) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const QRectF board = boardRect(); |
||||||
|
const double cell = (board.width() - (m_size - 1) * kGap) / m_size; |
||||||
|
|
||||||
|
const QColor onColor(255, 214, 92); // «включена» — тёплый жёлтый
|
||||||
|
const QColor offColor(44, 48, 56); // «выключена» — тёмно-серый
|
||||||
|
const QColor hintColor(120, 220, 120); // подсказка — зелёная точка
|
||||||
|
|
||||||
|
for (int r = 0; r < m_size; ++r) { |
||||||
|
for (int c = 0; c < m_size; ++c) { |
||||||
|
const QRectF rect( |
||||||
|
board.left() + c * (cell + kGap), |
||||||
|
board.top() + r * (cell + kGap), |
||||||
|
cell, cell); |
||||||
|
|
||||||
|
painter.setPen(QPen(QColor(20, 22, 26), 1.5)); |
||||||
|
painter.setBrush(displayValue(r, c) ? onColor : offColor); |
||||||
|
painter.drawRoundedRect(rect, 6, 6); |
||||||
|
|
||||||
|
// Подсказка: точка на клетках, чьё истинное значение равно 0.
|
||||||
|
if (m_hintEnabled && m_real[r * m_size + c] == 0) { |
||||||
|
painter.setPen(Qt::NoPen); |
||||||
|
painter.setBrush(hintColor); |
||||||
|
const double d = cell * 0.22; |
||||||
|
painter.drawEllipse(rect.center(), d / 2, d / 2); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Прямоугольник, занимаемый сеткой клеток (центрированный квадрат).
|
||||||
|
QRectF BoardWidget::boardRect() const { |
||||||
|
const int cell = kMinCell; |
||||||
|
const double side = cell * m_size + kGap * (m_size - 1); |
||||||
|
const double x = (width() - side) / 2.0; |
||||||
|
const double y = (height() - side) / 2.0; |
||||||
|
return QRectF(x, y, side, side); |
||||||
|
} |
||||||
@ -0,0 +1,54 @@ |
|||||||
|
#ifndef LIGHTSOUT_BOARDWIDGET_H |
||||||
|
#define LIGHTSOUT_BOARDWIDGET_H |
||||||
|
|
||||||
|
#include <QWidget> |
||||||
|
#include <QVector> |
||||||
|
|
||||||
|
// Виджет игрового поля: хранит истинные состояния клеток (real, 0/1),
|
||||||
|
// отображает XOR каждой клетки с четырьмя соседями, опционально показывает
|
||||||
|
// подсказку (точку) на клетках, чьё истинное значение равно 0.
|
||||||
|
class BoardWidget : public QWidget { |
||||||
|
Q_OBJECT |
||||||
|
public: |
||||||
|
explicit BoardWidget(QWidget *parent = nullptr); |
||||||
|
|
||||||
|
// Создаёт новую игру: поле size x size, случайная решаемая позиция.
|
||||||
|
void newGame(int size); |
||||||
|
|
||||||
|
// Включает/выключает показ подсказки (точка на клетках real == 0).
|
||||||
|
void setHintEnabled(bool enabled); |
||||||
|
|
||||||
|
// Показывается ли сейчас подсказка.
|
||||||
|
bool isHintEnabled() const { return m_hintEnabled; } |
||||||
|
|
||||||
|
// Число ходов с начала партии.
|
||||||
|
int moves() const { return m_moves; } |
||||||
|
|
||||||
|
signals: |
||||||
|
// Испускается после каждого хода; solved == true, если все клетки погашены.
|
||||||
|
void stateChanged(bool solved); |
||||||
|
|
||||||
|
protected: |
||||||
|
void paintEvent(QPaintEvent *event) override; |
||||||
|
void mousePressEvent(QMouseEvent *event) override; |
||||||
|
|
||||||
|
private: |
||||||
|
// Прямоугольник, занимаемый сеткой клеток (центрированный квадрат).
|
||||||
|
QRectF boardRect() const; |
||||||
|
|
||||||
|
// Отображаемое значение клетки: XOR её и четырёх соседей.
|
||||||
|
int displayValue(int row, int col) const; |
||||||
|
|
||||||
|
// Переключить клетку (обрабатывает клик), пересчитать и проверить победу.
|
||||||
|
void toggleCell(int row, int col); |
||||||
|
|
||||||
|
// Проверяет, все ли отображаемые клетки погашены.
|
||||||
|
bool isSolved() const; |
||||||
|
|
||||||
|
int m_size = 0; // размер поля (3..8)
|
||||||
|
QVector<quint8> m_real; // истинное состояние клеток
|
||||||
|
bool m_hintEnabled = false; // показывать подсказку
|
||||||
|
int m_moves = 0; // число ходов
|
||||||
|
}; |
||||||
|
|
||||||
|
#endif // LIGHTSOUT_BOARDWIDGET_H
|
||||||
@ -0,0 +1,21 @@ |
|||||||
|
#include "mainwindow.h" |
||||||
|
|
||||||
|
#include <QApplication> |
||||||
|
#include <QInputDialog> |
||||||
|
|
||||||
|
// Точка входа: запрашивает размер поля (3–8) и запускает игру.
|
||||||
|
int main(int argc, char *argv[]) { |
||||||
|
QApplication app(argc, argv); |
||||||
|
|
||||||
|
bool ok = false; |
||||||
|
const int size = QInputDialog::getInt( |
||||||
|
nullptr, QObject::tr("Выключить свет"), |
||||||
|
QObject::tr("Размер поля (3–8):"), 5, 3, 8, 1, &ok); |
||||||
|
if (!ok) { |
||||||
|
return 0; |
||||||
|
} |
||||||
|
|
||||||
|
MainWindow window(size); |
||||||
|
window.show(); |
||||||
|
return app.exec(); |
||||||
|
} |
||||||
@ -0,0 +1,73 @@ |
|||||||
|
#include "mainwindow.h" |
||||||
|
#include "boardwidget.h" |
||||||
|
|
||||||
|
#include <QInputDialog> |
||||||
|
#include <QLabel> |
||||||
|
#include <QMenuBar> |
||||||
|
#include <QMessageBox> |
||||||
|
#include <QPushButton> |
||||||
|
#include <QToolBar> |
||||||
|
#include <QVBoxLayout> |
||||||
|
#include <QWidget> |
||||||
|
|
||||||
|
MainWindow::MainWindow(int size, QWidget *parent) |
||||||
|
: QMainWindow(parent), m_size(size) { |
||||||
|
setWindowTitle(QStringLiteral("Выключить свет")); |
||||||
|
|
||||||
|
m_board = new BoardWidget(this); |
||||||
|
m_board->newGame(m_size); |
||||||
|
|
||||||
|
m_movesLabel = new QLabel(this); |
||||||
|
m_movesLabel->setText(tr("Ходы: 0")); |
||||||
|
|
||||||
|
auto *hintButton = new QPushButton(tr("Подсказка"), this); |
||||||
|
hintButton->setCheckable(true); |
||||||
|
connect(hintButton, &QPushButton::toggled, this, &MainWindow::toggleHint); |
||||||
|
|
||||||
|
auto *newButton = new QPushButton(tr("Новая игра"), this); |
||||||
|
connect(newButton, &QPushButton::clicked, this, &MainWindow::newGame); |
||||||
|
|
||||||
|
auto *controls = new QWidget(this); |
||||||
|
auto *layout = new QVBoxLayout(controls); |
||||||
|
layout->addWidget(m_board, 1); |
||||||
|
layout->addWidget(m_movesLabel); |
||||||
|
layout->addWidget(hintButton); |
||||||
|
layout->addWidget(newButton); |
||||||
|
setCentralWidget(controls); |
||||||
|
|
||||||
|
connect(m_board, &BoardWidget::stateChanged, this, &MainWindow::onBoardStateChanged); |
||||||
|
} |
||||||
|
|
||||||
|
// Начинает новую партию, запрашивая размер поля у пользователя.
|
||||||
|
void MainWindow::newGame() { |
||||||
|
bool ok = false; |
||||||
|
const int size = QInputDialog::getInt( |
||||||
|
this, tr("Новая игра"), |
||||||
|
tr("Размер поля (3–8):"), m_size, 3, 8, 1, &ok); |
||||||
|
if (!ok) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
m_size = size; |
||||||
|
m_board->newGame(m_size); |
||||||
|
m_movesLabel->setText(tr("Ходы: 0")); |
||||||
|
} |
||||||
|
|
||||||
|
// Переключает показ подсказок.
|
||||||
|
void MainWindow::toggleHint() { |
||||||
|
m_board->setHintEnabled(!m_board->isHintEnabled()); |
||||||
|
} |
||||||
|
|
||||||
|
// Обновляет счётчик ходов и сообщает о победе.
|
||||||
|
void MainWindow::onBoardStateChanged(bool solved) { |
||||||
|
m_movesLabel->setText(tr("Ходы: %1").arg(m_board->moves())); |
||||||
|
if (solved) { |
||||||
|
const auto ret = QMessageBox::information( |
||||||
|
this, tr("Победа!"), |
||||||
|
tr("Свет выключен за %1 ходов.").arg(m_board->moves()), |
||||||
|
QMessageBox::Ok | QMessageBox::Reset, QMessageBox::Ok); |
||||||
|
if (ret == QMessageBox::Reset) { |
||||||
|
newGame(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,26 @@ |
|||||||
|
#ifndef LIGHTSOUT_MAINWINDOW_H |
||||||
|
#define LIGHTSOUT_MAINWINDOW_H |
||||||
|
|
||||||
|
#include <QMainWindow> |
||||||
|
|
||||||
|
class BoardWidget; |
||||||
|
class QLabel; |
||||||
|
|
||||||
|
// Главное окно игры: доска, кнопки «Новая игра»/«Подсказка», счётчик ходов.
|
||||||
|
class MainWindow : public QMainWindow { |
||||||
|
Q_OBJECT |
||||||
|
public: |
||||||
|
explicit MainWindow(int size, QWidget *parent = nullptr); |
||||||
|
|
||||||
|
private slots: |
||||||
|
void newGame(); |
||||||
|
void toggleHint(); |
||||||
|
void onBoardStateChanged(bool solved); |
||||||
|
|
||||||
|
private: |
||||||
|
int m_size; |
||||||
|
BoardWidget *m_board; |
||||||
|
QLabel *m_movesLabel; |
||||||
|
}; |
||||||
|
|
||||||
|
#endif // LIGHTSOUT_MAINWINDOW_H
|
||||||
Loading…
Reference in new issue