fix: info/update never diverge from the Pro build that actually launches
info now shows the cached build that will launch AND the server's latest Pro version on separate lines, so the two can no longer silently diverge (a customer saw info report latest while launch ran a stale cache). - Pro version resolution: unpinned launch prefers the server latest when it is newer than or replaces a missing cached build, else stays on cache; advances the version marker so info and later offline launches match. - update command is license-aware: a valid Pro key updates the Pro binary, everyone else updates free. - Replace the fire-and-forget Pro background update thread with a foreground rate-limited check (one network call/hour), honoring CLOAKBROWSER_AUTO_UPDATE=false. - A valid Pro license never falls back to the free binary: get_effective_version returns None when no Pro build is cached; resolution fails loudly instead. - Tampering signal (BinaryVerificationError) surfaces verbatim on the unpinned upgrade path — the cached-Pro fallback is only for transient download failures. - get_effective_version(pro) requires the binary be executable, so info can't report a build launch would reject. - info --quick stays network-free (skips the server latest lookup); prints a 'not downloaded yet' line instead of 'None' when offline with no cache. - Align the JS .last_update_check marker to seconds (Python/.NET parity). - Mirror across Python, JS, and .NET wrappers; add update/CLI tests.
This commit is contained in:
+62
-16
@@ -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.")
|
||||
|
||||
|
||||
+10
-4
@@ -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"):
|
||||
|
||||
+143
-80
@@ -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()
|
||||
|
||||
@@ -101,6 +101,20 @@ static void PrintDiagnostics(Dictionary<string, object?> 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<string, object?> 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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -243,29 +243,32 @@ public static class Config
|
||||
/// <summary>
|
||||
/// Return the best available version: auto-updated if available, else platform default.
|
||||
/// Reads a platform-scoped marker file from the cache directory.
|
||||
/// When <paramref name="pro"/> is true, reads from the Pro-specific marker files.
|
||||
/// When <paramref name="pro"/> is true, reads from the Pro-specific marker and returns
|
||||
/// <c>null</c> 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 <c>null</c> as "resolve the latest Pro version from the server".
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>True when a binary exists and is executable. Canonical check shared with Download.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Parse "145.0.7718.0" into (145, 0, 7718, 0) for comparison.</summary>
|
||||
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);
|
||||
|
||||
@@ -30,7 +30,7 @@ internal static class Diagnostics
|
||||
catch (Exception ex) { env["platform_tag"] = $"unavailable ({ex.Message})"; }
|
||||
|
||||
Dictionary<string, object?> binary;
|
||||
try { binary = EffectiveBinary(entitledPro); }
|
||||
try { binary = EffectiveBinary(entitledPro, quick); }
|
||||
catch (Exception ex) { binary = new Dictionary<string, object?> { ["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<string, object?> EffectiveBinary(bool entitledPro)
|
||||
private static Dictionary<string, object?> 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<string, object?>
|
||||
{
|
||||
["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<string, object?>
|
||||
{
|
||||
["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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -973,14 +1026,16 @@ public static class Download
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsExecutable(string path)
|
||||
/// <summary>True when a cached, executable Pro binary exists for <paramref name="version"/>.</summary>
|
||||
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
|
||||
/// <summary>Synchronous convenience wrapper around <see cref="CheckForUpdateAsync"/>.</summary>
|
||||
public static string? CheckForUpdate() => CheckForUpdateAsync().GetAwaiter().GetResult();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static async Task<string?> 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;
|
||||
}
|
||||
|
||||
/// <summary>Synchronous convenience wrapper around <see cref="CheckForProUpdateAsync"/>.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Fire-and-forget Pro binary update check in a background task (rate-limited to once/hour).</summary>
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
|
||||
+56
-13
@@ -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<string, unknown>; ent
|
||||
}
|
||||
|
||||
/** Describe the binary ensureBinary would actually launch (no download). */
|
||||
async function effectiveBinary(entitledPro: boolean): Promise<Record<string, unknown>> {
|
||||
async function effectiveBinary(
|
||||
entitledPro: boolean,
|
||||
quick = false
|
||||
): Promise<Record<string, unknown>> {
|
||||
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<Record<string, unk
|
||||
};
|
||||
}
|
||||
const requested = normalizeRequestedVersion();
|
||||
let version: string;
|
||||
|
||||
// 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).
|
||||
let latestVersion: string | null = null;
|
||||
if (entitledPro && !quick) {
|
||||
latestVersion = await getProLatestVersion();
|
||||
}
|
||||
|
||||
let version: string | null;
|
||||
if (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 = (await getProLatestVersion()) || getEffectiveVersion(true);
|
||||
// "Will launch now" is the cached Pro build; if none is cached, the next launch
|
||||
// downloads latestVersion. getEffectiveVersion(true) returns null (never free).
|
||||
version = getEffectiveVersion(true) ?? latestVersion;
|
||||
} else {
|
||||
version = getEffectiveVersion(false);
|
||||
}
|
||||
const binPath = getBinaryPath(version, entitledPro);
|
||||
const binPath = version ? getBinaryPath(version, entitledPro) : null;
|
||||
return {
|
||||
version,
|
||||
latest_version: latestVersion,
|
||||
pinned: Boolean(requested),
|
||||
tier: entitledPro ? "pro" : "free",
|
||||
bundled_version: CHROMIUM_VERSION,
|
||||
path: binPath,
|
||||
installed: fs.existsSync(binPath),
|
||||
cache_dir: getBinaryDir(version, entitledPro),
|
||||
installed: binPath ? fs.existsSync(binPath) : false,
|
||||
cache_dir: version ? getBinaryDir(version, entitledPro) : null,
|
||||
override: null,
|
||||
};
|
||||
}
|
||||
@@ -171,7 +187,7 @@ export async function collectDiagnostics(quick: boolean): Promise<Record<string,
|
||||
}
|
||||
|
||||
try {
|
||||
diag.binary = await effectiveBinary(entitledPro);
|
||||
diag.binary = await effectiveBinary(entitledPro, quick);
|
||||
} catch (err) {
|
||||
diag.binary = { error: (err as Error).message };
|
||||
}
|
||||
@@ -231,6 +247,23 @@ function printDiagnostics(diag: Record<string, any>): 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<void> {
|
||||
|
||||
async function cmdUpdate(): Promise<void> {
|
||||
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.");
|
||||
}
|
||||
|
||||
+18
-4
@@ -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).
|
||||
|
||||
+149
-78
@@ -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<string | null> {
|
||||
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<string | null> {
|
||||
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<string,
|
||||
// Pro binary download
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** True when a cached, executable Pro binary exists for `version`. */
|
||||
function proBinaryReady(version: string | null): version is string {
|
||||
if (!version) return false;
|
||||
const p = getBinaryPath(version, true);
|
||||
return fs.existsSync(p) && isExecutable(p);
|
||||
}
|
||||
|
||||
/** Atomically write the latest Pro version marker for this platform. */
|
||||
function writeProVersionMarker(version: string): void {
|
||||
const cacheDir = getCacheDir();
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const marker = path.join(cacheDir, `latest_pro_version_${getPlatformTag()}`);
|
||||
const tmp = `${marker}.tmp`;
|
||||
fs.writeFileSync(tmp, version);
|
||||
fs.renameSync(tmp, marker);
|
||||
}
|
||||
|
||||
// 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
|
||||
// thrown rather than silently launching the free tier.
|
||||
async function ensureProBinary(
|
||||
licenseKey: string,
|
||||
requestedVersion?: string
|
||||
): Promise<string> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
+24
-3
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)}):
|
||||
|
||||
Reference in New Issue
Block a user