mirror of
https://github.com/aaddrick/claude-desktop-debian.git
synced 2026-05-17 00:26:21 +03:00
Add Playwright-based URL resolution for download links
- 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 <noreply@anthropic.com>
This commit is contained in:
177
.github/workflows/check-claude-version.yml
vendored
177
.github/workflows/check-claude-version.yml
vendored
@@ -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 <<EOF
|
||||
|
||||
225
scripts/resolve-download-url.py
Executable file
225
scripts/resolve-download-url.py
Executable file
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Resolve Claude Desktop download URLs by bypassing Cloudflare protection.
|
||||
|
||||
Uses Playwright to navigate to the redirect URL and capture the final
|
||||
download URL from network requests.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
|
||||
import requests
|
||||
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
|
||||
|
||||
|
||||
# Redirect URLs for each architecture
|
||||
REDIRECT_URLS = {
|
||||
"amd64": "https://claude.ai/redirect/claudedotcom.v1.290130bf-1c36-4eb0-9a93-2410ca43ae53/api/desktop/win32/x64/exe/latest/redirect",
|
||||
"arm64": "https://claude.ai/redirect/claudedotcom.v1.290130bf-1c36-4eb0-9a93-2410ca43ae53/api/desktop/win32/arm64/exe/latest/redirect",
|
||||
}
|
||||
|
||||
# User agent to appear as a regular browser
|
||||
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
|
||||
|
||||
def resolve_download_url(arch: str, timeout: int = 30000) -> 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()
|
||||
Reference in New Issue
Block a user