본문 바로가기
C.W.K.
Stream
Lesson 01 of 06 · published

swift build부터 설치된 진짜 .app까지

~17 min · bundle-signing, bundling, codesign, install, scripts

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"swift build 통과는 약속일 뿐이야. 증명해 주는 건 설치되고 검증된 번들이지."

스크립트가 Xcode 빌드 단계를 대신해

Xcode 프로젝트가 없으면 SwiftPM 바이너리를 앱으로 바꿔주는 게 아무것도 없어. 그래서 가족 Mac 앱마다 그 일을 하는 scripts/build-app.sh가 있고, 전부 같은 이유로 같은 순서를 따라.

  1. 릴리스로 빌드하고, 결과물이 어디 생겼는지 SwiftPM에 물어봐(--show-bin-path).
  2. Spark.app/Contents/{MacOS,Resources}조립해. 바이너리랑 Info.plist를 넣고, 아이콘이 있으면 그것도 넣는 거야.
  3. 버전을 정하는 파일이 하나뿐이도록, 제품 버전 파일의 마케팅 버전을 PlistBuddy로 Info.plist에 찍어.
  4. 확장 속성을 지워(xattr -cr). 번들 안의 Finder 메타데이터는 코드 서명을 깨뜨려.
  5. 다 만든 번들을 서명해. 다 만들기 전엔 절대 안 돼. 서명이 파일 전부를 봉인하거든.
  6. 검증해. codesign --verify --deep --strict를 돌리고, designated requirement가 cdhash가 아니라 인증서 기반인지 확인해.
  7. 대기시키고, 바꿔 끼우고, 다시 검증하고, 실패하면 되돌려. /Applications 옆에 숨긴 대기 이름으로 복사한 다음, 옛 앱을 옆으로 치우고 새 앱을 넣어. 설치된 사본을 검증하고, 하나라도 실패하면 이전 앱을 되돌려놔.
  8. 시스템이 새 빌드를 바로 알아보도록 LaunchServices에 등록해(lsregister -f).

덮어쓰지 않고 대기시키는 이유

커널은 서명된 실행 파일의 코드 서명을 파일마다(inode마다) 캐시해. 서명된 바이너리 파일 위에 그대로 덮어쓰면, 새 바이트에 옛 서명이 캐시된 채로 남을 수 있어. 그러면 서명이 잘못됐다며 실행이 죽는 식으로 드러나. 완성된 새 번들을 따로 쓴 다음 이름을 바꿔 넣으면, 커널은 그걸 새 파일로 받아들여. 게다가 바꿔 끼우는 방식이라, 설치가 실패해도 잘 돌던 이전 앱이 원래 있던 곳에 그대로 남아.

한 번 당하면 잊지 못할 디테일 두 가지

  • 명령줄 헬퍼에도 Info.plist가 필요해. 개인정보 권한을 요청하는 도구(가족의 캘린더 브리지는 EventKit을 읽어)는 Info.plist를 담을 번들이 없어서, macOS가 대화상자 한 번 안 띄우고 요청을 거절해. 해결책은 바이너리 안에 plist를 넣는 거야. -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker Info.plist로 넣고 서명해.
  • 안쪽 코드는 안에서 밖으로 서명해. 헬퍼, 번들에 넣은 실행 파일, 번들한 dylib는 그걸 담는 번들보다 먼저 서명해. 애플 가이드는 서명할 때 codesign --deep에 기대지 말고 안쪽 항목을 하나씩 명시적으로 서명하라고 해. --deep검증할 땐 괜찮아. 서드파티 dylib를 번들하고 불러오는 경로를 고쳐 쓰는 건 그 자체로 긴 이야기라서 /cwk-quests/ashen-reel-quest에서 다뤄.

Code

scripts/build-app.sh: 빌드, 조립, 버전 찍기, 서명, 검증, 대기, 교체, 등록·bash
#!/bin/zsh
set -euo pipefail
ROOT=${0:A:h:h}
IDENTITY=${SPARK_SIGN_IDENTITY:-"Spark Local Signing"}   # a stable, self-signed code-signing identity
BUNDLE_ID=com.example.spark
APP=$ROOT/.build/app/Spark.app
DEST=${SPARK_INSTALL_DIR:-/Applications}/Spark.app
STAGED=${DEST:h}/.Spark.app.install.$$
PREVIOUS=${DEST:h}/.Spark.app.previous.$$
LSREGISTER=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister

verify() {
  codesign --verify --deep --strict "$1"
  local req; req=$(codesign -dr - "$1" 2>&1)
  [[ $req == *"identifier \"$BUNDLE_ID\""* && $req == *'certificate leaf = H"'* ]] || {
    print -u2 "HARD_FAIL unstable designated requirement: $req"; return 1; }
}

cd "$ROOT"
swift build -c release --product Spark
BIN=$(swift build -c release --show-bin-path)/Spark

rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
install -m 755 "$BIN" "$APP/Contents/MacOS/Spark"
install -m 644 Resources/Info.plist "$APP/Contents/Info.plist"
if [[ -f Resources/Spark.icns ]]; then                        # named by CFBundleIconFile in Info.plist
  install -m 644 Resources/Spark.icns "$APP/Contents/Resources/Spark.icns"
fi
VERSION=$(/usr/bin/plutil -extract version raw -o - cwk-product.json)
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$APP/Contents/Info.plist"
xattr -cr "$APP"                                               # finish the bundle completely...
codesign --force --timestamp=none --sign "$IDENTITY" "$APP"    # ...then sign it
verify "$APP"

rm -rf "$STAGED"; ditto "$APP" "$STAGED"; verify "$STAGED"    # never overwrite a signed binary in place
[[ -d $DEST ]] && mv "$DEST" "$PREVIOUS"
if ! mv "$STAGED" "$DEST" || ! verify "$DEST"; then
  rm -rf "$DEST"; [[ -d $PREVIOUS ]] && mv "$PREVIOUS" "$DEST"
  print -u2 "HARD_FAIL install rolled back"; exit 1
fi
rm -rf "$PREVIOUS"
"$LSREGISTER" -f "$DEST" >/dev/null 2>&1 || true
print "SPARK_BUILD_OK $DEST version $VERSION"

External links

Exercise

Spark용 Resources/Info.plist랑 cwk-product.json을 만들고, 스크립트를 scripts/build-app.sh로 저장해. verify의 인증서 검사를 주석 처리하고, SPARK_SIGN_IDENTITY=- SPARK_INSTALL_DIR=$PWD/../Apps로 돌려봐(임시 폴더에 애드혹 서명). 버전이 찍힌 번들이 생기는지 확인해. 한 번 더 돌려서 대기 폴더나 이전 폴더가 남지 않는지도 확인해. 이어서 설치된 Info.plist를 손으로 고친 뒤 codesign --verify --deep --strict를 돌려, 봉인된 번들이 변조를 어떻게 보고하는지 봐.
Hint
애드혹 서명(--sign -)은 파이프라인을 익히기엔 괜찮지만, 그렇게 만든 requirement는 cdhash야. verify()의 인증서 검사가 거부하는 게 딱 그거고. 다음 두 레슨에서 안정적인 신원으로 바꿀 거야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.