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

앱은 폴더야

~14 min · platform-map, bundle, info-plist, identity

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Finder는 아이콘 하나를 보여줘. 시스템은 안에 계약서가 든 디렉터리를 봐."

하나 열어보자

.app은 전부 디렉터리야. Finder가 아이콘 하나로 그려줄 뿐이지. Mac 앱이면 Contents/ 밑에 이런 게 들어 있어. 앱을 설명하는 Info.plist, 실행 파일이 사는 MacOS/, 아이콘이랑 에셋이 있는 Resources/, 그리고 전부를 봉인하는 _CodeSignature/. 헬퍼나 확장은 그 안에 더 깊이 들어가(Frameworks/, PlugIns/, Library/LaunchAgents/). iOS 앱 번들은 구조가 납작해. 실행 파일, Info.plist, Assets.car가 맨 위에 있어. 공유 확장은 PlugIns/, 워치 앱은 Watch/ 밑에 들어가고, 개발용으로 설치한 빌드엔 embedded.mobileprovision도 붙어. TestFlight용으로 내보낸 아카이브에도 배포 프로필이 들어 있어.

번들은 ls로 그냥 열려. 네 빌드 스크립트 짜기 전에 시스템 앱 하나는 꼭 열어봐. 네가 배포하는 게 규칙이 정해진 폴더라는 걸 한 번 눈으로 보면, 이 퀘스트 나머지가 훨씬 잘 들어와.

Info.plist가 계약서야

  • CFBundleIdentifier: 시스템이 앱을 부르는 이름이야. 역도메인 형식으로 써. TCC 권한, 키체인 항목, LaunchServices 등록, App Group, App Store Connect 레코드가 전부 이 값에 묶여.
  • CFBundleExecutable: MacOS/ 안에서 실제로 실행되는 파일이야. 번들 표시 이름이랑 달라도 되고, 그래서 엉뚱한 이름으로 pkill 하는 스크립트는 아무것도 못 잡아.
  • CFBundleShortVersionString: 사람이 보는 마케팅 버전(1.2.0).
  • CFBundleVersion: 빌드 번호. TestFlight는 같은 번호를 두 번 안 받아줘.
  • LSMinimumSystemVersion(macOS) / MinimumOSVersion(iOS): 결과물에 적힌 배포 대상.
  • LSUIElement: Dock 아이콘 없는 메뉴 막대 유틸리티면 true.

서명이 폴더를 봉인해

_CodeSignature/CodeResources엔 번들 안 파일마다 해시가 적혀 있어. 서명한 다음에 Info.plist를 고치거나, 이미지를 하나 더 넣거나, Finder가 확장 속성을 붙이면 codesign --verify가 봉인된 리소스가 없거나 틀렸다고 해. 그래서 이 가족의 빌드 스크립트는 전부 번들부터 끝까지 완성해(버전 찍고, 아이콘 복사하고, 속성 지우고). 서명은 그 뒤에 하고, 검증은 서명이 끝난 다음에 해.

맨 실행 파일은 번들이 아니야

swift build.build/ 밑에 그냥 바이너리 하나를 만들어. 돌아가긴 하고 Bundle.main도 대답은 해. 근데 그 대답이 바이너리가 든 디렉터리야. 코드 주석 하나가 SwiftPM 실행 파일에선 Bundle.main.resourceURL이 nil이라고 주장해서 가족이 직접 재봤어. nil이 아니었고, .build/arm64-apple-macosx/debug를 가리켰어. 그러니까 resourceURL이 nil이 아니라고 해서 패키징됐다는 증거는 안 돼. 네가 실제로 필요한 파일이 있는지를 확인해.

Code

네 앱 만들기 전에 진짜 앱 속부터 봐·bash
APP=/System/Applications/Calculator.app
ls "$APP/Contents"                                   # Info.plist  MacOS  Resources  _CodeSignature ...
plutil -p "$APP/Contents/Info.plist" | head -20
/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Contents/Info.plist"
/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist"
codesign --verify --deep --strict --verbose=2 "$APP"  # valid on disk, satisfies its designated requirement
swift run일 때와 패키징된 .app일 때 Bundle.main이 하는 말·swift
// Sources/Spark/Spark.swift — run with `swift run`, then again from inside a built .app
import Foundation

@main
struct Spark {
    static func main() {
        print("bundlePath   :", Bundle.main.bundlePath)
        print("resourceURL  :", Bundle.main.resourceURL?.path ?? "nil")
        print("identifier   :", Bundle.main.bundleIdentifier ?? "nil")

        // swift run    -> bundlePath and resourceURL are .build/arm64-apple-macosx/debug, identifier nil
        // packaged app -> Spark.app and Spark.app/Contents/Resources, identifier com.example.spark

        // So never branch on resourceURL != nil to mean "packaged". Look for the content:
        let packaged = Bundle.main.url(forResource: "AppIcon", withExtension: "icns") != nil
        print("packaged     :", packaged)
    }
}

External links

Exercise

swift package init --type executable --name Spark로 빈 Spark 패키지를 만들고, Sources/Spark/Spark.swift 내용을 템플릿엔 없는 import Foundation까지 포함해 위 Bundle.main 프로그램으로 통째로 바꾼 다음 swift run으로 돌려. 값 세 개를 적어둬. 이어서 네 Mac에 깔린 메뉴 막대 앱 하나를 열어서 CFBundleIdentifier, CFBundleExecutable, CFBundleVersion, LSUIElement 설정 여부를 찾아. 출시한 뒤엔 절대 바꾸면 안 되는 값이 뭔지, 왜 그런지 적어봐.
Hint
plutil -p <App>/Contents/Info.plist면 키가 다 나와. 바꾸는 순간 권한이랑 키체인 접근이 조용히 초기화되는 게 식별자고, 오직 올라가기만 해야 하는 게 빌드 번호야.

Progress

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

댓글 0

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

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