본문 바로가기
C.W.K.
Lesson 05 of 05 · published

주소는 레지스트리에서, 빌드는 TestFlight로

~14 min · 설정, 테스트, testflight, 배포

Level 0첫 단어
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

손으로 친 주소는 없어

폰이 뭘 보내려면 바깥세상에서 딱 하나를 알아야 해. 엔진이 어디 사는지. 한동안 그건 소스 안의 리터럴이었어. 서비스 호스트의 HTTPS 문을 가리키는 호스트와 포트. 모양은 맞았어. 근데 예전에 가족 앱들이 하나씩 tailnet IP를 그대로 박거나, App Transport Security가 거절하는 평문 http로 흘러간 것도 바로 그런 리터럴 때문이었어.

이제 기본값은 조회야(첫 번째 코드 블록). MobileSettings.defaultEndpoint는 킷의 FamilyEndpoints한테 cwkSpark의 HTTPS 쌍둥이를 물어. 그 표는 가족의 포트 원장과 네트워크 원장으로 만들어져. 폰은 집 네트워크에서만 통하는 이름엔 절대 전화를 걸지 않아. 해외에 나간 폰은 집 네트워크에 있을 일이 없으니까. 조회에는 작은 규칙이 셋 따라붙어.

  • 서비스 호스트를 옛 표기로 적어 둔 저장 주소는 한 번 읽을 때 Spark의 HTTPS 쌍둥이로 바꾸고 그렇게 다시 저장해. 옛 표기란 IP 그대로 적은 tailnet 주소, 평문 http, 엔진 자체 포트 같은 거야. 다른 호스트는 친 그대로 둬.
  • 새로 친 주소도 같은 변환을 먼저 거쳐. 그 뒤에도 다른 tailnet 호스트로 가는 평문 http면 아이폰에서 거절하고, https 주소를 쓰라고 말해.
  • 주소를 바꾸면 오프라인 사본은 물러나고, 워처는 전부 멈추고, 클라이언트는 새로 만들어져. 아웃박스는 주소로 나뉘지 않으니까 기다리던 캡처는 그냥 새 엔진으로 가.

엔진이 있는 Mac을 따라가

가족은 피파 앱의 스위치 하나로 서비스를 다른 Mac으로 옮길 수 있어. 그 자리를 Pippa's Ark라고 불러. 원장만 아는 폰이라면 자기 엔진 말고는 전부 옛 자리로 계속 전화를 걸겠지. 그래서 SparkMobile은 cwkPippa가 엔진 호스트에서 내주는 호스트 프로필을 읽고 그대로 받아들여. Ark를 가리키면 가족 전체를 거기서 부르고, 다시 돌려놓으면 원장으로 돌아가. 이 일은 분리된 작업에서 유틸리티 우선순위로 해. 앱 실행 경로엔 절대 안 끼고, 앱이 테스트를 품고 있을 때도 안 하고, 새 주소를 저장할 때마다 한 번 더 해.

스위치 자체는 키체인에 있어. 앱은 키체인 접근 그룹을 둘 적어 둬. 자기 그룹이 먼저라서 이미 써 둔 항목은 원래 자리에 남고, 두 번째가 킷이 Ark 설정을 두는 공용 com.cwk.family 그룹이야. 피파 앱에서 스위치를 넘기면 Spark도 가족이랑 같이 움직여. 왜 새 App Group이 아니라 키체인 그룹이냐는 커밋에 적혀 있어. 새 App Group은 등록하려면 Xcode 계정 세션이 필요한데, 키체인 그룹은 필요 없거든.

원칙: 주소는 주인이 있는 사실이야. 실행할 때마다 주인한테 묻고, 리터럴은 알아보고 번역할 옛 표기로만 남겨 둬. 전화를 거는 곳으로는 절대 쓰지 마.

숫자로 센 테스트

스위트스냅샷 시점 개수지키는 것
엔진 pytest128코퍼스, 턴, 통역, 링크, 킷 드리프트 검사
웹 vitest67, 파일 13개브라우저 클라이언트의 파싱과 상태
iOS 단위 테스트 (XCTest)70, 파일 7개전송 순서, 업로드, 기다리는 캡처, 오프라인 사본, 공유, 주소, 순간 화면 하나의 폭
iOS UI 테스트2엔진이 끝내 못 듣는 캡처, 그리고 조인 회선 벤치

폰 스위트엔 자기 실행기가 있어(세 번째 코드 블록). 전용 시뮬레이터 아이디가 꼭 있어야 해. Mac 하나에서 여러 앱 세션이 시뮬레이터 목록을 같이 쓰니까, 마지막에 열려 있던 걸로 돌리면 다른 앱의 화면을 뺏거든. 먼저 Xcode 프로젝트를 다시 만들어. 다시 만들지 않고 추가한 테스트 파일은 스위트가 소리 없이 안 돌리는 파일이니까. 그리고 걸러 낸 파이프가 아니라 로그에 찍힌 TEST SUCCEEDED 배너로 판정해. 시뮬레이터엔 카메라가 없어서 UI 테스트는 심어 둔 카메라를 써. 실행 인자 하나가 진짜 셔터와 같은 준비 경로로 4032 x 3024짜리 가짜 스틸을 앱에 건네. 예상된 건너뛰기는 조인 회선 벤치 하나뿐이고, 속도 조절 프록시 뒤의 엔진을 지정했을 때만 돌아. 진짜 카메라와 라이브 스캐너는 기기에서 확인해.

TestFlight, 그리고 따로 떨어진 네 가지 사실

SparkMobile은 TestFlight로만 기기에 닿아. 파이프라인 스크립트는 킷에서 들여온 거야. 가족 iOS 앱 전부가 쓰는 프로그램 하나라서, 한 번 배운 가드는 어디서나 돌아. 순서는 이래. 먼저 설정이 덜 된 실행을 거절하고, App Store Connect 키를 확인하고, 전용 시뮬레이터를 요구해. 입력이 커밋돼 있는지, 킷이 깨끗한지, 아이콘이 제대로인지 보고 나서 스위트와 시각 게이트를 돌려. 그다음 빌드 번호를 올리고 아카이브한 뒤, 아카이브 도중 아무것도 안 움직였는지 확인해. 마지막으로 내보내거나 올리고, 번호 올린 걸 커밋해서 푸시해. 시각 게이트는 첫 화면과 모든 탭을 킷의 375 pt 아이폰에서 찍어. 기본 글자 크기로 한 번, 가장 큰 Dynamic Type으로 한 번. 그리고 잘린 라벨이나 화면 밖으로 나간 글자가 있으면 거절해. 레이아웃 버그 넷이 초록불 스위트를 그대로 통과해 나간 적이 있거든. 스위트는 대개 코드가 도는지를 보지, 화면마다 어떻게 생겼는지는 안 봐. 넷 중 마지막, 폰보다 넓게 그려지던 순간 화면은 호스팅 레이아웃 테스트를 하나 남겼어. 순간 화면 하나를 아이폰 폭 네 가지로 그려 보고, 뭐든 가장자리 밖에 그려지면 실패해. 화면 하나를 묶어 둔다고 모든 화면, 모든 글자 크기가 묶이는 건 아니라서, 게이트는 여전히 전부 찍어.

Spark만의 설정은 몇 줄이야. 빌드 번호는 project.yml에 있고, 워치 앱은 없고, 번호를 올리는 커밋은 spark-mobile로 시작해. 기록은 빌드 2부터 32까지 번호 올린 커밋 31개야. 빌드 2는 .ipa로 내보내기만 했고, 빌드 3부터 32까지는 업로드했어. 커밋마다 어느 커밋에서 빌드했는지, 어느 킷 커밋으로 컴파일했는지가 적혀 있어. git이 말할 수 있는 건 거기까지야. 스크립트 머리말(두 번째 코드 블록)은 사실 넷을 따로 세워. 올라갔다, Apple이 처리했다, 내부 그룹이 받았다, 기기에 설치됐다. 배포는 마지막 하나뿐이고, 레포가 기록하는 건 첫 번째 하나뿐이야. 그러니 솔직한 문장은 빌드 32까지 올라갔다는 거야.

이 레슨 주변 영역은 다른 퀘스트 두 개가 다뤄. Swift Quest는 공유 확장, 인텐트, 위젯, TestFlight를 일반론으로 가르쳐. 거기 예제 앱 이름도 Spark인데, 그 Spark는 워치 앱까지 달린 다른 가상의 앱이야. cwkSpark엔 워치 타깃이 없어. Firebrand Quest는 tailnet으로만 엔진에 닿는 또 다른 아이폰, 아이패드 겸용 앱을 엔진 게이트웨이 쪽에서 따라가.

Code

cwkSpark, clients/apple/SparkMobile/Sources/AppModel.swift: 레지스트리에서 온 기본 엔진, 옛 표기 정리, 호스트 프로필 받아들이기 (원문 그대로, 11-54행)·swift
struct MobileSettings: @unchecked Sendable {  // UserDefaults is thread-safe; Foundation just does not say so
    /// Spark's name in the port ledger.
    static let member = "cwkSpark"
    static var defaultEndpoint: EngineEndpoint { FamilyEndpoints.twin(member)! }
    static var defaultLocation: String { FamilyEndpoints.twinURL(member)!.absoluteString }
    private static let locationKey = "spark.engineLocation"
    private static let situationKey = "spark.situation"
    private static let sceneLanguageKey = "spark.sceneLanguage"

    let defaults: UserDefaults

    /// The saved location, with the service host named by an older spelling (its raw
    /// tailnet IP, plain http, the engine's own port) read as Spark's HTTPS
    /// twin and saved that way once (todo #442). Any other host stays.
    var engineLocation: String {
        get {
            guard let saved = defaults.string(forKey: Self.locationKey)?.nonEmpty else { return Self.defaultLocation }
            let canonical = Self.canonicalLocation(saved)
            if canonical != saved { defaults.set(canonical, forKey: Self.locationKey) }
            return canonical
        }
        nonmutating set { defaults.set(newValue, forKey: Self.locationKey) }
    }

    var endpoint: EngineEndpoint {
        EngineEndpoint(location: engineLocation) ?? Self.defaultEndpoint
    }

    /// Follow the Mac this phone is pointed at (cwkPippa todo #549): read the
    /// host profile cwkPippa serves on the engine's host, so a phone pointed at
    /// Pippa's Ark dials the whole family there, and one pointed back at the service host
    /// returns to the ledgers. Best effort. Returns whether the kept profile changed.
    func adoptHostProfile(fetch: @escaping FamilyEndpoints.HostProfileFetch = FamilyEndpoints.fetchHostProfile) async -> Bool {
        guard let base = try? endpoint.baseURL() else { return false }
        return await FamilyEndpoints.adoptHostProfile(near: base, fetch: fetch)
    }

    /// A recognized legacy host spelling becomes Spark's twin; anything else is returned as given.
    static func canonicalLocation(_ raw: String) -> String {
        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty, let twin = FamilyEndpoints.canonical(host: trimmed, for: member),
              let url = try? twin.baseURL() else { return raw }
        return url.absoluteString
    }
cwkSpark, scripts/mobile-testflight.sh: 들여온 파이프라인의 사용법과 네 가지 사실 (원문 그대로, 10-16행)·shell
#   scripts/mobile-testflight.sh            # guards → tests → build number → archive → .ipa
#   scripts/mobile-testflight.sh --upload   # …and hand it to App Store Connect
#   scripts/mobile-testflight.sh --plan     # resolve and check every fact; build nothing
#
# Upload is not delivery. Four separate facts: uploaded, Apple processed it, the internal
# group has it, the build is INSTALLED on the device. Only the last one is delivery — say
# which one is true (cwkPippa docs/IOS-NATIVE-DELIVERY.md §1).
cwkSpark, scripts/test-mobile.sh: 먼저 다시 만들고, 판정은 로그에 맡겨 (원문 그대로, 35-58행)·shell
# project.yml is the source; the .xcodeproj is generated output. A test file
# added without regenerating is a file the suite silently does not run — so
# regenerate before trusting a green result, never after.
(cd "$mobile" && xcodegen >/dev/null)

# xcodebuild refuses to overwrite a result bundle.
results="$mobile/build/mobile-tests.xcresult"
/bin/mkdir -p "$mobile/build"
/bin/rm -rf "$results"

xcodebuild test \
  -project "$mobile/SparkMobile.xcodeproj" \
  -scheme SparkMobile \
  -destination "platform=iOS Simulator,id=$SPARK_MOBILE_SIMULATOR_ID" \
  -resultBundlePath "$results" \
  > "$mobile/build/mobile-tests.log" 2>&1 || true

# The log's verdict decides, not a filtered pipe (learning #370).
if ! /usr/bin/grep -q "\*\* TEST SUCCEEDED \*\*" "$mobile/build/mobile-tests.log"; then
  /usr/bin/grep -E "error:|failed" "$mobile/build/mobile-tests.log" | /usr/bin/head -30 >&2 || true
  print -u2 "HARD_FAIL the iOS suite did not pass — see $mobile/build/mobile-tests.log"
  exit 1
fi
/usr/bin/grep -E "Executed [0-9]+ tests" "$mobile/build/mobile-tests.log" | /usr/bin/tail -1

External links

Exercise

모바일 앱이 아는 서버 주소를 전부 찾아서, 직접 관리하는 표 하나를 읽는 조회 하나 뒤로 옮겨 봐. 사용자가 저장해 뒀을 옛 표기를 알아보고 한 번만 고쳐 쓰는 이전 코드도 붙여. 다 됐으면 릴리스를 네 줄로 적어 봐. 올라갔다, 처리됐다, 테스터에게 배정됐다, 설치됐다. 그리고 레포가 실제로 증명할 수 있는 줄에 표시해.
Hint
조회는 매번 새 UserDefaults 스위트로 테스트해야 저장값이 테스트끼리 새지 않아. 릴리스에선 빌드 커밋과 업로드 로그가 첫 줄을 증명해. 나머지 셋은 App Store Connect와 기기에 있으니까, 솔직한 기록은 각각 어디서 확인했는지, 아니면 확인 안 했는지를 적는 거야.

Progress

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

댓글 0

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

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