mirror of
https://github.com/hackdiaz-dev/free-turn-proxy.git
synced 2026-10-12 09:41:53 +02:00
Merge pull request #25 from tremendous-stimulus/master
gomobile-биндинг для приложения iOS
This commit is contained in:
@@ -9,6 +9,9 @@ CLAUDE.md
|
||||
/build/
|
||||
*.test
|
||||
coverage.out
|
||||
# Скомпилированные бинари cmd/client и cmd/server
|
||||
/client
|
||||
/server
|
||||
.env
|
||||
clients.json
|
||||
client_config.json
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+41
-2
@@ -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))
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package ios
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// manualCaptchaOnly форсит ручное решение captcha с первой попытки (минуя
|
||||
// авто-решатель). Читается при старте сессии.
|
||||
var manualCaptchaOnly atomic.Bool
|
||||
|
||||
// SetManualCaptcha включает/выключает режим «всегда решать captcha вручную».
|
||||
// Применяется при следующем Start (или переподключении). Требует
|
||||
// зарегистрированного презентера, иначе ручной путь недоступен.
|
||||
func SetManualCaptcha(on bool) { manualCaptchaOnly.Store(on) }
|
||||
@@ -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 (*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 }
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
// Package ios экспортирует минимальный API для gomobile bind (iOS).
|
||||
// Все экспортированные функции используют только примитивные типы — ограничение gomobile.
|
||||
package ios
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"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"
|
||||
// StateCaptcha — пользователь решает captcha вручную. Отдельный статус, чтобы
|
||||
// UI не показывал ошибку, а watchdog не считал это зависшим подключением.
|
||||
StateCaptcha = "captcha"
|
||||
)
|
||||
|
||||
// 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) }
|
||||
|
||||
// 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 {
|
||||
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: clampToInt64(tx),
|
||||
RxTotal: clampToInt64(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
|
||||
|
||||
// Если приложение зарегистрировало презентер — включаем ручной 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(
|
||||
func(url string) { captchaActive.Store(true); p.Show(url) },
|
||||
func() { captchaActive.Store(false); p.Hide() },
|
||||
)
|
||||
}
|
||||
|
||||
prov, err := vk.New(vk.Config{
|
||||
// ParseClient гарантирует непустой Links (иначе вернул бы ошибку выше).
|
||||
// Мобайл передаёт одну ссылку, провайдер берёт один join-код.
|
||||
Link: cfg.VK.Links[0],
|
||||
Dialer: appDialer,
|
||||
ManualOnly: manualCaptchaOnly.Load(),
|
||||
StreamsPerCache: cfg.VK.StreamsPerCred,
|
||||
StreamsAlive: connectedStreams.Load,
|
||||
Log: logger,
|
||||
}, solver)
|
||||
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,
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// Пока пользователь решает 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
|
||||
}
|
||||
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})
|
||||
}
|
||||
@@ -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(clampToInt64(tx - prevTx))
|
||||
globalRxRate.Store(clampToInt64(rx - prevRx))
|
||||
prevTx, prevRx = tx, rx
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+29
@@ -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}"
|
||||
Reference in New Issue
Block a user