Un-notarized fallback builds (Apple Development identity) are rejected by spctl by design; the script must still publish them with a warning, otherwise the fallback path can never release. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
466 lines
15 KiB
Bash
Executable File
466 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# One-command Redline release: bump Info.plist, commit, signed build, zip,
|
|
# DMG, appcast, upload to mmd01, verify the public URLs.
|
|
# Hidden flag: --test — upload under .../redline/test/ and skip the git commit.
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
cd "${ROOT}"
|
|
|
|
PLIST="${ROOT}/Info.plist"
|
|
PLISTBUDDY="/usr/libexec/PlistBuddy"
|
|
REMOTE_HOST="mmd01"
|
|
REMOTE_BASE="/opt/mmd-installer-content/cowork/redline"
|
|
PUBLIC_BASE="https://get.baobab-ts.com/cowork/redline"
|
|
BUNDLE_ID="ai.flowmaster.shotdeck"
|
|
# Used both as the fallback signing identity and as what build-app.sh itself
|
|
# still hardcodes for its own (pre-final) signing pass.
|
|
FALLBACK_SIGN_IDENTITY="Apple Development: ben@flow-master.ai (QH2H9G2LK5)"
|
|
|
|
usage() {
|
|
echo "Usage: $0 <version> [\"notes\"]" >&2
|
|
exit 1
|
|
}
|
|
|
|
TEST_MODE=0
|
|
VERSION=""
|
|
NOTES=""
|
|
NOTES_SET=0
|
|
for arg in "$@"; do
|
|
case "${arg}" in
|
|
--test)
|
|
TEST_MODE=1
|
|
;;
|
|
--help|-h)
|
|
usage
|
|
;;
|
|
--*)
|
|
echo "Unknown argument: ${arg}" >&2
|
|
usage
|
|
;;
|
|
*)
|
|
if [[ -z "${VERSION}" ]]; then
|
|
VERSION="${arg}"
|
|
elif [[ "${NOTES_SET}" -eq 0 ]]; then
|
|
NOTES="${arg}"
|
|
NOTES_SET=1
|
|
else
|
|
echo "Unexpected extra argument: ${arg}" >&2
|
|
usage
|
|
fi
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ -z "${VERSION}" ]]; then
|
|
usage
|
|
fi
|
|
|
|
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][A-Za-z0-9.-]+)*$ ]]; then
|
|
echo "Version '${VERSION}' is not a semver (e.g. 1.2.3 or 0.0.0-test)." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "${VERSION}" == *'/'* || "${VERSION}" == *'..'* ]]; then
|
|
echo "Version contains illegal path characters: ${VERSION}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "${TEST_MODE}" -eq 1 ]]; then
|
|
REMOTE_DIR="${REMOTE_BASE}/test"
|
|
PUBLIC_DIR="${PUBLIC_BASE}/test"
|
|
else
|
|
REMOTE_DIR="${REMOTE_BASE}"
|
|
PUBLIC_DIR="${PUBLIC_BASE}"
|
|
fi
|
|
|
|
APP_BUNDLE="${ROOT}/.build/Redline.app"
|
|
ZIP_NAME="Redline-${VERSION}.zip"
|
|
DMG_NAME="Redline-${VERSION}.dmg"
|
|
ZIP_PATH="${ROOT}/.build/${ZIP_NAME}"
|
|
DMG_PATH="${ROOT}/.build/Redline.dmg"
|
|
APPCAST_PATH="${ROOT}/.build/appcast.json"
|
|
ZIP_URL="${PUBLIC_DIR}/${ZIP_NAME}"
|
|
APPCAST_URL="${PUBLIC_DIR}/appcast.json"
|
|
|
|
if [[ "${TEST_MODE}" -eq 1 ]]; then
|
|
echo "==> Publish Redline ${VERSION} (test)"
|
|
else
|
|
echo "==> Publish Redline ${VERSION}"
|
|
fi
|
|
echo " remote: ${REMOTE_HOST}:${REMOTE_DIR}/"
|
|
echo " public: ${PUBLIC_DIR}/"
|
|
|
|
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
echo "Not inside a git work tree." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Tracked files must match HEAD. Untracked files are ignored so this script
|
|
# can be dry-run (--test) before it is itself committed.
|
|
if [[ -n "$(git status --porcelain -uno)" ]]; then
|
|
echo "git tree is not clean; commit or stash before publishing." >&2
|
|
git status --porcelain -uno >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ ! -x "${PLISTBUDDY}" ]]; then
|
|
echo "PlistBuddy not found at ${PLISTBUDDY}" >&2
|
|
exit 1
|
|
fi
|
|
if [[ ! -f "${PLIST}" ]]; then
|
|
echo "Info.plist not found at ${PLIST}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
restore_plist() {
|
|
git checkout -- "${PLIST}" >/dev/null 2>&1 || true
|
|
}
|
|
|
|
if [[ "${TEST_MODE}" -eq 1 ]]; then
|
|
trap restore_plist EXIT
|
|
fi
|
|
|
|
CURRENT_BUILD="$("${PLISTBUDDY}" -c 'Print :CFBundleVersion' "${PLIST}")"
|
|
if [[ ! "${CURRENT_BUILD}" =~ ^[0-9]+$ ]]; then
|
|
echo "CFBundleVersion is not an integer: ${CURRENT_BUILD}" >&2
|
|
exit 1
|
|
fi
|
|
NEW_BUILD=$((CURRENT_BUILD + 1))
|
|
|
|
echo "==> Bumping Info.plist"
|
|
echo " CFBundleShortVersionString -> ${VERSION}"
|
|
echo " CFBundleVersion ${CURRENT_BUILD} -> ${NEW_BUILD}"
|
|
"${PLISTBUDDY}" -c "Set :CFBundleShortVersionString ${VERSION}" "${PLIST}"
|
|
"${PLISTBUDDY}" -c "Set :CFBundleVersion ${NEW_BUILD}" "${PLIST}"
|
|
|
|
if [[ "${TEST_MODE}" -eq 0 ]]; then
|
|
echo "==> Committing version bump on $(git rev-parse --abbrev-ref HEAD)"
|
|
git add "${PLIST}"
|
|
git commit -m "release: v${VERSION}"
|
|
else
|
|
echo "==> --test: skipping git commit of version bump"
|
|
fi
|
|
|
|
# --- Resolve the signing identity for the shipped artifacts -----------------
|
|
# REDLINE_KEYCHAIN (optional): a specific keychain to search/sign against,
|
|
# for hosts where the Developer ID identity does not live in the login
|
|
# keychain that codesign searches by default.
|
|
FIND_IDENTITY_ARGS=(-v -p codesigning)
|
|
CODESIGN_KEYCHAIN_ARGS=()
|
|
if [[ -n "${REDLINE_KEYCHAIN:-}" ]]; then
|
|
FIND_IDENTITY_ARGS+=("${REDLINE_KEYCHAIN}")
|
|
CODESIGN_KEYCHAIN_ARGS=(--keychain "${REDLINE_KEYCHAIN}")
|
|
fi
|
|
|
|
if [[ -n "${REDLINE_SIGN_IDENTITY:-}" ]]; then
|
|
SIGN_IDENTITY="${REDLINE_SIGN_IDENTITY}"
|
|
echo "==> Signing identity: ${SIGN_IDENTITY} (REDLINE_SIGN_IDENTITY)"
|
|
else
|
|
DEVELOPER_ID_LINE="$(security find-identity "${FIND_IDENTITY_ARGS[@]}" 2>/dev/null \
|
|
| grep -o '"Developer ID Application:[^"]*"' | head -n1 || true)"
|
|
DEVELOPER_ID="${DEVELOPER_ID_LINE//\"/}"
|
|
if [[ -n "${DEVELOPER_ID}" ]]; then
|
|
SIGN_IDENTITY="${DEVELOPER_ID}"
|
|
echo "==> Signing identity: ${SIGN_IDENTITY} (auto-detected Developer ID Application)"
|
|
else
|
|
SIGN_IDENTITY="${FALLBACK_SIGN_IDENTITY}"
|
|
echo
|
|
echo "************************************************************************"
|
|
echo "WARNING: signing with Apple Development identity — not Developer ID;"
|
|
echo "Gatekeeper will block first install on other Macs."
|
|
echo "************************************************************************"
|
|
echo
|
|
fi
|
|
fi
|
|
|
|
# Restricted HOMEs (agent sandboxes) hide the login keychain from codesign.
|
|
# Re-run signed steps with the account's real home when the identity is missing.
|
|
signing_home() {
|
|
if security find-identity -v -p codesigning 2>/dev/null | grep -Fq "${SIGN_IDENTITY}"; then
|
|
echo "${HOME}"
|
|
return
|
|
fi
|
|
local rh
|
|
rh="$(dscl . -read "/Users/$(id -un)" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
|
|
if [[ -n "${rh}" && -d "${rh}" ]]; then
|
|
echo "${rh}"
|
|
else
|
|
echo "${HOME}"
|
|
fi
|
|
}
|
|
|
|
run_signed() {
|
|
local sign_home
|
|
sign_home="$(signing_home)"
|
|
if [[ "${sign_home}" != "${HOME}" ]]; then
|
|
echo "==> Using HOME=${sign_home} so codesign can see the login keychain"
|
|
fi
|
|
HOME="${sign_home}" "$@"
|
|
}
|
|
|
|
echo "==> Building signed Redline.app"
|
|
run_signed ./scripts/build-app.sh
|
|
|
|
if [[ ! -d "${APP_BUNDLE}" ]]; then
|
|
echo "Signed app missing at ${APP_BUNDLE}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# build-app.sh always signs with its own hardcoded Apple Development identity
|
|
# first (it has to — that identifier+identity pair is what keeps the Screen
|
|
# Recording grant alive). Re-sign here with the identity actually resolved
|
|
# above, which is what ships. A no-op when the two happen to be the same.
|
|
echo "==> Signing ${APP_BUNDLE} with resolved identity"
|
|
run_signed codesign --force --options runtime --timestamp \
|
|
"${CODESIGN_KEYCHAIN_ARGS[@]}" \
|
|
--sign "${SIGN_IDENTITY}" \
|
|
--identifier "${BUNDLE_ID}" \
|
|
"${APP_BUNDLE}"
|
|
|
|
build_zip() {
|
|
mkdir -p "${ROOT}/.build"
|
|
(
|
|
cd "${ROOT}/.build"
|
|
rm -f "${ZIP_NAME}"
|
|
ditto -c -k --keepParent Redline.app "${ZIP_NAME}"
|
|
)
|
|
if [[ ! -s "${ZIP_PATH}" ]]; then
|
|
echo "Zip was not created at ${ZIP_PATH}" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# Rebuilds the manual-installer DMG from whatever is currently at
|
|
# ${APP_BUNDLE} — never re-invokes build-app.sh, so a prior custom signature
|
|
# or notarization staple on ${APP_BUNDLE} survives into the DMG untouched.
|
|
build_dmg() {
|
|
local staging="${ROOT}/.build/dmg-staging"
|
|
local mount_point="${ROOT}/.build/dmg-mnt"
|
|
|
|
rm -rf "${staging}"
|
|
mkdir -p "${staging}"
|
|
ditto "${APP_BUNDLE}" "${staging}/Redline.app"
|
|
ln -s /Applications "${staging}/Applications"
|
|
|
|
mkdir -p "$(dirname "${DMG_PATH}")"
|
|
rm -f "${DMG_PATH}"
|
|
hdiutil create -volname "Redline" -srcfolder "${staging}" -ov -format UDZO "${DMG_PATH}"
|
|
|
|
if [[ -d "${mount_point}" ]] && /sbin/mount | grep -F -q "${mount_point}"; then
|
|
hdiutil detach "${mount_point}" || hdiutil detach "${mount_point}" -force
|
|
fi
|
|
rm -rf "${mount_point}"
|
|
mkdir -p "${mount_point}"
|
|
|
|
hdiutil attach "${DMG_PATH}" -nobrowse -readonly -mountpoint "${mount_point}"
|
|
|
|
local ok=1
|
|
if [[ ! -d "${mount_point}/Redline.app" ]]; then
|
|
echo "Verification failed: Redline.app missing from mounted DMG" >&2
|
|
ok=0
|
|
fi
|
|
if [[ "${ok}" -eq 1 && "$(readlink "${mount_point}/Applications" 2>/dev/null || true)" != "/Applications" ]]; then
|
|
echo "Verification failed: Applications does not point at /Applications" >&2
|
|
ok=0
|
|
fi
|
|
if [[ "${ok}" -eq 1 ]] && ! codesign --verify --deep --verbose=2 "${mount_point}/Redline.app"; then
|
|
ok=0
|
|
fi
|
|
|
|
hdiutil detach "${mount_point}" || hdiutil detach "${mount_point}" -force || true
|
|
|
|
if [[ "${ok}" -ne 1 ]]; then
|
|
exit 1
|
|
fi
|
|
if [[ ! -s "${DMG_PATH}" ]]; then
|
|
echo "DMG was not created at ${DMG_PATH}" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
echo "==> Zipping Redline.app -> ${ZIP_PATH}"
|
|
build_zip
|
|
|
|
SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')"
|
|
ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")"
|
|
PUBDATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
|
|
|
echo "==> Zip SHA256: ${SHA256}"
|
|
echo " Zip bytes: ${ZIP_BYTES}"
|
|
|
|
# --- Notarization (optional) -------------------------------------------------
|
|
# Either REDLINE_NOTARY_PROFILE (a `notarytool store-credentials` keychain
|
|
# profile) or all three of REDLINE_NOTARY_KEY_ID / REDLINE_NOTARY_ISSUER /
|
|
# REDLINE_NOTARY_KEY_PATH (App Store Connect API key). Absent both: skip.
|
|
NOTARIZED=0
|
|
NOTARY_CONFIGURED=0
|
|
if [[ -n "${REDLINE_NOTARY_PROFILE:-}" ]]; then
|
|
NOTARY_CONFIGURED=1
|
|
elif [[ -n "${REDLINE_NOTARY_KEY_ID:-}" && -n "${REDLINE_NOTARY_ISSUER:-}" && -n "${REDLINE_NOTARY_KEY_PATH:-}" ]]; then
|
|
NOTARY_CONFIGURED=1
|
|
fi
|
|
|
|
if [[ "${NOTARY_CONFIGURED}" -eq 1 ]]; then
|
|
echo "==> Submitting ${ZIP_PATH} to notarytool"
|
|
NOTARY_ARGS=(xcrun notarytool submit "${ZIP_PATH}" --wait --timeout 30m)
|
|
if [[ -n "${REDLINE_NOTARY_PROFILE:-}" ]]; then
|
|
NOTARY_ARGS+=(--keychain-profile "${REDLINE_NOTARY_PROFILE}")
|
|
else
|
|
NOTARY_ARGS+=(
|
|
--key "${REDLINE_NOTARY_KEY_PATH}"
|
|
--key-id "${REDLINE_NOTARY_KEY_ID}"
|
|
--issuer "${REDLINE_NOTARY_ISSUER}"
|
|
)
|
|
fi
|
|
|
|
set +e
|
|
NOTARY_OUTPUT="$("${NOTARY_ARGS[@]}" 2>&1)"
|
|
NOTARY_STATUS=$?
|
|
set -e
|
|
echo "${NOTARY_OUTPUT}"
|
|
if [[ "${NOTARY_STATUS}" -ne 0 ]]; then
|
|
echo "notarytool submit failed (exit ${NOTARY_STATUS})." >&2
|
|
exit 1
|
|
fi
|
|
if ! grep -qi 'status: *Accepted' <<<"${NOTARY_OUTPUT}"; then
|
|
echo "notarytool did not report Accepted." >&2
|
|
exit 1
|
|
fi
|
|
NOTARIZED=1
|
|
|
|
echo "==> Stapling ${APP_BUNDLE}"
|
|
xcrun stapler staple "${APP_BUNDLE}"
|
|
|
|
echo "==> Rebuilding zip from the stapled app"
|
|
build_zip
|
|
SHA256="$(shasum -a 256 "${ZIP_PATH}" | awk '{print $1}')"
|
|
ZIP_BYTES="$(stat -f%z "${ZIP_PATH}")"
|
|
echo " Zip SHA256: ${SHA256}"
|
|
echo " Zip bytes: ${ZIP_BYTES}"
|
|
|
|
echo "==> Rebuilding DMG from the stapled app"
|
|
build_dmg
|
|
|
|
echo "==> Stapling ${DMG_PATH}"
|
|
xcrun stapler staple "${DMG_PATH}"
|
|
else
|
|
echo "==> REDLINE_NOTARY_KEY_ID/ISSUER/KEY_PATH (or REDLINE_NOTARY_PROFILE) not set"
|
|
echo "NOT NOTARIZED"
|
|
echo "==> Building manual installer DMG"
|
|
build_dmg
|
|
fi
|
|
|
|
echo "==> Gatekeeper check: spctl -a -vv -t exec ${APP_BUNDLE}"
|
|
set +e
|
|
SPCTL_OUTPUT="$(spctl -a -vv -t exec "${APP_BUNDLE}" 2>&1)"
|
|
SPCTL_STATUS=$?
|
|
set -e
|
|
echo "${SPCTL_OUTPUT}"
|
|
if [[ "${SPCTL_STATUS}" -ne 0 ]] || ! grep -qi 'accepted' <<<"${SPCTL_OUTPUT}"; then
|
|
if [[ "${NOTARIZED}" == "true" ]]; then
|
|
echo "spctl did not report accepted for ${APP_BUNDLE} although it was notarized." >&2
|
|
exit 1
|
|
fi
|
|
echo "WARNING: Gatekeeper does not accept this build (not notarized). First install on other Macs needs right-click > Open." >&2
|
|
fi
|
|
|
|
TEAM_IDENTIFIER="$(codesign -dv "${APP_BUNDLE}" 2>&1 | awk -F= '/^TeamIdentifier=/{print $2}')"
|
|
|
|
echo "==> Writing ${APPCAST_PATH}"
|
|
python3 - "${VERSION}" "${ZIP_URL}" "${SHA256}" "${NOTES}" "${PUBDATE}" "${APPCAST_PATH}" "${NOTARIZED}" "${TEAM_IDENTIFIER}" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
version, zip_url, sha256, notes, pub_date, out_path, notarized, team_identifier = sys.argv[1:]
|
|
payload = {
|
|
"version": version,
|
|
"zipURL": zip_url,
|
|
"sha256": sha256,
|
|
"notes": notes,
|
|
"pubDate": pub_date,
|
|
"notarized": notarized == "1",
|
|
"teamIdentifier": team_identifier,
|
|
}
|
|
with open(out_path, "w", encoding="utf-8") as fh:
|
|
json.dump(payload, fh, indent=2)
|
|
fh.write("\n")
|
|
PY
|
|
|
|
echo "==> Uploading to ${REMOTE_HOST}:${REMOTE_DIR}/"
|
|
ssh -o BatchMode=yes "${REMOTE_HOST}" "mkdir -p '${REMOTE_DIR}'"
|
|
rsync -e "ssh -o BatchMode=yes" -av "${ZIP_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/${ZIP_NAME}"
|
|
rsync -e "ssh -o BatchMode=yes" -av "${DMG_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/Redline.dmg"
|
|
rsync -e "ssh -o BatchMode=yes" -av "${DMG_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/${DMG_NAME}"
|
|
rsync -e "ssh -o BatchMode=yes" -av "${APPCAST_PATH}" "${REMOTE_HOST}:${REMOTE_DIR}/appcast.json"
|
|
ssh -o BatchMode=yes "${REMOTE_HOST}" \
|
|
"chmod 644 \
|
|
'${REMOTE_DIR}/${ZIP_NAME}' \
|
|
'${REMOTE_DIR}/Redline.dmg' \
|
|
'${REMOTE_DIR}/${DMG_NAME}' \
|
|
'${REMOTE_DIR}/appcast.json'"
|
|
|
|
echo "==> Verifying public appcast ${APPCAST_URL}"
|
|
APPCAST_BODY=""
|
|
ok=0
|
|
attempt=1
|
|
while [[ "${attempt}" -le 15 ]]; do
|
|
if APPCAST_BODY="$(curl -fsS "${APPCAST_URL}")"; then
|
|
echo "${APPCAST_BODY}"
|
|
if grep -F -q "${VERSION}" <<<"${APPCAST_BODY}"; then
|
|
echo "OK: appcast contains ${VERSION}"
|
|
ok=1
|
|
break
|
|
fi
|
|
echo "appcast fetched but does not contain '${VERSION}' (attempt ${attempt})" >&2
|
|
else
|
|
echo "appcast fetch failed (attempt ${attempt})" >&2
|
|
fi
|
|
attempt=$((attempt + 1))
|
|
sleep 2
|
|
done
|
|
if [[ "${ok}" -ne 1 ]]; then
|
|
echo "Public appcast verification failed for ${APPCAST_URL}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "==> Verifying public zip HEAD ${ZIP_URL}"
|
|
ok=0
|
|
attempt=1
|
|
HEAD_OUT=""
|
|
while [[ "${attempt}" -le 15 ]]; do
|
|
HEAD_OUT="$(curl -sS -D - -o /dev/null -I "${ZIP_URL}" || true)"
|
|
echo "${HEAD_OUT}"
|
|
HTTP_CODE="$(awk 'BEGIN{c=""} toupper($1) ~ /^HTTP\//{c=$2} END{print c}' <<<"${HEAD_OUT}" | tr -d '\r')"
|
|
CONTENT_LENGTH="$(awk 'tolower($1)=="content-length:" {gsub("\r","",$2); print $2}' <<<"${HEAD_OUT}" | tail -n 1)"
|
|
if [[ "${HTTP_CODE}" == "200" && "${CONTENT_LENGTH}" == "${ZIP_BYTES}" ]]; then
|
|
echo "OK: zip HTTP ${HTTP_CODE}, Content-Length ${CONTENT_LENGTH} matches local ${ZIP_BYTES}"
|
|
ok=1
|
|
break
|
|
fi
|
|
echo "zip HEAD mismatch (attempt ${attempt}): HTTP '${HTTP_CODE}', Content-Length '${CONTENT_LENGTH}', local '${ZIP_BYTES}'" >&2
|
|
attempt=$((attempt + 1))
|
|
sleep 2
|
|
done
|
|
if [[ "${ok}" -ne 1 ]]; then
|
|
echo "Public zip verification failed for ${ZIP_URL}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo
|
|
echo "Published v${VERSION}"
|
|
echo " identity: ${SIGN_IDENTITY}"
|
|
if [[ "${NOTARIZED}" -eq 1 ]]; then
|
|
echo " notarized: yes"
|
|
else
|
|
echo " notarized: no"
|
|
fi
|
|
echo " spctl: ${SPCTL_OUTPUT}"
|
|
echo " team: ${TEAM_IDENTIFIER}"
|
|
echo " appcast: ${APPCAST_URL}"
|
|
echo " zip: ${ZIP_URL}"
|
|
echo " sha256: ${SHA256}"
|
|
echo " dmg: ${PUBLIC_DIR}/${DMG_NAME}"
|
|
echo " dmg: ${PUBLIC_DIR}/Redline.dmg"
|