Shockbyte Backup Download
What this does
Every night at 2AM: logs into the Shockbyte panel and downloads the latest JJAC_Survival_World instance.zip backup to ~/scripts/shockbyte_dl/incoming/instance.zip — all unattended, even with the screen locked. No manual login needed under normal circumstances; the download script reuses a saved browser session and only falls back to an actual login when that session is missing or close to expiring.
What happens to the zip after it lands (unzip, dechunk, rsync to gmktec, archive, DVD burn) is covered on the backup.sh & DVD archiving page — this page is just the download leg.
The problem: Cloudflare
panel.shockbyte.com and auth.shockbyte.com sit behind Cloudflare bot protection. AppleScript driving Safari (JS clicks, keystrokes, accessibility-tree clicks, coordinate clicks) was reliably blocked — every approach, every time.
Playwright driving a real, visible Chrome window (headless=False) gets through fine. Headless mode does not. Don't switch this back to headless — it will just start failing again.
Files
All of these live together in ~/scripts/shockbyte_dl/:
shockbyte_download_backup.py nightly downloader
shockbyte_auth.py shared Keychain + login-flow logic
shockbyte_login_setup_auto.py standalone forced session refresh
shockbyte_login_setup.py manual fallback login (by hand)
com.david.shockbyte-backup.plist launchd schedule
shockbyte_auth_state.json saved session (written by the scripts, not checked in)
shockbyte_login_autofill_test.py also lives in the folder but isn't part of the pipeline — it's an experimental/diagnostic script for testing the Cloudflare-protected login form directly, explicitly marked "do not run from cron or launchd" in its own docstring. Safe to ignore.
backup.sh (post-download processing) lives on the backup.sh & DVD archiving page now.
One-time setup
python3 --version # confirm Python 3 is installed; install from python.org if not
cd ~/scripts/shockbyte_dl
python3 -m venv myvenv
source myvenv/bin/activate
pip3 install playwright psutil
playwright install chromium
psutil is used by shockbyte_download_backup.py to force-kill a stalled Chrome instance if a download hangs (see "Handling stalled downloads" below). Optional but strongly recommended — without it, a stalled download just logs a warning and leaves the hung Chrome process running.
Store credentials in Keychain — not Passwords.app!
Keychain Access.app → File → New Password Item:
Name: Shockbyte
Account: <login email>
Password: <login password>
Note to self: Keychain Access.app and the newer Passwords.app store different item types. Only Keychain Access's "generic password" is readable via security find-generic-password — a Safari-saved login under the same name won't be found by the script.
The plaintext SHOCKBYTE_USERNAME constant (not secret, only the password comes from Keychain) lives in shockbyte_auth.py, not shockbyte_login_setup_auto.py — that's the shared module both login scripts import, so it's the one to edit.
Get an initial session
python3 shockbyte_login_setup_auto.py
Approve the Keychain permission prompt with "Always Allow" so future unattended runs don't hang waiting on it.
Login form quirks (reverse-engineered by hand)
- Identifier-first flow: submit just the email, then a second page asks for the password.
- The email step's submit button starts disabled until their JS validates the field.
- The first click on it only shifts focus — confirmed by hand, not a Playwright bug — it takes a second click to actually submit.
Set up the LaunchAgent
mkdir -p ~/scripts/shockbyte_dl/logs
cp com.david.shockbyte-backup.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.david.shockbyte-backup.plist
launchctl enable gui/$(id -u)/com.david.shockbyte-backup
launchctl kickstart -k gui/$(id -u)/com.david.shockbyte-backup
Key detail: gui/<uid>, not system — runs inside the actual logged-in GUI session, which keeps working even with the screen locked (confirmed by testing). Sleep still kills it though — disable sleep in Energy settings.
The plist runs the whole thing through caffeinate -i (keeps the Mac awake for the duration) and writes stdout/stderr to ~/scripts/shockbyte_dl/logs/stdout.log and .../stderr.log — check those after a launchctl kickstart -k test run.
Note to self: a bootstrap failure ("5: Input/output error") usually just means the service is already loaded from an earlier attempt — check with launchctl print gui/$(id -u)/com.david.shockbyte-backup before assuming something's actually broken.
The schedule
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>2</integer>
<key>Minute</key><integer>0</integer>
</dict>
Keeping the session alive
Fully automatic now, no calendar reminder needed. Every run of shockbyte_download_backup.py:
- Checks the real expiry of the saved session (the
KEYCLOAK_SESSIONcookie) before doing anything else. - If there's no saved session at all, or it's within 48 hours of expiring, proactively logs back in via
shockbyte_auth.py'slogin_and_refresh_state()(Keychain-based) before attempting the download — reusing its own already-launched browser for that login rather than spawning a second browser process. - If it still gets bounced to the login page during the actual download (session expired despite the above, or expired between the proactive check and the download itself), it runs the same refresh once more and retries the download a single time before giving up.
- As a safety net independent of all that, it also logs a loud warning once you're within 3 days of expiry, in case the automatic refresh itself ever silently stops working.
If the automated login itself ever breaks (e.g. Cloudflare starts blocking it again, or the Shockbyte login form changes), the script exits with code 1 and tells you to run shockbyte_login_setup.py by hand as a fallback.
Handling stalled downloads
Failure mode this closes off: Playwright's Download.save_as() (and .path()/.failure()) has no timeout of its own — per the docs, it "will wait for the download to finish if necessary," i.e. forever. A download that starts fine but then stalls mid-transfer (flaky network, Chrome deciding to pause it, or the Mac sleeping mid-download) used to hang the script indefinitely, leaving a stalled Chrome window sitting open and "waiting to resume" for hours, unnoticed.
First attempt at a fix (reverted): wrapping save_as() in a Python watchdog thread with its own deadline. This broke a real run with greenlet.error: cannot switch to a different thread — Playwright's sync API isn't thread-safe; every call has to happen on the same thread that created the browser. The watchdog thread crashed the download immediately instead of timing it out gracefully.
Current design: process-level isolation instead of threads. Each full attempt (session refresh, navigation, and the download) runs single-threaded inside its own subprocess, invoked internally as:
python3 shockbyte_download_backup.py --internal-single-attempt <result_path>
main() spawns that subprocess with start_new_session=True (its own OS process group, covering Playwright's driver process, Chrome, and Chrome's helper processes) and waits on it with a plain timeout, SINGLE_ATTEMPT_TIMEOUT_SECONDS (currently 900s / 15 min). The subprocess writes its outcome as JSON to a result file before calling browser.close(), so a slow/hung teardown afterward doesn't turn a real success into a false failure.
- If the subprocess finishes normally,
main()reads the result file for the status ("success" / "expired" / "error"). - If it doesn't finish within the timeout,
main()force-kills the whole process group withos.killpg(..., SIGKILL)— taking down the subprocess and whatever Chrome/helper processes it spawned in one call — and treats the attempt as"stalled". Because a stall may mean Chrome's own networking stack is wedged, a stalled attempt retries with a brand-new subprocess and browser rather than reusing the old one, up toMAX_DOWNLOAD_ATTEMPTS(currently 2) full attempts. kill_marked_chrome()(usingpsutilto find processes tagged with a unique--shockbyte-markerflag passed at launch) runs as a belt-and-suspenders sweep after every attempt, in case anything escaped the process group.
A run that exhausts every retry (or a subprocess hangs past the timeout) exits with code 2, same as a page-structure-change error — check the log for "stalled" vs. a structure-change message to tell which one it was.
Detecting download completion via the filesystem, not Playwright's download event (added 8 August 2026)
Failure mode this closes off: the script used to start the download with page.expect_download(timeout=60000) and then block on download.save_as(save_path) — Playwright's own download-tracking event. On 2026-08-08, three consecutive real runs all died at exactly the 60-second expect_download timeout; after bumping that timeout to 5 minutes, a later run showed the download visibly complete in Chrome's own UI while Playwright's event still never fired at all. That pointed at Playwright's download-event delivery being unreliable here in general, not just slow to fire.
Likely root cause: this script launches Chrome via browser.launch(channel="chrome", ...), not launch_persistent_context(), so Playwright spins up a throwaway profile in a temp directory for the run and deletes it on browser.close(). If a native download landed inside that ephemeral profile's own download location instead of a real, persistent folder, it would explain both symptoms — the event never firing, and the finished file being absent from SAVE_DIR and the real ~/Downloads (confirmed by hand).
Fix: rather than chase down exactly which internal Chrome preference or event was misbehaving, detection was moved to the filesystem, which doesn't depend on any of that:
- A CDP session (
context.new_cdp_session(page)) explicitly forces Chrome to save intoSAVE_DIRviaPage.setDownloadBehavior, before the download is even triggered. _wait_for_new_download_to_start()pollsSAVE_DIRfor any new filesystem entry that wasn't there before the click — either Chrome's in-progress<name>.crdownloadfile, or (if it finishes fast enough that the.crdownloadstage is never observed) the final file itself. Bounded byDOWNLOAD_START_TIMEOUT_MS(currently 5 minutes); if nothing new appears in time, it dumps a debug screenshot/HTML snapshot and returns"error"._wait_for_download_to_finish()then polls until the.crdownloadfile is gone, the final file exists, and its size holds steady across two consecutive polls (guards against reading a file mid-write as if it were done). Deliberately unbounded, same as thedownload.save_as()call it replaces — the outerSINGLE_ATTEMPT_TIMEOUT_SECONDSsubprocess-level watchdog (see above) is still what catches a genuinely stalled transfer, not this function itself.
Both helpers are plain, blocking polling loops for the same reason save_as() was originally blocking and not wrapped in a watchdog thread — Playwright's sync API isn't thread-safe (see "Handling stalled downloads" above).
Exit codes (shockbyte_download_backup.py)
0— backup downloaded successfully (andbackup.sh, if present, also succeeded)1— no valid session available, and automatic re-login also failed; runshockbyte_login_setup.pyby hand2— page structure changed / couldn't find expected elements, or the download stalled/hung on every attempt3— Playwright not installed / other setup problem4— download succeeded butbackup.sh(post-processing — see the backup.sh & DVD archiving page) failed
Troubleshooting
launchctl bootstrapfails with "5: Input/output error" — usually means the service is already loaded from an earlier attempt. Check withlaunchctl print gui/$(id -u)/com.david.shockbyte-backup; if it prints something instead of erroring, it's already active — no fix needed (or runlaunchctl bootout gui/$(id -u)/com.david.shockbyte-backupfirst if you need to reload a changed plist). Otherwise check the plist's ownership/permissions (should berw-r--r--, owned by you) and validity (plutil -lint), and check for a leftover quarantine flag (xattr -d com.apple.quarantine ...orxattr -c ...to strip it if present).- "No saved session found" / "Got bounced to the login page" — the session expired or was never created. Run
shockbyte_login_setup_auto.py(orshockbyte_login_setup.pymanually if the automated one is misbehaving). - "Could not find the 'Extra Options' button" / "download-trigger icon" / any button-detection failure — Shockbyte changed their panel's page structure. These failure paths save a screenshot and HTML snapshot next to the script (
debug_*.png/debug_*.html) — share those and the selectors can be updated. - Couldn't get password from Keychain — confirm the Keychain item is a generic password (created via Keychain Access.app's "New Password Item", not something from Passwords.app/Safari's saved logins), named exactly
Shockbyte(or whateverKEYCHAIN_SERVICEis set to in the script). - Playwright errors about a missing browser — run
playwright install chromiumagain inside the venv. - Download stalls / script exits with code 2 after retries — see "Handling stalled downloads" above. Check the log for
"stalled"messages to confirm it's this rather than a page-structure error; if it keeps happening, it may point to a flaky network path or Shockbyte's download infra rather than anything in the script. greenlet.error: cannot switch to a different threadin stderr.log — a bug in an earlier version's stall-timeout implementation (a watchdog thread calling into Playwright's sync API from off-thread, which isn't supported). Fixed by moving to the subprocess-based design described above. If this reappears, it means something reintroduced a cross-thread Playwright call.
Known Playwright quirk: browser.close() can hang forever
Observed with real Chrome (channel="chrome") — happens after the important work (download, or a Keychain login) already succeeded, so no data is lost, but the script/terminal never gets control back without a Ctrl-C. Mainly relevant to shockbyte_login_setup_auto.py now — the main download script sidesteps this via the subprocess isolation described above.
Fix: run the whole browser flow in a background daemon thread with a generous, bounded timeout (LOGIN_TIMEOUT_SECONDS, 120s in shockbyte_login_setup_auto.py). If the close hangs, the main thread just stops waiting and exits anyway — a stuck daemon thread can't block that.
Known quirks worth remembering
- Shockbyte's login form is "identifier-first": you submit just the email, then a second page asks for the password.
- The email step's submit button starts disabled until their JS validates the field, and — confirmed by hand, not a Playwright bug — the first click on it only shifts focus rather than submitting; it takes a second click to actually submit. Both automated login scripts already retry the click a few times to handle this.
- Both scripts run headed (
headless=False) deliberately — Cloudflare's bot protection has reliably distinguished headless automation from a visible browser session in testing.
Scripts
shockbyte_download_backup.py
#!/usr/bin/env python3
#!/usr/bin/env python3
"""
Shockbyte Panel -- download the latest backup for JJAC_Survival_World.
Cron-safe: reuses the session saved in shockbyte_auth_state.json, so it
never touches the login page on a normal run (and never has to fight
Cloudflare's bot protection on auth.shockbyte.com directly). If that saved
session is missing, expired, or close to expiring, this script now
automatically calls into shockbyte_auth.py (Keychain-based login,
shared with shockbyte_login_setup_auto.py) to refresh it -- no manual
intervention needed under normal circumstances. shockbyte_login_setup.py
(fully manual login) remains as a fallback for whenever the automated
login itself breaks (bad password, Shockbyte changed their form again,
etc).
When a refresh is needed, this script reuses its own already-launched
Chrome `browser` for it (just a separate, unauthenticated context) rather
than spawning a whole second browser process -- see shockbyte_auth.py's
login_and_refresh_state().
Setup (one-time):
pip3 install playwright
playwright install chromium
python3 shockbyte_login_setup_auto.py # or shockbyte_login_setup.py by hand
Usage:
python3 shockbyte_download_backup.py
Exit codes:
0 = backup downloaded successfully (and backup.sh, if present, also succeeded)
1 = no valid session available, and automatic re-login also failed --
run shockbyte_login_setup.py by hand
2 = page structure changed / couldn't find expected elements, or the
download stalled/hung on every attempt (see SINGLE_ATTEMPT_TIMEOUT_SECONDS
and MAX_DOWNLOAD_ATTEMPTS)
3 = playwright not installed / other setup problem
4 = download succeeded but backup.sh (post-processing) failed
Internal use only -- do not run by hand:
python3 shockbyte_download_backup.py --internal-single-attempt <result_path>
Runs exactly one login-refresh+navigate+download attempt in this
process (single-threaded -- see the note above SINGLE_ATTEMPT_TIMEOUT_SECONDS
for why) and writes its outcome as JSON to <result_path>. main()
spawns this as a subprocess so it can enforce a hard timeout by
killing the whole thing at the OS level if it hangs, without
touching Playwright from more than one thread.
"""
import json
import os
import signal
import subprocess
import sys
import time
import uuid
from datetime import datetime
try:
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError
except ImportError:
print(
"Playwright isn't installed yet. Run:\n"
" pip3 install playwright\n"
" playwright install chromium",
file=sys.stderr,
)
sys.exit(3)
from shockbyte_auth import login_and_refresh_state
THIS_SCRIPT = os.path.abspath(__file__)
SCRIPT_DIR = os.path.dirname(THIS_SCRIPT)
STATE_FILE = os.path.join(SCRIPT_DIR, "shockbyte_auth_state.json")
SERVER_ID = "a3ab0419-59f8-4aa3-b6ee-7d1552d60bfc"
BACKUPS_URL = f"https://panel.shockbyte.com/server/{SERVER_ID}/backups"
# Where the downloaded zip gets saved, and what it's named. Other scripts
# expect a fixed filename at a fixed path (no timestamp), so this always
# overwrites the same file rather than accumulating dated copies.
SAVE_DIR = os.path.expanduser("/Users/david/scripts/shockbyte_dl/incoming/")
SAVE_FILENAME = "instance.zip"
# Runs right after a successful download: unzips instance.zip, prunes
# never-visited chunks, rsyncs the trimmed world to gmktec, and archives
# the raw zip to a few dated locations. Set to None to skip this step.
POST_PROCESS_SCRIPT = os.path.join(SCRIPT_DIR, "backup.sh")
# Start shouting in the log once the saved session is this close to expiring
# (belt-and-suspenders in case the automatic refresh below ever fails).
SESSION_WARNING_DAYS = 3
# Proactively refresh the saved session once it's within this many hours of
# expiring, *before* attempting a download -- rather than waiting to get
# bounced to the login page and reacting to it.
PROACTIVE_REFRESH_HOURS = 48
# Playwright's *sync* API (which this script uses) is not thread-safe --
# every call has to happen on the same thread that created the browser, or
# it raises "greenlet.error: cannot switch to a different thread" (see
# https://github.com/microsoft/playwright-python/issues/1422). An earlier
# version of this script tried to enforce a download timeout by calling
# the download-completion wait from a watchdog thread, which is exactly
# the pattern that breaks -- it crashed immediately instead of waiting.
#
# So timeouts here are enforced at the *process* level instead: each full
# attempt (session refresh + navigate + download, all single-threaded) runs
# in its own subprocess (see main() and --internal-single-attempt above).
# The parent just waits on that subprocess with a plain timeout and, if it
# hangs for any reason -- a stalled download, or browser.close()'s own
# known hang with real Chrome -- force-kills its entire process group
# rather than waiting on it forever.
SINGLE_ATTEMPT_TIMEOUT_SECONDS = 900
# How many full attempts (each a fresh subprocess + fresh browser) to make
# if one hangs/stalls. A stalled download may mean Chrome's networking
# stack itself is wedged, so retrying in the *same* browser/page isn't
# trusted -- each retry tears down and relaunches Chrome from scratch.
MAX_DOWNLOAD_ATTEMPTS = 2
# How long to wait for a new file to actually appear on disk in SAVE_DIR
# after clicking Confirm (see _wait_for_new_download_to_start, used in
# attempt_download). This used to be a 60s wait on Playwright's own
# "download" event instead; three consecutive real runs on 2026-08-08 all
# died at exactly that 60s mark, then -- after bumping this to 5 minutes --
# a later run showed the download visibly complete in Chrome's UI while
# Playwright's event tracking still never fired at all. That pointed at
# Playwright/real-Chrome download-event delivery being unreliable here
# generally, not just slow, so detection was moved to the filesystem (see
# the CDP setDownloadBehavior call in attempt_download). This timeout is
# now just "how long to wait for the transfer to begin on disk," with
# comfortable room under SINGLE_ATTEMPT_TIMEOUT_SECONDS (15 min) for the
# rest of the transfer to finish afterward.
DOWNLOAD_START_TIMEOUT_MS = 300000
def log(msg):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{timestamp}] {msg}")
def kill_marked_chrome(marker):
"""Force-kills any process (Chrome itself or one of its helper
processes) whose command line contains our unique per-launch marker
flag. main() already kills the whole subprocess's process group on a
timeout (which should take Chrome down with it, since it's normally
still in that group) -- this is a belt-and-suspenders sweep for any
Chrome/helper process that somehow escaped the group, plus routine
cleanup after a normal exit in case browser.close() left something
behind.
Requires `psutil` (not a Playwright dependency -- install separately:
`pip3 install psutil` in the same venv as this script). Degrades to a
no-op with a warning if it isn't installed."""
try:
import psutil
except ImportError:
log(
"psutil isn't installed, so a stalled/hung Chrome can't be force-killed "
"automatically -- run 'pip3 install psutil' in this script's venv to "
"enable that. Leaving any leftover Chrome process running for now."
)
return []
killed = []
for proc in psutil.process_iter(["pid", "cmdline"]):
try:
cmdline = proc.info.get("cmdline") or []
if any(marker in arg for arg in cmdline):
proc.kill()
killed.append(proc.info["pid"])
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
if killed:
log(f"Force-killed {len(killed)} leftover Chrome process(es) tagged with marker {marker}: {killed}")
return killed
def get_session_expiry():
"""Reads the KEYCLOAK_SESSION cookie's real expiry straight out of the
saved storage_state file. Returns a datetime, or None if the file/cookie
doesn't exist or has no fixed expiry. Doesn't touch the network."""
try:
with open(STATE_FILE, "r", encoding="utf-8") as f:
state = json.load(f)
cookie = next(
(c for c in state.get("cookies", []) if c.get("name") == "KEYCLOAK_SESSION"),
None,
)
if not cookie or not cookie.get("expires") or cookie["expires"] <= 0:
return None
return datetime.fromtimestamp(cookie["expires"])
except Exception as e:
log(f"(Couldn't check session expiry ahead of time: {e})")
return None
def _list_dir_safe(d):
try:
return set(os.listdir(d))
except FileNotFoundError:
return set()
def _wait_for_new_download_to_start(watch_dir, before_names, timeout_seconds, poll_interval=1.0):
"""Polls watch_dir on disk for any new filesystem entry that wasn't in
before_names -- either Chrome's in-progress '<name>.crdownload' file,
or (if it finishes fast enough that we never observe the .crdownload
stage) the final file itself. Returns the "base" name with any
'.crdownload' suffix stripped, or None if nothing new appeared within
timeout_seconds.
Deliberately doesn't use Playwright's own expect_download()/download
event -- see the note in attempt_download() above the CDP
setDownloadBehavior call for why that isn't trustworthy here."""
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
new_names = _list_dir_safe(watch_dir) - before_names
if new_names:
name = sorted(new_names)[0]
if name.endswith(".crdownload"):
name = name[: -len(".crdownload")]
return name
time.sleep(poll_interval)
return None
def _wait_for_download_to_finish(watch_dir, base_name, poll_interval=3.0, stable_checks=2):
"""Polls watch_dir until the in-progress '<base_name>.crdownload' file
is gone, '<base_name>' exists, and its size holds steady across
`stable_checks` consecutive polls. Runs indefinitely -- deliberately
unbounded, same as the old download.save_as() call it replaces: the
outer SINGLE_ATTEMPT_TIMEOUT_SECONDS subprocess-level watchdog in
main() is what catches a genuinely stalled/hung transfer, not this
function itself."""
final_path = os.path.join(watch_dir, base_name)
crdownload_path = final_path + ".crdownload"
stable_count = 0
last_size = -1
while True:
if not os.path.exists(crdownload_path) and os.path.exists(final_path):
size = os.path.getsize(final_path)
if size == last_size and size > 0:
stable_count += 1
if stable_count >= stable_checks:
return final_path
else:
stable_count = 0
last_size = size
else:
stable_count = 0
time.sleep(poll_interval)
def attempt_download(browser):
"""Runs the actual Playwright flow on an already-launched `browser`:
navigate to the backups page, find and click the download button, save
the file. Returns one of:
"success" -- downloaded fine
"expired" -- bounced to the login page, saved session is no good
"error" -- some other page-structure/timeout problem (see log/debug files)
A stalled/hung download isn't detected here at all -- see the note
above SINGLE_ATTEMPT_TIMEOUT_SECONDS for why (Playwright's sync API
isn't thread-safe, so we can't watchdog this call from another
thread). Instead, main() runs this whole attempt in a subprocess and
enforces the timeout from outside it.
Never closes `browser` itself (the caller owns its lifecycle), and
never calls sys.exit(), so the caller can retry after a refresh.
"""
context = browser.new_context(storage_state=STATE_FILE)
try:
page = context.new_page()
log(f"Navigating to {BACKUPS_URL}")
page.goto(BACKUPS_URL, wait_until="domcontentloaded", timeout=60000)
if "auth.shockbyte.com" in page.url:
log("Got bounced to the login page -- saved session has expired.")
return "expired"
def dump_debug(tag):
debug_png = os.path.join(SCRIPT_DIR, f"debug_screenshot_{tag}.png")
debug_html = os.path.join(SCRIPT_DIR, f"debug_page_{tag}.html")
try:
page.screenshot(path=debug_png, full_page=True)
with open(debug_html, "w", encoding="utf-8") as f:
f.write(page.content())
log(f"Saved screenshot to {debug_png}")
log(f"Saved page HTML to {debug_html}")
except Exception as e:
log(f"Couldn't capture debug info: {e}")
# The page has more than one "Extra Options" menu on it (e.g. the
# Scheduled Backups card has its own). We need the one that belongs
# to the real Backups table specifically, so scope everything to
# the <table> whose header row contains a "Created" column.
try:
page.wait_for_selector("table:has(th:has-text('Created'))", timeout=30000)
except PlaywrightTimeoutError:
log("Timed out waiting for the backups table to appear at all.")
log(f"Current URL: {page.url}")
dump_debug("no_table")
return "error"
backups_table = page.locator("table:has(th:has-text('Created'))").first
# The table shows animated skeleton placeholder rows while the real
# backup rows are still loading. Wait for those to clear.
try:
page.wait_for_function(
"""(table) => !table.querySelector('tbody .animate-pulse')""",
arg=backups_table.element_handle(),
timeout=30000,
)
except PlaywrightTimeoutError:
log("Timed out waiting for the backups table's loading skeleton to clear.")
dump_debug("skeleton_stuck")
return "error"
first_row = backups_table.locator("tbody tr").first
try:
first_row.wait_for(state="visible", timeout=15000)
except PlaywrightTimeoutError:
log("Backups table finished loading but no row appeared -- maybe there are no backups?")
dump_debug("no_rows")
return "error"
row_buttons = first_row.locator("button")
row_button_count = row_buttons.count()
extra_options_idx = None
for i in range(row_button_count):
text = (row_buttons.nth(i).inner_text() or "").strip().lower()
if text == "extra options":
extra_options_idx = i
break
if extra_options_idx is None:
log("Could not find the row's 'Extra Options' button -- page structure may have changed.")
dump_debug("no_extra_options_in_row")
return "error"
trigger = None
for i in range(extra_options_idx - 1, -1, -1):
b = row_buttons.nth(i)
label = (b.inner_text() or b.get_attribute("aria-label") or "").strip()
icononly = b.get_attribute("data-icononly")
if icononly == "true" and label == "":
trigger = b
break
if trigger is None:
log("Could not find the row's download-trigger icon button.")
log(f"Inspecting the {row_button_count} buttons found in the first backup row:")
for i in range(row_button_count):
b = row_buttons.nth(i)
text = (b.inner_text() or "").strip()
aria = b.get_attribute("aria-label")
icononly = b.get_attribute("data-icononly")
cls = b.get_attribute("class")
outer = b.evaluate("el => el.outerHTML")
log(f" [{i}] text={text!r} aria-label={aria!r} data-icononly={icononly!r} class={cls!r}")
log(f" outerHTML={outer[:300]}")
dump_debug("no_trigger_in_row")
return "error"
log("Clicking the download-trigger icon...")
trigger.click()
# Wait for the "Download Backup" verification popup's real Download button
try:
page.wait_for_selector("button:has-text('Download')", timeout=15000)
except PlaywrightTimeoutError:
log("Timed out waiting for the Download confirmation popup.")
return "error"
confirm_btn = page.locator("button:has-text('Download')").last
os.makedirs(SAVE_DIR, exist_ok=True)
# Force Chrome to save directly into SAVE_DIR via CDP, and confirm
# completion by watching that directory on disk -- deliberately
# NOT using Playwright's own expect_download()/download event.
# On 2026-08-08, a real run visibly completed a download in
# Chrome's own UI, but expect_download() still timed out, and the
# finished file wasn't in SAVE_DIR *or* the real ~/Downloads
# (confirmed by hand). The likely explanation: this script
# launches Chrome via browser.launch(channel="chrome", ...), not
# launch_persistent_context(), so Playwright spins up a throwaway
# profile in a temp dir for the run and deletes it on
# browser.close() -- if the native download landed inside that
# ephemeral profile's own download location instead of a real,
# persistent folder, it would explain the file vanishing without
# a trace either place we checked. Rather than chase down exactly
# which internal event/preference is misbehaving, this pins the
# download location explicitly and confirms success at the
# filesystem level, which doesn't depend on any of that.
cdp = context.new_cdp_session(page)
cdp.send("Page.setDownloadBehavior", {"behavior": "allow", "downloadPath": SAVE_DIR})
before_names = _list_dir_safe(SAVE_DIR)
log("Confirming download...")
confirm_btn.click()
base_name = _wait_for_new_download_to_start(
SAVE_DIR, before_names, timeout_seconds=DOWNLOAD_START_TIMEOUT_MS / 1000
)
if base_name is None:
log(
f"Timed out after {DOWNLOAD_START_TIMEOUT_MS / 1000:.0f}s waiting for a new "
f"file to appear in {SAVE_DIR} -- dumping a screenshot/HTML snapshot to see "
f"what the page actually looked like when it gave up."
)
dump_debug("download_never_started")
return "error"
log(f"Download started on disk as {base_name!r} -- waiting for it to finish...")
# Deliberately a plain, blocking poll loop -- see the note above
# SINGLE_ATTEMPT_TIMEOUT_SECONDS for why this can't be wrapped in
# a watchdog thread, and the note on _wait_for_download_to_finish
# for why it's intentionally unbounded here: main()'s
# subprocess-level timeout is what catches a genuine stall.
downloaded_path = _wait_for_download_to_finish(SAVE_DIR, base_name)
save_path = os.path.join(SAVE_DIR, SAVE_FILENAME)
if downloaded_path != save_path:
os.replace(downloaded_path, save_path)
log(f"Backup saved to: {save_path}")
return "success"
finally:
context.close()
def run_session_and_download(browser):
"""Handles session freshness (proactive + reactive refresh) and the
download attempt(s), all reusing the same already-launched `browser`.
Returns the final status string: "success", "expired", or "error"."""
if not os.path.exists(STATE_FILE):
log(f"No saved session found at {STATE_FILE}.")
log("Attempting an automatic initial login...")
if not login_and_refresh_state(browser=browser, log=log):
log("Automatic login failed and no saved session exists.")
log("Run shockbyte_login_setup.py by hand to get started.")
return "expired"
else:
expires_at = get_session_expiry()
if expires_at is not None:
hours_left = (expires_at - datetime.now()).total_seconds() / 3600
days_left = int(hours_left // 24)
if hours_left <= SESSION_WARNING_DAYS * 24:
log(
f"*** HEADS UP: saved session expires in ~{days_left} day(s), "
f"on {expires_at:%Y-%m-%d}. ***"
)
if hours_left <= PROACTIVE_REFRESH_HOURS:
log(
f"Session expires in ~{hours_left:.1f} hour(s), which is within "
f"the {PROACTIVE_REFRESH_HOURS}-hour proactive-refresh window -- "
f"refreshing now before attempting the download."
)
# Best-effort: if this fails, still fall through and try the
# download with the old session -- it might still be valid.
login_and_refresh_state(browser=browser, log=log)
status = attempt_download(browser)
if status == "expired":
log("Saved session was expired -- attempting an automatic re-login and one retry.")
if login_and_refresh_state(browser=browser, log=log):
status = attempt_download(browser)
else:
log("Automatic re-login failed. Run shockbyte_login_setup.py by hand to recover.")
return "expired"
if status == "expired":
# Refresh succeeded but we still got bounced on retry -- something
# deeper is wrong (bad credentials in Keychain, Shockbyte changed
# the login flow again, etc). Don't loop forever.
log("Still couldn't get a valid session after refreshing once. Giving up for this run.")
return status
def _single_attempt_main(result_path):
"""Runs exactly one login-refresh+navigate+download attempt, entirely
on this (the only) thread, and writes {"status": ...} as JSON to
result_path. This is invoked as `python3 shockbyte_download_backup.py
--internal-single-attempt <result_path>` by main(), which is the only
thing that ever calls this. Never raises -- any exception is caught
and reported as an "error" status so the parent always gets a result
file to read (or a clear reason it didn't)."""
marker = os.environ.get("SHOCKBYTE_CHROME_MARKER", "")
launch_args = [f"--shockbyte-marker={marker}"] if marker else []
def write_result(status):
try:
with open(result_path, "w", encoding="utf-8") as f:
json.dump({"status": status}, f)
except Exception as e:
log(f"Couldn't write result file {result_path}: {e}")
status = "error"
try:
with sync_playwright() as p:
try:
browser = p.chromium.launch(headless=False, channel="chrome", args=launch_args)
except Exception:
browser = p.chromium.launch(headless=False, args=launch_args)
try:
status = run_session_and_download(browser)
except Exception as e:
log(f"Unhandled exception during browser flow: {e}")
status = "error"
# Write the result *before* calling browser.close(). Real
# Chrome's close()/teardown has its own known hang in some
# environments; if that happens here, main() will time this
# whole subprocess out and kill it -- but by then the result
# file already correctly says "success" (or whatever really
# happened), so a slow teardown doesn't turn a real success
# into a false failure.
write_result(status)
try:
browser.close()
except Exception as e:
log(f"browser.close() raised (known quirk, ignoring): {e}")
except Exception as e:
log(f"Unhandled exception launching/tearing down the browser: {e}")
write_result("error")
sys.exit(0)
def main():
status = "error"
for attempt in range(1, MAX_DOWNLOAD_ATTEMPTS + 1):
marker = f"shockbyte-run-{uuid.uuid4().hex[:12]}"
result_path = os.path.join(SCRIPT_DIR, f".shockbyte_attempt_result_{marker}.json")
# Tagging every launch with a unique, otherwise-meaningless flag
# lets us find and force-kill *this* Chrome instance later (via
# kill_marked_chrome) without touching any other Chrome window on
# the machine. Chrome ignores flags it doesn't recognize, so this
# is harmless to the browser itself.
env = os.environ.copy()
env["SHOCKBYTE_CHROME_MARKER"] = marker
log(f"Starting download attempt {attempt}/{MAX_DOWNLOAD_ATTEMPTS} (marker={marker})...")
# Each attempt runs the whole login-refresh+navigate+download flow
# in its own subprocess -- single-threaded inside (Playwright's
# sync API isn't thread-safe, see the note above
# SINGLE_ATTEMPT_TIMEOUT_SECONDS), so we enforce the hang/stall
# timeout from *outside* the process instead of from a watchdog
# thread inside it. start_new_session=True puts the subprocess (and
# everything it spawns -- Playwright's driver, Chrome, Chrome's
# helper processes) into its own OS process group, so a timeout can
# take the whole tree down in one os.killpg() call.
proc = subprocess.Popen(
[sys.executable, THIS_SCRIPT, "--internal-single-attempt", result_path],
cwd=SCRIPT_DIR,
env=env,
start_new_session=True,
)
try:
proc.wait(timeout=SINGLE_ATTEMPT_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired:
log(
f"Attempt {attempt} didn't finish within {SINGLE_ATTEMPT_TIMEOUT_SECONDS}s -- "
f"treating it as stalled/hung and force-killing its whole process group."
)
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except ProcessLookupError:
pass
proc.wait() # reap the now-dead process
kill_marked_chrome(marker) # belt-and-suspenders for anything outside the group
status = "stalled"
else:
status = None
if os.path.exists(result_path):
try:
with open(result_path, "r", encoding="utf-8") as f:
status = json.load(f).get("status")
except Exception as e:
log(f"Couldn't read result file from attempt {attempt}: {e}")
if status is None:
log(
f"Attempt {attempt} exited (code {proc.returncode}) without writing a "
f"result file -- treating as error."
)
status = "error"
kill_marked_chrome(marker) # routine cleanup in case close() left something behind
if os.path.exists(result_path):
try:
os.remove(result_path)
except OSError:
pass
if status != "stalled":
break
if attempt < MAX_DOWNLOAD_ATTEMPTS:
log(f"Retrying with a fresh browser (attempt {attempt + 1}/{MAX_DOWNLOAD_ATTEMPTS})...")
else:
log(f"Download stalled/hung on the final attempt ({attempt}/{MAX_DOWNLOAD_ATTEMPTS}). Giving up for this run.")
if status in ("error", "stalled"):
sys.exit(2)
if status == "expired":
sys.exit(1)
# status == "success" from here on.
if POST_PROCESS_SCRIPT and os.path.exists(POST_PROCESS_SCRIPT):
log(f"Running post-processing script: {POST_PROCESS_SCRIPT}")
# Inherits this process's stdout/stderr, so its output lands in the
# same log file launchd is already writing to.
result = subprocess.run(["/bin/zsh", POST_PROCESS_SCRIPT])
if result.returncode != 0:
log(f"backup.sh exited with code {result.returncode} -- check the log above for where it failed.")
sys.exit(4)
log("Post-processing finished successfully.")
elif POST_PROCESS_SCRIPT:
log(f"POST_PROCESS_SCRIPT is set to {POST_PROCESS_SCRIPT} but that file doesn't exist -- skipping.")
sys.exit(0)
if __name__ == "__main__":
if len(sys.argv) >= 3 and sys.argv[1] == "--internal-single-attempt":
_single_attempt_main(sys.argv[2])
else:
main()
shockbyte_auth.py
#!/usr/bin/env python3
"""
Shared Shockbyte login/Keychain logic, used by both:
- shockbyte_login_setup_auto.py (thin standalone CLI wrapper -- run this
by hand whenever you just want to force a session refresh, or test
Keychain access in isolation)
- shockbyte_download_backup.py (imports login_and_refresh_state()
directly and, when possible, reuses its own already-launched Chrome
`browser` instead of spawning a second one via subprocess)
Credential storage:
Your password is NOT stored anywhere on disk in plaintext. It's read
from macOS Keychain at runtime via the `security` command-line tool.
One-time setup:
1. Open Keychain Access.app
2. File -> New Password Item...
- Keychain Item Name: Shockbyte
- Account Name: your Shockbyte login email
- Password: your Shockbyte password
3. Click Add.
The first time this runs, macOS will show a permission prompt
("security" wants to use your confidential information...). Click
"Always Allow" so future unattended runs (e.g. from launchd) don't
hang waiting for that dialog.
Why headed (visible) Chrome, not headless:
The Shockbyte panel and login pages sit behind Cloudflare bot
protection that has reliably distinguished headless automation from a
normal browser session in our testing. headless=False (a real, visible
browser window) has worked consistently; headless=True has not.
Login flow quirks handled here (reverse-engineered via manual testing):
- The login form is "identifier-first": you submit just the email,
then a second page/step asks for the password.
- The email step's submit button (#login) starts disabled until
their JS validates the field, and -- confirmed by hand, this isn't
a Playwright artifact -- the first click on it only shifts focus
rather than submitting; it takes a second click to actually submit.
login_flow() below retries the click a few times to handle that.
"""
import os
import subprocess
import time
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
STATE_FILE = os.path.join(SCRIPT_DIR, "shockbyte_auth_state.json")
PANEL_URL = "https://panel.shockbyte.com/"
KEYCHAIN_SERVICE = "Shockbyte"
# Your Shockbyte login email. Not sensitive the way the password is, so it
# just lives here as plain text -- only the password comes from Keychain.
SHOCKBYTE_USERNAME = "" # <-- fill this in
def default_log(msg):
print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}")
def dump_debug(page, tag, log=default_log):
png = os.path.join(SCRIPT_DIR, f"login_auto_{tag}.png")
html = os.path.join(SCRIPT_DIR, f"login_auto_{tag}.html")
try:
page.screenshot(path=png, full_page=True)
with open(html, "w", encoding="utf-8") as f:
f.write(page.content())
log(f"Saved screenshot to {png}")
log(f"Saved page HTML to {html}")
except Exception as e:
log(f"Couldn't capture debug info: {e}")
def get_keychain_password(service=KEYCHAIN_SERVICE):
"""Reads just the password for a generic-password Keychain item using
the `security` CLI. Raises RuntimeError with a clear message if the
item isn't found or Keychain access is denied.
(We deliberately don't try to also extract the account/email from the
item's attributes via `security ... -g` -- that output's format is
inconsistent across macOS versions/encodings and proved too fragile to
parse reliably. The email isn't actually sensitive, so it's just set
above as SHOCKBYTE_USERNAME instead.)"""
pw = subprocess.run(
["security", "find-generic-password", "-s", service, "-w"],
capture_output=True,
text=True,
)
if pw.returncode != 0:
raise RuntimeError(
f"No Keychain item named '{service}' found (or access was denied). "
f"security said: {pw.stderr.strip()}"
)
return pw.stdout.strip()
def login_flow(page, username, password, log=default_log):
"""Drives the actual Shockbyte login form on an already-created,
unauthenticated Playwright `page`. Returns True on success, False on
any failure (with debug screenshot/HTML saved via dump_debug)."""
log(f"Navigating to {PANEL_URL}")
page.goto(PANEL_URL, wait_until="domcontentloaded", timeout=60000)
page.wait_for_timeout(3000)
log(f"Current URL: {page.url}")
if "Just a moment" in (page.title() or "") or "challenge" in page.url.lower():
log("Cloudflare challenge appeared before the login form loaded.")
log("Waiting up to 20s to see if it clears...")
for _ in range(20):
page.wait_for_timeout(1000)
if "Just a moment" not in (page.title() or ""):
break
log(f"URL after waiting: {page.url}")
username_selectors = ["#username", "input[name='username']", "input[type='email']"]
step1_submit_selectors = ["#login", "input[type='submit']", "button[type='submit']"]
password_selectors = ["#password", "input[name='password']", "input[type='password']"]
step2_submit_selectors = ["#kc-login", "#login", "input[type='submit']", "button[type='submit']"]
username_field = None
for sel in username_selectors:
try:
page.wait_for_selector(sel, timeout=10000)
username_field = page.locator(sel).first
log(f"Found username field via selector: {sel}")
break
except PlaywrightTimeoutError:
continue
if username_field is None:
log("Could not find a username field on the page -- page structure may have changed.")
dump_debug(page, "no_username_field", log=log)
return False
log("Typing username...")
username_field.click()
username_field.type(username, delay=80)
step1_submit = None
for sel in step1_submit_selectors:
if page.locator(sel).count() > 0:
step1_submit = page.locator(sel).first
break
if step1_submit is None:
log("Could not find the step-1 submit button. Trying Enter key instead.")
username_field.press("Enter")
else:
try:
page.wait_for_function(
"(btn) => !btn.disabled",
arg=step1_submit.element_handle(),
timeout=5000,
)
except PlaywrightTimeoutError:
log("Step-1 submit button never became enabled -- clicking anyway.")
# Known quirk: the first click just shifts focus; it takes a
# second click to actually submit. Retry a few times.
for attempt in range(1, 4):
log(f"Clicking step-1 submit (email) -- attempt {attempt}...")
step1_submit.click()
try:
page.wait_for_selector(password_selectors[0], timeout=4000)
break
except PlaywrightTimeoutError:
if any(page.locator(sel).count() > 0 for sel in password_selectors):
break
continue
password_field = None
for sel in password_selectors:
try:
page.wait_for_selector(sel, timeout=15000)
password_field = page.locator(sel).first
log(f"Found password field via selector: {sel}")
break
except PlaywrightTimeoutError:
continue
if password_field is None:
log("Could not find a password field after submitting the username step.")
log(f"Current URL: {page.url}")
dump_debug(page, "no_password_field_step2", log=log)
return False
log("Typing password...")
password_field.click()
password_field.type(password, delay=80)
submit_btn = None
for sel in step2_submit_selectors:
if page.locator(sel).count() > 0:
submit_btn = page.locator(sel).first
log(f"Found step-2 submit button via selector: {sel}")
break
if submit_btn is None:
log("Could not find a step-2 submit button. Trying Enter key instead.")
password_field.press("Enter")
else:
log("Clicking submit...")
submit_btn.click()
page.wait_for_timeout(5000)
log(f"URL after submit: {page.url}")
if "auth.shockbyte.com" in page.url:
log("Login failed -- still on auth.shockbyte.com. Could be a bad password,")
log("an unexpected form flow, or a Cloudflare block. Capturing debug info.")
dump_debug(page, "still_on_login", log=log)
return False
# Make sure we can actually see the panel (not, say, stuck on some
# intermediate account-setup screen) before the caller saves the session.
try:
page.wait_for_url("**panel.shockbyte.com**", timeout=15000)
except PlaywrightTimeoutError:
log(f"Landed somewhere unexpected: {page.url}")
dump_debug(page, "unexpected_landing", log=log)
return False
return True
def login_and_refresh_state(browser=None, log=default_log):
"""High-level entry point: gets credentials, logs in, and saves a fresh
shockbyte_auth_state.json on success. Returns True/False.
If `browser` is given (an already-launched Playwright Browser), reuses
it -- just opens a new, unauthenticated context on it for the login,
and leaves the browser itself open for the caller to keep using
afterward (e.g. shockbyte_download_backup.py reusing the same browser
for the download that follows). If `browser` is None, launches and
fully tears down its own browser -- used when running standalone via
shockbyte_login_setup_auto.py.
"""
if not SHOCKBYTE_USERNAME:
log("SHOCKBYTE_USERNAME isn't set yet -- edit shockbyte_auth.py and fill in")
log("your Shockbyte login email near the top of the file.")
return False
try:
password = get_keychain_password(KEYCHAIN_SERVICE)
except RuntimeError as e:
log(f"Couldn't get password from Keychain: {e}")
log(
f"Set it up via Keychain Access.app: New Password Item, "
f"name '{KEYCHAIN_SERVICE}', account = your Shockbyte email, "
f"password = your Shockbyte password."
)
return False
username = SHOCKBYTE_USERNAME
log(f"Got password from Keychain. Using username: {username}")
if browser is not None:
return _login_with_browser(browser, username, password, log)
with sync_playwright() as p:
try:
b = p.chromium.launch(headless=False, channel="chrome")
except Exception:
log("(Real Chrome not found, using bundled Chromium instead.)")
b = p.chromium.launch(headless=False)
try:
return _login_with_browser(b, username, password, log)
finally:
b.close()
def _login_with_browser(browser, username, password, log):
context = browser.new_context()
try:
page = context.new_page()
ok = login_flow(page, username, password, log=log)
if ok:
context.storage_state(path=STATE_FILE)
log(f"Login succeeded. Saved refreshed session to: {STATE_FILE}")
return ok
finally:
context.close()
shockbyte_login_setup_auto.py
#!/usr/bin/env python3
"""
Shockbyte Panel -- standalone entry point for forcing a session refresh by
hand (e.g. to test Keychain access in isolation, or just get a fresh
shockbyte_auth_state.json right now without waiting for the nightly run).
The actual login logic lives in shockbyte_auth.py, shared with
shockbyte_download_backup.py (which imports it directly and reuses its own
already-launched browser rather than spawning a second one via this script).
Usage:
python3 shockbyte_login_setup_auto.py
Exit codes:
0 = login succeeded, shockbyte_auth_state.json refreshed
1 = login failed -- see the log output / debug screenshot+HTML above
3 = playwright not installed
Note on hanging:
Playwright's browser.close() (and/or its own internal teardown) has
been observed to hang indefinitely with real Chrome (channel="chrome")
in some environments -- a known upstream quirk, not something in our
own login logic. It happens *after* the important part (saving the
session file) already succeeded. To make sure this script always
returns control to your terminal, the whole flow runs in a background
daemon thread with a generous but bounded timeout; if it's still stuck
after that, we stop waiting and exit anyway rather than hang forever.
"""
import sys
import threading
try:
import playwright # noqa: F401
except ImportError:
print(
"Playwright isn't installed yet. Run:\n"
" pip3 install playwright\n"
" playwright install chromium\n"
"then try again.",
file=sys.stderr,
)
sys.exit(3)
from shockbyte_auth import login_and_refresh_state, default_log
LOGIN_TIMEOUT_SECONDS = 120
def main():
result = {}
def worker():
result["ok"] = login_and_refresh_state(log=default_log)
t = threading.Thread(target=worker, daemon=True)
t.start()
t.join(timeout=LOGIN_TIMEOUT_SECONDS)
if "ok" not in result:
default_log(
f"Didn't finish within {LOGIN_TIMEOUT_SECONDS}s -- likely stuck in "
f"Playwright/Chrome teardown *after* a successful login (check "
f"shockbyte_auth_state.json's modified time to confirm). Giving "
f"up waiting and exiting rather than hanging the terminal."
)
sys.exit(1)
sys.exit(0 if result["ok"] else 1)
if __name__ == "__main__":
main()
shockbyte_login_setup.py
#!/usr/bin/env python3
"""
Shockbyte Panel -- one-time interactive login setup.
Run this manually (not from cron). It opens a real, visible Chrome window,
you log in by hand exactly as you normally would, and once you're in,
Playwright saves the authenticated session (cookies etc.) to a JSON file.
The download script (shockbyte_download_backup.py) reuses that saved
session so it never has to go through the login page itself -- which
matters because auth.shockbyte.com sits behind Cloudflare bot protection
that reliably distinguishes genuine human interaction (this script) from
automated form-filling (which is why the AppleScript approach kept failing
inconsistently).
Re-run this whenever the saved session expires (the download script will
tell you clearly if that's happened).
Setup (one-time):
pip3 install playwright
playwright install chromium
Usage:
python3 shockbyte_login_setup.py
"""
import os
import sys
try:
from playwright.sync_api import sync_playwright
except ImportError:
sys.exit(
"Playwright isn't installed yet. Run:\n"
" pip3 install playwright\n"
" playwright install chromium\n"
"then try again."
)
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
STATE_FILE = os.path.join(SCRIPT_DIR, "shockbyte_auth_state.json")
PANEL_URL = "https://panel.shockbyte.com/"
def main():
with sync_playwright() as p:
# Prefer real installed Chrome over the bundled Chromium if available --
# it's a more "normal" browser fingerprint. Falls back automatically
# if Chrome isn't installed.
try:
browser = p.chromium.launch(headless=False, channel="chrome")
except Exception:
print("(Real Chrome not found, using Playwright's bundled Chromium instead.)")
browser = p.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto(PANEL_URL)
print()
print("=" * 70)
print("A browser window has opened to the Shockbyte panel.")
print("Please log in by hand -- email, then password, same as always.")
print("Once you can see your server list (My Servers page), come back")
print("to this terminal window and press Enter.")
print("=" * 70)
input("Press Enter after you've finished logging in... ")
# Sanity check: make sure we're not still sitting on the login page
if "auth.shockbyte.com" in page.url:
print()
print("It looks like you're still on the login page (URL contains")
print("auth.shockbyte.com). Finish logging in, then press Enter again.")
input("Press Enter once you're actually logged in... ")
context.storage_state(path=STATE_FILE)
print()
print(f"Saved your session to: {STATE_FILE}")
print("You can now run shockbyte_download_backup.py (including from cron).")
browser.close()
if __name__ == "__main__":
main()
com.david.shockbyte-backup.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.david.shockbyte-backup</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/caffeinate</string>
<string>-i</string>
<string>/Users/david/scripts/shockbyte_dl/myvenv/bin/python3</string>
<string>/Users/david/scripts/shockbyte_dl/shockbyte_download_backup.py</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/david/scripts/shockbyte_dl</string>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>2</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>RunAtLoad</key>
<false/>
<key>StandardOutPath</key>
<string>/Users/david/scripts/shockbyte_dl/logs/stdout.log</string>
<key>StandardErrorPath</key>
<string>/Users/david/scripts/shockbyte_dl/logs/stderr.log</string>
<key>ProcessType</key>
<string>Interactive</string>
</dict>
</plist>