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

네 이론 말고 기기를 읽어

~16 min · proof-and-fleet, devicectl, diagnostics, crash-logs, share-extension

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"침묵의 대가는 꼬박 하루였어. 두 번."

아무 일도 안 한 공유

주인이 아이폰의 메일에서 PDF를 공유했는데, 훈련 앱의 공유 확장이 아무것도 안 했어. 공유 시트에 앱이 보였고, 작성 시트가 열렸고, 메모를 치고 올렸는데, 앱에 항목이 끝내 안 들어왔어. 시뮬레이터 확인(Safari의 PDF를 앱으로 공유)은 통과했었어. 다음 빌드에선 시뮬레이터에서 드러난 진짜 결함을 고쳤지만, 그건 폰에서 난 문제가 아니었고. 이론에 기대 빌드 두 개를 날리고 나서야 세션은 짐작을 멈추고 케이블로 폰을 읽었어. TestFlight 설치는 그대로 두고, 디버거도 붙이지 않은 채로.

답을 알려준 읽기 세 가지

  1. 기기에서 복사한 크래시 로그: 보고서 87개, 확장에서 나온 건 없음. 죽진 않았어.
  2. devicectl device info processes프로세스 목록: 공유하고 한 시간이 지났는데 확장 실행 파일이 앱 옆에서 아직 돌고 있었어. 확장은 요청을 완료하거나 취소하면 곧 끝나니까, 아직 살아 있다는 건 둘 다 안 했다는 뜻이야. 이걸로 문제가 "실패"가 아니라 "멈춤"으로 바뀌었어.
  3. 그 사실을 알고 나서 읽은 소스: 확장은 checked continuation 안에서 NSItemProvider.loadItem을 기다렸어. 그런데 기기의 메일 PDF 제공자에선 완료 핸들러가 끝내 불리지 않았어. 시뮬레이터에선 Safari 제공자가 제대로 응답했고.

고친 건 세 가지야. 먼저 파일을 담은 제공자가 원하는 방식으로 불러오게 했어(loadFileRepresentation. URL은 완료 핸들러 안에서만 유효하니까 바이트도 거기서 읽고). 다음으로 모든 불러오기에 30초 마감을 걸었는데, 먼저 끝나는 쪽이 이기는 경주 방식이야(태스크 그룹은 안 돼. 절대 안 돌아오는 자식까지 모든 자식을 기다리거든). 끝으로 시간 초과는 알림과 취소된 요청으로 바꿨어.

거짓말한 읽기

읽기 하나는 틀렸고, 그게 이 이야기에서 제일 쓸모 있는 부분이야. App Group 컨테이너 사본에 inbox/ 폴더가 없어서 "확장이 아무것도 안 썼다"로 읽혔어. 그런데 사실 devicectl device copy from은 열 수 없는 항목을 조용히 빼먹어. 게다가 inbox는 complete 파일 보호로 쓰여 있었고. 사본에 없다는 건 사본에 대한 증거일 뿐이야. 믿을 만한 신호는 걸러질 수 없는 것들이었어. 아직 돌고 있는 프로세스, 그리고 확장 크래시가 하나도 없는 크래시 목록.

앱이 흔적을 남기게 설계해

연결된 기기의 시스템 로그를 모으는 것처럼 root 권한이 필요한 기기 읽기도 있어. 에이전트 세션은 그런 방법에 기댈 수 없지. 그러니 두고두고 쓸 답은 앱 안에 만들어둬야 해. 확장은 이제 단계마다 공유 컨테이너의 작은 파일에 한 줄씩 덧붙여. 적는 건 알아본 종류, 등록된 타입 식별자, 바이트 수, id, 에러야. 메모나 파일 내용은 절대 안 적고, 끝부분만 남게 크기를 제한해. 앱 설정 화면에선 그 끝부분을 볼 수 있고, 케이블로 연결한 Mac에선 파일을 복사해 갈 수 있어. 다음에 또 "아무 일도 안 일어났어"라는 일이 생기면, 흔적이 멈춘 단계의 이름을 알려줄 거야.

Code

디버거 없이 케이블로 TestFlight 폰 읽기·bash
device=Example-iPhone              # a name, UDID or identifier from: xcrun devicectl list devices
out=build/device-read; mkdir -p "$out"

# Is the extension still alive long after its sheet closed? Then it never finished: a hang.
xcrun devicectl device info processes --device "$device" | grep -i spark

# Did anything crash? (--source "" is required: a subpath is refused with "File paths cannot contain '..'")
xcrun devicectl device copy from --device "$device" --domain-type systemCrashLogs \
  --source "" --destination "$out/crashes"

# What did the app and its extension leave in the shared container?
xcrun devicectl device copy from --device "$device" --domain-type appGroupDataContainer \
  --domain-identifier group.com.example.spark --source "" --destination "$out/group"
tail -20 "$out/group/share-trail.log"

# Caution: the copy silently OMITS files it cannot open. A file written with complete
# protection may be missing from the copy while it exists on the phone.
단계 흔적: 사실만, 끝부분만 남기고, 설정과 케이블 둘 다에서 읽기·swift
import Foundation

/// One line per step, in a file a cabled Mac and the app's own Settings can both read.
/// Facts only (kinds, type identifiers, byte counts, ids, errors), never the user's content.
struct StepTrail: Sendable {
    let url: URL
    var cap = 16 * 1024   // keep the tail; a trail that grows forever becomes the next problem

    init(directory: URL, name: String = "share-trail.log") {
        url = directory.appending(path: name)
    }

    func record(_ step: String, now: Date = .now) {
        let line = "\(now.formatted(.iso8601)) \(step)\n"
        var data = (try? Data(contentsOf: url)) ?? Data()
        data.append(Data(line.utf8))
        if data.count > cap {
            let tail = data.suffix(cap)
            // Start the kept tail at a line boundary so the first line is never half a line.
            data = tail.firstIndex(of: UInt8(ascii: "\n")).map { Data(tail[tail.index(after: $0)...]) } ?? Data(tail)
        }
        #if os(iOS)
        try? data.write(to: url, options: [.atomic, .completeFileProtection])
        #else
        try? data.write(to: url, options: .atomic)
        #endif
    }

    func tail(lines: Int = 20) -> [String] {
        guard let text = try? String(contentsOf: url, encoding: .utf8) else { return [] }
        return Array(text.split(separator: "\n").suffix(lines).map(String.init))
    }
}

External links

Exercise

SparkMobile 공유 확장에 StepTrail을 넣고 공유 단계마다 한 줄씩 적어. 적을 건 알아본 종류, 불러오기마다의 타입 식별자와 바이트 수, 쓰기, 그리고 완료나 취소야. 설정 화면엔 마지막 스무 줄을 보여줘. 이어서 기기를 케이블로 연결하고 위의 읽기 세 가지를 돌려서, 각각이 증명한 것과 증명하지 못한 것을 적어. 마지막으로 흔적 파일 자체를 complete 보호로 쓰고 컨테이너 복사를 다시 해서, 그 파일이 사본에 들어오는지 봐.
Hint
개수와 식별자만 적어서 내용이 흔적에 안 들어가게 해. 설정엔 흔적이 보이는데 복사 명령이 그 파일을 안 가져오면, 쓰기가 빠진 게 아니라 조용한 누락을 재현한 거야.

Progress

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

댓글 0

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

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