Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Detect Complete Browser Downloads on a Mac

Browser downloads do not announce completion. Chrome and Chromium write a .crdownload file, Firefox writes .part, and Safari writes .download while the final file grows. When the transfer finishes, the browser renames the temp file to the target name. Automation that reads ~/Downloads immediately after a click races that rename and processes half-written files. The reliable answer is a detector that waits for the temp extension to disappear, confirms the file size has stopped changing, and optionally verifies a SHA-256 before handing the path to the next pipeline stage.

Pick the Figment plan that matches the job

Run Chromium, Firefox, or Edge automation on the $29/month Figment virtual Intel macOS environment (2 vCPU, 8 GiB RAM, 80 GiB disk). Selenium, Playwright, and Puppeteer all drive Chromium fine on Intel macOS. Access is through the browser console; the browser and the detector run in the same desktop user session. Request a quote for inbound networking rules you need.

Move up to an M-series Mac from $95/month when you need Safari WebDriver features that require Apple silicon, iOS simulator runs that require ARM, or Xcode 27. The exact chip, RAM, and disk at the $95 entry are confirmed in the configuration quote, so agree the hardware before committing. The physical i3 Mac at $49/month and larger M-series options collect requests until supply is confirmed.

If this detector sits inside a managed CI job you would rather not babysit, the Managed Build Mac at $249/month bundles one M-series Mac with CI setup, backups, monitoring, and 30 minutes of monthly support. The Managed Mac Team at $999/month doubles the Macs and runners and adds team setup and one hour of monthly support.

Watch the 80 GiB disk when you repeatedly download large files; combine this guide with your own cleanup step between runs.

Inputs and permissions

InputSource
Downloads directory~/Downloads by default for Chrome, Firefox, Edge, and Safari
Expected filename patternA glob matching the target file (e.g. invoice-*.pdf)
Expected size (bytes), optionalThe server's Content-Length header or a manifest
Expected SHA-256, optionalA release manifest or a value you recorded earlier
Stability window (seconds)Unchanged-size interval that counts as "done"; 3 s is a safe default
Timeout (seconds)Hard cap so a failed download does not hang the job

The detector runs as the agent user; no elevated permissions are needed. If a browser profile redirects downloads elsewhere, override the path when you launch the script.

Implement the detector

Browser temp extensions and completion signals:

BrowserTemp extensionCompletion signal
Chrome / Chromium / Edge.crdownloadTemp file renamed to final filename
Firefox.partTemp file renamed to final filename
Safari.downloadTemp file renamed to final filename

The script polls every half second for the temp extension to disappear, confirms the file size is stable for the stability window, then optionally checks size and SHA-256.

#!/usr/bin/env python3
"""
detect_download.py — wait for a browser download to complete.

Usage:
    python3 detect_download.py \
        --pattern "report-*.pdf" \
        --timeout 120 \
        [--expected-sha256 <hex>] \
        [--expected-bytes <n>] \
        [--dir ~/Downloads]

Exits 0 on success and prints the final file path to stdout.
Exits 1 on timeout, size mismatch, or checksum mismatch.
"""
import argparse, fnmatch, hashlib, sys, time
from pathlib import Path
from typing import Optional

TEMP_SUFFIXES = {".crdownload", ".part", ".download", ".partial", ".opdownload"}
POLL_INTERVAL = 0.5       # seconds between polls
STABILITY_WINDOW = 3.0    # seconds of unchanged size before "complete"


def find_candidate(download_dir: Path, pattern: str) -> Optional[Path]:
    for p in download_dir.iterdir():
        if p.suffix in TEMP_SUFFIXES:
            continue
        if fnmatch.fnmatch(p.name, pattern):
            return p
    return None


def is_size_stable(path: Path, window: float) -> bool:
    size_before = path.stat().st_size
    time.sleep(window)
    try:
        size_after = path.stat().st_size
    except FileNotFoundError:
        return False
    return size_before == size_after and size_before > 0


def sha256_of(path: Path) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--pattern", required=True)
    ap.add_argument("--timeout", type=float, default=120)
    ap.add_argument("--expected-sha256", default=None)
    ap.add_argument("--expected-bytes", type=int, default=None)
    ap.add_argument("--dir", default="~/Downloads")
    args = ap.parse_args()

    download_dir = Path(args.dir).expanduser()
    deadline = time.monotonic() + args.timeout
    print(f"Watching {download_dir} for '{args.pattern}' "
          f"(timeout {args.timeout}s)…", flush=True)

    while time.monotonic() < deadline:
        candidate = find_candidate(download_dir, args.pattern)
        if candidate is None or not is_size_stable(candidate, STABILITY_WINDOW):
            time.sleep(POLL_INTERVAL)
            continue

        if args.expected_bytes is not None:
            actual = candidate.stat().st_size
            if actual != args.expected_bytes:
                print(f"ERROR: size mismatch: expected {args.expected_bytes}, "
                      f"got {actual}", file=sys.stderr)
                return 1

        if args.expected_sha256 is not None:
            actual_hash = sha256_of(candidate)
            if actual_hash.lower() != args.expected_sha256.lower():
                print("ERROR: checksum mismatch.\n"
                      f"  expected: {args.expected_sha256}\n"
                      f"  actual:   {actual_hash}", file=sys.stderr)
                return 1

        print(str(candidate), flush=True)
        return 0

    print(f"ERROR: timeout after {args.timeout}s waiting for '{args.pattern}'.",
          file=sys.stderr)
    return 1


if __name__ == "__main__":
    sys.exit(main())

Use it in a pipeline:

# Trigger the download first (e.g. a Selenium click), then wait:
if ! FILE=$(python3 detect_download.py \
        --pattern "invoice-*.pdf" \
        --timeout 60 \
        --expected-sha256 "a3f1...c9d2"); then
    echo "Download failed or timed out" >&2
    exit 1
fi
echo "Downloaded to: $FILE"
# Now safe to process "$FILE"

If you do not have a hash in advance, drop --expected-sha256 and pass --expected-bytes from the server's Content-Length header. With neither available, the stability window alone is the completion signal and three seconds of unchanged size is reliable for local-disk writes.

Failure and recovery checks

Run these on the Mac before you wire the script into a real pipeline.

Check 1 — temp extension is ignored:

touch ~/Downloads/report-test.pdf.crdownload
python3 detect_download.py --pattern "report-test.pdf" --timeout 5
# Expected: exits 1 (timeout); the .crdownload file is correctly skipped.
rm ~/Downloads/report-test.pdf.crdownload

Check 2 — stability window fires:

printf 'test content\n' > ~/Downloads/report-test.pdf
python3 detect_download.py --pattern "report-test.pdf" --timeout 10
# Expected: exits 0 and prints the full path after ~3 s.
rm ~/Downloads/report-test.pdf

Check 3 — checksum mismatch is caught:

printf 'wrong content\n' > ~/Downloads/report-test.pdf
python3 detect_download.py \
    --pattern "report-test.pdf" \
    --timeout 10 \
    --expected-sha256 "0000000000000000000000000000000000000000000000000000000000000000"
# Expected: exits 1 with "checksum mismatch".
rm ~/Downloads/report-test.pdf

Event-driven alternative. For high-frequency workflows, replace the polling loop with fswatch, which uses macOS File System Events and kqueue to deliver change notifications with lower CPU cost than polling. FSEvents coalesces changes at directory granularity, so the shell callback still needs the temp-extension and stability checks — a change notification only means "something happened in ~/Downloads", not "a download just finished".

# Requires: brew install fswatch
# Verify --event flag names with `fswatch --help`; they vary by version.
fswatch -0 ~/Downloads | while IFS= read -r -d '' path; do
    base=$(basename "$path")
    case "$base" in
        *.crdownload|*.part|*.download|*.partial|*.opdownload|*.tmp) continue ;;
    esac
    python3 detect_download.py --pattern "$base" --timeout 30 || continue
done

Worked cost example

One agent running 24/7 on the $29 virtual Intel macOS plan costs $29 per calendar month flat — the $29 is monthly, not pro-rated and not a daily charge. If a run uses the Mac for only 14 days inside a month, you still pay the full $29 that month. Two parallel detectors on two separate $29 VMs cost 2 × $29 = $58 per month. Moving the same job to an M-series Mac from $95/month raises the floor to $95; confirm the exact chip, RAM, and disk in the quote before you commit, because "from $95" is an entry price, not a specific M4/16 GB/256 GB configuration.

The detector itself uses negligible CPU at a 0.5 s poll, so cost is driven by the hosted Mac plan, not by the script.

PlanFits this job when
Virtual Intel macOS — $29/moChromium, Firefox, Edge automation; most web downloads
M-series Mac — from $95/moSafari features requiring Apple silicon, iOS simulators, ARM targets
Managed Build Mac — $249/moDownloads inside a managed CI pipeline (setup, backups, monitoring, 30 min support)

Start or migrate

Open Figment Mac Hosting to review the plan, then begin provisioning at /mac-hosting/start. Once the Mac is ready:

  1. Copy detect_download.py to the agent user's working directory.
  2. Run Checks 1–3 against ~/Downloads before wiring the script into your pipeline.
  3. Replace any time.sleep(...) or fixed-delay wait in the existing automation with a call to detect_download.py and use its stdout as the file path for the next step.

Related guides:


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms