在 macOS 27 上运行 Xcode 26

在 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.哪条路能打开

两条,后者才能日常用:

  1. 临时 :终端里直接执行 /Applications/Xcode.app/Contents/MacOS/Xcode。能启动,但 Xcode 是终端会话的子进程,关窗口时的 SIGHUP 会把它一起带走。nohup / disown 只挡信号,不解决会话归属。
  2. 正式 :注册一个 gui/$(id -u) 域下的 LaunchAgent,用 launchctl kickstart -p 拉起同一个二进制。Xcode 的父进程变成 launchd,与终端彻底解耦,窗口关掉也不影响它。KeepAlive=false + RunAtLoad=false 保证不开机自启、退出后不重启。

一句话:把「终端的子进程」换成「launchd 的子进程」,终端就不必一直开着了。

这是绕过系统策略的旁路,不受 Apple 支持;能启动不等于 SDK / 模拟器 / 真机调试都可用。

4.操作步骤

  1. 复制文末整段脚本,保存为 Launch-Xcode26.command。
  2. 终端执行一次 chmod +x Launch-Xcode26.command(只需一次)。
  3. 之后双击即可,Xcode 启动后终端窗口会自动关闭。
  4. 首次关窗可能弹「自动化」授权,允许即可;拒绝也不影响 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》的解决思路。

相关推荐
茶底世界之下1 天前
山间站台不必转暖:用 Awaking 留住冷光与红箱
ios·app·产品
songgeb2 天前
YYImage Bug:一行之差,正常 JPEG 如何在 iOS 被读成条纹
ios·swift
茶底世界之下2 天前
阴天花摊不必变暖:用 Awaking 留住花色与湿街
ios·app·产品
Fatbobman(东坡肘子)3 天前
快速消失的硬盘容量 -- 肘子的 Swift 周报 #160
开发语言·人工智能·redis·ios·swiftui·iphone·swift
茶底世界之下3 天前
斜光下的旧自行车:用 Awaking 留一版复古胶片色
ios·app·产品
2501_916008893 天前
iPhone 设备信息查看,用 KeyMob 看型号、序列号、电池健康和 UDID
android·ios·小程序·https·uni-app·iphone·webview
茶底世界之下4 天前
iPhone Duo 适配实战,Awaking 踩过的 8 个坑
ios·swiftui·swift
跨境数据猎手4 天前
HTTP 响应体编码与压缩:识别、解码
网络协议·http·ios