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 = []