在 macOS 27 上打开 Xcode 26
方案与初始脚本来自 Aexsi《在 MacOS27 上运行 Xcode26》;本文在其基础上做了加固、验证与整理,文末为加固后的完整脚本。
1. 怎么回事
最近想着安装个Xcode 27.2研究研究iPhone Due如何适配,结果我下载完毕之后不让我使用说需要升级系统,结果我就把Mac的版本从26.6升级到了27.2结果就出现了旧版的Xcode26.6不让使用了。后来看了Aexsi《在 MacOS27 上运行 Xcode26》这个作者写的才解决这个问题,所以这里也非常感谢原作者,然后这篇是在原来的基础上做了完善和加固的版本。
2.被拦的不是二进制,是启动路径:
rust
Finder / Dock / open / xed -> Launch Services -> 拦下 Xcode 26
直接执行 Contents/MacOS/Xcode -> 不经过 Launch Services -> 能启动
Xcode 26 的 Info.plist 里 LSMinimumSystemVersion 是 26.2,只是最低系统版本(下限),包内没有任何「禁止在 27 运行」的声明。所以拦截来自系统侧的启动策略,不是签名损坏,也不是 App 包本身的问题。
3.哪条路能打开
两条,后者才能日常用:
- 临时 :终端里直接执行
/Applications/Xcode.app/Contents/MacOS/Xcode。能启动,但 Xcode 是终端会话的子进程,关窗口时的 SIGHUP 会把它一起带走。nohup/disown只挡信号,不解决会话归属。 - 正式 :注册一个
gui/$(id -u)域下的 LaunchAgent,用launchctl kickstart -p拉起同一个二进制。Xcode 的父进程变成 launchd,与终端彻底解耦,窗口关掉也不影响它。KeepAlive=false+RunAtLoad=false保证不开机自启、退出后不重启。
一句话:把「终端的子进程」换成「launchd 的子进程」,终端就不必一直开着了。
这是绕过系统策略的旁路,不受 Apple 支持;能启动不等于 SDK / 模拟器 / 真机调试都可用。
4.操作步骤
- 复制文末整段脚本,保存为
Launch-Xcode26.command。 - 终端执行一次
chmod +x Launch-Xcode26.command(只需一次)。 - 之后双击即可,Xcode 启动后终端窗口会自动关闭。
- 首次关窗可能弹「自动化」授权,允许即可;拒绝也不影响 Xcode,手动关窗口就行。
常用参数:--status 只读体检,--no-close 保留窗口调试,--uninstall 卸载(Xcode 还在运行时会拒绝执行)。启动失败看 ~/Library/Logs/Xcode26-on-macOS27.log。
5.完整脚本
zsh
#!/bin/zsh
#
# Launch Xcode 26 on macOS 27 by handing the binary to launchd directly,
# bypassing the Launch Services restriction on Finder / Dock / open / xed.
#
# Origin: Aexsi, "在 MacOS27 上运行 Xcode26", https://www.jianshu.com/p/e880d7096abc
# This is a hardened revision. Differences from the original:
# 1. No iTerm2 self-relaunch. The original re-opens itself in iTerm2 when
# TERM_PROGRAM != iTerm.app; that can loop if the variable differs, and
# it left the iTerm2 window open. Both terminals now close uniformly.
# 2. Plist drift detection: an already-registered job is re-bootstrapped
# only when its keys no longer match what this script expects.
# 3. Log rotation with a size cap (Xcode emits very chatty layout logs).
# 4. Readiness is polled against both launchctl and the real process,
# instead of a single `sleep 2; kill -0`.
# 5. --status / --uninstall / --no-close / --help. --uninstall refuses to
# run while Xcode is alive, because `launchctl bootout` kills the job.
# 6. All external commands use absolute paths; paths and version are
# overridable through environment variables.
#
# Usage:
# Launch-Xcode26.command start Xcode, close the terminal window
# Launch-Xcode26.command --no-close start Xcode, keep the window (debug)
# Launch-Xcode26.command --status inspect the launchd job (read only)
# Launch-Xcode26.command --uninstall unregister the job and remove its plist
# Launch-Xcode26.command --help show this help
#
# Environment overrides:
# XCODE_APP, EXPECTED_XCODE_MAJOR, LOG_MAX_BYTES
#
# Note: closing an Apple_Terminal / iTerm2 window uses AppleScript. The first
# run may require granting Automation permission in System Settings > Privacy
# & Security > Automation. If denied, the window simply stays open; Xcode is
# unaffected because launchd, not the shell, owns the process.
set -eu
readonly XCODE_APP="${XCODE_APP:-/Applications/Xcode.app}"
readonly XCODE_BIN="${XCODE_APP}/Contents/MacOS/Xcode"
readonly VERSION_PLIST="${XCODE_APP}/Contents/Info.plist"
readonly EXPECTED_MAJOR="${EXPECTED_XCODE_MAJOR:-26}"
readonly LOG_DIR="${HOME}/Library/Logs"
readonly LOG_FILE="${LOG_DIR}/Xcode26-on-macOS27.log"
readonly LOG_MAX_BYTES="${LOG_MAX_BYTES:-1048576}"
readonly LAUNCH_DIR="${HOME}/Library/Application Support/Xcode26Launcher"
readonly PLIST_FILE="${LAUNCH_DIR}/launchd.plist"
readonly LABEL="local.xcode26.manual-launch"
readonly DOMAIN="gui/$(/usr/bin/id -u)"
readonly SERVICE="${DOMAIN}/${LABEL}"
CLOSE_TERMINAL=1
TMP_PLIST=""
cleanup() {
if [[ -n "${TMP_PLIST}" && -f "${TMP_PLIST}" ]]; then
/bin/rm -f "${TMP_PLIST}"
fi
}
trap cleanup EXIT
usage() {
/bin/cat <<'EOF'
Usage: Launch-Xcode26.command [option]
(no option) Start Xcode via launchd, then close this terminal window.
--no-close Start Xcode but keep the terminal window open (debugging).
--status Show the launchd job state, plist and log size. Read only.
--uninstall Unregister the job, remove its plist. Refuses to run while
Xcode is still running. The log file is kept.
--help Show this help.
Environment overrides:
XCODE_APP=/path/to/Xcode.app EXPECTED_XCODE_MAJOR=26 LOG_MAX_BYTES=1048576
EOF
}
# ---------- launchd / process helpers ----------
job_exists() {
/bin/launchctl print "${SERVICE}" >/dev/null 2>&1
}
# Read a top-level `key = value` line from `launchctl print`. Nested blocks
# (coalitions etc.) also contain `state = ...`, so only single-tab lines count.
job_field() {
/bin/launchctl print "${SERVICE}" 2>/dev/null | /usr/bin/awk -v key="$1" '
substr($0,1,1) == "\t" && substr($0,2,1) != "\t" {
t = $0
sub(/^\t/, "", t)
n = index(t, " = ")
if (n > 0 && substr(t, 1, n-1) == key) {
print substr(t, n+3)
exit
}
}'
}
xcode_pid() {
/usr/bin/pgrep -fx "${XCODE_BIN}" | /usr/bin/head -n 1 || true
}
# The original script only checked `launchctl print` for the job's existence.
# If Xcode was moved or the plist was edited by hand, the stale job would be
# reused forever. Compare the keys we care about instead of a byte-level diff.
plist_matches() {
[[ -f "${PLIST_FILE}" ]] || return 1
local got
got=$(/usr/bin/plutil -extract Label raw "${PLIST_FILE}" 2>/dev/null) || return 1
[[ "${got}" == "${LABEL}" ]] || return 1
got=$(/usr/bin/plutil -extract Program raw "${PLIST_FILE}" 2>/dev/null) || return 1
[[ "${got}" == "${XCODE_BIN}" ]] || return 1
got=$(/usr/bin/plutil -extract ProcessType raw "${PLIST_FILE}" 2>/dev/null) || return 1
[[ "${got}" == "Interactive" ]] || return 1
got=$(/usr/bin/plutil -extract KeepAlive raw "${PLIST_FILE}" 2>/dev/null) || return 1
[[ "${got}" == "false" ]] || return 1
got=$(/usr/bin/plutil -extract RunAtLoad raw "${PLIST_FILE}" 2>/dev/null) || return 1
[[ "${got}" == "false" ]] || return 1
got=$(/usr/bin/plutil -extract StandardOutPath raw "${PLIST_FILE}" 2>/dev/null) || return 1
[[ "${got}" == "${LOG_FILE}" ]] || return 1
got=$(/usr/bin/plutil -extract StandardErrorPath raw "${PLIST_FILE}" 2>/dev/null) || return 1
[[ "${got}" == "${LOG_FILE}" ]] || return 1
return 0
}
write_plist() {
TMP_PLIST="${PLIST_FILE}.tmp.$$"
/bin/rm -f "${TMP_PLIST}"
/usr/bin/plutil -create xml1 "${TMP_PLIST}"
/usr/bin/plutil -insert Label -string "${LABEL}" "${TMP_PLIST}"
/usr/bin/plutil -insert Program -string "${XCODE_BIN}" "${TMP_PLIST}"
/usr/bin/plutil -insert KeepAlive -bool false "${TMP_PLIST}"
/usr/bin/plutil -insert RunAtLoad -bool false "${TMP_PLIST}"
/usr/bin/plutil -insert ProcessType -string Interactive "${TMP_PLIST}"
/usr/bin/plutil -insert StandardOutPath -string "${LOG_FILE}" "${TMP_PLIST}"
/usr/bin/plutil -insert StandardErrorPath -string "${LOG_FILE}" "${TMP_PLIST}"
/bin/mv -f "${TMP_PLIST}" "${PLIST_FILE}"
TMP_PLIST=""
}
ensure_job() {
local need_bootstrap=0
if ! job_exists; then
need_bootstrap=1
elif ! plist_matches; then
print "Registered job is stale; re-registering it."
/bin/launchctl bootout "${SERVICE}" >/dev/null 2>&1 || true
need_bootstrap=1
fi
if (( need_bootstrap )); then
write_plist
/bin/launchctl bootstrap "${DOMAIN}" "${PLIST_FILE}"
fi
}
# Xcode writes AppKit layout chatter to stderr (e.g.
# tableView(_:adjustCellFrame:forRow:)). Rotate before launchd opens the file,
# and only when Xcode is not running, so we never truncate a live handle.
rotate_log() {
[[ -f "${LOG_FILE}" ]] || return 0
local size
size=$(/usr/bin/stat -f%z "${LOG_FILE}" 2>/dev/null) || return 0
if (( size > LOG_MAX_BYTES )); then
/bin/mv -f "${LOG_FILE}" "${LOG_FILE}.1"
: > "${LOG_FILE}"
print "Rotated oversized log to ${LOG_FILE}.1"
fi
}
# ---------- terminal handling ----------
# `&!` is zsh's background + disown. Without it, the SIGHUP caused by closing
# the window would kill the very subshell that is trying to close the window.
close_launcher_terminal() {
(( CLOSE_TERMINAL )) || return 0
local launcher_tty
launcher_tty=$(/usr/bin/tty) || return 0
[[ "${launcher_tty}" == /dev/tty* ]] || return 0
case "${TERM_PROGRAM:-}" in
Apple_Terminal)
(
/bin/sleep 1
/usr/bin/osascript \
-e 'on run argv' \
-e 'set targetTTY to item 1 of argv' \
-e 'tell application "Terminal"' \
-e 'repeat with w in windows' \
-e 'if (count of tabs of w) is 1 then' \
-e 'set t to tab 1 of w' \
-e 'if tty of t is targetTTY then' \
-e 'close w' \
-e 'exit repeat' \
-e 'end if' \
-e 'end if' \
-e 'end repeat' \
-e 'end tell' \
-e 'end run' "${launcher_tty}"
) </dev/null >/dev/null 2>&1 &!
;;
iTerm.app)
# The original had no iTerm2 branch, so its window never closed.
# Closing the current session leaves other tabs/windows alone.
(
/bin/sleep 1
/usr/bin/osascript \
-e 'tell application id "com.googlecode.iterm2"' \
-e 'close current session of current window' \
-e 'end tell'
) </dev/null >/dev/null 2>&1 &!
;;
esac
}
# ---------- subcommands ----------
show_status() {
print "Label : ${LABEL}"
print "Service : ${SERVICE}"
print "Plist : ${PLIST_FILE}"
print "Log : ${LOG_FILE}"
if job_exists; then
print "Job : registered, state=$(job_field state), runs=$(job_field runs), pid=$(job_field pid)"
if plist_matches; then
print "Config : up to date"
else
print "Config : STALE - will be rebuilt on the next launch"
fi
else
print "Job : not registered"
fi
if [[ -f "${LOG_FILE}" ]]; then
print "Log size: $(/usr/bin/stat -f%z "${LOG_FILE}" 2>/dev/null || print '?') bytes (cap ${LOG_MAX_BYTES})"
fi
local pid
pid=$(xcode_pid)
[[ -n "${pid}" ]] && print "Xcode : running (PID ${pid})" || print "Xcode : not running"
}
uninstall() {
# `launchctl bootout` terminates processes still owned by the job. Never
# do that silently to a user who may have unsaved work in Xcode.
local pid
pid=$(xcode_pid)
if [[ -n "${pid}" ]]; then
print -u2 "Xcode is still running (PID ${pid}). Quit Xcode first, then run --uninstall again."
exit 1
fi
if job_exists; then
/bin/launchctl bootout "${SERVICE}"
print "Unregistered ${SERVICE}."
else
print "Service was not registered."
fi
if [[ -f "${PLIST_FILE}" ]]; then
/bin/rm "${PLIST_FILE}"
print "Removed ${PLIST_FILE}."
fi
print "Log file left in place at ${LOG_FILE} (remove it manually if no longer needed)."
}
# ---------- main ----------
main() {
case "${1:-}" in
--help|-h) usage; exit 0 ;;
--status) show_status; exit 0 ;;
--uninstall) uninstall; exit 0 ;;
--no-close) CLOSE_TERMINAL=0 ;;
"") ;;
*) print -u2 "Unknown option: $1"; usage >&2; exit 2 ;;
esac
if [[ ! -x "${XCODE_BIN}" ]]; then
print -u2 "Xcode ${EXPECTED_MAJOR} was not found at ${XCODE_APP}"
print -u2 "Move it to ${XCODE_APP}, or set XCODE_APP to its real path."
exit 1
fi
local version
version=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "${VERSION_PLIST}" 2>/dev/null || true)
# NOTE: keep the wildcard OUTSIDE the quotes. In zsh, a quoted `*` inside
# [[ ... == ... ]] is a literal character, so "${E}.*" would never match
# a concrete version like 26.6. `case` patterns make this explicit.
case "${version}" in
"${EXPECTED_MAJOR}"|"${EXPECTED_MAJOR}".*) ;;
*)
print -u2 "Expected Xcode ${EXPECTED_MAJOR}.x at ${XCODE_APP}, found ${version:-unknown}."
print -u2 "Refusing to run the bypass against a different major version."
exit 1
;;
esac
local pid
pid=$(xcode_pid)
if [[ -n "${pid}" ]]; then
print "Xcode ${version} is already running (PID ${pid}). Nothing to do."
close_launcher_terminal
exit 0
fi
umask 077
/bin/mkdir -p "${LOG_DIR}" "${LAUNCH_DIR}"
rotate_log
ensure_job
print "Starting Xcode ${version}..."
local started_pid
started_pid=$(/bin/launchctl kickstart -p "${SERVICE}") || {
print -u2 "launchctl kickstart failed. Check ${LOG_FILE}"
exit 1
}
# A single `sleep 2; kill -0` can race with a slow Xcode startup. Poll
# both the launchd job and the real process for up to 10 seconds.
local ready=0 i
for i in {1..10}; do
if [[ "$(job_field state)" == "running" && -n "$(xcode_pid)" ]] \
&& kill -0 "${started_pid}" 2>/dev/null; then
ready=1
break
fi
/bin/sleep 1
done
if (( ! ready )); then
print -u2 "Xcode exited shortly after launch. Check ${LOG_FILE}"
exit 1
fi
print "Xcode is running (PID ${started_pid})."
print "Log: ${LOG_FILE}"
close_launcher_terminal
}
main "$@"
最后也再次鸣谢Aexsi《在 MacOS27 上运行 Xcode26》的解决思路。