diff --git a/cloakbrowser/__main__.py b/cloakbrowser/__main__.py index f08b77c..524103f 100644 --- a/cloakbrowser/__main__.py +++ b/cloakbrowser/__main__.py @@ -109,7 +109,7 @@ def _resolve_license() -> tuple[dict, bool]: return {"tier": "invalid", "valid": False}, False -def _effective_binary(entitled_pro: bool) -> dict: +def _effective_binary(entitled_pro: bool, quick: bool = False) -> dict: """Describe the binary ensure_binary would actually launch (no download). Mirrors ensure_binary's resolution (override > version pin > license tier). @@ -130,6 +130,8 @@ def _effective_binary(entitled_pro: bool) -> dict: if override: return { "version": None, + "latest_version": None, + "pinned": False, "tier": "override", "bundled_version": CHROMIUM_VERSION, "path": override, @@ -139,25 +141,37 @@ def _effective_binary(entitled_pro: bool) -> dict: } requested = normalize_requested_version(None) + + # For a Pro license, surface the server's latest separately from the version + # that will actually launch, so `info` can never silently diverge from launch + # (the divergence a customer hit: info showed latest, launch ran a stale cache). + # --quick keeps `info` fully network-free (skip the server latest lookup). + latest_version = None + if entitled_pro and not quick: + from .license import get_pro_latest_version + + latest_version = get_pro_latest_version() + if requested: version = requested elif entitled_pro: - # Mirror ensure_binary: a Pro launch resolves the latest Pro version over - # the network. Without this, a fresh Pro user (no cached marker) would see - # the free base version paired with the -pro path, which never ships. - from .license import get_pro_latest_version - - version = get_pro_latest_version() or get_effective_version(pro=True) + # "Will launch now" is the cached Pro build; if none is cached, the next + # launch downloads latest_version. get_effective_version(pro=True) returns + # None (never the free base) when nothing is cached. + version = get_effective_version(pro=True) or latest_version else: version = get_effective_version() - path = get_binary_path(version, pro=entitled_pro) + + path = get_binary_path(version, pro=entitled_pro) if version else None return { "version": version, + "latest_version": latest_version, + "pinned": bool(requested), "tier": "pro" if entitled_pro else "free", "bundled_version": CHROMIUM_VERSION, - "path": str(path), - "installed": path.exists(), - "cache_dir": str(get_binary_dir(version, pro=entitled_pro)), + "path": str(path) if path else None, + "installed": bool(path) and path.exists(), + "cache_dir": str(get_binary_dir(version, pro=entitled_pro)) if version else None, "override": None, } @@ -185,7 +199,7 @@ def _collect_diagnostics(quick: bool) -> dict: diag["environment"]["platform_tag"] = f"unavailable ({exc})" try: - diag["binary"] = _effective_binary(entitled_pro) + diag["binary"] = _effective_binary(entitled_pro, quick=quick) except Exception as exc: # platform unsupported, etc. diag["binary"] = {"error": str(exc)} @@ -257,7 +271,28 @@ def _print_diagnostics(diag: dict) -> None: if binary["tier"] == "override": print("Version: set via CLOAKBROWSER_BINARY_PATH (see Launch line)") else: - print(f"Version: {binary['version']} ({binary['tier']})") + latest = binary.get("latest_version") + if latest: + # Pro: show what launches now AND the server's latest, so the two + # can never silently diverge. + print(f"Version: {binary['version']} ({binary['tier']}) — will launch") + if latest == binary["version"]: + print(f"Latest: {latest} (up to date)") + elif binary.get("pinned"): + print( + f"Latest: {latest} (available — pinned; unset " + "CLOAKBROWSER_VERSION to upgrade)" + ) + else: + print(f"Latest: {latest} (available — next launch upgrades)") + elif binary["version"] is None: + # Pro with no cached build and no server answer (e.g. offline). + print( + f"Version: not downloaded yet ({binary['tier']}) " + "— next launch downloads the latest" + ) + else: + print(f"Version: {binary['version']} ({binary['tier']})") print(f"Binary: {binary['path']}") print(f"Installed: {binary['installed']}") if binary.get("cache_dir"): @@ -325,13 +360,24 @@ def cmd_info(args: argparse.Namespace) -> None: def cmd_update(args: argparse.Namespace) -> None: - from .download import check_for_update + from .download import check_for_pro_update, check_for_update logger = logging.getLogger("cloakbrowser") logger.info("Checking for updates...") - new_version = check_for_update() + + # A valid Pro license updates the Pro binary; everyone else updates free. + _, entitled_pro = _resolve_license() + if entitled_pro: + from .license import resolve_license_key + + new_version = check_for_pro_update(resolve_license_key(None)) + label = "Pro Chromium" + else: + new_version = check_for_update() + label = "Chromium" + if new_version: - print(f"Updated to Chromium {new_version}") + print(f"Updated to {label} {new_version}") else: print("Already up to date.") diff --git a/cloakbrowser/config.py b/cloakbrowser/config.py index bdbf0da..338462d 100644 --- a/cloakbrowser/config.py +++ b/cloakbrowser/config.py @@ -199,12 +199,16 @@ def check_platform_available() -> None: ) -def get_effective_version(pro: bool = False) -> str: +def get_effective_version(pro: bool = False) -> str | None: """Return the best available version: auto-updated if available, else platform default. Reads a platform-scoped marker file from the cache directory. Returns the platform's hardcoded version if no update has been downloaded. - When pro=True, reads from the Pro-specific marker files. + + When ``pro=True``, reads from the Pro-specific marker and returns ``None`` when + no cached Pro binary matches it. A valid Pro license must NEVER fall back to the + free binary, so there is deliberately no free-version fallback here — callers + treat ``None`` as "resolve the latest Pro version from the server" instead. """ base = get_chromium_version() cache = get_cache_dir() @@ -216,11 +220,13 @@ def get_effective_version(pro: bool = False) -> str: version = marker.read_text().strip() if version: binary = get_binary_path(version, pro=True) - if binary.exists(): + # Match launch's _pro_binary_ready (exists AND executable) so + # `info` never reports a build that launch would reject. + if binary.exists() and os.access(binary, os.X_OK): return version except (ValueError, OSError): pass - return base + return None # Free tier: try platform-scoped marker first, fall back to legacy marker for name in (f"latest_version_{get_platform_tag()}", "latest_version"): diff --git a/cloakbrowser/download.py b/cloakbrowser/download.py index a370ce0..dd8208a 100644 --- a/cloakbrowser/download.py +++ b/cloakbrowser/download.py @@ -304,16 +304,31 @@ def _download_and_extract(version: str | None = None) -> None: tmp_path.unlink(missing_ok=True) +def _pro_binary_ready(version: str | None) -> bool: + """True when a cached, executable Pro binary exists for ``version``.""" + if not version: + return False + path = get_binary_path(version, pro=True) + return path.exists() and _is_executable(path) + + def _ensure_pro_binary( license_key: str, requested_version: str | None = None, ) -> str: - """Ensure the Pro binary is downloaded and cached. Returns the binary path.""" + """Ensure the Pro binary is downloaded and cached. Returns the binary path. + + A valid Pro license NEVER falls back to the free binary. If the latest Pro + build cannot be resolved or downloaded and no cached Pro binary exists, the + error is raised rather than silently launching the free tier. + """ from .license import get_pro_latest_version + # --- Pinned: launch the exact requested version, no server cross-check, no + # marker write (a rollback pin must not stick future unpinned launches). --- if requested_version: - binary_path = get_binary_path(requested_version, pro=True) - if binary_path.exists() and _is_executable(binary_path): + if _pro_binary_ready(requested_version): + binary_path = get_binary_path(requested_version, pro=True) logger.debug( "Pinned Pro binary found in cache: %s (version %s)", binary_path, @@ -321,31 +336,86 @@ def _ensure_pro_binary( ) _show_welcome(pro=True) return str(binary_path) - version = requested_version - else: - effective = get_effective_version(pro=True) - binary_path = get_binary_path(effective, pro=True) - - if binary_path.exists() and _is_executable(binary_path): - logger.debug( - "Pro binary found in cache: %s (version %s)", binary_path, effective + logger.info( + "Downloading Pro Chromium %s for %s...", requested_version, get_platform_tag() + ) + _download_pro_binary(requested_version, license_key) + binary_path = get_binary_path(requested_version, pro=True) + if not binary_path.exists(): + raise RuntimeError( + f"Pro download completed but binary not found at: {binary_path}" ) - _show_welcome(pro=True) - _maybe_trigger_pro_update_check(license_key) - return str(binary_path) + _show_welcome(pro=True) + return str(binary_path) - version = get_pro_latest_version() - if not version: - raise RuntimeError("Could not determine latest Pro version from server") + # --- Unpinned: track the server's latest stable. --- + effective = get_effective_version(pro=True) - binary_path = get_binary_path(version, pro=True) - if binary_path.exists() and _is_executable(binary_path): + # Honor CLOAKBROWSER_AUTO_UPDATE=false the way the free path does: if the user + # froze updates AND a Pro build is already cached, keep it and skip the server + # check. With no cached build we must still fetch one — a valid Pro license can + # never launch the free binary. (The `update` CLI ignores this and always acts.) + frozen = os.environ.get("CLOAKBROWSER_AUTO_UPDATE", "").lower() == "false" + if frozen and _pro_binary_ready(effective): + logger.debug("Pro auto-update disabled; using cached %s", effective) + _show_welcome(pro=True) + return str(get_binary_path(effective, pro=True)) + + # get_pro_latest_version() is rate-limited to one network call per hour and + # returns a cached string in between, so this foreground check stays cheap on + # steady-state launches while still landing new stable after a version gap. + latest = get_pro_latest_version() + + # Prefer the server's latest when it is newer than — or replaces a missing — + # the cached build. Otherwise stay on the cached Pro binary (fast, offline-ok). + if latest and ( + not _pro_binary_ready(effective) # also covers effective is None + or _version_newer(latest, effective) + ): + version: str | None = latest + else: + version = effective + + if version is None: + # Valid Pro license but nothing resolvable (server unreachable AND no + # cached Pro build). Never downgrade to the free binary — fail loudly. + raise RuntimeError("Could not determine latest Pro version from server") + + if _pro_binary_ready(version): + binary_path = get_binary_path(version, pro=True) + # Advance the marker if this cached build is newer than what the marker names, + # so `info` (and a later server-outage launch) reflect the build we actually + # launch — never a stale marker. + if version != effective: + try: + _write_pro_version_marker(version) + except OSError: + pass logger.debug("Pro binary found in cache: %s (version %s)", binary_path, version) _show_welcome(pro=True) return str(binary_path) - logger.info("Downloading Pro Chromium %s for %s...", version, get_platform_tag()) - _download_pro_binary(version, license_key) + # `version` (the server latest) needs downloading. On failure, fall back to a + # cached Pro build if we have one — never the free binary. + try: + logger.info( + "Downloading Pro Chromium %s for %s...", version, get_platform_tag() + ) + _download_pro_binary(version, license_key) + except BinaryVerificationError: + # A tampering signal must surface verbatim — never mask it behind the + # cached-Pro fallback, which is only for transient download failures. + raise + except Exception: + if _pro_binary_ready(effective): + logger.warning( + "Pro update to %s failed; launching cached Pro binary %s", + version, + effective, + ) + _show_welcome(pro=True) + return str(get_binary_path(effective, pro=True)) + raise binary_path = get_binary_path(version, pro=True) if not binary_path.exists(): @@ -353,16 +423,11 @@ def _ensure_pro_binary( f"Pro download completed but binary not found at: {binary_path}" ) - # Write Pro version marker (atomic) only for unpinned latest resolution. - # A rollback pin must not make future unpinned launches stick to the old build. - if not requested_version: - marker = get_cache_dir() / f"latest_pro_version_{get_platform_tag()}" - try: - tmp = marker.with_suffix(".tmp") - tmp.write_text(version) - os.replace(str(tmp), str(marker)) - except OSError: - pass + # Advance the marker so future unpinned launches use this build. + try: + _write_pro_version_marker(version) + except OSError: + pass _show_welcome(pro=True) return str(binary_path) @@ -863,14 +928,14 @@ def binary_info(browser_version: str | None = None) -> dict: info matches what a pinned launch actually runs, instead of latest. """ requested = normalize_requested_version(browser_version) - # Prefer Pro only if a Pro binary actually exists on disk. + # Prefer Pro only if a Pro binary actually exists on disk. get_effective_version + # returns None for Pro when nothing is cached (it never falls back to free). pro_version = requested or get_effective_version(pro=True) - pro_path = get_binary_path(pro_version, pro=True) - pro = pro_path.exists() and _is_executable(pro_path) + pro = _pro_binary_ready(pro_version) # already false for a None version if pro: effective = pro_version - binary_path = pro_path + binary_path = get_binary_path(pro_version, pro=True) else: effective = requested or get_effective_version() binary_path = get_binary_path(effective) @@ -920,6 +985,39 @@ def check_for_update() -> str | None: return latest +def check_for_pro_update(license_key: str) -> str | None: + """Move a Pro install to the server's latest stable. Blocks until complete. + + Returns the new version when a newer Pro build is downloaded or an + already-cached newer build is activated, else None (already up to date or the + server could not be reached). Requires a valid Pro license key. + """ + from .license import get_pro_latest_version + + latest = get_pro_latest_version() + if not latest: + return None + + effective = get_effective_version(pro=True) + if effective and not _version_newer(latest, effective) and _pro_binary_ready( + effective + ): + # Already on the latest cached Pro build. + return None + + if not _pro_binary_ready(latest): + logger.info("Downloading Pro Chromium %s...", latest) + _download_pro_binary(latest, license_key) + binary_path = get_binary_path(latest, pro=True) + if not binary_path.exists(): + raise RuntimeError( + f"Pro download completed but binary not found at: {binary_path}" + ) + + _write_pro_version_marker(latest) + return latest + + def _should_check_for_update() -> bool: """Check if auto-update is enabled and rate limit hasn't been hit.""" if os.environ.get("CLOAKBROWSER_AUTO_UPDATE", "").lower() == "false": @@ -973,6 +1071,16 @@ def _write_version_marker(version: str) -> None: tmp.rename(marker) +def _write_pro_version_marker(version: str) -> None: + """Atomically write the latest Pro version marker for this platform.""" + cache_dir = get_cache_dir() + cache_dir.mkdir(parents=True, exist_ok=True) + marker = cache_dir / f"latest_pro_version_{get_platform_tag()}" + tmp = marker.with_suffix(".tmp") + tmp.write_text(version) + os.replace(str(tmp), str(marker)) + + _wrapper_update_checked = False @@ -1051,48 +1159,3 @@ def _maybe_trigger_update_check() -> None: return t = threading.Thread(target=_check_and_download_update, daemon=True) t.start() - - -def _maybe_trigger_pro_update_check(license_key: str) -> None: - """Fire-and-forget Pro binary update check in a daemon thread.""" - check_file = get_cache_dir() / ".last_pro_update_check" - if check_file.exists(): - try: - last_check = float(check_file.read_text().strip()) - if time.time() - last_check < UPDATE_CHECK_INTERVAL: - return - except (ValueError, OSError): - pass - - def _check(): - try: - from .license import get_pro_latest_version - - check_file.parent.mkdir(parents=True, exist_ok=True) - check_file.write_text(str(time.time())) - - latest = get_pro_latest_version() - if not latest: - return - - if get_binary_path(latest, pro=True).exists(): - return - - logger.info( - "Newer Pro binary available: %s. Downloading in background...", latest - ) - _download_pro_binary(latest, license_key) - - marker = get_cache_dir() / f"latest_pro_version_{get_platform_tag()}" - tmp = marker.with_suffix(".tmp") - tmp.write_text(latest) - os.replace(str(tmp), str(marker)) - logger.info( - "Pro background update complete: %s ready. Will use on next launch.", - latest, - ) - except Exception: - logger.debug("Pro background update failed", exc_info=True) - - t = threading.Thread(target=_check, daemon=True) - t.start() diff --git a/dotnet/src/CloakBrowser.Cli/Program.cs b/dotnet/src/CloakBrowser.Cli/Program.cs index edbd0d5..61b13be 100644 --- a/dotnet/src/CloakBrowser.Cli/Program.cs +++ b/dotnet/src/CloakBrowser.Cli/Program.cs @@ -101,6 +101,20 @@ static void PrintDiagnostics(Dictionary diag) { if ((string)binary["tier"]! == "override") Console.WriteLine("Version: set via CLOAKBROWSER_BINARY_PATH (see Launch line)"); + else if (binary.TryGetValue("latest_version", out var lv) && lv is string latest && !string.IsNullOrEmpty(latest)) + { + // Pro: show what launches now AND the server's latest, so the two can't diverge. + Console.WriteLine($"Version: {binary["version"]} ({binary["tier"]}) — will launch"); + if (latest == binary["version"] as string) + Console.WriteLine($"Latest: {latest} (up to date)"); + else if (binary.TryGetValue("pinned", out var p) && p is true) + Console.WriteLine($"Latest: {latest} (available — pinned; unset CLOAKBROWSER_VERSION to upgrade)"); + else + Console.WriteLine($"Latest: {latest} (available — next launch upgrades)"); + } + else if (binary["version"] is null) + // Pro with no cached build and no server answer (e.g. offline). + Console.WriteLine($"Version: not downloaded yet ({binary["tier"]}) — next launch downloads the latest"); else Console.WriteLine($"Version: {binary["version"]} ({binary["tier"]})"); Console.WriteLine($"Binary: {binary["path"]}"); @@ -184,9 +198,32 @@ static void PrintDiagnostics(Dictionary diag) static async Task CmdUpdate() { CloakLog.Info("Checking for updates..."); - string? newVersion = await Download.CheckForUpdateAsync().ConfigureAwait(false); + + // A valid Pro license updates the Pro binary; everyone else updates free. + // Mirrors Diagnostics.ResolveLicense: a custom download URL disables Pro routing. + string? key = License.ResolveLicenseKey(); + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CLOAKBROWSER_DOWNLOAD_URL"))) key = null; + bool entitledPro = false; + if (!string.IsNullOrEmpty(key)) + { + try { entitledPro = License.ValidateLicense(key!)?.Valid == true; } + catch { entitledPro = false; } + } + + string? newVersion; + string label; + if (entitledPro) + { + newVersion = await Download.CheckForProUpdateAsync(key!).ConfigureAwait(false); + label = "Pro Chromium"; + } + else + { + newVersion = await Download.CheckForUpdateAsync().ConfigureAwait(false); + label = "Chromium"; + } Console.WriteLine(newVersion != null - ? $"Updated to Chromium {newVersion}" + ? $"Updated to {label} {newVersion}" : "Already up to date."); } diff --git a/dotnet/src/CloakBrowser/Config.cs b/dotnet/src/CloakBrowser/Config.cs index 8201092..c2a014b 100644 --- a/dotnet/src/CloakBrowser/Config.cs +++ b/dotnet/src/CloakBrowser/Config.cs @@ -243,29 +243,32 @@ public static class Config /// /// Return the best available version: auto-updated if available, else platform default. /// Reads a platform-scoped marker file from the cache directory. - /// When is true, reads from the Pro-specific marker files. + /// When is true, reads from the Pro-specific marker and returns + /// null when no cached Pro binary matches it. A valid Pro license must NEVER fall + /// back to the free binary, so there is deliberately no free-version fallback for Pro — + /// callers treat null as "resolve the latest Pro version from the server". /// - public static string GetEffectiveVersion(bool pro = false) + public static string? GetEffectiveVersion(bool pro = false) { var baseVersion = GetChromiumVersion(); var cache = GetCacheDir(); if (pro) { - // Pro marker is authoritative for the Pro tier - no VersionNewer guard - // (Pro versions are independent of the bundled free version, e.g. 148 vs 146). var proMarker = Path.Combine(cache, $"latest_pro_version_{GetPlatformTag()}"); if (File.Exists(proMarker)) { try { var version = File.ReadAllText(proMarker).Trim(); - if (!string.IsNullOrEmpty(version) && File.Exists(GetBinaryPath(version, pro: true))) + // Match launch's ProBinaryReady (exists AND executable) so `info` + // never reports a build that launch would reject. + if (!string.IsNullOrEmpty(version) && IsExecutableFile(GetBinaryPath(version, pro: true))) return version; } catch (Exception ex) when (ex is FormatException or IOException) { } } - return baseVersion; + return null; } foreach (var name in new[] { $"latest_version_{GetPlatformTag()}", "latest_version" }) @@ -289,6 +292,15 @@ public static class Config return baseVersion; } + /// True when a binary exists and is executable. Canonical check shared with Download. + internal static bool IsExecutableFile(string path) + { + if (!File.Exists(path)) return false; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return true; + var mode = File.GetUnixFileMode(path); + return (mode & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) != 0; + } + /// Parse "145.0.7718.0" into (145, 0, 7718, 0) for comparison. public static int[] VersionTuple(string v) => v.Split('.').Select(int.Parse).ToArray(); @@ -403,7 +415,7 @@ public static class Config { declared = null; } - string version; + string? version; if (!string.IsNullOrEmpty(declared)) { version = declared!; @@ -417,6 +429,8 @@ public static class Config bool pro = !string.IsNullOrEmpty(License.ResolveLicenseKey(licenseKey)); version = GetEffectiveVersion(pro); } + // No cached Pro build resolvable (GetEffectiveVersion returned null) → fail safe. + if (version == null) return false; try { return !VersionNewer(HeadlessNoViewportMinVersion, version); diff --git a/dotnet/src/CloakBrowser/Diagnostics.cs b/dotnet/src/CloakBrowser/Diagnostics.cs index 142f201..1d03fb9 100644 --- a/dotnet/src/CloakBrowser/Diagnostics.cs +++ b/dotnet/src/CloakBrowser/Diagnostics.cs @@ -30,7 +30,7 @@ internal static class Diagnostics catch (Exception ex) { env["platform_tag"] = $"unavailable ({ex.Message})"; } Dictionary binary; - try { binary = EffectiveBinary(entitledPro); } + try { binary = EffectiveBinary(entitledPro, quick); } catch (Exception ex) { binary = new Dictionary { ["error"] = ex.Message }; } diag["binary"] = binary; @@ -125,7 +125,7 @@ internal static class Diagnostics // Describe the binary EnsureBinary would actually launch (no download). // Unlike Download.BinaryInfo(), a Pro binary on disk is only reported when // the license entitles Pro — so a keyless run shows the free binary. - private static Dictionary EffectiveBinary(bool entitledPro) + private static Dictionary EffectiveBinary(bool entitledPro, bool quick = false) { string? over = Config.GetLocalBinaryOverride(); if (!string.IsNullOrEmpty(over)) @@ -133,6 +133,8 @@ internal static class Diagnostics return new Dictionary { ["version"] = null, + ["latest_version"] = null, + ["pinned"] = false, ["tier"] = "override", ["bundled_version"] = Config.ChromiumVersion, ["path"] = over, @@ -142,25 +144,34 @@ internal static class Diagnostics }; } string? requested = Config.NormalizeRequestedVersion(); - string version; + + // For a Pro license, surface the server's latest separately from the version + // that will actually launch, so `info` can never silently diverge from launch + // (the divergence a customer hit: info showed latest, launch ran a stale cache). + // --quick keeps `info` fully network-free (skip the server latest lookup). + string? latestVersion = (entitledPro && !quick) ? License.GetProLatestVersion() : null; + + string? version; if (!string.IsNullOrEmpty(requested)) version = requested!; else if (entitledPro) - // Mirror EnsureBinary: a Pro launch resolves the latest Pro version over - // the network. Without this a fresh Pro user (no cached marker) would see - // the free base version paired with the -pro path, which never ships. - version = License.GetProLatestVersion() ?? Config.GetEffectiveVersion(true); + // "Will launch now" is the cached Pro build; if none is cached, the next + // launch downloads latestVersion. GetEffectiveVersion(true) returns null + // (never the free base) when nothing is cached. + version = Config.GetEffectiveVersion(true) ?? latestVersion; else version = Config.GetEffectiveVersion(false); - string path = Config.GetBinaryPath(version, entitledPro); + string? path = version != null ? Config.GetBinaryPath(version, entitledPro) : null; return new Dictionary { ["version"] = version, + ["latest_version"] = latestVersion, + ["pinned"] = !string.IsNullOrEmpty(requested), ["tier"] = entitledPro ? "pro" : "free", ["bundled_version"] = Config.ChromiumVersion, ["path"] = path, - ["installed"] = File.Exists(path), - ["cache_dir"] = Config.GetBinaryDir(version, entitledPro), + ["installed"] = path != null && File.Exists(path), + ["cache_dir"] = version != null ? Config.GetBinaryDir(version, entitledPro) : null, ["override"] = null, }; } diff --git a/dotnet/src/CloakBrowser/Download.cs b/dotnet/src/CloakBrowser/Download.cs index a29b229..d0e1221 100644 --- a/dotnet/src/CloakBrowser/Download.cs +++ b/dotnet/src/CloakBrowser/Download.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Formats.Tar; using System.IO.Compression; using System.Net.Http; @@ -249,7 +250,8 @@ public static class Download } // Check for auto-updated version first, then fall back to hardcoded. - var effective = Config.GetEffectiveVersion(); + // Free tier never returns null (bundled base is the floor). + var effective = Config.GetEffectiveVersion()!; var binaryPath = Config.GetBinaryPath(effective); if (File.Exists(binaryPath) && IsExecutable(binaryPath)) @@ -367,40 +369,91 @@ public static class Download return pinnedPath; } + // Unpinned: track the server's latest stable. var effective = Config.GetEffectiveVersion(pro: true); - var binaryPath = Config.GetBinaryPath(effective, pro: true); - if (File.Exists(binaryPath) && IsExecutable(binaryPath)) + // Honor CLOAKBROWSER_AUTO_UPDATE=false the way the free path does: frozen AND a + // cached Pro build present → keep it, skip the server check. With no cached build + // we must still fetch one — a valid Pro license never launches the free binary. + // (The `update` CLI ignores this and always acts.) + var frozen = string.Equals( + Environment.GetEnvironmentVariable("CLOAKBROWSER_AUTO_UPDATE"), "false", + StringComparison.OrdinalIgnoreCase); + if (frozen && ProBinaryReady(effective)) { - CloakLog.Debug("Pro binary found in cache: {0} (version {1})", binaryPath, effective); ShowWelcome(pro: true); - MaybeTriggerProUpdateCheck(licenseKey); - return binaryPath; + return Config.GetBinaryPath(effective, pro: true); } - var version = License.GetProLatestVersion(); - if (string.IsNullOrEmpty(version)) + // GetProLatestVersion() is rate-limited to one network call per hour and returns a + // cached string in between, so this foreground check stays cheap steady-state. + var latest = License.GetProLatestVersion(); + + // Prefer the server's latest when it is newer than — or replaces a missing — the + // cached build. Otherwise stay on the cached Pro binary (fast, offline-ok). + string? version; + if (!string.IsNullOrEmpty(latest) && + (!ProBinaryReady(effective) || Config.VersionNewer(latest, effective!))) // !ProBinaryReady covers effective == null + { + version = latest; + } + else + { + version = effective; + } + + if (version == null) + { + // Valid Pro license but nothing resolvable (server unreachable AND no cached + // Pro build). Never downgrade to the free binary — fail loudly. throw new InvalidOperationException("Could not determine latest Pro version from server"); - - binaryPath = Config.GetBinaryPath(version, pro: true); - if (File.Exists(binaryPath) && IsExecutable(binaryPath)) - { - CloakLog.Debug("Pro binary found in cache: {0} (version {1})", binaryPath, version); - ShowWelcome(pro: true); - return binaryPath; } - CloakLog.Info("Downloading Pro Chromium {0} for {1}...", version, Config.GetPlatformTag()); - await DownloadProBinaryAsync(version, licenseKey, ct).ConfigureAwait(false); + var readyPath = Config.GetBinaryPath(version, pro: true); + if (File.Exists(readyPath) && IsExecutable(readyPath)) + { + // Advance the marker if this cached build is newer than what the marker names, + // so `info` (and a later server-outage launch) reflect the build we actually + // launch — never a stale marker. + if (version != effective) + WriteProVersionMarker(version); + CloakLog.Debug("Pro binary found in cache: {0} (version {1})", readyPath, version); + ShowWelcome(pro: true); + return readyPath; + } - binaryPath = Config.GetBinaryPath(version, pro: true); - if (!File.Exists(binaryPath)) + // `version` (the server latest) needs downloading. On failure, fall back to a + // cached Pro build if we have one — never the free binary. + try + { + CloakLog.Info("Downloading Pro Chromium {0} for {1}...", version, Config.GetPlatformTag()); + await DownloadProBinaryAsync(version, licenseKey, ct).ConfigureAwait(false); + } + catch (BinaryVerificationError) + { + // A tampering signal must surface verbatim — never mask it behind the + // cached-Pro fallback, which is only for transient download failures. + throw; + } + catch (Exception) + { + if (ProBinaryReady(effective)) + { + CloakLog.Warning("Pro update to {0} failed; launching cached Pro binary {1}", version, effective); + ShowWelcome(pro: true); + return Config.GetBinaryPath(effective, pro: true); + } + throw; + } + + var downloadedPath = Config.GetBinaryPath(version, pro: true); + if (!File.Exists(downloadedPath)) throw new InvalidOperationException( - $"Pro download completed but binary not found at: {binaryPath}"); + $"Pro download completed but binary not found at: {downloadedPath}"); WriteProVersionMarker(version); ShowWelcome(pro: true); - return binaryPath; + return downloadedPath; } /// @@ -973,14 +1026,16 @@ public static class Download } } - private static bool IsExecutable(string path) + /// True when a cached, executable Pro binary exists for . + private static bool ProBinaryReady([NotNullWhen(true)] string? version) { - if (!File.Exists(path)) return false; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return true; - var mode = File.GetUnixFileMode(path); - return (mode & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) != 0; + if (string.IsNullOrEmpty(version)) return false; + var p = Config.GetBinaryPath(version, pro: true); + return File.Exists(p) && IsExecutable(p); } + private static bool IsExecutable(string path) => Config.IsExecutableFile(path); + private static void MakeExecutable(string path) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; @@ -1033,21 +1088,22 @@ public static class Download // browserVersion (or CLOAKBROWSER_VERSION) pins the reported version so the // info matches what a pinned launch actually runs, instead of latest. var requested = Config.NormalizeRequestedVersion(browserVersion); - // Prefer Pro only if a Pro binary actually exists on disk. + // Prefer Pro only if a Pro binary actually exists on disk. GetEffectiveVersion + // returns null for Pro when nothing is cached (it never falls back to free). var proVersion = requested ?? Config.GetEffectiveVersion(pro: true); - var proPath = Config.GetBinaryPath(proVersion, pro: true); - var pro = File.Exists(proPath) && IsExecutable(proPath); + var pro = ProBinaryReady(proVersion); string effective; string binaryPath; if (pro) { - effective = proVersion; - binaryPath = proPath; + // pro == true implies proVersion is non-null (ProBinaryReady). + effective = proVersion!; + binaryPath = Config.GetBinaryPath(proVersion!, pro: true); } else { - effective = requested ?? Config.GetEffectiveVersion(); + effective = requested ?? Config.GetEffectiveVersion()!; binaryPath = Config.GetBinaryPath(effective); } @@ -1092,6 +1148,41 @@ public static class Download /// Synchronous convenience wrapper around . public static string? CheckForUpdate() => CheckForUpdateAsync().GetAwaiter().GetResult(); + /// + /// Move a Pro install to the server's latest stable. Blocks until complete. Returns the + /// new version when a newer Pro build is downloaded or an already-cached newer build is + /// activated, else null (already up to date or the server could not be reached). + /// Requires a valid Pro license key. + /// + public static async Task CheckForProUpdateAsync(string licenseKey, CancellationToken ct = default) + { + var latest = License.GetProLatestVersion(); + if (string.IsNullOrEmpty(latest)) return null; + + var effective = Config.GetEffectiveVersion(pro: true); + if (effective != null && !Config.VersionNewer(latest, effective) && ProBinaryReady(effective)) + { + // Already on the latest cached Pro build. + return null; + } + + if (!ProBinaryReady(latest)) + { + CloakLog.Info("Downloading Pro Chromium {0}...", latest); + await DownloadProBinaryAsync(latest, licenseKey, ct).ConfigureAwait(false); + var p = Config.GetBinaryPath(latest, pro: true); + if (!File.Exists(p)) + throw new InvalidOperationException($"Pro download completed but binary not found at: {p}"); + } + + WriteProVersionMarker(latest); + return latest; + } + + /// Synchronous convenience wrapper around . + public static string? CheckForProUpdate(string licenseKey) => + CheckForProUpdateAsync(licenseKey).GetAwaiter().GetResult(); + private static bool ShouldCheckForUpdate() { if ((Environment.GetEnvironmentVariable("CLOAKBROWSER_AUTO_UPDATE") ?? "").ToLowerInvariant() == "false") @@ -1268,45 +1359,4 @@ public static class Download if (!ShouldCheckForUpdate()) return; _ = Task.Run(CheckAndDownloadUpdateAsync); } - - /// Fire-and-forget Pro binary update check in a background task (rate-limited to once/hour). - private static void MaybeTriggerProUpdateCheck(string licenseKey) - { - var checkFile = Path.Combine(Config.GetCacheDir(), ".last_pro_update_check"); - if (File.Exists(checkFile)) - { - try - { - var lastCheck = double.Parse(File.ReadAllText(checkFile).Trim(), - System.Globalization.CultureInfo.InvariantCulture); - var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0; - if (now - lastCheck < UpdateCheckInterval) - return; - } - catch (Exception ex) when (ex is FormatException or IOException) { } - } - - _ = Task.Run(async () => - { - try - { - Directory.CreateDirectory(Path.GetDirectoryName(checkFile)!); - var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0; - File.WriteAllText(checkFile, now.ToString(System.Globalization.CultureInfo.InvariantCulture)); - - var latest = License.GetProLatestVersion(); - if (string.IsNullOrEmpty(latest)) return; - if (File.Exists(Config.GetBinaryPath(latest, pro: true))) return; - - CloakLog.Info("Newer Pro binary available: {0}. Downloading in background...", latest); - await DownloadProBinaryAsync(latest, licenseKey, CancellationToken.None).ConfigureAwait(false); - WriteProVersionMarker(latest); - CloakLog.Info("Pro background update complete: {0} ready. Will use on next launch.", latest); - } - catch (Exception) - { - CloakLog.Debug("Pro background update failed"); - } - }); - } } diff --git a/dotnet/tests/CloakBrowser.Tests/LicenseTests.cs b/dotnet/tests/CloakBrowser.Tests/LicenseTests.cs index 23bc7a6..c86e956 100644 --- a/dotnet/tests/CloakBrowser.Tests/LicenseTests.cs +++ b/dotnet/tests/CloakBrowser.Tests/LicenseTests.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Net; using System.Net.Http; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -283,12 +284,59 @@ public class LicenseTests : IDisposable } [Fact] - public void EffectiveVersion_pro_marker_without_binary_falls_back() + public void EffectiveVersion_pro_marker_without_binary_returns_null() { var marker = Path.Combine(_tmp, $"latest_pro_version_{Config.GetPlatformTag()}"); File.WriteAllText(marker, "148.0.7778.215.2"); - // Marker present but no Pro binary on disk -> falls back to bundled version. - Assert.Equal(Config.GetChromiumVersion(), Config.GetEffectiveVersion(pro: true)); + // Ticket 431 Fix 4: marker present but no Pro binary on disk -> null, NOT the + // free base. A valid Pro license must never fall back to the free binary. + Assert.Null(Config.GetEffectiveVersion(pro: true)); + } + + [Fact] + public void EffectiveVersion_pro_no_marker_returns_null_free_returns_base() + { + // No Pro marker at all -> null for Pro; free tier still resolves to a version. + Assert.Null(Config.GetEffectiveVersion(pro: true)); + Assert.Equal(Config.GetChromiumVersion(), Config.GetEffectiveVersion(pro: false)); + } + + // Create a fake cached, executable Pro binary for `version`. + private static void MakeProBinary(string version) + { + var p = Config.GetBinaryPath(version, pro: true); + Directory.CreateDirectory(Path.GetDirectoryName(p)!); + File.WriteAllText(p, "binary"); + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + File.SetUnixFileMode(p, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + [Fact] + public void CheckForProUpdate_already_latest_returns_null() + { + // Ticket 431 Fix 1: `update` on a Pro install already at latest is a no-op. + File.WriteAllText( + Path.Combine(_tmp, $"latest_pro_version_{Config.GetPlatformTag()}"), + "148.0.7778.215.5"); + MakeProBinary("148.0.7778.215.5"); + License.ProLatestVersionOverride = () => "148.0.7778.215.5"; + try + { + Assert.Null(Download.CheckForProUpdate("cb_key")); + } + finally { License.ProLatestVersionOverride = null; } + } + + [Fact] + public void CheckForProUpdate_server_down_returns_null() + { + License.ProLatestVersionOverride = () => null; + try + { + Assert.Null(Download.CheckForProUpdate("cb_key")); + } + finally { License.ProLatestVersionOverride = null; } } // ======================================================================= diff --git a/js/src/cli.ts b/js/src/cli.ts index 8ab84b7..532e471 100644 --- a/js/src/cli.ts +++ b/js/src/cli.ts @@ -10,7 +10,7 @@ * npx cloakbrowser clear-cache # Remove cached binaries */ -import { ensureBinary, checkForUpdate, clearCache } from "./download.js"; +import { ensureBinary, checkForUpdate, checkForProUpdate, clearCache } from "./download.js"; import { getLocalBinaryOverride, getCacheDir, @@ -114,11 +114,16 @@ async function resolveLicense(): Promise<{ license: Record; ent } /** Describe the binary ensureBinary would actually launch (no download). */ -async function effectiveBinary(entitledPro: boolean): Promise> { +async function effectiveBinary( + entitledPro: boolean, + quick = false +): Promise> { const override = getLocalBinaryOverride(); if (override) { return { version: null, + latest_version: null, + pinned: false, tier: "override", bundled_version: CHROMIUM_VERSION, path: override, @@ -128,25 +133,36 @@ async function effectiveBinary(entitledPro: boolean): Promise): void { } else { if (binary.tier === "override") { console.log("Version: set via CLOAKBROWSER_BINARY_PATH (see Launch line)"); + } else if (binary.latest_version) { + // Pro: show what launches now AND the server's latest, so the two can't diverge. + console.log(`Version: ${binary.version} (${binary.tier}) — will launch`); + if (binary.latest_version === binary.version) { + console.log(`Latest: ${binary.latest_version} (up to date)`); + } else if (binary.pinned) { + console.log( + `Latest: ${binary.latest_version} (available — pinned; unset CLOAKBROWSER_VERSION to upgrade)` + ); + } else { + console.log(`Latest: ${binary.latest_version} (available — next launch upgrades)`); + } + } else if (binary.version === null) { + // Pro with no cached build and no server answer (e.g. offline). + console.log( + `Version: not downloaded yet (${binary.tier}) — next launch downloads the latest` + ); } else { console.log(`Version: ${binary.version} (${binary.tier})`); } @@ -310,9 +343,19 @@ async function cmdInfo(args: string[]): Promise { async function cmdUpdate(): Promise { console.error("Checking for updates..."); - const newVersion = await checkForUpdate(); + // A valid Pro license updates the Pro binary; everyone else updates free. + const { entitledPro } = await resolveLicense(); + let newVersion: string | null; + let label: string; + if (entitledPro) { + newVersion = await checkForProUpdate(resolveLicenseKey()!); + label = "Pro Chromium"; + } else { + newVersion = await checkForUpdate(); + label = "Chromium"; + } if (newVersion) { - console.log(`Updated to Chromium ${newVersion}`); + console.log(`Updated to ${label} ${newVersion}`); } else { console.log("Already up to date."); } diff --git a/js/src/config.ts b/js/src/config.ts index fa6bc5a..985636b 100644 --- a/js/src/config.ts +++ b/js/src/config.ts @@ -176,26 +176,38 @@ export function getFallbackDownloadUrl(version?: string): string { return `${GITHUB_DOWNLOAD_BASE_URL}/chromium-v${v}/${getArchiveName()}`; } -export function getEffectiveVersion(pro = false): string { +export function getEffectiveVersion(pro?: false): string; +export function getEffectiveVersion(pro: boolean): string | null; +export function getEffectiveVersion(pro = false): string | null { const base = getChromiumVersion(); const cacheDir = getCacheDir(); if (pro) { + // A valid Pro license must NEVER fall back to the free binary, so there is + // deliberately no free-version fallback here — return null when no cached Pro + // binary matches the marker; callers resolve the latest Pro version instead. const marker = path.join(cacheDir, `latest_pro_version_${getPlatformTag()}`); try { if (fs.existsSync(marker)) { const version = fs.readFileSync(marker, "utf-8").trim(); if (version) { const binary = getBinaryPath(version, true); + // Match launch's proBinaryReady (exists AND executable) so `info` never + // reports a build that launch would reject. if (fs.existsSync(binary)) { - return version; + try { + fs.accessSync(binary, fs.constants.X_OK); + return version; + } catch { + // Present but not executable → not launch-ready. + } } } } } catch { // Marker unreadable } - return base; + return null; } // Free tier: try platform-scoped marker first, fall back to legacy marker for upgrades from <0.3.0 @@ -269,7 +281,7 @@ export function binarySupportsHeadlessNoViewport( } catch { declared = undefined; } - let version: string; + let version: string | null; if (declared) { version = declared; } else if (getLocalBinaryOverride()) { @@ -280,6 +292,8 @@ export function binarySupportsHeadlessNoViewport( const pro = Boolean(resolveLicenseKey(licenseKey)); version = getEffectiveVersion(pro); } + // No cached Pro build resolvable (getEffectiveVersion returned null) → fail safe. + if (version === null) return false; try { // Fail safe (feature OFF) on a malformed version — parseVersion yields NaN // instead of throwing, so guard explicitly (Python/.NET throw + fail OFF). diff --git a/js/src/download.ts b/js/src/download.ts index 935e7bb..316bcc6 100644 --- a/js/src/download.ts +++ b/js/src/download.ts @@ -37,7 +37,9 @@ import { import { resolveLicenseKey, validateLicense, getProLatestVersion } from "./license.js"; const DOWNLOAD_TIMEOUT_MS = 600_000; // 10 minutes -const UPDATE_CHECK_INTERVAL_MS = 3_600_000; // 1 hour +// Seconds, matching the Python/.NET `.last_update_check` marker format so the +// three wrappers share one cache dir without corrupting each other's rate limit. +const UPDATE_CHECK_INTERVAL_SEC = 3600; // 1 hour // Free-tier welcome banner re-show interval (3 days, in seconds). Free users see // the Pro upsell again after this gap; Pro users see it only once (see showWelcome). // Seconds (not ms) so the shared marker is consistent with the Python/.NET wrappers. @@ -202,13 +204,13 @@ export function binaryInfo(browserVersion?: string): BinaryInfo { // browserVersion (or CLOAKBROWSER_VERSION) pins the reported version so the // info matches what a pinned launch actually runs, instead of latest. const requested = normalizeRequestedVersion(browserVersion); - // Prefer Pro only if a Pro binary actually exists on disk. + // Prefer Pro only if a Pro binary actually exists on disk. getEffectiveVersion + // returns null for Pro when nothing is cached (it never falls back to free). const proVersion = requested ?? getEffectiveVersion(true); - const proPath = getBinaryPath(proVersion, true); - const isPro = fs.existsSync(proPath) && isExecutable(proPath); + const isPro = proBinaryReady(proVersion); const effective = isPro ? proVersion : (requested ?? getEffectiveVersion(false)); - const binaryPath = isPro ? proPath : getBinaryPath(effective, false); + const binaryPath = isPro ? getBinaryPath(proVersion, true) : getBinaryPath(effective, false); return { version: effective, bundledVersion: CHROMIUM_VERSION, @@ -238,6 +240,36 @@ export async function checkForUpdate(): Promise { return latest; } +/** + * Move a Pro install to the server's latest stable. Blocks until complete. + * Returns the new version when a newer Pro build is downloaded or an + * already-cached newer build is activated, else null (already up to date or the + * server could not be reached). Requires a valid Pro license key. + */ +export async function checkForProUpdate(licenseKey: string): Promise { + const latest = await getProLatestVersion(); + if (!latest) return null; + + const effective = getEffectiveVersion(true); + if (effective && !versionNewer(latest, effective) && proBinaryReady(effective)) { + // Already on the latest cached Pro build. + return null; + } + + if (!proBinaryReady(latest)) { + console.log(`[cloakbrowser] Downloading Pro Chromium ${latest}...`); + await downloadProBinary(latest, licenseKey); + if (!fs.existsSync(getBinaryPath(latest, true))) { + throw new Error( + `Pro download completed but binary not found at: ${getBinaryPath(latest, true)}` + ); + } + } + + writeProVersionMarker(latest); + return latest; +} + // --------------------------------------------------------------------------- // Welcome message (shown once per install) // --------------------------------------------------------------------------- @@ -652,45 +684,121 @@ async function downloadFile(url: string, dest: string, headers?: Record { - let version: string; + // Pinned: launch the exact requested version, no server cross-check, no marker + // write (a rollback pin must not stick future unpinned launches). if (requestedVersion) { - const requestedPath = getBinaryPath(requestedVersion, true); - if (fs.existsSync(requestedPath) && isExecutable(requestedPath)) { + if (proBinaryReady(requestedVersion)) { showWelcome(true); - return requestedPath; + return getBinaryPath(requestedVersion, true); } - version = requestedVersion; - } else { - const effective = getEffectiveVersion(true); - const effectivePath = getBinaryPath(effective, true); - - if (fs.existsSync(effectivePath) && isExecutable(effectivePath)) { - showWelcome(true); - maybeTriggerProUpdateCheck(licenseKey); - return effectivePath; + console.log( + `[cloakbrowser] Downloading Pro Chromium ${requestedVersion} for ${getPlatformTag()}...` + ); + await downloadProBinary(requestedVersion, licenseKey); + const p = getBinaryPath(requestedVersion, true); + if (!fs.existsSync(p)) { + throw new Error(`Pro download completed but binary not found at: ${p}`); } - - const latest = await getProLatestVersion(); - if (!latest) { - throw new Error("Could not determine latest Pro version from server"); - } - version = latest; - } - - const versionPath = getBinaryPath(version, true); - if (fs.existsSync(versionPath) && isExecutable(versionPath)) { showWelcome(true); - return versionPath; + return p; } - console.log( - `[cloakbrowser] Downloading Pro Chromium ${version} for ${getPlatformTag()}...` - ); - await downloadProBinary(version, licenseKey); + // Unpinned: track the server's latest stable. + const effective = getEffectiveVersion(true); + + // Honor CLOAKBROWSER_AUTO_UPDATE=false the way the free path does: frozen AND a + // cached Pro build present → keep it, skip the server check. With no cached build + // we must still fetch one — a valid Pro license never launches the free binary. + // (The `update` CLI ignores this and always acts.) + const frozen = + (process.env.CLOAKBROWSER_AUTO_UPDATE ?? "").toLowerCase() === "false"; + if (frozen && proBinaryReady(effective)) { + showWelcome(true); + return getBinaryPath(effective, true); + } + + // getProLatestVersion() is rate-limited to one network call per hour and returns + // a cached string in between, so this foreground check stays cheap steady-state. + const latest = await getProLatestVersion(); + + // Prefer the server's latest when it is newer than — or replaces a missing — + // the cached build. Otherwise stay on the cached Pro binary (fast, offline-ok). + let version: string | null; + if ( + latest && + (!proBinaryReady(effective) || // also covers effective === null + versionNewer(latest, effective)) + ) { + version = latest; + } else { + version = effective; + } + + if (version === null) { + // Valid Pro license but nothing resolvable (server unreachable AND no cached + // Pro build). Never downgrade to the free binary — fail loudly. + throw new Error("Could not determine latest Pro version from server"); + } + + if (proBinaryReady(version)) { + // Advance the marker if this cached build is newer than what the marker names, + // so `info` (and a later server-outage launch) reflect the build we actually + // launch — never a stale marker. + if (version !== effective) { + try { + writeProVersionMarker(version); + } catch { + // Non-fatal + } + } + showWelcome(true); + return getBinaryPath(version, true); + } + + // `version` (the server latest) needs downloading. On failure, fall back to a + // cached Pro build if we have one — never the free binary. + try { + console.log( + `[cloakbrowser] Downloading Pro Chromium ${version} for ${getPlatformTag()}...` + ); + await downloadProBinary(version, licenseKey); + } catch (err) { + // A tampering signal must surface verbatim — never mask it behind the + // cached-Pro fallback, which is only for transient download failures. + if (err instanceof BinaryVerificationError) throw err; + if (proBinaryReady(effective)) { + console.log( + `[cloakbrowser] Pro update to ${version} failed; launching cached Pro binary ${effective}` + ); + showWelcome(true); + return getBinaryPath(effective, true); + } + throw err; + } const downloadedPath = getBinaryPath(version, true); if (!fs.existsSync(downloadedPath)) { @@ -699,17 +807,11 @@ async function ensureProBinary( ); } - // Write Pro version marker only for unpinned latest resolution. A rollback - // pin must not make future unpinned launches stick to the old build. - if (!requestedVersion) { - try { - const cacheDir = getCacheDir(); - fs.mkdirSync(cacheDir, { recursive: true }); - const marker = path.join(cacheDir, `latest_pro_version_${getPlatformTag()}`); - fs.writeFileSync(marker, version); - } catch { - // Non-fatal - } + // Advance the marker so future unpinned launches use this build. + try { + writeProVersionMarker(version); + } catch { + // Non-fatal } showWelcome(true); @@ -958,7 +1060,8 @@ function shouldCheckForUpdate(): boolean { const checkFile = path.join(getCacheDir(), ".last_update_check"); try { const lastCheck = Number(fs.readFileSync(checkFile, "utf-8").trim()); - if (Date.now() - lastCheck < UPDATE_CHECK_INTERVAL_MS) return false; + if (Math.floor(Date.now() / 1000) - lastCheck < UPDATE_CHECK_INTERVAL_SEC) + return false; } catch { /* file doesn't exist or unreadable */ } @@ -1040,7 +1143,7 @@ async function checkAndDownloadUpdate(): Promise { fs.mkdirSync(cacheDir, { recursive: true }); fs.writeFileSync( path.join(cacheDir, ".last_update_check"), - String(Date.now()) + String(Math.floor(Date.now() / 1000)) ); const platformVersion = getChromiumVersion(); @@ -1080,35 +1183,3 @@ function maybeTriggerUpdateCheck(): void { checkAndDownloadUpdate().catch(() => { }); } -function maybeTriggerProUpdateCheck(licenseKey: string): void { - const checkFile = path.join(getCacheDir(), ".last_pro_update_check"); - try { - if (fs.existsSync(checkFile)) { - const lastCheck = parseFloat(fs.readFileSync(checkFile, "utf-8").trim()); - if (Date.now() - lastCheck * 1000 < UPDATE_CHECK_INTERVAL_MS) return; - } - } catch { - // unreadable — proceed - } - - (async () => { - try { - fs.mkdirSync(path.dirname(checkFile), { recursive: true }); - fs.writeFileSync(checkFile, String(Date.now() / 1000)); - - const latest = await getProLatestVersion(); - if (!latest) return; - - if (fs.existsSync(getBinaryPath(latest, true))) return; - - console.log(`[cloakbrowser] Newer Pro binary available: ${latest}. Downloading in background...`); - await downloadProBinary(latest, licenseKey); - - const marker = path.join(getCacheDir(), `latest_pro_version_${getPlatformTag()}`); - fs.writeFileSync(marker, latest); - console.log(`[cloakbrowser] Pro background update complete: ${latest} ready. Will use on next launch.`); - } catch (err) { - // non-fatal - } - })(); -} diff --git a/js/tests/update.test.ts b/js/tests/update.test.ts index cfaac6e..c768780 100644 --- a/js/tests/update.test.ts +++ b/js/tests/update.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { CHROMIUM_VERSION, getChromiumVersion, @@ -329,6 +331,39 @@ describe("effective version", () => { else delete process.env.CLOAKBROWSER_CACHE_DIR; } }); + + // Ticket 431 Fix 4: a valid Pro license must NEVER fall back to the free binary. + it("returns null for Pro when nothing is cached (never the free base)", () => { + const orig = process.env.CLOAKBROWSER_CACHE_DIR; + process.env.CLOAKBROWSER_CACHE_DIR = `/tmp/cloakbrowser-test-${Date.now()}-pro`; + try { + expect(getEffectiveVersion(true)).toBeNull(); + // Free tier still resolves to a concrete version. + expect(getEffectiveVersion(false)).toBe(getChromiumVersion()); + } finally { + if (orig) process.env.CLOAKBROWSER_CACHE_DIR = orig; + else delete process.env.CLOAKBROWSER_CACHE_DIR; + } + }); + + it("returns null for Pro when the marker's binary is missing", () => { + const orig = process.env.CLOAKBROWSER_CACHE_DIR; + const dir = `/tmp/cloakbrowser-test-${Date.now()}-promarker`; + process.env.CLOAKBROWSER_CACHE_DIR = dir; + try { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, `latest_pro_version_${getPlatformTag()}`), + "148.0.7778.215.5" + ); + // Marker present, but no binary on disk → null, not the free base. + expect(getEffectiveVersion(true)).toBeNull(); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + if (orig) process.env.CLOAKBROWSER_CACHE_DIR = orig; + else delete process.env.CLOAKBROWSER_CACHE_DIR; + } + }); }); describe("ensureBinary", () => { diff --git a/tests/test_cli.py b/tests/test_cli.py index d6fc9a5..f7572bb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -57,16 +57,37 @@ def test_keyless_reports_free_binary(capsys): def test_valid_key_reports_pro_binary(capsys): - """A server-validated key -> the binary section reflects the PRO binary.""" + """A server-validated key -> the binary section reflects the PRO binary. + + ``latest_version`` reports the server's latest (mocked); ``version`` is the + build that will actually launch (a cached Pro build if present, otherwise the + latest it will fetch). The two are surfaced separately so they can't diverge. + """ valid = LicenseInfo(valid=True, plan="business", expires=None) + # quick=False: the server latest lookup is skipped under --quick (network-free), + # so exercise the full path to see latest_version populated. with patch("cloakbrowser.license.get_pro_latest_version", return_value="148.0.0.0"): - _run(Namespace(quick=True, json=True), key="cb_test", license_info=valid) + _run(Namespace(quick=False, json=True), key="cb_test", license_info=valid) data = json.loads(capsys.readouterr().out) assert data["binary"]["tier"] == "pro" - assert data["binary"]["version"] == "148.0.0.0" + assert data["binary"]["latest_version"] == "148.0.0.0" + # A Pro user always resolves to a Pro version (cached-or-latest), never the free base. + assert data["binary"]["version"] assert data["license"]["tier"] == "business" +def test_quick_skips_pro_latest_lookup(capsys): + """--quick keeps `info` network-free: no server latest-version lookup for Pro.""" + valid = LicenseInfo(valid=True, plan="business", expires=None) + with patch( + "cloakbrowser.license.get_pro_latest_version", return_value="148.0.0.0" + ) as mock_latest: + _run(Namespace(quick=True, json=True), key="cb_test", license_info=valid) + data = json.loads(capsys.readouterr().out) + mock_latest.assert_not_called() + assert data["binary"]["latest_version"] is None + + def test_invalid_key_falls_back_to_free(capsys): """A key the server rejects -> not entitled -> free binary, not Pro.""" invalid = LicenseInfo(valid=False, plan="solo", expires=None) diff --git a/tests/test_launch.py b/tests/test_launch.py index f805e19..a2e159d 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -28,8 +28,14 @@ def test_removed_backend_kwarg_raises(env, monkeypatch): launch_persistent_context("/tmp/cloakbrowser-test-profile", backend="patchright") -def test_binary_info(): - """binary_info() returns expected structure.""" +def test_binary_info(tmp_path, monkeypatch): + """binary_info() returns expected structure. + + Isolate the cache dir: with no cached Pro binary present, binary_info reports + the free base version. (Without isolation this reads the developer's real + ~/.cloakbrowser, which may hold a cached Pro build and flip the version.) + """ + monkeypatch.setenv("CLOAKBROWSER_CACHE_DIR", str(tmp_path)) info = binary_info() assert "version" in info assert "platform" in info diff --git a/tests/test_license.py b/tests/test_license.py index f115b73..5cc4891 100644 --- a/tests/test_license.py +++ b/tests/test_license.py @@ -344,6 +344,7 @@ class TestConfigPro: bp = get_binary_path("147.0.5555.1", pro=True) bp.parent.mkdir(parents=True, exist_ok=True) bp.write_text("fake") + bp.chmod(0o755) # get_effective_version(pro) requires an executable binary version = get_effective_version(pro=True) diff --git a/tests/test_update.py b/tests/test_update.py index c5d47c7..811b1f8 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -35,6 +35,7 @@ from cloakbrowser.download import ( _verify_pro_download, _verify_signature, _write_version_marker, + check_for_pro_update, check_for_update, clear_cache, ensure_binary, @@ -600,6 +601,289 @@ class TestEnsureBinary: assert result == str(fake_binary) +def _make_pro_binary(version: str): + """Create a fake cached, executable Pro binary for `version`.""" + from cloakbrowser.config import get_binary_path + + bp = get_binary_path(version, pro=True) + bp.parent.mkdir(parents=True, exist_ok=True) + bp.write_bytes(b"binary") + bp.chmod(0o755) + return bp + + +class TestUnpinnedProUpgrade: + """Ticket 431: an unpinned Pro launch must track the server's latest stable, + never roll down to a stale cached build, and never fall back to the free binary.""" + + OLD = "148.0.7778.215.3" + NEW = "148.0.7778.215.5" + + def test_upgrades_to_server_latest(self, tmp_path): + marker = tmp_path / f"latest_pro_version_{get_platform_tag()}" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + marker.write_text(self.OLD) + _make_pro_binary(self.OLD) # stale build cached + + def fake_download(version, key): + assert version == self.NEW + _make_pro_binary(self.NEW) + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", + return_value=self.NEW, + ), + patch( + "cloakbrowser.download._download_pro_binary", + side_effect=fake_download, + ) as mock_dl, + ): + from cloakbrowser.config import get_binary_path + + result = _ensure_pro_binary("cb_key") + mock_dl.assert_called_once() + assert result == str(get_binary_path(self.NEW, pro=True)) + assert marker.read_text() == self.NEW # marker advanced, not stuck + + def test_cached_newer_build_advances_marker_no_download(self, tmp_path): + """Marker names an OLD build but a NEWER build is already cached (the customer's + multi-version cache): resolve to newest, no download, and advance the marker so + `info` never diverges from what launches.""" + marker = tmp_path / f"latest_pro_version_{get_platform_tag()}" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + marker.write_text(self.OLD) + _make_pro_binary(self.OLD) + _make_pro_binary(self.NEW) # newer build already on disk + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", + return_value=self.NEW, + ), + patch("cloakbrowser.download._download_pro_binary") as mock_dl, + ): + from cloakbrowser.config import get_binary_path + + result = _ensure_pro_binary("cb_key") + mock_dl.assert_not_called() # already cached, no download + assert result == str(get_binary_path(self.NEW, pro=True)) + assert marker.read_text() == self.NEW # marker advanced, no stale divergence + + def test_steady_state_no_download(self, tmp_path): + marker = tmp_path / f"latest_pro_version_{get_platform_tag()}" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + marker.write_text(self.NEW) + _make_pro_binary(self.NEW) # already on latest + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", + return_value=self.NEW, + ), + patch("cloakbrowser.download._download_pro_binary") as mock_dl, + ): + from cloakbrowser.config import get_binary_path + + result = _ensure_pro_binary("cb_key") + mock_dl.assert_not_called() + assert result == str(get_binary_path(self.NEW, pro=True)) + + def test_server_down_uses_cached_pro(self, tmp_path): + """Server unreachable → launch the cached Pro build, never fail, never free.""" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + (tmp_path / f"latest_pro_version_{get_platform_tag()}").write_text(self.OLD) + _make_pro_binary(self.OLD) + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", return_value=None + ), + patch("cloakbrowser.download._download_pro_binary") as mock_dl, + ): + from cloakbrowser.config import get_binary_path + + result = _ensure_pro_binary("cb_key") + mock_dl.assert_not_called() + assert result == str(get_binary_path(self.OLD, pro=True)) + + def test_download_failure_falls_back_to_cached(self, tmp_path): + """A failed upgrade download falls back to the cached Pro build, not free.""" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + (tmp_path / f"latest_pro_version_{get_platform_tag()}").write_text(self.OLD) + _make_pro_binary(self.OLD) + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", + return_value=self.NEW, + ), + patch( + "cloakbrowser.download._download_pro_binary", + side_effect=RuntimeError("network down"), + ), + ): + from cloakbrowser.config import get_binary_path + + result = _ensure_pro_binary("cb_key") + assert result == str(get_binary_path(self.OLD, pro=True)) + + def test_verification_error_surfaces_not_cached_fallback(self, tmp_path): + """A tampering signal (BinaryVerificationError) must propagate verbatim, even + with a cached Pro build present — never masked by the cached-fallback path.""" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + (tmp_path / f"latest_pro_version_{get_platform_tag()}").write_text(self.OLD) + _make_pro_binary(self.OLD) + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", + return_value=self.NEW, + ), + patch( + "cloakbrowser.download._download_pro_binary", + side_effect=BinaryVerificationError("checksum mismatch"), + ), + ): + with pytest.raises(BinaryVerificationError, match="checksum mismatch"): + _ensure_pro_binary("cb_key") + + def test_no_cache_no_server_raises_never_free(self, tmp_path): + """No cached Pro build AND no server → hard error, never the free binary.""" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", return_value=None + ), + patch("cloakbrowser.download._download_pro_binary") as mock_dl, + ): + with pytest.raises(RuntimeError, match="latest Pro version"): + _ensure_pro_binary("cb_key") + mock_dl.assert_not_called() + + def test_auto_update_false_keeps_cached_no_server_check(self, tmp_path): + """CLOAKBROWSER_AUTO_UPDATE=false + a cached Pro build → keep it, no upgrade, + no server check (parity with the free path's freeze semantics).""" + with patch.dict( + os.environ, + { + "CLOAKBROWSER_CACHE_DIR": str(tmp_path), + "CLOAKBROWSER_AUTO_UPDATE": "false", + }, + ): + (tmp_path / f"latest_pro_version_{get_platform_tag()}").write_text(self.OLD) + _make_pro_binary(self.OLD) + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", + return_value=self.NEW, + ) as mock_latest, + patch("cloakbrowser.download._download_pro_binary") as mock_dl, + ): + from cloakbrowser.config import get_binary_path + + result = _ensure_pro_binary("cb_key") + mock_dl.assert_not_called() + mock_latest.assert_not_called() # frozen → no server check at all + assert result == str(get_binary_path(self.OLD, pro=True)) + + def test_missing_cache_downloads_latest_never_free(self, tmp_path): + """Marker names a build whose binary is gone → fetch latest Pro, never 146.x.""" + marker = tmp_path / f"latest_pro_version_{get_platform_tag()}" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + marker.write_text(self.OLD) # marker present, but NO binary on disk + + def fake_download(version, key): + assert version == self.NEW # never the free base (146.x) + _make_pro_binary(self.NEW) + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", + return_value=self.NEW, + ), + patch( + "cloakbrowser.download._download_pro_binary", + side_effect=fake_download, + ), + ): + from cloakbrowser.config import get_binary_path + + result = _ensure_pro_binary("cb_key") + assert result == str(get_binary_path(self.NEW, pro=True)) + + +class TestCheckForProUpdate: + """`cloakbrowser update` for Pro installs (ticket 431 Fix 1).""" + + OLD = "148.0.7778.215.3" + NEW = "148.0.7778.215.5" + + def test_downloads_and_writes_marker(self, tmp_path): + marker = tmp_path / f"latest_pro_version_{get_platform_tag()}" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + marker.write_text(self.OLD) + _make_pro_binary(self.OLD) + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", + return_value=self.NEW, + ), + patch( + "cloakbrowser.download._download_pro_binary", + side_effect=lambda v, k: _make_pro_binary(v), + ) as mock_dl, + ): + result = check_for_pro_update("cb_key") + mock_dl.assert_called_once() + assert result == self.NEW + assert marker.read_text() == self.NEW + + def test_already_latest_returns_none(self, tmp_path): + marker = tmp_path / f"latest_pro_version_{get_platform_tag()}" + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + marker.write_text(self.NEW) + _make_pro_binary(self.NEW) + + with ( + patch( + "cloakbrowser.license.get_pro_latest_version", + return_value=self.NEW, + ), + patch("cloakbrowser.download._download_pro_binary") as mock_dl, + ): + assert check_for_pro_update("cb_key") is None + mock_dl.assert_not_called() + + def test_server_down_returns_none(self, tmp_path): + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + with patch( + "cloakbrowser.license.get_pro_latest_version", return_value=None + ): + assert check_for_pro_update("cb_key") is None + + +class TestEffectiveVersionProNoFreeFallback: + """get_effective_version(pro=True) must return None — never the free base — + when no cached Pro binary matches the marker (ticket 431 Fix 4).""" + + def test_none_when_no_cached_pro_binary(self, tmp_path): + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + # Marker points at a version whose binary is not on disk. + (tmp_path / f"latest_pro_version_{get_platform_tag()}").write_text( + "148.0.7778.215.5" + ) + assert get_effective_version(pro=True) is None + + def test_none_when_no_marker(self, tmp_path): + with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}): + assert get_effective_version(pro=True) is None + # Free tier still resolves to a concrete version. + assert get_effective_version(pro=False) == get_chromium_version() + + class TestWriteVersionMarker: def test_creates_file(self, tmp_path): with patch.dict(os.environ, {"CLOAKBROWSER_CACHE_DIR": str(tmp_path)}):