feat(cli): info --proxy resolves exit IP + timezone + locale
Wire the existing launch()-time geoip resolver into the info/doctor command across Python, JS, and .NET. With --proxy, info resolves the exit IP and the timezone/locale a launch would apply (caching the GeoIP DB if absent) and prints them in text and --json. Plain info is unchanged (no network) and now hints at the flag. Adds diagnostics tests in all three suites.
This commit is contained in:
@@ -8,6 +8,7 @@ Changes are tagged: **[wrapper]** for Python/JS wrapper, **[binary]** for Chromi
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **[wrapper]** **`cloakbrowser info --proxy <url>` now resolves the exit IP, timezone, and locale a launch would apply through that proxy.** Previously `info` only reported whether the GeoIP database file was present; it never resolved anything, so there was no way to confirm a proxy hands you a timezone and locale that match its exit IP before launching. Passing `--proxy` runs the same resolution `geoip=True` uses at launch (downloading the GeoIP database if it is not cached) and prints the exit IP, timezone, and locale, in text and `--json` output. Plain `info` is unchanged and still makes no network call; it now points at the new flag. Python, JavaScript, and .NET.
|
||||
- **[wrapper]** **`humanize=True` no longer misses clicks on pages that are still loading.** When a page kept reflowing after the element was scrolled into view, the wrapper waited for the position to settle but never scrolled again — by then the element could have been pushed off screen, so the click was dispatched at coordinates outside the viewport and landed on nothing. The action reported success and the click had simply not happened. The element is now re-scrolled into view after the settle wait, and the pointer-events check no longer downgrades an already-confirmed miss to "undetermined" when a later probe times out, so a click that cannot land raises instead of passing silently. Measured on a page reflowing for 10–25 seconds: previously a silent miss after ~32s, now a successful click. Pages that reflow longer than the call's timeout still raise, as before. Python, JavaScript, and .NET.
|
||||
|
||||
---
|
||||
|
||||
@@ -484,7 +484,7 @@ python -m cloakbrowser clear-cache # Remove cached binaries
|
||||
|
||||
`login` with no argument prompts you to paste a license key or press Enter to get a free key via a GitHub sign-in; `login <key>` saves a key directly. Both validate the key, then store it at `~/.cloakbrowser/license.key` so every launch picks it up.
|
||||
|
||||
`info` reports the binary that will actually launch given your license, runs a quick launch test (and flags missing system libraries on Linux), shows your license tier, and checks fonts, GeoIP, and optional dependencies. Add `--quick` to skip the launch test or `--json` for machine-readable output.
|
||||
`info` reports the binary that will actually launch given your license, runs a quick launch test (and flags missing system libraries on Linux), shows your license tier, and checks fonts, GeoIP, and optional dependencies. Add `--quick` to skip the launch test or `--json` for machine-readable output. Add `--proxy <url>` to resolve the exit IP, timezone, and locale a launch would apply through that proxy (the same `geoip=True` resolution; downloads the GeoIP DB if not cached) — useful for confirming a proxy hands you a timezone/locale that matches its exit IP.
|
||||
|
||||
`CLOAKBROWSER_RELEASE_CHANNEL=preview` also applies to `install`, `info`, and `update`. `info` shows the exact version that will launch and whether Preview resolved to Stable for the current platform.
|
||||
|
||||
|
||||
@@ -238,8 +238,13 @@ def _effective_binary(entitled_pro: bool, quick: bool = False) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _collect_diagnostics(quick: bool) -> dict:
|
||||
"""Gather environment + binary diagnostics without triggering a download."""
|
||||
def _collect_diagnostics(quick: bool, proxy: str | None = None) -> dict:
|
||||
"""Gather environment + binary diagnostics.
|
||||
|
||||
Does not trigger a download unless ``proxy`` is given: with a proxy, the
|
||||
exit IP + timezone + locale a launch would apply are resolved (which caches
|
||||
the GeoIP DB if absent, exactly like a real launch).
|
||||
"""
|
||||
diag: dict = {}
|
||||
|
||||
from ._version import __version__
|
||||
@@ -317,6 +322,23 @@ def _collect_diagnostics(quick: bool) -> dict:
|
||||
db_path = _get_geoip_dir() / GEOIP_DB_FILENAME
|
||||
diag["geoip"] = {"db_present": db_path.exists(), "path": str(db_path)}
|
||||
|
||||
# Live resolution — only when a proxy is explicitly given. Mirrors launch():
|
||||
# resolves the exit IP and, computing tz/locale, caches the DB if absent.
|
||||
if proxy:
|
||||
try:
|
||||
from .geoip import resolve_proxy_geo_with_ip
|
||||
|
||||
tz, locale, exit_ip = resolve_proxy_geo_with_ip(proxy)
|
||||
diag["geoip"]["resolved"] = {
|
||||
"exit_ip": exit_ip,
|
||||
"timezone": tz,
|
||||
"locale": locale,
|
||||
}
|
||||
except ImportError:
|
||||
diag["geoip"]["resolved"] = {"error": "geoip2 not installed"}
|
||||
except Exception as exc: # network/proxy failure — never crash `info`
|
||||
diag["geoip"]["resolved"] = {"error": str(exc)}
|
||||
|
||||
# Optional Python modules.
|
||||
diag["modules"] = {
|
||||
label: _module_available(module)
|
||||
@@ -444,7 +466,18 @@ def _print_diagnostics(diag: dict) -> None:
|
||||
print(f"Sessions: {active} seat{'' if active == 1 else 's'} in use")
|
||||
|
||||
geoip = diag["geoip"]
|
||||
print(f"GeoIP DB: {'present' if geoip['db_present'] else 'not downloaded (optional)'}")
|
||||
db_line = "present" if geoip["db_present"] else "not downloaded (optional)"
|
||||
resolved = geoip.get("resolved")
|
||||
if resolved is None:
|
||||
db_line += " (pass --proxy <url> to resolve exit IP + timezone/locale)"
|
||||
print(f"GeoIP DB: {db_line}")
|
||||
if resolved is not None:
|
||||
if resolved.get("error"):
|
||||
print(f"Exit IP: (could not resolve — {resolved['error']})")
|
||||
else:
|
||||
print(f"Exit IP: {resolved.get('exit_ip') or '(unknown)'}")
|
||||
print(f"Timezone: {resolved.get('timezone') or '(unknown)'}")
|
||||
print(f"Locale: {resolved.get('locale') or '(unknown)'}")
|
||||
|
||||
print("Modules:")
|
||||
for label, available in diag["modules"].items():
|
||||
@@ -453,7 +486,7 @@ def _print_diagnostics(diag: dict) -> None:
|
||||
|
||||
def cmd_info(args: argparse.Namespace) -> None:
|
||||
quick = getattr(args, "quick", False)
|
||||
diag = _collect_diagnostics(quick=quick)
|
||||
diag = _collect_diagnostics(quick=quick, proxy=getattr(args, "proxy", None))
|
||||
if getattr(args, "json", False):
|
||||
import json
|
||||
|
||||
@@ -612,6 +645,14 @@ def main() -> None:
|
||||
help="Skip the binary launch test (faster; the license is still validated)",
|
||||
)
|
||||
p.add_argument("--json", action="store_true", help="Emit diagnostics as JSON")
|
||||
p.add_argument(
|
||||
"--proxy",
|
||||
metavar="URL",
|
||||
help=(
|
||||
"Resolve the exit IP, timezone, and locale a launch would apply "
|
||||
"through this proxy (downloads the GeoIP DB if not cached)"
|
||||
),
|
||||
)
|
||||
|
||||
_add_info_flags(sub.add_parser("info", help="Environment + binary diagnostics"))
|
||||
_add_info_flags(sub.add_parser("doctor", help="Alias for info"))
|
||||
|
||||
@@ -80,8 +80,9 @@ static void CmdInfo(string[] flags)
|
||||
{
|
||||
bool quick = flags.Contains("--quick") || flags.Contains("--no-launch");
|
||||
bool asJson = flags.Contains("--json");
|
||||
string? proxy = GetFlagValue(flags, "--proxy");
|
||||
|
||||
var diag = Diagnostics.Collect(quick);
|
||||
var diag = Diagnostics.Collect(quick, proxy);
|
||||
|
||||
if (asJson)
|
||||
{
|
||||
@@ -93,6 +94,16 @@ static void CmdInfo(string[] flags)
|
||||
}
|
||||
}
|
||||
|
||||
static string? GetFlagValue(string[] flags, string name)
|
||||
{
|
||||
string prefix = name + "=";
|
||||
foreach (var f in flags)
|
||||
if (f.StartsWith(prefix, StringComparison.Ordinal))
|
||||
return f[prefix.Length..];
|
||||
int i = Array.IndexOf(flags, name);
|
||||
return i != -1 && i + 1 < flags.Length ? flags[i + 1] : null;
|
||||
}
|
||||
|
||||
static void PrintDiagnostics(Dictionary<string, object?> diag)
|
||||
{
|
||||
var env = (Dictionary<string, object?>)diag["environment"]!;
|
||||
@@ -231,7 +242,24 @@ static void PrintDiagnostics(Dictionary<string, object?> diag)
|
||||
}
|
||||
|
||||
var geoip = (Dictionary<string, object?>)diag["geoip"]!;
|
||||
Console.WriteLine($"GeoIP DB: {(geoip["db_present"] is true ? "present" : "not downloaded (optional)")}");
|
||||
bool hasResolved = geoip.TryGetValue("resolved", out var resolvedObj) && resolvedObj is Dictionary<string, object?>;
|
||||
string dbLine = geoip["db_present"] is true ? "present" : "not downloaded (optional)";
|
||||
if (!hasResolved)
|
||||
dbLine += " (pass --proxy <url> to resolve exit IP + timezone/locale)";
|
||||
Console.WriteLine($"GeoIP DB: {dbLine}");
|
||||
if (hasResolved && resolvedObj is Dictionary<string, object?> resolved)
|
||||
{
|
||||
if (resolved.TryGetValue("error", out var errObj) && errObj is not null)
|
||||
{
|
||||
Console.WriteLine($"Exit IP: (could not resolve — {errObj})");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Exit IP: {resolved.GetValueOrDefault("exit_ip") ?? "(unknown)"}");
|
||||
Console.WriteLine($"Timezone: {resolved.GetValueOrDefault("timezone") ?? "(unknown)"}");
|
||||
Console.WriteLine($"Locale: {resolved.GetValueOrDefault("locale") ?? "(unknown)"}");
|
||||
}
|
||||
}
|
||||
|
||||
if (diag.TryGetValue("modules", out var modulesObj) && modulesObj is Dictionary<string, object?> modules)
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace CloakBrowser;
|
||||
/// </summary>
|
||||
internal static class Diagnostics
|
||||
{
|
||||
internal static Dictionary<string, object?> Collect(bool quick)
|
||||
internal static Dictionary<string, object?> Collect(bool quick, string? proxy = null)
|
||||
{
|
||||
var diag = new Dictionary<string, object?>();
|
||||
|
||||
@@ -94,7 +94,29 @@ internal static class Diagnostics
|
||||
|
||||
// GeoIP DB — presence only, never downloads.
|
||||
string dbPath = Path.Combine(Config.GetCacheDir(), "geoip", "GeoLite2-City.mmdb");
|
||||
diag["geoip"] = new Dictionary<string, object?> { ["db_present"] = File.Exists(dbPath), ["path"] = dbPath };
|
||||
var geoip = new Dictionary<string, object?> { ["db_present"] = File.Exists(dbPath), ["path"] = dbPath };
|
||||
diag["geoip"] = geoip;
|
||||
|
||||
// Live resolution — only when a proxy is explicitly given. Mirrors
|
||||
// LaunchAsync: resolves the exit IP and, computing tz/locale, caches the
|
||||
// DB if absent. Never crashes `info` — failures land in ["error"].
|
||||
if (!string.IsNullOrEmpty(proxy))
|
||||
{
|
||||
try
|
||||
{
|
||||
var (tz, locale, exitIp) = GeoIp.ResolveProxyGeoWithIpAsync(proxy).GetAwaiter().GetResult();
|
||||
geoip["resolved"] = new Dictionary<string, object?>
|
||||
{
|
||||
["exit_ip"] = exitIp,
|
||||
["timezone"] = tz,
|
||||
["locale"] = locale,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
geoip["resolved"] = new Dictionary<string, object?> { ["error"] = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
// Dependency assemblies — mirrors the Python/JS modules section. These are
|
||||
// hard NuGet references, so "missing" here means a broken deployment.
|
||||
|
||||
@@ -56,6 +56,32 @@ public class DiagnosticsTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_proxy_never_resolves_geoip_and_stays_network_free()
|
||||
{
|
||||
var tmp = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
|
||||
Directory.CreateDirectory(tmp);
|
||||
string? prevCache = Environment.GetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR");
|
||||
string? prevKey = Environment.GetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY");
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", tmp);
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", null);
|
||||
|
||||
// Default `info` (no proxy) must not resolve — no exit-IP lookup, no
|
||||
// GeoIP DB download. The "resolved" key is only added when a proxy is given.
|
||||
var diag = Diagnostics.Collect(quick: true);
|
||||
var geoip = Assert.IsType<Dictionary<string, object?>>(diag["geoip"]);
|
||||
Assert.False(geoip.ContainsKey("resolved"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", prevCache);
|
||||
Environment.SetEnvironmentVariable("CLOAKBROWSER_LICENSE_KEY", prevKey);
|
||||
try { Directory.Delete(tmp, recursive: true); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Preview_reports_stable_fallback_and_next_launch_version()
|
||||
{
|
||||
|
||||
+41
-3
@@ -25,6 +25,7 @@ import {
|
||||
WRAPPER_VERSION,
|
||||
} from "./config.js";
|
||||
import { countFontsPresent, WINDOWS_FONT_TELLS, OFFICE_FONT_TELLS } from "./fonts.js";
|
||||
import { resolveProxyGeo } from "./geoip.js";
|
||||
import { resolveLicenseKey, validateLicense, getProLatestRelease, getActiveSessionCount, type LicenseInfo } from "./license.js";
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
@@ -209,7 +210,10 @@ async function effectiveBinary(
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectDiagnostics(quick: boolean): Promise<Record<string, unknown>> {
|
||||
export async function collectDiagnostics(
|
||||
quick: boolean,
|
||||
proxy?: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
const diag: Record<string, any> = {};
|
||||
|
||||
diag.environment = {
|
||||
@@ -277,6 +281,17 @@ export async function collectDiagnostics(quick: boolean): Promise<Record<string,
|
||||
const dbPath = path.join(getCacheDir(), "geoip", "GeoLite2-City.mmdb");
|
||||
diag.geoip = { db_present: fs.existsSync(dbPath), path: dbPath };
|
||||
|
||||
// Live resolution — only when a proxy is explicitly given. Mirrors launch():
|
||||
// resolves the exit IP and, computing tz/locale, caches the DB if absent.
|
||||
if (proxy) {
|
||||
try {
|
||||
const { timezone, locale, exitIp } = await resolveProxyGeo(proxy);
|
||||
diag.geoip.resolved = { exit_ip: exitIp, timezone, locale };
|
||||
} catch (err) {
|
||||
diag.geoip.resolved = { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
// Optional peer deps.
|
||||
diag.modules = {
|
||||
"playwright-core": moduleAvailable("playwright-core"),
|
||||
@@ -405,7 +420,21 @@ function printDiagnostics(diag: Record<string, any>): void {
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`GeoIP DB: ${diag.geoip.db_present ? "present" : "not downloaded (optional)"}`);
|
||||
const resolved = diag.geoip.resolved;
|
||||
let dbLine = diag.geoip.db_present ? "present" : "not downloaded (optional)";
|
||||
if (resolved === undefined) {
|
||||
dbLine += " (pass --proxy <url> to resolve exit IP + timezone/locale)";
|
||||
}
|
||||
console.log(`GeoIP DB: ${dbLine}`);
|
||||
if (resolved !== undefined) {
|
||||
if (resolved.error) {
|
||||
console.log(`Exit IP: (could not resolve — ${resolved.error})`);
|
||||
} else {
|
||||
console.log(`Exit IP: ${resolved.exit_ip ?? "(unknown)"}`);
|
||||
console.log(`Timezone: ${resolved.timezone ?? "(unknown)"}`);
|
||||
console.log(`Locale: ${resolved.locale ?? "(unknown)"}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Modules:");
|
||||
for (const [label, available] of Object.entries(diag.modules)) {
|
||||
@@ -413,10 +442,19 @@ function printDiagnostics(diag: Record<string, any>): void {
|
||||
}
|
||||
}
|
||||
|
||||
function getFlagValue(args: string[], flag: string): string | undefined {
|
||||
const eq = args.find((a) => a.startsWith(`${flag}=`));
|
||||
if (eq) return eq.slice(flag.length + 1);
|
||||
const i = args.indexOf(flag);
|
||||
if (i !== -1 && i + 1 < args.length) return args[i + 1];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function cmdInfo(args: string[]): Promise<void> {
|
||||
const quick = args.includes("--quick") || args.includes("--no-launch");
|
||||
const asJson = args.includes("--json");
|
||||
const diag = await collectDiagnostics(quick);
|
||||
const proxy = getFlagValue(args, "--proxy");
|
||||
const diag = await collectDiagnostics(quick, proxy);
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify(diag, null, 2));
|
||||
} else {
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as license from "../src/license.js";
|
||||
import * as geoip from "../src/geoip.js";
|
||||
|
||||
// collectDiagnostics reads the cache dir (license key file, binary path) and,
|
||||
// in --quick mode, never spawns the binary — so an isolated temp cache dir is
|
||||
@@ -75,4 +76,35 @@ describe("collectDiagnostics", () => {
|
||||
expect(typeof diag.geoip.db_present).toBe("boolean");
|
||||
expect(Object.keys(diag.modules).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not resolve geoip when no proxy is given", async () => {
|
||||
const spy = vi.spyOn(geoip, "resolveProxyGeo");
|
||||
const { collectDiagnostics } = await import("../src/cli.js");
|
||||
const diag = (await collectDiagnostics(true)) as Record<string, any>;
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(diag.geoip.resolved).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves exit IP + timezone + locale when a proxy is given", async () => {
|
||||
const spy = vi.spyOn(geoip, "resolveProxyGeo").mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de-DE",
|
||||
exitIp: "203.0.113.9",
|
||||
});
|
||||
const { collectDiagnostics } = await import("../src/cli.js");
|
||||
const diag = (await collectDiagnostics(true, "http://p:8080")) as Record<string, any>;
|
||||
expect(spy).toHaveBeenCalledWith("http://p:8080");
|
||||
expect(diag.geoip.resolved).toEqual({
|
||||
exit_ip: "203.0.113.9",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de-DE",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a resolution failure without throwing", async () => {
|
||||
vi.spyOn(geoip, "resolveProxyGeo").mockRejectedValue(new Error("proxy refused"));
|
||||
const { collectDiagnostics } = await import("../src/cli.js");
|
||||
const diag = (await collectDiagnostics(true, "http://p:8080")) as Record<string, any>;
|
||||
expect(diag.geoip.resolved.error).toContain("proxy refused");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,54 @@ def test_info_quick_skips_launch(capsys):
|
||||
assert "skipped (--quick)" in out
|
||||
|
||||
|
||||
def test_info_without_proxy_shows_hint_and_never_resolves(capsys):
|
||||
with patch("cloakbrowser.geoip.resolve_proxy_geo_with_ip") as resolver:
|
||||
_run(Namespace(quick=True, json=False))
|
||||
resolver.assert_not_called()
|
||||
out = capsys.readouterr().out
|
||||
assert "pass --proxy" in out
|
||||
assert "Exit IP:" not in out
|
||||
|
||||
|
||||
def test_info_proxy_resolves_and_prints_exit_ip(capsys):
|
||||
with patch(
|
||||
"cloakbrowser.geoip.resolve_proxy_geo_with_ip",
|
||||
return_value=("Europe/Berlin", "de-DE", "203.0.113.9"),
|
||||
) as resolver:
|
||||
_run(Namespace(quick=True, json=False, proxy="http://p:8080"))
|
||||
resolver.assert_called_once_with("http://p:8080")
|
||||
out = capsys.readouterr().out
|
||||
assert "Exit IP: 203.0.113.9" in out
|
||||
assert "Timezone: Europe/Berlin" in out
|
||||
assert "Locale: de-DE" in out
|
||||
assert "pass --proxy" not in out # hint suppressed once resolved
|
||||
|
||||
|
||||
def test_info_proxy_json_includes_resolved(capsys):
|
||||
with patch(
|
||||
"cloakbrowser.geoip.resolve_proxy_geo_with_ip",
|
||||
return_value=("Europe/Berlin", "de-DE", "203.0.113.9"),
|
||||
):
|
||||
_run(Namespace(quick=True, json=True, proxy="http://p:8080"))
|
||||
data = json.loads(capsys.readouterr().out)
|
||||
assert data["geoip"]["resolved"] == {
|
||||
"exit_ip": "203.0.113.9",
|
||||
"timezone": "Europe/Berlin",
|
||||
"locale": "de-DE",
|
||||
}
|
||||
|
||||
|
||||
def test_info_proxy_resolution_failure_is_reported_not_fatal(capsys):
|
||||
with patch(
|
||||
"cloakbrowser.geoip.resolve_proxy_geo_with_ip",
|
||||
side_effect=RuntimeError("proxy refused"),
|
||||
):
|
||||
_run(Namespace(quick=True, json=False, proxy="http://p:8080"))
|
||||
out = capsys.readouterr().out
|
||||
assert "could not resolve" in out
|
||||
assert "proxy refused" in out
|
||||
|
||||
|
||||
def test_keyless_reports_free_binary(capsys):
|
||||
"""No license key -> the binary section reflects the FREE binary."""
|
||||
_run(Namespace(quick=True, json=True))
|
||||
|
||||
Reference in New Issue
Block a user