From 3e813c5d5ff483b432c6959b8364eef678b104dd Mon Sep 17 00:00:00 2001 From: aaddrick Date: Mon, 5 Jan 2026 17:34:16 -0500 Subject: [PATCH] Add Playwright-based URL resolution for download links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add scripts/resolve-download-url.py to resolve Cloudflare-protected redirect URLs using Playwright browser automation - Update check-claude-version.yml workflow to use the new script - Automatically update build.sh with resolved URLs when changes detected - Support both amd64 and arm64 architectures with fallback derivation - Verify derived URLs exist before committing changes This replaces the static Google Cloud Storage URLs which are no longer being updated by Anthropic. The new approach resolves the official redirect endpoints to get current download URLs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/check-claude-version.yml | 177 +++++++++++----- scripts/resolve-download-url.py | 225 +++++++++++++++++++++ 2 files changed, 353 insertions(+), 49 deletions(-) create mode 100755 scripts/resolve-download-url.py diff --git a/.github/workflows/check-claude-version.yml b/.github/workflows/check-claude-version.yml index cd48331..bb97844 100644 --- a/.github/workflows/check-claude-version.yml +++ b/.github/workflows/check-claude-version.yml @@ -18,87 +18,151 @@ jobs: fetch-depth: 0 token: ${{ secrets.GH_PAT }} - - name: Set up environment + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y p7zip-full wget + pip install playwright requests + playwright install chromium - - name: Download and check Claude version - id: check_version + - name: Resolve download URLs + id: resolve_urls run: | - CLAUDE_DOWNLOAD_URL="https://storage.googleapis.com/osprey-downloads-c02f6a0d-347c-492b-a752-3e0651722e97/nest-win-x64/Claude-Setup-x64.exe" + echo "Resolving Claude Desktop download URLs..." - WORK_DIR=$(mktemp -d) - cd "$WORK_DIR" + # Run the resolver script - stdout has KEY=VALUE pairs, stderr has status messages + python scripts/resolve-download-url.py all --format both > resolved_urls.txt || true - if ! wget -q -O Claude-Setup-x64.exe "$CLAUDE_DOWNLOAD_URL"; then - echo "Failed to download Claude Desktop installer" + echo "Resolved URLs:" + cat resolved_urls.txt + + # Parse the output + AMD64_URL=$(grep "^AMD64_URL=" resolved_urls.txt | cut -d= -f2-) + ARM64_URL=$(grep "^ARM64_URL=" resolved_urls.txt | cut -d= -f2-) + AMD64_VERSION=$(grep "^AMD64_VERSION=" resolved_urls.txt | cut -d= -f2-) + ARM64_VERSION=$(grep "^ARM64_VERSION=" resolved_urls.txt | cut -d= -f2-) + + echo "AMD64 URL: $AMD64_URL" + echo "ARM64 URL: $ARM64_URL" + echo "AMD64 Version: $AMD64_VERSION" + echo "ARM64 Version: $ARM64_VERSION" + + # Use AMD64 version as the canonical version (they should match) + CLAUDE_VERSION="${AMD64_VERSION:-$ARM64_VERSION}" + + if [ -z "$AMD64_URL" ]; then + echo "::error::Failed to resolve AMD64 download URL" exit 1 fi - EXTRACT_DIR="$WORK_DIR/extract" - mkdir -p "$EXTRACT_DIR" - - if ! 7z x -y Claude-Setup-x64.exe -o"$EXTRACT_DIR" > /dev/null 2>&1; then - echo "Failed to extract installer" - exit 1 - fi - - cd "$EXTRACT_DIR" - NUPKG_FILE=$(find . -maxdepth 1 -name "AnthropicClaude-*.nupkg" | head -1) - - if [ -z "$NUPKG_FILE" ]; then - echo "Could not find AnthropicClaude nupkg file" - exit 1 - fi - - # Extract version from nupkg filename - CLAUDE_VERSION=$(echo "$NUPKG_FILE" | grep -oP 'AnthropicClaude-\K[0-9]+\.[0-9]+\.[0-9]+(?=-full|-arm64-full)') - - if [ -z "$CLAUDE_VERSION" ]; then - echo "Could not extract version from nupkg filename" - exit 1 - fi - - echo "Detected Claude Desktop version: $CLAUDE_VERSION" + echo "amd64_url=$AMD64_URL" >> $GITHUB_OUTPUT + echo "arm64_url=$ARM64_URL" >> $GITHUB_OUTPUT echo "claude_version=$CLAUDE_VERSION" >> $GITHUB_OUTPUT - # Clean up - cd / - rm -rf "$WORK_DIR" + - name: Get current URLs from build.sh + id: current_urls + run: | + # Extract current URLs from build.sh + CURRENT_AMD64_URL=$(grep -E 'HOST_ARCH.*=.*"amd64"' -A3 build.sh | grep 'CLAUDE_DOWNLOAD_URL=' | head -1 | sed 's/.*CLAUDE_DOWNLOAD_URL="\([^"]*\)".*/\1/') + CURRENT_ARM64_URL=$(grep -E 'HOST_ARCH.*=.*"arm64"' -A3 build.sh | grep 'CLAUDE_DOWNLOAD_URL=' | head -1 | sed 's/.*CLAUDE_DOWNLOAD_URL="\([^"]*\)".*/\1/') + + echo "Current AMD64 URL: $CURRENT_AMD64_URL" + echo "Current ARM64 URL: $CURRENT_ARM64_URL" + + echo "current_amd64_url=$CURRENT_AMD64_URL" >> $GITHUB_OUTPUT + echo "current_arm64_url=$CURRENT_ARM64_URL" >> $GITHUB_OUTPUT - name: Check if update needed id: check_update env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - CLAUDE_VERSION="${{ steps.check_version.outputs.claude_version }}" - REPO_VERSION="${{ vars.REPO_VERSION }}" + NEW_AMD64_URL="${{ steps.resolve_urls.outputs.amd64_url }}" + NEW_ARM64_URL="${{ steps.resolve_urls.outputs.arm64_url }}" + CURRENT_AMD64_URL="${{ steps.current_urls.outputs.current_amd64_url }}" + CURRENT_ARM64_URL="${{ steps.current_urls.outputs.current_arm64_url }}" + CLAUDE_VERSION="${{ steps.resolve_urls.outputs.claude_version }}" STORED_CLAUDE_VERSION="${{ vars.CLAUDE_DESKTOP_VERSION }}" + REPO_VERSION="${{ vars.REPO_VERSION }}" echo "Current stored Claude version: $STORED_CLAUDE_VERSION" echo "Detected Claude version: $CLAUDE_VERSION" echo "Repository version: $REPO_VERSION" - if [ "$CLAUDE_VERSION" != "$STORED_CLAUDE_VERSION" ]; then - echo "New Claude Desktop version detected!" + UPDATE_NEEDED=false + + # Check if AMD64 URL changed + if [ "$NEW_AMD64_URL" != "$CURRENT_AMD64_URL" ]; then + echo "AMD64 URL has changed" + UPDATE_NEEDED=true + fi + + # Check if ARM64 URL changed (only if we got a new one) + if [ -n "$NEW_ARM64_URL" ] && [ "$NEW_ARM64_URL" != "$CURRENT_ARM64_URL" ]; then + echo "ARM64 URL has changed" + UPDATE_NEEDED=true + fi + + # Check if version changed + if [ -n "$CLAUDE_VERSION" ] && [ "$CLAUDE_VERSION" != "$STORED_CLAUDE_VERSION" ]; then + echo "Claude version has changed" + UPDATE_NEEDED=true + fi + + if [ "$UPDATE_NEEDED" = "true" ]; then + echo "Update needed!" echo "update_needed=true" >> $GITHUB_OUTPUT - + # Construct the new tag NEW_TAG="v${REPO_VERSION}+claude${CLAUDE_VERSION}" echo "new_tag=$NEW_TAG" >> $GITHUB_OUTPUT echo "New tag will be: $NEW_TAG" else - echo "Claude Desktop version unchanged" + echo "No updates needed" echo "update_needed=false" >> $GITHUB_OUTPUT fi - - name: Update version variable and create tag + - name: Update build.sh with new URLs + if: steps.check_update.outputs.update_needed == 'true' + run: | + NEW_AMD64_URL="${{ steps.resolve_urls.outputs.amd64_url }}" + NEW_ARM64_URL="${{ steps.resolve_urls.outputs.arm64_url }}" + CURRENT_AMD64_URL="${{ steps.current_urls.outputs.current_amd64_url }}" + CURRENT_ARM64_URL="${{ steps.current_urls.outputs.current_arm64_url }}" + + echo "Updating build.sh with new URLs..." + + # Update AMD64 URL + if [ -n "$NEW_AMD64_URL" ] && [ "$NEW_AMD64_URL" != "$CURRENT_AMD64_URL" ]; then + echo "Updating AMD64 URL..." + # Escape special characters for sed + ESCAPED_CURRENT=$(printf '%s\n' "$CURRENT_AMD64_URL" | sed 's/[[\.*^$()+?{|]/\\&/g') + ESCAPED_NEW=$(printf '%s\n' "$NEW_AMD64_URL" | sed 's/[&/\]/\\&/g') + sed -i "s|$ESCAPED_CURRENT|$ESCAPED_NEW|g" build.sh + fi + + # Update ARM64 URL (if we have a new one) + if [ -n "$NEW_ARM64_URL" ] && [ "$NEW_ARM64_URL" != "$CURRENT_ARM64_URL" ]; then + echo "Updating ARM64 URL..." + ESCAPED_CURRENT=$(printf '%s\n' "$CURRENT_ARM64_URL" | sed 's/[[\.*^$()+?{|]/\\&/g') + ESCAPED_NEW=$(printf '%s\n' "$NEW_ARM64_URL" | sed 's/[&/\]/\\&/g') + sed -i "s|$ESCAPED_CURRENT|$ESCAPED_NEW|g" build.sh + fi + + echo "Updated build.sh:" + grep "CLAUDE_DOWNLOAD_URL=" build.sh + + - name: Commit and push changes if: steps.check_update.outputs.update_needed == 'true' env: GH_TOKEN: ${{ secrets.GH_PAT }} run: | - CLAUDE_VERSION="${{ steps.check_version.outputs.claude_version }}" + CLAUDE_VERSION="${{ steps.resolve_urls.outputs.claude_version }}" NEW_TAG="${{ steps.check_update.outputs.new_tag }}" # Check if we have a PAT @@ -107,14 +171,29 @@ jobs: exit 1 fi - gh variable set CLAUDE_DESKTOP_VERSION --body "$CLAUDE_VERSION" - - echo "Creating and pushing new tag: $NEW_TAG" - # Configure git git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + # Check if there are changes to commit + if git diff --quiet build.sh; then + echo "No changes to build.sh" + else + git add build.sh + git commit -m "Update Claude Desktop download URLs to version $CLAUDE_VERSION + +Updated download URLs resolved from official redirect endpoints. + +🤖 Generated with [Claude Code](https://claude.com/claude-code)" + git push + echo "Changes committed and pushed" + fi + + # Update the version variable + gh variable set CLAUDE_DESKTOP_VERSION --body "$CLAUDE_VERSION" + + echo "Creating and pushing new tag: $NEW_TAG" + # Create annotated tag git tag -a "$NEW_TAG" -m "Update to Claude Desktop $CLAUDE_VERSION" @@ -129,7 +208,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | NEW_TAG="${{ steps.check_update.outputs.new_tag }}" - CLAUDE_VERSION="${{ steps.check_version.outputs.claude_version }}" + CLAUDE_VERSION="${{ steps.resolve_urls.outputs.claude_version }}" # Create release notes using heredoc RELEASE_NOTES=$(cat < str | None: + """ + Resolve the actual download URL for the given architecture. + + Args: + arch: Architecture to resolve ('amd64' or 'arm64') + timeout: Timeout in milliseconds + + Returns: + The resolved download URL, or None if resolution failed + """ + if arch not in REDIRECT_URLS: + print(f"Error: Unknown architecture '{arch}'", file=sys.stderr) + return None + + redirect_url = REDIRECT_URLS[arch] + resolved_url = None + + def handle_request(request): + nonlocal resolved_url + url = request.url + # Look for the final Google Cloud Storage URL + if "storage.googleapis.com" in url and url.endswith(".exe"): + resolved_url = url + + def handle_download(download): + nonlocal resolved_url + resolved_url = download.url + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context( + user_agent=USER_AGENT, + viewport={"width": 1920, "height": 1080}, + accept_downloads=True, + ) + + # Apply stealth settings + context.add_init_script(""" + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + Object.defineProperty(navigator, 'platform', { get: () => 'Linux x86_64' }); + Object.defineProperty(navigator, 'vendor', { get: () => 'Google Inc.' }); + """) + + page = context.new_page() + page.on("request", handle_request) + page.on("download", handle_download) + + try: + # Navigate to the redirect URL - this will trigger a download + page.goto(redirect_url, timeout=timeout, wait_until="commit") + # Give it a moment to capture the download URL + page.wait_for_timeout(2000) + except PlaywrightTimeout: + # Timeout is expected - we just need to capture the redirect + pass + except Exception as e: + # "Download is starting" error is expected and means we captured the URL + if "Download is starting" not in str(e): + print(f"Error navigating to {redirect_url}: {e}", file=sys.stderr) + finally: + browser.close() + + return resolved_url + + +def extract_version_from_url(url: str) -> str | None: + """ + Extract version number from a download URL. + + The URL typically contains a path like /1.0.1234/ with the version. + """ + match = re.search(r"/(\d+\.\d+\.\d+)/", url) + if match: + return match.group(1) + return None + + +def verify_url_exists(url: str, timeout: int = 10) -> bool: + """ + Verify that a URL exists by making a HEAD request. + + Returns True if the URL returns a 200 status code. + """ + try: + response = requests.head(url, timeout=timeout, allow_redirects=True) + return response.status_code == 200 + except requests.RequestException: + return False + + +def derive_arm64_url_from_amd64(amd64_url: str) -> str | None: + """ + Derive the ARM64 download URL from an AMD64 URL by pattern substitution. + + Handles both old and new URL patterns: + Old: https://storage.googleapis.com/.../nest-win-x64/Claude-Setup-x64.exe + New: https://downloads.claude.ai/releases/win32/x64/1.0.xxx/Claude-xxx.exe + """ + if not amd64_url: + return None + + arm64_url = amd64_url + + # New URL pattern: downloads.claude.ai/releases/win32/x64/ -> /arm64/ + arm64_url = arm64_url.replace("/win32/x64/", "/win32/arm64/") + + # Old URL pattern: storage.googleapis.com + arm64_url = arm64_url.replace("nest-win-x64", "nest-win-arm64") + arm64_url = arm64_url.replace("Claude-Setup-x64.exe", "Claude-Setup-arm64.exe") + arm64_url = arm64_url.replace("-x64.exe", "-arm64.exe") + + # Only return if we actually made changes + if arm64_url != amd64_url: + return arm64_url + return None + + +def main(): + parser = argparse.ArgumentParser( + description="Resolve Claude Desktop download URLs" + ) + parser.add_argument( + "arch", + choices=["amd64", "arm64", "all"], + help="Architecture to resolve (amd64, arm64, or all)", + ) + parser.add_argument( + "--timeout", + type=int, + default=30000, + help="Timeout in milliseconds (default: 30000)", + ) + parser.add_argument( + "--format", + choices=["url", "version", "both"], + default="url", + help="Output format (default: url)", + ) + + args = parser.parse_args() + + architectures = ["amd64", "arm64"] if args.arch == "all" else [args.arch] + + results = {} + for arch in architectures: + print(f"Resolving {arch} download URL...", file=sys.stderr) + url = resolve_download_url(arch, args.timeout) + + if url: + version = extract_version_from_url(url) + results[arch] = {"url": url, "version": version, "derived": False} + print(f" Resolved: {url}", file=sys.stderr) + if version: + print(f" Version: {version}", file=sys.stderr) + else: + print(f" Failed to resolve {arch} URL via redirect", file=sys.stderr) + results[arch] = None + + # If ARM64 failed but AMD64 succeeded, try to derive ARM64 URL + if args.arch == "all" and results.get("arm64") is None and results.get("amd64"): + print("Attempting to derive ARM64 URL from AMD64 URL...", file=sys.stderr) + derived_url = derive_arm64_url_from_amd64(results["amd64"]["url"]) + if derived_url: + print(f" Derived URL: {derived_url}", file=sys.stderr) + print(" Verifying URL exists...", file=sys.stderr) + if verify_url_exists(derived_url): + version = extract_version_from_url(derived_url) + results["arm64"] = {"url": derived_url, "version": version, "derived": True} + print(f" ✓ Verified ARM64 URL exists", file=sys.stderr) + else: + print(" ✗ Derived URL does not exist (404)", file=sys.stderr) + else: + print(" Could not derive ARM64 URL pattern", file=sys.stderr) + + # Output results based on format + for arch, result in results.items(): + if result is None: + continue + + prefix = f"{arch.upper()}_" if len(architectures) > 1 else "" + + if args.format == "url": + print(f"{prefix}URL={result['url']}") + elif args.format == "version": + if result["version"]: + print(f"{prefix}VERSION={result['version']}") + elif args.format == "both": + print(f"{prefix}URL={result['url']}") + if result["version"]: + print(f"{prefix}VERSION={result['version']}") + + # Exit with error if any resolution failed + if any(r is None for r in results.values()): + sys.exit(1) + + +if __name__ == "__main__": + main()