diff --git a/cloakbrowser/__main__.py b/cloakbrowser/__main__.py index d03e427..2e18b85 100644 --- a/cloakbrowser/__main__.py +++ b/cloakbrowser/__main__.py @@ -306,6 +306,7 @@ def _collect_diagnostics(quick: bool, proxy: str | None = None) -> dict: # Omitted entirely off Linux, where it carries no signal. if platform.system() == "Linux": from .browser import ( + _MAC_FONT_TELLS, _OFFICE_FONT_TELLS, _WINDOWS_FONT_TELLS, _count_fonts_present, @@ -313,11 +314,16 @@ def _collect_diagnostics(quick: bool, proxy: str | None = None) -> dict: # Strict count, not "any one present" — real font installs are atomic # (you have the whole pack or none), so report how complete the set is. + # Both Windows and macOS sets are counted unconditionally: `info` has no + # persona flag, so we report each raw count and let the user read the one + # for the persona they spoof. win_n = _count_fonts_present(_WINDOWS_FONT_TELLS) office_n = _count_fonts_present(_OFFICE_FONT_TELLS) + mac_n = _count_fonts_present(_MAC_FONT_TELLS) diag["fonts"] = { "windows": None if win_n is None else [win_n, len(_WINDOWS_FONT_TELLS)], "office": None if office_n is None else [office_n, len(_OFFICE_FONT_TELLS)], + "macos": None if mac_n is None else [mac_n, len(_MAC_FONT_TELLS)], } diag["license"] = license_info @@ -477,6 +483,17 @@ def _print_diagnostics(diag: dict) -> None: # persona (~53% of real machines have none), so no install nudge. verdict = "ok" if n == total else "absent" if n == 0 else "partial" print(f"Office fonts: {verdict} ({n}/{total})") + mac = diag["fonts"].get("macos") + if mac is None: + print("Mac fonts: unknown (fc-list unavailable)") + else: + n, total = mac + verdict = "ok" if n == total else "missing" if n == 0 else "partial" + print(f"Mac fonts: {verdict} ({n}/{total})") + if n < total: + # Phrased conditionally: some Mac fonts also ship on a Windows box, + # so a partial count is expected unless macOS is the intended persona. + print(f" {ARROW} if spoofing macOS on this host, copy the real Mac fonts (Helvetica Neue, Menlo, SF/Apple system fonts)") lic = diag["license"] tier = lic["tier"] diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py index e9b3b21..c20e0b0 100644 --- a/cloakbrowser/browser.py +++ b/cloakbrowser/browser.py @@ -1520,6 +1520,34 @@ _OFFICE_FONT_TELLS = ( "Bookman Old Style", ) +# macOS OS fonts — the core set present on essentially every real Mac. Their +# absence on a macOS-spoofing Linux host degrades results, just as a Windows- +# spoofing host needs the Windows set above. Several entries (Arial, Georgia, +# Times New Roman, Courier New, Tahoma) also ship on a Windows-provisioned box, +# so a partial count on a non-Mac host is normal, not a fault. +_MAC_FONT_TELLS = ( + "Apple Color Emoji", + "Arial", + "Arial Narrow", + "Arial Unicode MS", + "Comic Sans MS", + "Courier", + "Courier New", + "Georgia", + "Gill Sans", + "Helvetica", + "Helvetica Neue", + "Impact", + "Menlo", + "Microsoft Sans Serif", + "Monaco", + "Tahoma", + "Times New Roman", + "Trebuchet MS", + "Webdings", + "Wingdings", +) + _font_warning_checked = False diff --git a/dotnet/src/CloakBrowser.Cli/Program.cs b/dotnet/src/CloakBrowser.Cli/Program.cs index 6ae5f68..624dad4 100644 --- a/dotnet/src/CloakBrowser.Cli/Program.cs +++ b/dotnet/src/CloakBrowser.Cli/Program.cs @@ -206,6 +206,20 @@ static void PrintDiagnostics(Dictionary diag) string verdict = n == total ? "ok" : n == 0 ? "absent" : "partial"; Console.WriteLine($"Office fonts: {verdict} ({n}/{total})"); } + if (fonts.TryGetValue("macos", out var macObj) && macObj is int[] mac) + { + int n = mac[0], total = mac[1]; + string verdict = n == total ? "ok" : n == 0 ? "missing" : "partial"; + Console.WriteLine($"Mac fonts: {verdict} ({n}/{total})"); + if (n < total) + // Phrased conditionally: some Mac fonts also ship on a Windows box, + // so a partial count is expected unless macOS is the intended persona. + Console.WriteLine(" → if spoofing macOS on this host, copy the real Mac fonts (Helvetica Neue, Menlo, SF/Apple system fonts)"); + } + else + { + Console.WriteLine("Mac fonts: unknown (fc-list unavailable)"); + } } var lic = (Dictionary)diag["license"]!; diff --git a/dotnet/src/CloakBrowser/CloakLauncher.cs b/dotnet/src/CloakBrowser/CloakLauncher.cs index d010ac6..9f9c3c0 100644 --- a/dotnet/src/CloakBrowser/CloakLauncher.cs +++ b/dotnet/src/CloakBrowser/CloakLauncher.cs @@ -480,6 +480,20 @@ public static class CloakLauncher "Monotype Corsiva", "Bookman Old Style", }; + // macOS OS fonts — the core set present on essentially every real Mac. Their + // absence on a macOS-spoofing Linux host degrades results, just as a Windows- + // spoofing host needs the Windows set above. Several entries (Arial, Georgia, + // Times New Roman, Courier New, Tahoma) also ship on a Windows box, so a + // partial count off-Mac is normal. + internal static readonly string[] MacFontTells = + { + "Apple Color Emoji", "Arial", "Arial Narrow", "Arial Unicode MS", + "Comic Sans MS", "Courier", "Courier New", "Georgia", "Gill Sans", + "Helvetica", "Helvetica Neue", "Impact", "Menlo", "Microsoft Sans Serif", + "Monaco", "Tahoma", "Times New Roman", "Trebuchet MS", "Webdings", + "Wingdings", + }; + internal static bool _fontWarningChecked; /// diff --git a/dotnet/src/CloakBrowser/Diagnostics.cs b/dotnet/src/CloakBrowser/Diagnostics.cs index 3a32b58..12c1f72 100644 --- a/dotnet/src/CloakBrowser/Diagnostics.cs +++ b/dotnet/src/CloakBrowser/Diagnostics.cs @@ -79,18 +79,22 @@ internal static class Diagnostics diag["launch"] = launch; } - // Windows-font probe — only meaningful on a Linux host spoofing Windows. - // Omitted entirely off Linux, where it carries no signal. + // Windows/macOS-font probe — only meaningful on a Linux host spoofing + // another OS. Omitted entirely off Linux, where it carries no signal. if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { // Strict count, not "any one present" — real font installs are atomic // (you have the whole pack or none), so report how complete the set is. + // Both sets are counted unconditionally: `info` has no persona flag, so + // we report each raw count and let the user read the one they spoof. int? winN = CloakLauncher.CountFontsPresent(CloakLauncher.WindowsFontTells); int? officeN = CloakLauncher.CountFontsPresent(CloakLauncher.OfficeFontTells); + int? macN = CloakLauncher.CountFontsPresent(CloakLauncher.MacFontTells); diag["fonts"] = new Dictionary { ["windows"] = winN is null ? null : new[] { winN.Value, CloakLauncher.WindowsFontTells.Length }, ["office"] = officeN is null ? null : new[] { officeN.Value, CloakLauncher.OfficeFontTells.Length }, + ["macos"] = macN is null ? null : new[] { macN.Value, CloakLauncher.MacFontTells.Length }, }; } diff --git a/dotnet/tests/CloakBrowser.Tests/FontWarningTests.cs b/dotnet/tests/CloakBrowser.Tests/FontWarningTests.cs index 04d1705..0178aa9 100644 --- a/dotnet/tests/CloakBrowser.Tests/FontWarningTests.cs +++ b/dotnet/tests/CloakBrowser.Tests/FontWarningTests.cs @@ -192,4 +192,39 @@ public class FontWarningTests try { Directory.Delete(tmp, recursive: true); } catch { /* best-effort */ } } } + + [Fact] + public void MacFontTells_is_the_20_font_mac_core_set() + { + Assert.Equal(20, CloakLauncher.MacFontTells.Length); + Assert.Contains("Helvetica Neue", CloakLauncher.MacFontTells); + } + + [Fact] + public void CountFontsPresent_counts_the_mac_set_from_fc_list() + { + // Shim an "fc-list" on PATH that lists only 2 of the mac tells (POSIX only). + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + + var dir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(dir); + var shim = Path.Combine(dir, "fc-list"); + File.WriteAllText(shim, "#!/bin/sh\necho '/x/Menlo.ttc: Menlo:style=Regular'\necho '/x/Monaco.ttf: Monaco:style=Regular'\n"); + using (var chmod = Process.Start(new ProcessStartInfo("chmod", $"+x \"{shim}\"") { UseShellExecute = false })) + { + chmod!.WaitForExit(); + } + + var prevPath = Environment.GetEnvironmentVariable("PATH"); + try + { + Environment.SetEnvironmentVariable("PATH", dir + Path.PathSeparator + prevPath); + Assert.Equal(2, CloakLauncher.CountFontsPresent(CloakLauncher.MacFontTells)); + } + finally + { + Environment.SetEnvironmentVariable("PATH", prevPath); + try { Directory.Delete(dir, recursive: true); } catch { /* best-effort */ } + } + } } diff --git a/js/src/cli.ts b/js/src/cli.ts index c8e7522..de15c39 100644 --- a/js/src/cli.ts +++ b/js/src/cli.ts @@ -24,7 +24,7 @@ import { CHROMIUM_VERSION, WRAPPER_VERSION, } from "./config.js"; -import { countFontsPresent, WINDOWS_FONT_TELLS, OFFICE_FONT_TELLS } from "./fonts.js"; +import { countFontsPresent, WINDOWS_FONT_TELLS, OFFICE_FONT_TELLS, MACOS_FONT_TELLS } from "./fonts.js"; import { resolveProxyGeo } from "./geoip.js"; import { resolveLicenseKey, validateLicense, getProLatestRelease, getSessionSeats, type LicenseInfo } from "./license.js"; import { execFileSync, spawn } from "node:child_process"; @@ -268,16 +268,20 @@ export async function collectDiagnostics( if (!ok) diag.launch.missing_libs = missingSharedLibs(binPath); } - // Windows-font probe — only meaningful on a Linux host spoofing Windows. - // Omitted entirely off Linux, where it carries no signal. + // Windows/macOS-font probe — only meaningful on a Linux host spoofing another + // OS. Omitted entirely off Linux, where it carries no signal. if (os.platform() === "linux") { // Strict count, not "any one present" — real font installs are atomic // (you have the whole pack or none), so report how complete the set is. + // Both sets are counted unconditionally: `info` has no persona flag, so we + // report each raw count and let the user read the one for the persona they spoof. const winN = countFontsPresent(WINDOWS_FONT_TELLS); const officeN = countFontsPresent(OFFICE_FONT_TELLS); + const macN = countFontsPresent(MACOS_FONT_TELLS); diag.fonts = { windows: winN === null ? null : [winN, WINDOWS_FONT_TELLS.length], office: officeN === null ? null : [officeN, OFFICE_FONT_TELLS.length], + macos: macN === null ? null : [macN, MACOS_FONT_TELLS.length], }; } @@ -436,6 +440,19 @@ function printDiagnostics(diag: Record): void { const verdict = n === total ? "ok" : n === 0 ? "absent" : "partial"; console.log(`Office fonts: ${verdict} (${n}/${total})`); } + const mac = diag.fonts.macos; + if (mac === undefined || mac === null) { + console.log("Mac fonts: unknown (fc-list unavailable)"); + } else { + const [n, total] = mac; + const verdict = n === total ? "ok" : n === 0 ? "missing" : "partial"; + console.log(`Mac fonts: ${verdict} (${n}/${total})`); + if (n < total) { + // Phrased conditionally: some Mac fonts also ship on a Windows box, so a + // partial count is expected unless macOS is the intended persona. + console.log(" → if spoofing macOS on this host, copy the real Mac fonts (Helvetica Neue, Menlo, SF/Apple system fonts)"); + } + } } const lic = diag.license; diff --git a/js/src/fonts.ts b/js/src/fonts.ts index 3279f5e..7b1eb4b 100644 --- a/js/src/fonts.ts +++ b/js/src/fonts.ts @@ -45,6 +45,34 @@ export const OFFICE_FONT_TELLS = [ "Bookman Old Style", ]; +// macOS OS fonts — the core set present on essentially every real Mac. Their +// absence on a macOS-spoofing Linux host degrades results, just as a Windows- +// spoofing host needs the Windows set above. Several entries (Arial, Georgia, +// Times New Roman, Courier New, Tahoma) also ship on a Windows box, so a partial +// count off-Mac is normal. +export const MACOS_FONT_TELLS = [ + "Apple Color Emoji", + "Arial", + "Arial Narrow", + "Arial Unicode MS", + "Comic Sans MS", + "Courier", + "Courier New", + "Georgia", + "Gill Sans", + "Helvetica", + "Helvetica Neue", + "Impact", + "Menlo", + "Microsoft Sans Serif", + "Monaco", + "Tahoma", + "Times New Roman", + "Trebuchet MS", + "Webdings", + "Wingdings", +]; + let fontWarningChecked = false; /** diff --git a/js/tests/cli.test.ts b/js/tests/cli.test.ts index a1b1069..4c77a35 100644 --- a/js/tests/cli.test.ts +++ b/js/tests/cli.test.ts @@ -72,9 +72,14 @@ describe("collectDiagnostics", () => { const diag = (await collectDiagnostics(true)) as Record; expect(diag.binary).toBeDefined(); - // fonts section only present on Linux - if (os.platform() === "linux") expect(diag.fonts.windows).toBeDefined(); - else expect(diag.fonts).toBeUndefined(); + // fonts section only present on Linux — reports both the Windows and macOS + // persona sets so a user can read the count for whichever they spoof. + if (os.platform() === "linux") { + expect(diag.fonts.windows).toBeDefined(); + expect(diag.fonts.macos).toBeDefined(); + } else { + expect(diag.fonts).toBeUndefined(); + } expect(typeof diag.geoip.db_present).toBe("boolean"); expect(Object.keys(diag.modules).length).toBeGreaterThan(0); }); diff --git a/js/tests/fonts.test.ts b/js/tests/fonts.test.ts index 7a90e9f..4795353 100644 --- a/js/tests/fonts.test.ts +++ b/js/tests/fonts.test.ts @@ -138,3 +138,17 @@ describe("maybeWarnWindowsFonts", () => { expect(String(warn.mock.calls[0][0])).toContain(MSG); }); }); + +describe("MACOS_FONT_TELLS", () => { + it("holds the 20-font mac core set and counts via fc-list", async () => { + const { MACOS_FONT_TELLS, countFontsPresent } = await import("../src/fonts.js"); + expect(MACOS_FONT_TELLS.length).toBe(20); + expect(MACOS_FONT_TELLS).toContain("Helvetica Neue"); + const cp = await import("node:child_process"); + // fc-list listing with only 2 of the mac tells present. + vi.mocked(cp.execFileSync).mockReturnValue( + "/x/Menlo.ttc: Menlo:style=Regular\n/x/Monaco.ttf: Monaco:style=Regular" as any, + ); + expect(countFontsPresent(MACOS_FONT_TELLS as string[])).toBe(2); + }); +}); diff --git a/tests/test_cli.py b/tests/test_cli.py index 3bf8e2a..6b6dea2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -306,6 +306,56 @@ def test_free_license_shows_upgrade_hint(capsys): assert "cloakbrowser.dev" in out +# --------------------------------------------------------------------------- +# Font probe — the Linux-only Windows/macOS-persona font check. Forces the Linux +# branch and mocks fc-list so the count is deterministic on any host. +# --------------------------------------------------------------------------- + + +def _fonts_linux(mac_present): + """Patch context: force the Linux font branch and stub fc-list so the Windows + and Office sets read fully present while the mac set reports `mac_present`. + """ + from cloakbrowser.browser import _MAC_FONT_TELLS + + def fake_count(tells): + return mac_present if tells is _MAC_FONT_TELLS else len(tells) + + return ( + patch("cloakbrowser.__main__.platform.system", return_value="Linux"), + patch("cloakbrowser.browser._count_fonts_present", side_effect=fake_count), + ) + + +def test_info_reports_partial_mac_fonts_with_conditional_nudge(capsys): + # 5/20 = the Windows-box overlap (Arial, Georgia, Times New Roman, ...). + p_platform, p_count = _fonts_linux(5) + with p_platform, p_count: + _run(Namespace(quick=True, json=False)) + out = capsys.readouterr().out + assert "Mac fonts: partial (5/20)" in out + assert "if spoofing macOS on this host" in out + + +def test_info_full_mac_fonts_has_no_nudge(capsys): + from cloakbrowser.browser import _MAC_FONT_TELLS + + p_platform, p_count = _fonts_linux(len(_MAC_FONT_TELLS)) + with p_platform, p_count: + _run(Namespace(quick=True, json=False)) + out = capsys.readouterr().out + assert "Mac fonts: ok (20/20)" in out + assert "if spoofing macOS on this host" not in out + + +def test_info_json_includes_mac_font_count_on_linux(capsys): + p_platform, p_count = _fonts_linux(5) + with p_platform, p_count: + _run(Namespace(quick=True, json=True)) + data = json.loads(capsys.readouterr().out) + assert data["fonts"]["macos"] == [5, 20] + + # --------------------------------------------------------------------------- # Launch test — exercises the real subprocess path (not --quick) against a stub # executable, so the launch-test code is actually covered by CI.