
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.
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.
| Input | Source |
|---|---|
| Downloads directory | ~/Downloads by default for Chrome, Firefox, Edge, and Safari |
| Expected filename pattern | A glob matching the target file (e.g. invoice-*.pdf) |
| Expected size (bytes), optional | The server's Content-Length header or a manifest |
| Expected SHA-256, optional | A 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.
Browser temp extensions and completion signals:
| Browser | Temp extension | Completion signal |
|---|---|---|
| Chrome / Chromium / Edge | .crdownload | Temp file renamed to final filename |
| Firefox | .part | Temp file renamed to final filename |
| Safari | .download | Temp 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.
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
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.
| Plan | Fits this job when |
|---|---|
| Virtual Intel macOS — $29/mo | Chromium, Firefox, Edge automation; most web downloads |
| M-series Mac — from $95/mo | Safari features requiring Apple silicon, iOS simulators, ARM targets |
| Managed Build Mac — $249/mo | Downloads inside a managed CI pipeline (setup, backups, monitoring, 30 min support) |
Open Figment Mac Hosting to review the plan, then begin provisioning at /mac-hosting/start. Once the Mac is ready:
detect_download.py to the agent user's working directory.~/Downloads before wiring the script into your pipeline.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: