diff --git a/client/main.go b/client/main.go
index 9219f52..41d90ae 100644
--- a/client/main.go
+++ b/client/main.go
@@ -962,7 +962,9 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede
}
case captchaSolveModeManual:
log.Printf("[STREAM %d] [Captcha] Triggering manual captcha fallback...", streamID)
- manualCtx, manualCancel := context.WithTimeout(ctx, 60*time.Second)
+ // Use context.Background() so that a short deadline on the parent ctx
+ // (e.g. the overall auth timeout) doesn't cut the user's solve time short.
+ manualCtx, manualCancel := context.WithTimeout(context.Background(), 3*time.Minute)
type manualRes struct {
token string
@@ -989,8 +991,24 @@ func getTokenChain(ctx context.Context, link string, streamID int, creds VKCrede
successToken = res.token
captchaKey = res.key
solveErr = res.err
+ // Token may be present even when err != nil (e.g. srv.Shutdown
+ // timed out on iSH after the token was already received).
+ // Treat a non-empty token as success regardless of the error.
+ if successToken != "" || captchaKey != "" {
+ if solveErr != nil {
+ log.Printf("[STREAM %d] [Captcha] Token received (ignoring cleanup error: %v)", streamID, solveErr)
+ solveErr = nil
+ }
+ log.Printf("[STREAM %d] [Captcha] Successfully got token from browser", streamID)
+ } else if solveErr != nil {
+ log.Printf("[STREAM %d] [Captcha] solveCaptchaViaProxy returned error: %v", streamID, solveErr)
+ }
case <-manualCtx.Done():
- solveErr = fmt.Errorf("manual captcha timed out after 60s")
+ if manualCtx.Err() == context.DeadlineExceeded {
+ solveErr = fmt.Errorf("manual captcha timed out after 3m")
+ } else {
+ solveErr = fmt.Errorf("manual captcha interrupted: %w", manualCtx.Err())
+ }
}
manualCancel()
}
@@ -1791,7 +1809,7 @@ func oneTurnConnectionLoop(ctx context.Context, turnParams *turnParams, peer *ne
func setupGlobalResolver() {
dialer := &net.Dialer{
- Timeout: 5 * time.Second,
+ Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}
dnsServers := []string{"77.88.8.8:53", "77.88.8.1:53", "8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"}
diff --git a/client/manual_captcha.go b/client/manual_captcha.go
index d41e572..cddc1fc 100644
--- a/client/manual_captcha.go
+++ b/client/manual_captcha.go
@@ -265,14 +265,52 @@ func rewriteCaptchaHTML(html string, targetURL *neturl.URL) string {
function handleSuccessToken(token) {
if (!token) return;
+ console.log('Captcha solved, sending token to proxy...');
+ var body = 'token=' + encodeURIComponent(token);
+
+ // sendBeacon is the most reliable on mobile Safari:
+ // it's fire-and-forget and works even if the page navigates away.
+ if (navigator && navigator.sendBeacon) {
+ var blob = new Blob([body], {type: 'application/x-www-form-urlencoded'});
+ var sent = navigator.sendBeacon('/local-captcha-result', blob);
+ if (sent) {
+ console.log('Token sent via sendBeacon');
+ showDone();
+ return;
+ }
+ }
+
+ // Fallback: fetch
fetch('/local-captcha-result', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
- body: 'token=' + encodeURIComponent(token)
- }).then(function() {
- document.body.innerHTML = '
Done! You can close the page.
';
- setTimeout(function() { window.close(); }, 300);
- }).catch(function() {});
+ body: body
+ }).then(function(r) {
+ console.log('Proxy acknowledged token');
+ showDone();
+ }).catch(function(e) {
+ console.error('Fetch failed, trying form submit...', e);
+ // Last resort: form POST (navigates the page)
+ var form = document.createElement('form');
+ form.method = 'POST';
+ form.action = '/local-captcha-result';
+ var input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = 'token';
+ input.value = token;
+ form.appendChild(input);
+ document.body.appendChild(form);
+ form.submit();
+ });
+ }
+
+ function showDone() {
+ document.body.innerHTML = '' +
+ '
✔ Done!
' +
+ '
Captcha solved successfully. You can close this tab now.
' +
+ '
';
+ // On iOS, window.close() often doesn't work, so we just let the user know they are done.
+ setTimeout(function() { window.close(); }, 1000);
}
var origOpen = XMLHttpRequest.prototype.open;
@@ -462,10 +500,14 @@ func runCaptchaServerAndWait(handler http.Handler, captchaURL string, keyCh <-ch
key := <-keyCh
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancel()
- if err := srv.Shutdown(ctx); err != nil {
- return "", err
+ // Best-effort shutdown: the token is already received, so even if
+ // Shutdown times out (e.g. because ishConn.SetDeadline is a no-op
+ // on iSH and active connections can't be force-closed), we still
+ // return the token successfully.
+ shutCtx, shutCancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer shutCancel()
+ if err := srv.Shutdown(shutCtx); err != nil {
+ log.Printf("%s: shutdown warning (token already received): %v", logPrefix, err)
}
return key, nil
@@ -614,8 +656,15 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
mux := http.NewServeMux()
mux.HandleFunc("/local-captcha-result", func(w http.ResponseWriter, r *http.Request) {
- notifyKey(keyCh, r.FormValue("token")) // r.FormValue automatically parses the form
+ token := r.FormValue("token")
+ if token != "" {
+ log.Printf("[Captcha] Received success token from browser (%d bytes)", len(token))
+ notifyKey(keyCh, token)
+ } else {
+ log.Printf("[Captcha] Received empty token from browser")
+ }
w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Content-Type", "text/plain")
_, _ = fmt.Fprint(w, "ok")
})
@@ -651,6 +700,22 @@ func solveCaptchaViaProxy(redirectURI string, dialer *dnsdialer.Dialer) (string,
}
// Allow the browser to use the resource cross-origin
res.Header.Set("Access-Control-Allow-Origin", "*")
+
+ // captchaNotRobot.check goes to api.vk.ru (different host from the main
+ // proxy upstream vk.com), so it is routed through /generic_proxy.
+ // Extract the success_token here so the server path works on iOS
+ // even if the browser-side JS callback never fires.
+ if strings.Contains(targetAuthURL, "captchaNotRobot.check") {
+ bodyBytes, readErr := io.ReadAll(res.Body)
+ if readErr == nil {
+ _ = res.Body.Close()
+ res.Body = io.NopCloser(bytes.NewReader(bodyBytes))
+ res.ContentLength = int64(len(bodyBytes))
+ res.Header.Set("Content-Length", fmt.Sprint(len(bodyBytes)))
+ notifyKey(keyCh, extractSuccessToken(bodyBytes))
+ }
+ }
+
return nil
},
}