SparkMobile은 아이폰과 아이패드를 함께 받는 XcodeGen 타깃 하나야. 원본은 project.yml이고, .xcodeproj는 생성해서 쓰고 레포엔 안 남겨. 최소 버전은 iOS 18이야. Vision 텍스트 인식의 Swift 버전을 쓰기 때문이지. 언어는 Swift 6이고, 가족 Swift 패키지인 CwkKit을 경로 의존성으로 받아서 제품 두 개를 링크해. CwkKitCore는 가족 앱이 다 같이 쓰는 배관이야. 캡처 아웃박스, 가족 엔드포인트, 공유 인박스가 여기 있어. CwkKitMedia는 같이 쓰는 화면이야. 업로드 줄, 문구 카드, Talk with Pippa가 여기 있지. 라이브 스캐너용으로 VisionKit도 따라 들어와. 번들 안엔 확장 두 개가 박혀 있어. 공유 시트용 SparkShare, 제어 센터 버튼용 SparkWidgets. 빌드 번호는 앱이랑 두 확장이 하나를 같이 써. 확장 번호가 본체랑 다르면 App Store Connect가 거절하거든.
폰에선 킷이 Spark 어디보다 중요해. Spark의 아웃박스는 킷의 CaptureOutbox에 Spark 페이로드를 담은 거고, 다음 레슨의 업로드 줄도 킷 거야. 가족이 이런 코드를 사본이 어긋나지 않게 나눠 쓰는 방법은 Sibling Kit Quest에서 가르쳐. 이 레슨은 Spark가 거기에 뭘 담는지 봐.
네트워크한테 묻기 전에 간직해
규칙은 짧아. 캡처는 네트워크가 알기 전에 이미 안전해야 해. 아빠가 셔터를 누르면 AppModel.capture(photo:)가 요청 하나 보내기 전에 세 가지를 해(두 번째 코드 블록). 먼저 캡처 시점의 사실을 정해. 사진에 자기 시각이 있으면 그걸 쓰고, 없으면 간직한 시각을 써. 다음으로 전송에 필요한 아이디를 전부 만들어. 순간의 ULID, 피파가 읽을 사본의 미디어 아이디와 원본의 미디어 아이디, 실용 번역 턴의 요청 아이디, 아웃박스 항목의 아이디까지. 마지막으로 파일 두 개를 파일 보호를 걸어 앱 컨테이너에 쓰고, 항목을 아웃박스에 확정해. 백그라운드 드레인은 그다음에야 시작돼.
문서 주석도 딱 그렇게 말해. 이 함수가 예외 없이 돌아오면 순간은 안전하고, 아이디도 이미 확정이야. 아웃박스 확정이 실패하면 써 둔 파일을 다시 지워. 그래서 폰엔 어느 항목도 가리키지 않는 바이트가 남지 않아. 비행기 모드에서 찍은 캡처도 엔진에서 갖게 될 정체성을 처음부터 갖고 있고, 딱 한 번 완료돼.
셔터와 전송 사이의 계약은 페이로드 하나에 다 들어 있어(첫 번째 코드 블록). 맨 아래 done 집합을 봐. 전송은 단계가 끝날 때마다 여기에 표시하고, 재시도는 이걸 보고 어디서 이어갈지 알아.
원칙: 정체성은 일이 벌어진 자리에서 만들어. 서버에서 태어나는 아이디는 비행기 모드에선 존재할 수가 없고, 아이디 없는 재시도는 다시 보낸 건지 두 번 찍은 건지 구분하지 못해.
피파가 제일 빨리 보이는 순서
사진 캡처는 파일이 두 개야. 내가 읽을 2048 px 사본, 그리고 아빠가 간직할 원본 해상도 파일. 느린 회선에선 원본만 몇 분이 걸려. 그래서 순서는 답을 기준으로 짜고, 보관은 기다리게 해.
단계
가는 것
여기 있는 이유
1
사본의 업로드 줄
요청 전에 먼저 띄워서, 첫 초부터 화면이 움직여
2
순간 기록
폰에서 만든 아이디와 캡처 사실. 항목 아이디가 클라이언트 요청 아이디야
3
사본의 바이트
사본 자기 미디어 아이디로 올라가
4
실용 번역 턴
사본을 마무리하는 요청이야. 원본이 기다리는 동안 피파가 읽기 시작해
5
원본
무거운 파일은 맨 끝
6
문서의 텍스트
공유한 PDF나 워드 파일일 때만. 엔진이 읽어서 순간에 넣어
7
기기 사본
엔진의 기록과 파일을 오프라인용으로 저장해야 항목이 물러날 수 있어
단계는 전부 몇 번을 해도 결과가 같아. 순간과 파일은 폰에서 만든 아이디를 달고, 턴은 요청 아이디를 달고, done에 이미 있는 단계는 건너뛰어. 세 번째 코드 블록이 이걸 못 박는 테스트야. 첫 번째 패스가 순간과 사본을 보내고 턴이 503을 받으면, 다음 패스는 턴과 원본만 보내.
실패는 두 가지
전송은 잘못된 걸 두 갈래로 나누고, 그 선은 킷의 OutboxFailure.classify가 그어. 4xx는 대부분 영구 실패야. 필드 형식이 틀렸거나 파일이 크기 제한을 넘었으면 그 캡처 자체의 잘못이라, 다시 보내도 소용없어. 그런 항목은 확인이 필요한 상태로 멈춰. 줄엔 다시 시도 버튼이 없고, 아빠는 엔진이 댄 이유를 봐. 근데 4xx 중 다섯 개는 일시 실패로 따로 빼 놨어. 408, 425, 429는 서버가 대놓고 다시 해 달라는 말이야. 401과 403은 캡처가 아니라 세션이 틀렸다는 뜻이라, 이걸 영구로 치면 아무 잘못 없는 캡처가 큐에서 하나씩 전부 멈춰 서. Spark 자기 문서 주석(outboxFailure 위)엔 아직 4xx는 영구라고 적혀 있지만, 실제로 판정하는 건 킷의 switch 문이야. 이 다섯 개, 그리고 회선이 끊겼거나 엔진이 503을 준 경우는 일시 실패야. 항목은 다시 대기로 돌아가고 다음 패스가 또 시도해. 거절당한 캡처를 끝없이 재시도하지도 않고, 닿을 수 있는 캡처를 포기하지도 않아.
큐가 멈추지 않게 하는 장치가 하나 더 있어. 킷의 드레인은 패스가 시작될 때 항목 목록을 받아 가. 그래서 예전엔 패스 도중에 간직한 사진이 다음 동기화 패스까지 그냥 앉아 있었어. Spark의 drain()은 호출을 합쳐. 패스가 도는 중에 들어온 호출은 패스를 한 번 더 돌려 달라고 하고, 새 캡처는 지금 가는 것 바로 뒤에 가.
Code
cwkSpark, clients/apple/SparkMobile/Sources/Outbox.swift: 캡처 페이로드와 완료 목록 (원문 그대로, 10-41행)·swift
struct CapturePayload: Codable, Equatable, Sendable {
var momentId: String
var kind: String
var capture: CaptureTruth
var situation: String?
var text: String?
var place: PlaceSample?
var pippaFile: String?
var pippaMediaId: String?
var originalFile: String?
var originalMediaId: String?
var originalFilename: String?
var originalContentType: String?
var lines: [VisionLine]
var practicalRequestId: String
/// A text kept for high-quality work: no practical reading is asked.
/// Optional so entries queued before it existed still decode.
var skipsReading: Bool?
/// A document (a shared PDF, a Word file): after the original is up,
/// the engine reads it into the moment's text (step 6).
var readsOriginalAsText: Bool?
/// A crumb sent here from Waystone or Vesta: the moment arrives linked
/// to it. Optional so entries queued before it existed still decode.
var links: [ItemRef]?
/// How far the send got: each step is idempotent, and a step already
/// done is not repeated (the original is the heavy one).
var done: Set<Step> = []
enum Step: String, Codable, Sendable {
case moment, copy, turn, original, text
}
}
cwkSpark, clients/apple/SparkMobile/Sources/AppModel.swift: 요청 전에 사진을 간직하고 아이디를 전부 만드는 곳 (원문 그대로, 276-306행)·swift
/// Keep a photo on this device, then try the engine. The moment is safe
/// the moment this returns without throwing; its id is final already.
@discardableResult
func capture(photo: PreparedPhoto, situation: Situation, place: PlaceSample?,
momentId: String = ULID.mint(), otherwise truth: CaptureTruth? = nil,
entryId: String = ClientRequestId.mint(prefix: "moment-"), links: [ItemRef]? = nil) async throws -> String {
let capture = Self.truth(takenAt: photo.takenAt, otherwise: truth)
// A camera still carries no metadata of its own; the moment's truth
// fills it, on the original and on the copy Pippa reads (todo #590).
var photo = photo
photo.original = PhotoMetadata.filled(photo.original, capturedAt: capture.capturedAt, place: place)
photo.forPippa = PhotoMetadata.filled(photo.forPippa, capturedAt: capture.capturedAt, place: place)
let payload = CapturePayload(
momentId: momentId,
kind: "photo",
capture: capture,
situation: situation.rawValue,
place: place,
pippaMediaId: ULID.mint(),
originalMediaId: ULID.mint(),
originalFilename: photo.originalFilename,
originalContentType: photo.originalContentType,
lines: photo.lines,
practicalRequestId: ClientRequestId.mint(prefix: "practical-"),
links: links)
_ = try await outbox.enqueue(payload, photo: photo, entryId: entryId)
settings.situation = situation
queued = await outbox.unsent
Task { await drain() }
return momentId
}
cwkSpark, clients/apple/SparkMobile/Tests/SparkMobileTests.swift: 전송 순서, 그리고 끝난 단계를 건너뛰는 재시도 (원문 그대로, 250-293행)·swift
func testTheSendGoesMomentCopyTurnOriginalAndSkipsWhatIsDone() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let outbox = MomentOutbox(directory: directory)
let photo = PreparedPhoto(original: Data("original".utf8), originalFilename: "IMG_1.HEIC",
originalContentType: "image/heic", forPippa: Data("copy".utf8),
lines: [], takenAt: nil)
let payload = CapturePayload(momentId: Self.momentId, kind: "photo", capture: CaptureTruth.now(),
situation: "restaurant", pippaMediaId: "COPY", originalMediaId: "ORIGINAL",
originalFilename: "IMG_1.HEIC", originalContentType: "image/heic",
lines: [], practicalRequestId: "practical-abc12345")
let entry = try await outbox.enqueue(payload, photo: photo)
let media: [String: Any] = ["media_id": "M", "moment_id": Self.momentId, "filename": "f", "url": "/u", "role": "r"]
let scripted = ScriptedHTTPTransport(replies: [
ScriptedHTTPTransport.json(["moment": Self.momentJSON()]),
ScriptedHTTPTransport.json(["media": media]),
ScriptedHTTPTransport.json(["detail": "Spark's brain is not reachable"], status: 503),
])
do {
_ = try await client(scripted).send(entry, outbox: outbox, label: "Menu")
XCTFail("a 503 must leave the entry for the next pass")
} catch let failure as OutboxFailure {
XCTAssertFalse(failure.isPermanent)
}
XCTAssertEqual(scripted.calls.map(\.path), [
"/api/moments", "/api/moments/\(Self.momentId)/media/COPY", "/api/moments/\(Self.momentId)/practical",
])
// The next pass skips the moment and the copy it already sent.
let again = try await XCTUnwrapAsync(await outbox.entry(momentId: Self.momentId))
XCTAssertEqual(again.payload.done, [.moment, .copy])
let retry = ScriptedHTTPTransport(replies: [
ScriptedHTTPTransport.json(["turn": ["id": "t1", "status": "running"]]),
ScriptedHTTPTransport.json(["media": media]),
])
let sent = try await client(retry).send(again, outbox: outbox, label: "Menu")
XCTAssertEqual(sent, Self.momentId)
XCTAssertEqual(retry.calls.map(\.path), [
"/api/moments/\(Self.momentId)/practical", "/api/moments/\(Self.momentId)/media/ORIGINAL",
])
XCTAssertEqual(retry.calls.last?.query, "role=original&filename=IMG_1.HEIC")
XCTAssertEqual(retry.calls.last?.headers["Content-Type"], "image/heic")
}
무거운 걸 보내는 앱 하나를 골라서 캡처 아웃박스를 만들어 봐. 원본이 큰 사진 같은 거면 좋아. 아이디는 전부 기기에서 만들고, 첫 요청 전에 파일부터 쓰고, 끝난 단계는 항목에 적고, 무거운 파일이 가기 전에 사용자가 답을 보게 순서를 짜. 다 짰으면 세 번째 요청에 503을 돌려주도록 답을 미리 짜 둔 가짜 전송을 붙이고, 다음 패스가 남은 것만 보내는지 확인해.
Hint
단계는 항목과 같이 저장되는 작은 집합으로 두고, 요청마다 먼저 확인해. Spark 테스트에선 가짜 전송이 받은 경로를 전부 기록하니까, 확인은 그냥 목록 비교야. 첫 패스는 순간, 사본, 턴이고 두 번째 패스는 턴, 원본이야.
Progress
Progress is local-only — sign in to sync across devices.