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