diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py index 080e520..e9b3b21 100644 --- a/cloakbrowser/browser.py +++ b/cloakbrowser/browser.py @@ -1586,13 +1586,18 @@ def _maybe_warn_windows_fonts(chrome_args: list[str]) -> None: if present is None or present: return # full set present, or can't determine — don't warn # Write straight to stderr (like the welcome banner and the JS/.NET - # wrappers) so an app's logging config can't silence it. - sys.stderr.write( - "[cloakbrowser] Incomplete Windows font set — installing the full " - "set is strongly advised for best results when spoofing Windows on " - "Linux. https://github.com/CloakHQ/cloakbrowser#font-setup-on-linux " - "(silence: CLOAKBROWSER_SUPPRESS_FONT_WARNING=1)\n" - ) + # wrappers) so an app's logging config can't silence it. ASCII-only and + # swallow-everything: on a legacy Windows console stderr is cp1252/strict + # and this write is on the launch path (ticket 2354). + try: + sys.stderr.write( + "[cloakbrowser] Incomplete Windows font set - installing the full " + "set is strongly advised for best results when spoofing Windows on " + "Linux. https://github.com/CloakHQ/cloakbrowser#font-setup-on-linux " + "(silence: CLOAKBROWSER_SUPPRESS_FONT_WARNING=1)\n" + ) + except Exception: + pass try: marker.parent.mkdir(parents=True, exist_ok=True) marker.write_text("") diff --git a/cloakbrowser/download.py b/cloakbrowser/download.py index a1acb62..cf681bb 100644 --- a/cloakbrowser/download.py +++ b/cloakbrowser/download.py @@ -113,6 +113,20 @@ def _warn_preview_fallback() -> None: ) +def _emit(text: str) -> None: + """Write a cosmetic line to stderr, swallowing any error. + + On a legacy Windows console sys.stderr is cp1252/strict; an unencodable + glyph raises UnicodeEncodeError. Because the welcome banner runs on the + binary-download path, that would abort the whole launch (ticket 2354). + A banner is decoration - never let it propagate. + """ + try: + sys.stderr.write(text) + except Exception: + pass + + def _show_welcome(tier: str = "keyless") -> None: """Show welcome message on launch. A marker file gates the cadence: a paid (Pro) key shows once ever; free (keyless or a free GitHub key) re-shows every @@ -126,36 +140,40 @@ def _show_welcome(tier: str = "keyless") -> None: marker = get_cache_dir() / ".welcome_shown" if not _welcome_due(marker, pro=(tier == "pro")): return - sys.stderr.write("\n") - sys.stderr.write(" CloakBrowser — stealth Chromium for automation\n") - sys.stderr.write(" https://github.com/CloakHQ/CloakBrowser\n") - sys.stderr.write("\n") + # ASCII-only banner + a swallow-everything writer: on a legacy Windows + # console sys.stderr is cp1252/strict, so a non-ASCII glyph (or any IO + # error) here would raise and abort the whole binary-download path. A + # cosmetic banner must never be able to kill a launch. + _emit("\n") + _emit(" CloakBrowser - stealth Chromium for automation\n") + _emit(" https://github.com/CloakHQ/CloakBrowser\n") + _emit("\n") if tier == "pro": - sys.stderr.write( - f" CloakBrowser Pro active (v{PRO_MAJOR}) — latest binary, newest patches.\n" + _emit( + f" CloakBrowser Pro active (v{PRO_MAJOR}) - latest binary, newest patches.\n" ) - sys.stderr.write(" Pro support → support@cloakbrowser.dev\n") + _emit(" Pro support -> support@cloakbrowser.dev\n") elif tier == "free": - sys.stderr.write( + _emit( f" CloakBrowser free (v{PRO_MAJOR}): the latest binary, 1 concurrent session.\n" ) - sys.stderr.write( - " For more than one concurrent session → https://cloakbrowser.dev\n" + _emit( + " For more than one concurrent session -> https://cloakbrowser.dev\n" ) else: free_major = CHROMIUM_VERSION.split(".")[0] - sys.stderr.write( + _emit( f" Running the free binary (v{free_major}). " f"The latest binary (v{PRO_MAJOR}) is free too, with 1 concurrent session.\n" ) - sys.stderr.write( + _emit( " Get your key: run cloakbrowser login or visit https://cloakbrowser.dev/free\n" ) - sys.stderr.write( - " For more than one concurrent session → https://cloakbrowser.dev\n" + _emit( + " For more than one concurrent session -> https://cloakbrowser.dev\n" ) - sys.stderr.write(" Star us if CloakBrowser helps your project!\n") - sys.stderr.write("\n") + _emit(" Star us if CloakBrowser helps your project!\n") + _emit("\n") try: marker.parent.mkdir(parents=True, exist_ok=True) marker.write_text(str(int(time.time()))) @@ -1222,7 +1240,7 @@ def _check_wrapper_update() -> None: latest = resp.json()["info"]["version"] if _version_newer(latest, _wrapper_version): logger.warning( - "Update available: cloakbrowser %s → %s. " + "Update available: cloakbrowser %s -> %s. " "Run: pip install --upgrade cloakbrowser", _wrapper_version, latest, diff --git a/dotnet/src/CloakBrowser/CloakLauncher.cs b/dotnet/src/CloakBrowser/CloakLauncher.cs index c414109..d010ac6 100644 --- a/dotnet/src/CloakBrowser/CloakLauncher.cs +++ b/dotnet/src/CloakBrowser/CloakLauncher.cs @@ -568,7 +568,7 @@ public static class CloakLauncher var present = WindowsFontsPresent(); if (present != false) return; // true (full set) or null (undeterminable) CloakLog.Warning( - "[cloakbrowser] Incomplete Windows font set — installing the full " + + "[cloakbrowser] Incomplete Windows font set - installing the full " + "set is strongly advised for best results when spoofing Windows on " + "Linux. https://github.com/CloakHQ/cloakbrowser#font-setup-on-linux " + "(silence: CLOAKBROWSER_SUPPRESS_FONT_WARNING=1)"); diff --git a/dotnet/src/CloakBrowser/Download.cs b/dotnet/src/CloakBrowser/Download.cs index cfac5f8..c2c5b4e 100644 --- a/dotnet/src/CloakBrowser/Download.cs +++ b/dotnet/src/CloakBrowser/Download.cs @@ -134,25 +134,28 @@ public static class Download /// "free" — a free GitHub key (latest binary, one concurrent session) /// "keyless" — no key, running the older free binary (invite the free login) /// - private static void ShowWelcome(string tier = "keyless") + internal static void ShowWelcome(string tier = "keyless") { var marker = Path.Combine(Config.GetCacheDir(), ".welcome_shown"); if (!WelcomeDue(marker, tier == "pro")) return; + // ASCII-only banner: on a legacy Windows console a non-ASCII glyph can + // crash or mojibake the write, and this runs on the binary-download + // path (ticket 2354). Keep the three wrappers byte-parallel. var sb = new System.Text.StringBuilder(); sb.Append('\n'); - sb.Append(" CloakBrowser — stealth Chromium for automation\n"); + sb.Append(" CloakBrowser - stealth Chromium for automation\n"); sb.Append(" https://github.com/CloakHQ/CloakBrowser\n"); sb.Append('\n'); if (tier == "pro") { - sb.Append($" CloakBrowser Pro active (v{ProMajor}) — latest binary, newest patches.\n"); - sb.Append(" Pro support → support@cloakbrowser.dev\n"); + sb.Append($" CloakBrowser Pro active (v{ProMajor}) - latest binary, newest patches.\n"); + sb.Append(" Pro support -> support@cloakbrowser.dev\n"); } else if (tier == "free") { sb.Append($" CloakBrowser free (v{ProMajor}): the latest binary, 1 concurrent session.\n"); - sb.Append(" For more than one concurrent session → https://cloakbrowser.dev\n"); + sb.Append(" For more than one concurrent session -> https://cloakbrowser.dev\n"); } else { @@ -160,7 +163,7 @@ public static class Download sb.Append($" Running the free binary (v{freeMajor}). " + $"The latest binary (v{ProMajor}) is free too, with 1 concurrent session.\n"); sb.Append(" Get your key: run cloakbrowser login or visit https://cloakbrowser.dev/free\n"); - sb.Append(" For more than one concurrent session → https://cloakbrowser.dev\n"); + sb.Append(" For more than one concurrent session -> https://cloakbrowser.dev\n"); } sb.Append(" Star us if CloakBrowser helps your project!\n"); sb.Append('\n'); diff --git a/dotnet/tests/CloakBrowser.Tests/FontWarningTests.cs b/dotnet/tests/CloakBrowser.Tests/FontWarningTests.cs index 4d2d366..04d1705 100644 --- a/dotnet/tests/CloakBrowser.Tests/FontWarningTests.cs +++ b/dotnet/tests/CloakBrowser.Tests/FontWarningTests.cs @@ -58,6 +58,45 @@ public class WelcomeCadenceTests } } +/// +/// The welcome banner runs on the binary-download path. On a legacy Windows +/// console a non-ASCII glyph can crash or mojibake the write (ticket 2354), so +/// the banner must be pure ASCII. Mirrors the Python/JS welcome tests. +/// Serialized: mutates CLOAKBROWSER_CACHE_DIR and Console.Error. +/// +[Collection("env-serial")] +public class WelcomeBannerAsciiTests +{ + [Theory] + [InlineData("keyless")] + [InlineData("free")] + [InlineData("pro")] + public void Banner_is_ascii_only(string tier) + { + var prev = Environment.GetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR"); + var tmp = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tmp); + var origErr = Console.Error; + var buf = new StringWriter(); + try + { + Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", tmp); + Console.SetError(buf); + Download.ShowWelcome(tier); // marker absent -> shows + var outText = buf.ToString(); + Assert.NotEqual("", outText); + foreach (var c in outText) + Assert.True(c <= 0x7F, $"non-ASCII U+{(int)c:X4} in {tier} banner"); + } + finally + { + Console.SetError(origErr); + Environment.SetEnvironmentVariable("CLOAKBROWSER_CACHE_DIR", prev); + try { Directory.Delete(tmp, recursive: true); } catch { /* best-effort */ } + } + } +} + /// /// Linux Windows-font mismatch warning. Platform detection and fc-list aren't /// mockable without a DI refactor, so these cover the deterministic paths: the diff --git a/js/src/download.ts b/js/src/download.ts index 33892ee..c984d5b 100644 --- a/js/src/download.ts +++ b/js/src/download.ts @@ -355,24 +355,28 @@ function warnPreviewFallback(): void { ); } -function showWelcome(tier = "keyless"): void { +// Exported for tests only; not re-exported from the package index. +export function showWelcome(tier = "keyless"): void { const marker = path.join(getCacheDir(), ".welcome_shown"); if (!welcomeDue(marker, tier === "pro")) return; + // ASCII-only banner: on a legacy Windows console a non-ASCII glyph can + // crash or mojibake the write, and this runs on the binary-download path + // (ticket 2354). Keep the three wrappers byte-parallel. console.error(); - console.error(" CloakBrowser — stealth Chromium for automation"); + console.error(" CloakBrowser - stealth Chromium for automation"); console.error(" https://github.com/CloakHQ/CloakBrowser"); console.error(); if (tier === "pro") { console.error( - ` CloakBrowser Pro active (v${PRO_MAJOR}) — latest binary, newest patches.`, + ` CloakBrowser Pro active (v${PRO_MAJOR}) - latest binary, newest patches.`, ); - console.error(" Pro support → support@cloakbrowser.dev"); + console.error(" Pro support -> support@cloakbrowser.dev"); } else if (tier === "free") { console.error( ` CloakBrowser free (v${PRO_MAJOR}): the latest binary, 1 concurrent session.`, ); console.error( - " For more than one concurrent session → https://cloakbrowser.dev", + " For more than one concurrent session -> https://cloakbrowser.dev", ); } else { const freeMajor = CHROMIUM_VERSION.split(".")[0]; @@ -384,7 +388,7 @@ function showWelcome(tier = "keyless"): void { " Get your key: run cloakbrowser login or visit https://cloakbrowser.dev/free", ); console.error( - " For more than one concurrent session → https://cloakbrowser.dev", + " For more than one concurrent session -> https://cloakbrowser.dev", ); } console.error(" Star us if CloakBrowser helps your project!"); @@ -1218,7 +1222,7 @@ export async function checkWrapperUpdate(): Promise { const data = (await resp.json()) as { version: string }; if (data.version && versionNewer(data.version, WRAPPER_VERSION)) { console.warn( - `[cloakbrowser] Update available: ${WRAPPER_VERSION} → ${data.version}. ` + + `[cloakbrowser] Update available: ${WRAPPER_VERSION} -> ${data.version}. ` + `Run: npm install cloakbrowser@latest` ); } diff --git a/js/src/geoip.ts b/js/src/geoip.ts index be018f0..78dbd3e 100644 --- a/js/src/geoip.ts +++ b/js/src/geoip.ts @@ -221,7 +221,7 @@ async function resolveExitIp(proxyUrl: string | null | undefined, timeoutMs?: nu try { ({ SocksProxyAgent } = await import("socks-proxy-agent")); } catch { - console.warn("[cloakbrowser] socks-proxy-agent not installed — cannot resolve exit IP through SOCKS5 proxy. Install it: npm install socks-proxy-agent"); + console.warn("[cloakbrowser] socks-proxy-agent not installed - cannot resolve exit IP through SOCKS5 proxy. Install it: npm install socks-proxy-agent"); return null; } const { default: https } = await import("node:https"); diff --git a/js/tests/welcome.test.ts b/js/tests/welcome.test.ts new file mode 100644 index 0000000..2a7f914 --- /dev/null +++ b/js/tests/welcome.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { showWelcome } from "../src/download.js"; + +// The welcome banner runs on the binary-download path. On a legacy Windows +// console a non-ASCII glyph can crash or mojibake the write (ticket 2354), so +// the banner must be pure ASCII. Mirrors the Python test_welcome_banner suite. +describe("welcome banner", () => { + let cacheDir: string; + let prevCache: string | undefined; + let errSpy: ReturnType; + + beforeEach(() => { + cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cb-welcome-")); + prevCache = process.env.CLOAKBROWSER_CACHE_DIR; + process.env.CLOAKBROWSER_CACHE_DIR = cacheDir; + errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + errSpy.mockRestore(); + if (prevCache === undefined) delete process.env.CLOAKBROWSER_CACHE_DIR; + else process.env.CLOAKBROWSER_CACHE_DIR = prevCache; + fs.rmSync(cacheDir, { recursive: true, force: true }); + }); + + for (const tier of ["keyless", "free", "pro"]) { + it(`${tier} banner is ASCII-only`, () => { + showWelcome(tier); + const out = errSpy.mock.calls.map((c) => String(c[0] ?? "")).join("\n"); + expect(out).not.toBe(""); + // eslint-disable-next-line no-control-regex + expect(out).toMatch(/^[\x00-\x7F]*$/); + }); + } +}); diff --git a/tests/test_welcome_banner.py b/tests/test_welcome_banner.py index e4d2256..f652ee9 100644 --- a/tests/test_welcome_banner.py +++ b/tests/test_welcome_banner.py @@ -1,4 +1,6 @@ """Tier-aware launch banner: keyless (v146) / free key / paid, + cadence.""" +import io +import sys import time from pathlib import Path @@ -55,6 +57,25 @@ def test_free_reshows_after_interval(cache, capsys): assert _banner(capsys, "free") != "" # interval passed -> shows again +@pytest.mark.parametrize("tier", ["keyless", "free", "pro"]) +def test_banner_is_ascii_only(cache, capsys, tier): + """Banner must be pure ASCII so a legacy Windows console (cp1252/strict) + can always encode it. Regression for ticket 2354 (the '->' glyph).""" + out = _banner(capsys, tier) + assert out != "" + non_ascii = [c for c in out if ord(c) > 0x7F] + assert non_ascii == [], f"non-ASCII in {tier} banner: {non_ascii!r}" + + +@pytest.mark.parametrize("tier", ["keyless", "free", "pro"]) +def test_banner_never_raises_on_cp1252_stderr(cache, monkeypatch, tier): + """Even if a future glyph slips in, a cp1252/strict stderr must not abort + the launch — the banner is cosmetic. Reproduces ticket 2354's crash path.""" + strict = io.TextIOWrapper(io.BytesIO(), encoding="cp1252", errors="strict") + monkeypatch.setattr(sys, "stderr", strict) + download._show_welcome(tier) # pre-fix: UnicodeEncodeError; post-fix: returns + + def test_ensure_pro_binary_maps_plan_to_tier(monkeypatch): """A free-plan key shows the free banner; any other plan shows Pro.""" seen = []