From d255bc5f826042080e7b9f5dfae58804e0889b47 Mon Sep 17 00:00:00 2001 From: Tremendous Stimulus <295218925+tremendous-stimulus@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:27:05 +0300 Subject: [PATCH 1/6] =?UTF-8?q?feat(ios):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20iOS=20framework?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + go.mod | 17 ++- go.sum | 16 +++ internal/stats/stats.go | 43 +++++- ios/logbuf.go | 65 +++++++++ ios/mobile.go | 285 ++++++++++++++++++++++++++++++++++++++++ ios/mobilestats.go | 45 +++++++ scripts/build-ios.sh | 29 ++++ 8 files changed, 494 insertions(+), 9 deletions(-) create mode 100644 ios/logbuf.go create mode 100644 ios/mobile.go create mode 100644 ios/mobilestats.go create mode 100755 scripts/build-ios.sh diff --git a/.gitignore b/.gitignore index 03b3b69..56306e2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ CLAUDE.md /build/ *.test coverage.out +# Скомпилированные бинари cmd/client и cmd/server +/client +/server .env clients.json client_config.json diff --git a/go.mod b/go.mod index ab24d51..e9aa936 100644 --- a/go.mod +++ b/go.mod @@ -14,9 +14,9 @@ require ( github.com/pion/turn/v5 v5.0.4 github.com/xtaci/kcp-go/v5 v5.6.72 github.com/xtaci/smux v1.5.57 - golang.org/x/crypto v0.52.0 + golang.org/x/crypto v0.53.0 golang.org/x/crypto/x509roots/fallback v0.0.0-20260522023950-e5306b2f3e96 - golang.org/x/net v0.55.0 + golang.org/x/net v0.56.0 ) require ( @@ -37,10 +37,13 @@ require ( github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect github.com/tjfoc/gmsm v1.4.1 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.45.0 // indirect + golang.org/x/tools v0.46.0 // indirect ) + +tool golang.org/x/mobile/cmd/gobind diff --git a/go.sum b/go.sum index 90844ca..6067162 100644 --- a/go.sum +++ b/go.sum @@ -95,14 +95,20 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/crypto/x509roots/fallback v0.0.0-20260522023950-e5306b2f3e96 h1:O1tFPDuiV6lk+c24ew9UqcKgmhgDF6aPXwSvYGxVR2A= golang.org/x/crypto/x509roots/fallback v0.0.0-20260522023950-e5306b2f3e96/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2 h1:zoM1gIKhVkcQNm43kad8OHLgPNoJ12xIqmxHtKr8Mug= +golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2/go.mod h1:QGMqsqLn6orFQ/ksqYMf+Fa33Soa1vPoHEd0Pj7N+lQ= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -112,12 +118,16 @@ golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -126,12 +136,16 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -141,6 +155,8 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= diff --git a/internal/stats/stats.go b/internal/stats/stats.go index f136041..a12119c 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -23,9 +23,42 @@ func New(enabled bool) *Stats { return &Stats{enabled: enabled} } +// Сессионные глобальные счётчики суммарного трафика. Инкремент идёт только +// между StartGlobalCount и StopGlobalCount — этим явно управляет вызывающий +// (mobile-биндинг на старте/дисконнекте сессии). Рассчитаны на одну активную +// сессию в процессе; вне сессии учёта нет. +var ( + globalTx atomic.Uint64 + globalRx atomic.Uint64 + trackGlobal atomic.Bool +) + +// StartGlobalCount включает учёт суммарного трафика в глобальных счётчиках. +func StartGlobalCount() { + trackGlobal.Store(true) +} + +// StopGlobalCount выключает учёт и обнуляет глобальные счётчики. +func StopGlobalCount() { + trackGlobal.Store(false) + globalTx.Store(0) + globalRx.Store(0) +} + +// GlobalCounters возвращает накопленные суммарные tx/rx байт текущей сессии. +func GlobalCounters() (tx, rx uint64) { + return globalTx.Load(), globalRx.Load() +} + // AddTx учитывает n переданных байт. func (s *Stats) AddTx(n int) { - if !s.enabled || n <= 0 { + if n <= 0 { + return + } + if trackGlobal.Load() { + globalTx.Add(uint64(n)) + } + if !s.enabled { return } s.tx.Add(uint64(n)) @@ -33,7 +66,13 @@ func (s *Stats) AddTx(n int) { // AddRx учитывает n полученных байт. func (s *Stats) AddRx(n int) { - if !s.enabled || n <= 0 { + if n <= 0 { + return + } + if trackGlobal.Load() { + globalRx.Add(uint64(n)) + } + if !s.enabled { return } s.rx.Add(uint64(n)) diff --git a/ios/logbuf.go b/ios/logbuf.go new file mode 100644 index 0000000..64bff6a --- /dev/null +++ b/ios/logbuf.go @@ -0,0 +1,65 @@ +package ios + +import ( + "fmt" + "log" + "strings" + "sync" + "time" +) + +const logBufMax = 500 + +type logBuffer struct { + mu sync.Mutex + lines []string +} + +func (b *logBuffer) append(line string) { + b.mu.Lock() + defer b.mu.Unlock() + b.lines = append(b.lines, line) + if len(b.lines) > logBufMax { + b.lines = b.lines[len(b.lines)-logBufMax:] + } +} + +func (b *logBuffer) get() string { + b.mu.Lock() + defer b.mu.Unlock() + return strings.Join(b.lines, "\n") +} + +func (b *logBuffer) clear() { + b.mu.Lock() + defer b.mu.Unlock() + b.lines = b.lines[:0] +} + +var sharedLogBuf = &logBuffer{} + +// GetLogs возвращает последние логи фреймворка (до 500 строк). +func GetLogs() string { return sharedLogBuf.get() } + +// ClearLogs очищает буфер логов. +func ClearLogs() { sharedLogBuf.clear() } + +// bufLogger реализует logx.Logger, пишет в stderr и в sharedLogBuf. +type bufLogger struct{ debug bool } + +func (l *bufLogger) write(level, format string, v ...any) { + msg := fmt.Sprintf(format, v...) + line := time.Now().Format("15:04:05") + " [" + level + "] " + msg + log.Print("[" + level + "] " + msg) + sharedLogBuf.append(line) +} + +func (l *bufLogger) Debugf(format string, v ...any) { + if l.debug { + l.write("DBG", format, v...) + } +} +func (l *bufLogger) Infof(format string, v ...any) { l.write("INF", format, v...) } +func (l *bufLogger) Warnf(format string, v ...any) { l.write("WRN", format, v...) } +func (l *bufLogger) Errorf(format string, v ...any) { l.write("ERR", format, v...) } +func (l *bufLogger) DebugEnabled() bool { return l.debug } diff --git a/ios/mobile.go b/ios/mobile.go new file mode 100644 index 0000000..650c960 --- /dev/null +++ b/ios/mobile.go @@ -0,0 +1,285 @@ +// Package ios экспортирует минимальный API для gomobile bind (iOS). +// Все экспортированные функции используют только примитивные типы — ограничение gomobile. +package ios + +import ( + "bytes" + "context" + "errors" + "fmt" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/samosvalishe/free-turn-proxy/internal/client/dnsdial" + "github.com/samosvalishe/free-turn-proxy/internal/config" + "github.com/samosvalishe/free-turn-proxy/internal/provider/vk" + "github.com/samosvalishe/free-turn-proxy/internal/proxy/udprelay" + "github.com/samosvalishe/free-turn-proxy/internal/stats" + "github.com/samosvalishe/free-turn-proxy/internal/transport/dtlsdial" +) + +// State — состояние подключения. +// gomobile генерирует константы IosStateXxx. +const ( + StateIdle = "idle" + StateConnecting = "connecting" + StateConnected = "connected" + StateError = "error" +) + +// Snapshot — единый консистентный снимок состояния сессии для UI: и стадия +// подключения, и статистика трафика. gomobile bind транслирует в ObjC-класс +// IosSnapshot. Один GetState() на тик заменяет прежние GetStatus/GetStats/ +// IsRunning — UI получает согласованный срез без гонок порядка чтения. +type Snapshot struct { + State string // idle | connecting | connected | error + Streams int // подключённых TURN-потоков прямо сейчас + Total int // целевое число потоков + ErrMsg string // непустой при State == error + TxTotal int64 // всего отправлено байт + RxTotal int64 // всего получено байт + TxRate int64 // текущая скорость отправки, байт/с + RxRate int64 // текущая скорость получения, байт/с +} + +// statusInfo — внутренний снимок стадии подключения (без статистики). +type statusInfo struct { + state string + streams int + total int + errMsg string +} + +// connectTimeout — сколько ждём первый успешный стрим. Если за это время ни +// один поток так и не поднялся (connectedStreams не стал > 0), считаем, что +// все стримы упали, и переходим в error вместо вечного connecting. Ошибка +// одного стрима не страшна — пока хоть один живой, таймаут не срабатывает. +const connectTimeout = 15 * time.Second + +var ( + mu sync.Mutex + cancelFn context.CancelFunc + statusVal atomic.Value // *statusInfo + running atomic.Bool + sessionGen atomic.Int64 // номер текущей сессии; растёт на каждый Start/Stop +) + +func setStatus(s *statusInfo) { statusVal.Store(s) } + +// GetState возвращает текущий снимок состояния сессии: стадию подключения +// плюс статистику трафика, собранные на чтении из внутренних атомиков. +func GetState() *Snapshot { + st, _ := statusVal.Load().(*statusInfo) + if st == nil { + st = &statusInfo{state: StateIdle} + } + tx, rx := stats.GlobalCounters() + return &Snapshot{ + State: st.state, + Streams: st.streams, + Total: st.total, + ErrMsg: st.errMsg, + TxTotal: int64(tx), + RxTotal: int64(rx), + TxRate: globalTxRate.Load(), + RxRate: globalRxRate.Load(), + } +} + +// Start запускает прокси-клиент. +// +// - link: ссылка VK https://vk.com/call/join/... или freeturn:// URI +// - peer: адрес freeturn-сервера на VPS, например "1.2.3.4:56000" +// - dns: DNS-серверы через запятую, например "8.8.8.8"; "" — авто +// - listen: локальный bind, например "127.0.0.1:9000"; "" — дефолт +// - transport: "tcp" или "udp"; "" — дефолт tcp +// - obfKey: ключ обфускации rtpopus (64 hex символа); "" — без обфускации. +// Должен совпадать с -obf-key сервера. +func Start(link, peer, dns, listen, transport, obfKey string) error { + mu.Lock() + defer mu.Unlock() + + if running.Load() { + return fmt.Errorf("already running") + } + if listen == "" { + listen = "127.0.0.1:9000" + } + + if transport == "" { + transport = "tcp" + } + args := []string{"-link", link, "-peer", peer, "-listen", listen, "-transport", transport} + if dns != "" { + args = append(args, "-dns-servers", dns) + } + if obfKey != "" { + args = append(args, "-obf-profile", "rtpopus", "-obf-key", obfKey) + } + + cfg, err := config.ParseClient(args, &bytes.Buffer{}) + if err != nil { + return err + } + + ClearLogs() + stats.StartGlobalCount() + + ctx, cancel := context.WithCancel(context.Background()) + cancelFn = cancel + running.Store(true) + gen := sessionGen.Add(1) + setStatus(&statusInfo{state: StateConnecting, total: cfg.TURN.N}) + go startRateMeter(ctx) + + go func() { + // Терминальный статус выставляем ровно один раз — здесь. Сначала + // отменяем контекст и ждём горутину-счётчик, чтобы она не затёрла + // финальный статус своим connecting/connected. Статус ставим ДО сброса + // running: тогда UI, увидев IsRunning()==false, гарантированно прочитает + // уже актуальный error/idle. + // finalErr — ошибка из udprelay.Run; watchdogErr — «не поднялся ни один + // стрим за connectTimeout». Читаются в defer после counters.Wait(), что + // даёт happens-after для записи watchdogErr из горутины-счётчика. + var finalErr error + var watchdogErr error + var counters sync.WaitGroup + + defer func() { + cancel() + counters.Wait() + mu.Lock() + defer mu.Unlock() + // Сессию мог уже сменить Stop() или новый Start() — тогда общий стейт + // принадлежит им, и трогать его нельзя, иначе затрём актуальный статус. + if sessionGen.Load() != gen { + return + } + stats.StopGlobalCount() + err := finalErr + if err == nil { + err = watchdogErr + } + if err != nil { + setStatus(&statusInfo{state: StateError, total: cfg.TURN.N, errMsg: err.Error()}) + } else { + setStatus(&statusInfo{state: StateIdle}) + } + running.Store(false) + cancelFn = nil + }() + + logger := &bufLogger{debug: false} + dnsdial.SetLogger(logger) + + if cfg.DNS.Servers != nil { + dnsdial.SetUDPDNSServers(cfg.DNS.Servers) + } + appDialer := dnsdial.AppDialer(cfg.DNS.Mode) + dnsdial.InstallGlobalResolver(cfg.DNS.Mode) + + var connectedStreams atomic.Int32 + + // nil solver — manual captcha через браузер на мобильном недоступна. + // При captcha-блоке поток упадёт с ErrFatalNoStreams; пользователь + // должен переподключиться позже. + prov, err := vk.New(vk.Config{ + // ParseClient гарантирует непустой Links (иначе вернул бы ошибку выше). + // Мобайл передаёт одну ссылку, провайдер берёт один join-код. + Link: cfg.VK.Links[0], + Dialer: appDialer, + StreamsPerCache: cfg.VK.StreamsPerCred, + StreamsAlive: connectedStreams.Load, + Log: logger, + }, nil) + if err != nil { + finalErr = fmt.Errorf("provider: %v", err) + return + } + + peerAddr, err := net.ResolveUDPAddr("udp", cfg.Proxy.Peer) + if err != nil { + finalErr = fmt.Errorf("bad peer addr: %v", err) + return + } + + dtlsDialer := &dtlsdial.Dialer{ + HandshakeTimeout: 20 * time.Second, + HandshakeSem: make(chan struct{}, 3), + } + + udpParams := &udprelay.Params{ + Host: cfg.TURN.Host, + Port: cfg.TURN.Port, + TransportUDP: cfg.TURN.TransportUDP, + ObfKey: cfg.Obf.Key, + GetCreds: udprelay.GetCredsFunc(func(ctx context.Context, streamID int) (string, string, []string, error) { + c, err := prov.GetCredentials(ctx, streamID) + if err != nil { + return "", "", nil, err + } + return c.User, c.Pass, c.ServerAddrs, nil + }), + ClientID: "ios-mobile", + } + + // Обновляем счётчик потоков по мере подключения и сторожим стартовый + // таймаут: если за connectTimeout ни один стрим не поднялся — считаем + // подключение неуспешным, пишем watchdogErr и гасим всё через cancel. + counters.Add(1) + go func() { + defer counters.Done() + deadline := time.Now().Add(connectTimeout) + everConnected := false + for { + select { + case <-ctx.Done(): + return + case <-time.After(500 * time.Millisecond): + n := connectedStreams.Load() + if n > 0 { + everConnected = true + } + state := StateConnecting + if n > 0 { + state = StateConnected + } + setStatus(&statusInfo{state: state, streams: int(n), total: cfg.TURN.N}) + + if !everConnected && time.Now().After(deadline) { + watchdogErr = fmt.Errorf("не удалось подключиться: ни один поток не поднялся за %s — проверьте ссылку на звонок и адрес сервера (подробности в логах)", connectTimeout) + cancel() + return + } + } + } + }() + + if err := udprelay.Run(ctx, dtlsDialer, prov, logger, &connectedStreams, udpParams, peerAddr, cfg.Proxy.Listen, cfg.TURN.N); err != nil { + if !errors.Is(ctx.Err(), context.Canceled) { + finalErr = err + } + } + }() + + return nil +} + +// Stop останавливает прокси-клиент. +func Stop() { + mu.Lock() + defer mu.Unlock() + if cancelFn != nil { + cancelFn() + cancelFn = nil + // Освобождаем сессию сразу, не дожидаясь догасания фоновой горутины. + // Бамп поколения гарантирует, что её defer не затрёт стейт после нас и + // не помешает немедленному переподключению. + running.Store(false) + sessionGen.Add(1) + stats.StopGlobalCount() + } + setStatus(&statusInfo{state: StateIdle}) +} diff --git a/ios/mobilestats.go b/ios/mobilestats.go new file mode 100644 index 0000000..cda8e82 --- /dev/null +++ b/ios/mobilestats.go @@ -0,0 +1,45 @@ +package ios + +import ( + "context" + "sync/atomic" + "time" + + "github.com/samosvalishe/free-turn-proxy/internal/stats" +) + +// Мгновенная скорость (байт/с), пересчитывается startRateMeter раз в секунду. +// Суммарные счётчики живут в stats (stats.GlobalCounters) — там же, где учёт байт. +var ( + globalTxRate atomic.Int64 + globalRxRate atomic.Int64 +) + +// startRateMeter раз в секунду пересчитывает мгновенную скорость (байт/с) по +// дельте суммарных счётчиков stats. Учётом трафика управляет Start/Stop явно +// (StartGlobalCount/StopGlobalCount) — здесь только чтение. Живёт ровно одну +// сессию: завершается по отмене ctx с остановкой тикера, без вечных горутин. +func startRateMeter(ctx context.Context) { + t := time.NewTicker(time.Second) + defer t.Stop() + var prevTx, prevRx uint64 + for { + select { + case <-ctx.Done(): + globalTxRate.Store(0) + globalRxRate.Store(0) + return + case <-t.C: + tx, rx := stats.GlobalCounters() + // Счётчики могли обнулиться (StopGlobalCount) — пропускаем такой тик, + // чтобы не получить отрицательную дельту через переполнение uint64. + if tx < prevTx || rx < prevRx { + prevTx, prevRx = tx, rx + continue + } + globalTxRate.Store(int64(tx - prevTx)) + globalRxRate.Store(int64(rx - prevRx)) + prevTx, prevRx = tx, rx + } + } +} diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh new file mode 100755 index 0000000..10ed1c5 --- /dev/null +++ b/scripts/build-ios.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Собирает Ios.xcframework из пакета ios/ для iOS и iOS Simulator. +# Использование: ./scripts/build-ios.sh [путь/к/выходному/каталогу] +# По умолчанию кладёт фреймворк в ./build/ внутри репозитория. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT_DIR="${1:-"${REPO_ROOT}/build"}" +FRAMEWORK="${OUT_DIR}/Ios.xcframework" + +if ! command -v gomobile &>/dev/null; then + echo "gomobile не найден. Установите:" + echo " go install golang.org/x/mobile/cmd/gomobile@latest" + echo " gomobile init" + exit 1 +fi + +mkdir -p "${OUT_DIR}" +rm -rf "${FRAMEWORK}" + +echo "Собираем Ios.xcframework → ${FRAMEWORK}" +cd "${REPO_ROOT}" +gomobile bind \ + -target ios,iossimulator \ + -o "${FRAMEWORK}" \ + github.com/samosvalishe/free-turn-proxy/ios + +echo "Готово: ${FRAMEWORK}" From c683670abcb836c0b03cd4f625e76ee8b8f8e5cb Mon Sep 17 00:00:00 2001 From: Tremendous Stimulus <295218925+tremendous-stimulus@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:44:11 +0300 Subject: [PATCH 2/6] =?UTF-8?q?fix(ios):=20=D0=BF=D1=80=D0=BE=D0=BA=D0=B8?= =?UTF-8?q?=D0=B4=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=D1=84?= =?UTF-8?q?=D0=B8=D0=BB=D1=8C=20=D0=BE=D0=B1=D1=84=D1=83=D1=81=D0=BA=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=B2=20udprelay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit udpParams заполнял только ObfKey, но не Profile/ObfTiming, как это делает CLI-клиент. Без профиля клиент обфусцировал трафик не так, как ждёт сервер (OBF_PROFILE=rtpopus): VK-аутентификация и TURN-аллокация проходили, но дальше Tx рос, Rx оставался 0 и стримы отваливались. --- ios/mobile.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ios/mobile.go b/ios/mobile.go index 650c960..12679e9 100644 --- a/ios/mobile.go +++ b/ios/mobile.go @@ -214,7 +214,9 @@ func Start(link, peer, dns, listen, transport, obfKey string) error { Host: cfg.TURN.Host, Port: cfg.TURN.Port, TransportUDP: cfg.TURN.TransportUDP, + Profile: string(cfg.Obf.Profile), ObfKey: cfg.Obf.Key, + ObfTiming: cfg.Obf.Timing, GetCreds: udprelay.GetCredsFunc(func(ctx context.Context, streamID int) (string, string, []string, error) { c, err := prov.GetCredentials(ctx, streamID) if err != nil { From 0baefd19fcc31107f8de1fbb6966ecca9de40388 Mon Sep 17 00:00:00 2001 From: Tremendous Stimulus <295218925+tremendous-stimulus@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:50:05 +0300 Subject: [PATCH 3/6] =?UTF-8?q?style(ios):=20=D1=83=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=82=D1=8C=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=87?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20gosec/revive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - безопасная конверсия uint64→int64 (clampToInt64) для счётчиков, экспортируемых через gomobile, чтобы не ловить G115; - анонимный ресивер у bufLogger.write (unused-receiver). --- ios/logbuf.go | 2 +- ios/mobile.go | 15 +++++++++++++-- ios/mobilestats.go | 4 ++-- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/ios/logbuf.go b/ios/logbuf.go index 64bff6a..8c1c178 100644 --- a/ios/logbuf.go +++ b/ios/logbuf.go @@ -47,7 +47,7 @@ func ClearLogs() { sharedLogBuf.clear() } // bufLogger реализует logx.Logger, пишет в stderr и в sharedLogBuf. type bufLogger struct{ debug bool } -func (l *bufLogger) write(level, format string, v ...any) { +func (*bufLogger) write(level, format string, v ...any) { msg := fmt.Sprintf(format, v...) line := time.Now().Format("15:04:05") + " [" + level + "] " + msg log.Print("[" + level + "] " + msg) diff --git a/ios/mobile.go b/ios/mobile.go index 12679e9..5d5558c 100644 --- a/ios/mobile.go +++ b/ios/mobile.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "math" "net" "sync" "sync/atomic" @@ -68,6 +69,16 @@ var ( func setStatus(s *statusInfo) { statusVal.Store(s) } +// clampToInt64 насыщает uint64 до int64: gomobile экспортирует только int64, +// а счётчики байт — uint64. Реальный трафик до math.MaxInt64 не доходит, но +// насыщение делает конверсию безопасной от переполнения. +func clampToInt64(u uint64) int64 { + if u > math.MaxInt64 { + return math.MaxInt64 + } + return int64(u) +} + // GetState возвращает текущий снимок состояния сессии: стадию подключения // плюс статистику трафика, собранные на чтении из внутренних атомиков. func GetState() *Snapshot { @@ -81,8 +92,8 @@ func GetState() *Snapshot { Streams: st.streams, Total: st.total, ErrMsg: st.errMsg, - TxTotal: int64(tx), - RxTotal: int64(rx), + TxTotal: clampToInt64(tx), + RxTotal: clampToInt64(rx), TxRate: globalTxRate.Load(), RxRate: globalRxRate.Load(), } diff --git a/ios/mobilestats.go b/ios/mobilestats.go index cda8e82..a3499a5 100644 --- a/ios/mobilestats.go +++ b/ios/mobilestats.go @@ -37,8 +37,8 @@ func startRateMeter(ctx context.Context) { prevTx, prevRx = tx, rx continue } - globalTxRate.Store(int64(tx - prevTx)) - globalRxRate.Store(int64(rx - prevRx)) + globalTxRate.Store(clampToInt64(tx - prevTx)) + globalRxRate.Store(clampToInt64(rx - prevRx)) prevTx, prevRx = tx, rx } } From e2d59ef7e529e45c8e2966b6dbc7c16c5f96ce6d Mon Sep 17 00:00:00 2001 From: Tremendous Stimulus <295218925+tremendous-stimulus@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:21:08 +0300 Subject: [PATCH 4/6] =?UTF-8?q?feat(ios):=20=D1=80=D1=83=D1=87=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20fallback=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20VK=20captcha=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D0=B5=D0=BD=D1=82=D0=B5=D1=80=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Когда авто-решатель captcha не справился, прокидываем в vk-провайдер ManualSolverFunc, который переиспользует SolveViaProxy и сигналит UI через gomobile-интерфейс CaptchaPresenter (Show/Hide). Без презентера поведение прежнее. Файлы библиотеки не затронуты - мостик в новом файле vk/iosmanual.go. --- internal/provider/vk/iosmanual.go | 52 +++++++++++++++++++++++++++++++ ios/captcha.go | 36 +++++++++++++++++++++ ios/mobile.go | 13 +++++--- 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 internal/provider/vk/iosmanual.go create mode 100644 ios/captcha.go diff --git a/internal/provider/vk/iosmanual.go b/internal/provider/vk/iosmanual.go new file mode 100644 index 0000000..e1e2b9f --- /dev/null +++ b/internal/provider/vk/iosmanual.go @@ -0,0 +1,52 @@ +package vk + +import ( + "context" + "fmt" + "net" + "sync" + + "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/captcha" + manualcaptcha "github.com/samosvalishe/free-turn-proxy/internal/provider/vk/internal/captcha/manual" +) + +// iosManualURL — адрес локального прокси manual-captcha, который поднимает +// SolveViaProxy. Должен совпадать с портом из пакета manual (captchaListenPort). +// Приложение наводит на него WebView, пока пользователь решает captcha. +const iosManualURL = "http://127.0.0.1:8765" + +// iosManualMu сериализует ручное решение captcha: пользователю показываем +// одновременно только одно окно, даже если в captcha упёрлись сразу несколько +// стримов/кредов. +var iosManualMu sync.Mutex + +// IOSManualSolver строит ManualSolverFunc для мобильного приложения. Извлечение +// success_token переиспользует manualcaptcha.SolveViaProxy — ту же логику, что и +// десктопный CLI (локальный прокси сам ловит токен из ответов VK). show +// вызывается с URL прокси перед показом captcha (приложение должно открыть +// WebView на этот адрес), hide — после завершения, чтобы закрыть окно. +// +// Функция предназначена только для ios-биндинга и нигде в библиотеке/CLI не +// вызывается; поведение vk-провайдера для прочих вызывающих не меняется. +func IOSManualSolver(show func(url string), hide func()) ManualSolverFunc { + return func(ctx context.Context, e *captcha.Error, dialer net.Dialer) (string, string, error) { + if e.RedirectURI == "" { + return "", "", fmt.Errorf("manual captcha: no redirect_uri") + } + + // Один WebView за раз: SolveViaProxy слушает фиксированный порт, да и + // показывать пользователю несколько captcha разом смысла нет. + iosManualMu.Lock() + defer iosManualMu.Unlock() + + if show != nil { + show(iosManualURL) + } + if hide != nil { + defer hide() + } + + token, err := manualcaptcha.SolveViaProxy(ctx, e.RedirectURI, dialer) + return token, "", err + } +} diff --git a/ios/captcha.go b/ios/captcha.go new file mode 100644 index 0000000..2b5578c --- /dev/null +++ b/ios/captcha.go @@ -0,0 +1,36 @@ +package ios + +import "sync" + +// CaptchaPresenter — мост в UI приложения для ручного решения captcha. gomobile +// генерирует из этого интерфейса ObjC-протокол IosCaptchaPresenter, который +// реализует Swift. Вызывается из Go, когда авто-решатель captcha не справился и +// нужен ручной fallback. +// +// - Show(url): открыть WebView на локальном прокси-адресе url, где пользователь +// решает captcha. Метод блокирующим быть не обязан. +// - Hide(): captcha решена или отменена — закрыть окно. +type CaptchaPresenter interface { + Show(url string) + Hide() +} + +var ( + captchaMu sync.RWMutex + captchaPresenter CaptchaPresenter +) + +// SetCaptchaPresenter регистрирует UI-презентер ручной captcha. Передайте nil, +// чтобы отключить ручной fallback — тогда при провале авто-captcha поток просто +// упадёт, как раньше. Вызывать один раз при старте приложения, до Start. +func SetCaptchaPresenter(p CaptchaPresenter) { + captchaMu.Lock() + captchaPresenter = p + captchaMu.Unlock() +} + +func currentCaptchaPresenter() CaptchaPresenter { + captchaMu.RLock() + defer captchaMu.RUnlock() + return captchaPresenter +} diff --git a/ios/mobile.go b/ios/mobile.go index 5d5558c..2878a5d 100644 --- a/ios/mobile.go +++ b/ios/mobile.go @@ -193,9 +193,14 @@ func Start(link, peer, dns, listen, transport, obfKey string) error { var connectedStreams atomic.Int32 - // nil solver — manual captcha через браузер на мобильном недоступна. - // При captcha-блоке поток упадёт с ErrFatalNoStreams; пользователь - // должен переподключиться позже. + // Если приложение зарегистрировало презентер — включаем ручной fallback + // captcha (авто-решатель пробуется первым, при провале показываем WebView). + // Без презентера solver == nil: при captcha-блоке поток упадёт, как раньше. + var solver vk.ManualSolverFunc + if p := currentCaptchaPresenter(); p != nil { + solver = vk.IOSManualSolver(p.Show, p.Hide) + } + prov, err := vk.New(vk.Config{ // ParseClient гарантирует непустой Links (иначе вернул бы ошибку выше). // Мобайл передаёт одну ссылку, провайдер берёт один join-код. @@ -204,7 +209,7 @@ func Start(link, peer, dns, listen, transport, obfKey string) error { StreamsPerCache: cfg.VK.StreamsPerCred, StreamsAlive: connectedStreams.Load, Log: logger, - }, nil) + }, solver) if err != nil { finalErr = fmt.Errorf("provider: %v", err) return From a1a14cf75d85d127ab92906ad2881e8dab63c648 Mon Sep 17 00:00:00 2001 From: Tremendous Stimulus <295218925+tremendous-stimulus@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:57:57 +0300 Subject: [PATCH 5/6] =?UTF-8?q?feat(ios):=20=D1=84=D0=BB=D0=B0=D0=B3=20Set?= =?UTF-8?q?ManualCaptcha=20-=20=D1=84=D0=BE=D1=80=D1=81=D0=B8=D1=82=D1=8C?= =?UTF-8?q?=20=D1=80=D1=83=D1=87=D0=BD=D0=BE=D0=B5=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20VK=20captcha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Экспортируем SetManualCaptcha(bool); при включении прокидываем ManualOnly в vk.Config, и решатель идёт ручным путём с первой попытки (минуя авто-решатель). --- ios/captcha.go | 14 +++++++++++++- ios/mobile.go | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/ios/captcha.go b/ios/captcha.go index 2b5578c..119d4b7 100644 --- a/ios/captcha.go +++ b/ios/captcha.go @@ -1,6 +1,9 @@ package ios -import "sync" +import ( + "sync" + "sync/atomic" +) // CaptchaPresenter — мост в UI приложения для ручного решения captcha. gomobile // генерирует из этого интерфейса ObjC-протокол IosCaptchaPresenter, который @@ -34,3 +37,12 @@ func currentCaptchaPresenter() CaptchaPresenter { defer captchaMu.RUnlock() return captchaPresenter } + +// manualCaptchaOnly форсит ручное решение captcha с первой попытки (минуя +// авто-решатель). Читается при старте сессии. +var manualCaptchaOnly atomic.Bool + +// SetManualCaptcha включает/выключает режим «всегда решать captcha вручную». +// Применяется при следующем Start (или переподключении). Требует +// зарегистрированного презентера, иначе ручной путь недоступен. +func SetManualCaptcha(on bool) { manualCaptchaOnly.Store(on) } diff --git a/ios/mobile.go b/ios/mobile.go index 2878a5d..651be95 100644 --- a/ios/mobile.go +++ b/ios/mobile.go @@ -206,6 +206,7 @@ func Start(link, peer, dns, listen, transport, obfKey string) error { // Мобайл передаёт одну ссылку, провайдер берёт один join-код. Link: cfg.VK.Links[0], Dialer: appDialer, + ManualOnly: manualCaptchaOnly.Load(), StreamsPerCache: cfg.VK.StreamsPerCred, StreamsAlive: connectedStreams.Load, Log: logger, From 9e366e1123eecbfca52368ef15d0cd02f3d2b368 Mon Sep 17 00:00:00 2001 From: Tremendous Stimulus <295218925+tremendous-stimulus@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:13:55 +0300 Subject: [PATCH 6/6] =?UTF-8?q?feat(ios):=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81?= =?UTF-8?q?=20captcha=20+=20=D0=BF=D0=B0=D1=83=D0=B7=D0=B0=20watchdog=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пока пользователь решает captcha вручную, поднимаем сессионный captchaActive: watchdog не считает подключение зависшим (двигает дедлайн) и отдаёт StateCaptcha вместо connecting, чтобы UI не показывал ошибку. --- ios/mobile.go | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/ios/mobile.go b/ios/mobile.go index 651be95..f7438fa 100644 --- a/ios/mobile.go +++ b/ios/mobile.go @@ -28,6 +28,9 @@ const ( StateConnecting = "connecting" StateConnected = "connected" StateError = "error" + // StateCaptcha — пользователь решает captcha вручную. Отдельный статус, чтобы + // UI не показывал ошибку, а watchdog не считал это зависшим подключением. + StateCaptcha = "captcha" ) // Snapshot — единый консистентный снимок состояния сессии для UI: и стадия @@ -196,9 +199,16 @@ func Start(link, peer, dns, listen, transport, obfKey string) error { // Если приложение зарегистрировало презентер — включаем ручной fallback // captcha (авто-решатель пробуется первым, при провале показываем WebView). // Без презентера solver == nil: при captcha-блоке поток упадёт, как раньше. + // + // captchaActive (сессионный) поднимается на время ручного решения: пока он + // true, watchdog не считает подключение зависшим и UI показывает StateCaptcha. + var captchaActive atomic.Bool var solver vk.ManualSolverFunc if p := currentCaptchaPresenter(); p != nil { - solver = vk.IOSManualSolver(p.Show, p.Hide) + solver = vk.IOSManualSolver( + func(url string) { captchaActive.Store(true); p.Show(url) }, + func() { captchaActive.Store(false); p.Hide() }, + ) } prov, err := vk.New(vk.Config{ @@ -261,6 +271,16 @@ func Start(link, peer, dns, listen, transport, obfKey string) error { if n > 0 { everConnected = true } + + // Пока пользователь решает captcha — таймаут на паузу (двигаем + // дедлайн вперёд, чтобы после решения дать свежее окно) и + // показываем отдельный статус вместо «зависшего» connecting. + if captchaActive.Load() { + deadline = time.Now().Add(connectTimeout) + setStatus(&statusInfo{state: StateCaptcha, streams: int(n), total: cfg.TURN.N}) + continue + } + state := StateConnecting if n > 0 { state = StateConnected