Skip to content

Instantly share code, notes, and snippets.

@hongkongkiwi
Created June 29, 2026 13:29
Show Gist options
  • Select an option

  • Save hongkongkiwi/958054c98672b85ddcab6cd136213a48 to your computer and use it in GitHub Desktop.

Select an option

Save hongkongkiwi/958054c98672b85ddcab6cd136213a48 to your computer and use it in GitHub Desktop.
Ad-hoc re-codesign macOS .app bundles with optional xattr clearing
#!/bin/bash
set -euo pipefail
usage() {
cat <<EOF
Usage: $(basename "$0") [--clear-attrs] <path-to-app>
Re-codesign an .app bundle deeply with an ad-hoc signature.
Options:
--clear-attrs, -q Recursively clear ALL extended attributes on the bundle
(com.apple.quarantine, com.apple.provenance, com.apple.macl,
etc.) before re-signing. Useful for downloaded apps that
refuse to launch. Clears recursively via xattr -cr.
Argument forms accepted:
/Applications/Stremio.app full path
/Applications/Stremio full path without extension
Stremio bare name -> /Applications/Stremio.app
Stremio.app bare name with extension -> /Applications/Stremio.app
Examples:
$(basename "$0") Stremio
$(basename "$0") /Applications/My App.app
$(basename "$0") -q Stremio
EOF
}
# Strip a trailing ".app" from the argument if present.
strip_app_suffix() {
local arg="$1"
if [[ "$arg" == *.app ]]; then
printf '%s' "${arg%.app}"
else
printf '%s' "$arg"
fi
}
# Resolve argument: accept full path, full path without extension,
# bare name, or bare name with extension. Resolves to /Applications/<name>.app
# only when the argument isn't an existing directory path.
#
# Returns 0 and prints resolved path on success; returns 1 and prints error
# to stderr on failure. Caller MUST check exit status — under set -e in
# bash 3.2, the failing command substitution in an assignment does not abort.
resolve_target() {
local arg="$1"
# Reject empty input immediately — produces a clean error instead of
# the nonsensical "checked /Applications/.app".
if [[ -z "$arg" ]]; then
echo "ERROR: Empty argument. Pass an .app bundle path or name." >&2
return 1
fi
# Full path to an existing directory (e.g. /Applications/Stremio.app,
# /Volumes/App/Stremio.app, ~/Apps/My App.app).
if [[ -d "$arg" ]]; then
printf '%s' "$arg"
return
fi
# Full path without extension (e.g. /Applications/Stremio).
if [[ -d "${arg}.app" ]]; then
printf '%s' "${arg}.app"
return
fi
# Bare name, optionally with .app suffix (e.g. Stremio, Stremio.app).
local name
name="$(strip_app_suffix "$arg")"
if [[ -d "/Applications/${name}.app" ]]; then
printf '%s' "/Applications/${name}.app"
return
fi
# Resolution failed — tailor the error to the form the user passed.
if [[ "$arg" == /* ]]; then
# Absolute path that doesn't exist — don't invent a /Applications fallback.
echo "ERROR: '$arg' is not an existing .app bundle." >&2
else
echo "ERROR: No .app bundle found for '$arg' (checked /Applications/${name}.app)." >&2
fi
return 1
}
clear_attrs=0
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-q|--clear-attrs) clear_attrs=1; shift ;;
--) shift; break ;;
-*) echo "Unknown option: $1" >&2; usage; exit 2 ;;
*) break ;;
esac
done
if [[ $# -ne 1 ]]; then
usage
exit 2
fi
# Preflight: codesign is the core tool. Fail fast with a clear message if missing.
if ! command -v codesign >/dev/null 2>&1; then
echo "ERROR: 'codesign' not found on PATH. Install Xcode Command Line Tools: xcode-select --install" >&2
exit 1
fi
# Resolve target. resolve_target prints errors to stderr and returns 1 on
# failure — check explicitly to avoid the doubled-error bug where bash 3.2's
# set -e leaves $target empty and the downstream .app suffix check fires.
if ! target="$(resolve_target "$1")"; then
exit 1
fi
# Enforce that the resolved target is actually an .app bundle. The script is
# named codesign-app; signing arbitrary directories or non-.app bundles is out
# of scope and almost always a user mistake.
if [[ "$target" != *.app ]]; then
echo "ERROR: '$target' is not an .app bundle (must end in .app)." >&2
echo " To sign other bundle types, call codesign directly." >&2
exit 1
fi
# Optional: clear extended attributes recursively (quarantine, provenance, macl,
# etc.). Common requirement for downloaded apps that Gatekeeper blocks.
# Non-fatal: if xattr fails (SIP, permissions, missing binary), warn with the
# real error message and continue to the re-sign.
if [[ $clear_attrs -eq 1 ]]; then
if ! command -v xattr >/dev/null 2>&1; then
echo "WARN: 'xattr' not found on PATH; skipping attribute clear." >&2
else
echo "Clearing extended attributes on: $target"
local_xattr_err=""
if ! local_xattr_err="$(xattr -cr "$target" 2>&1)"; then
echo "WARN: xattr -cr failed. Continuing with re-sign." >&2
if [[ -n "$local_xattr_err" ]]; then
printf '%s\n' "$local_xattr_err" | sed 's/^/ xattr: /' >&2
fi
fi
fi
fi
echo "Re-signing: $target"
# NOTE: --deep is deprecated for Developer ID signing but remains the correct
# tool for ad-hoc (-) re-signing of bundles with nested code (helpers, frameworks).
sudo codesign --force --deep --sign - "$target"
echo
echo "Verifying signature:"
# Informational only — codesign -dv may report issues on nested code even when
# the top-level sign succeeded. Do not let it abort the script.
codesign -dv --verbose=4 "$target" 2>&1 | sed 's/^/ /' || true
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment