읽기는 워처가 맡아
초기 빌드에선 폰에서 어떤 순간을 열면 빈 페이지가 떴어. 오류도 없고, 요청도 하나 없었어. 원인은 SwiftUI의 수명 규칙이었어. .task는 그걸 시작한 뷰가 허물어질 때마다 취소되는데, 아이폰의 분할 뷰는 들어가는 길에 상세 뷰를 몇 번씩 만들었다 버려. 페이지의 워처가 뷰의 상태 안에 살았으니까, 버려질 사본이 워처를 가질 수도 있었고, 화면에 남은 사본은 워처를 아예 시작하지 못했지.
고친 방법은 주인을 옮기는 거였어. 이제 AppModel이 열린 순간마다 MomentWatcher를 하나씩 갖고, 화면은 시작, 새로 고침, 멈춤만 말해. 화면이 보러 들어오고 나갈 때마다 수를 세고, 마지막 화면이 떠나고 1초가 지나서야 워처가 일을 멈춰. 그래서 들어오는 길에 버려진 사본이, 살아남은 사본이 기다리던 읽기를 취소하는 일은 없어. 워처는 아웃박스도 알아(두 번째 코드 블록). 아직 보내는 중인 순간은 404를 돌려주니까, 워처는 계속 찾아. 처음 90초는 1.5초마다, 그 뒤로는 순간이 열려 있는 동안 5초마다. 다음 포그라운드에 가져온 공유가 도착하는 데 96초 걸린 적이 있는데, 거기서 말없이 포기하는 워처였으면 페이지는 또 비었을 거야. 순간의 이벤트 스트림은 턴이 도는 동안만 열리고, 끊기면 화면은 그냥 네트워크 오류 대신 Tailscale을 확인하라고 말해.
끝난 작업은 기기에 남아
보낼 캡처가 기다리는 건 오프라인의 절반이야. 나머지 절반은 이미 끝난 전부야. 번역, 설명, 버전, 간직한 표현, 어근, 여행, 사진, 녹음된 피파 목소리. OfflineCorpus가 이걸 전부 읽기용 사본으로 폰에 둬. 킷의 갈아 끼울 수 있는 미러 위에서, 엔진 주소로 이름 붙인 디렉터리 안에. 목록과 열린 순간은 네트워크를 읽기 전에 이 사본부터 그리니까, 비행기 모드에서 연 화면도 엔진이 마지막으로 한 말을 보여줘.
Spark가 열려 있는 동안 60초마다 동기화 패스가 돌아. 먼저 아웃박스를 비우고, 그다음 이미 있는 걸 복사해. 순간 기록 전체를 페이지 단위로, 사진 전부, 녹음된 목소리 조각 전부, 어근, 여행, 간직한 표현까지. 복사하는 쪽은 아무것도 만들지 않아. 엔진이 아직 안 만든 번역도, 목소리도, 그림도 요청하지 않아. 그 앞의 드레인은 얘기가 달라. 아웃박스에서 기다리던 캡처는 거기서 실용 번역 턴을 보내니까, 패스 하나가 그 캡처의 첫 읽기를 시작할 수 있어. 어차피 그 캡처가 언젠가 보낼 요청이긴 하지만. 한 번도 합성한 적 없는 음성은 온라인에서 직접 눌러야 하고, 한 번 들은 읽기는 폰에서 다시 재생돼.
사본엔 어려운 문제가 하나 있어. 옛 답이 새 답보다 늦게 도착하는 거야. 첫 번째 코드 블록이 그 울타리야. 읽기는 경로마다 표를 받아. 앱을 거친 쓰기나 이벤트 스트림에서 기록된 결과는 세대 번호를 올려. 엔진 주소를 바꾸면 저장소 전체가 물러나. 표가 더는 유효하지 않은 읽기는 저장되지 않고 버려져. 아웃박스 파일은 엔진의 기록과 파일이 이 사본에 다 써진 다음에야 물러나고, 디스크 쓰기가 실패하면 캡처는 큐에 그대로 남아.
들어오는 문들
- 공유 시트.
SparkShare는 어느 앱에서든 사진, 파일, PDF, 링크, 글을 받아. 엔진이랑 말하지 않고 네트워크도 기다리지 않아. 들어온 걸 App Group 인박스에 쓰는데, 순간 아이디와 캡처 사실은 공유하는 그 자리에서 만들어. 그리고 닫혀. 앱은 다음에 앞으로 나올 때 그걸 캡처로 간직해. 그래서 저녁에 공유한 메뉴판은 앱이 아침에 가져가도 저녁 시각을 지켜. 공유의 첫 파일은 공유 때 만든 아이디를 받으니까, 충돌 뒤에 다시 가져와도 같은 순간이고 같은 아웃박스 항목이야. 나머지 파일은 각자 순간이 되는데, 아이디는 가져올 때 새로 만들어. 그래서 파일 여럿짜리 공유를 가져오다 중간에 충돌하면 그 파일들은 두 번 들어올 수 있어. 테스트 하나는 사진 24장을 통과시켜. 읽을 수 없는 공유는 알리고, 아빠가 버릴 때까지 남겨 둬. - 제어 센터.
ControlWidget하나가 제어 센터와 잠금 화면에 Spark Camera 버튼을 놓고, 동작 버튼에도 걸 수 있어. 그 인텐트는 일부러 앱을 열어(세 번째 코드 블록). 사진엔 앱의 카메라, Vision, 아웃박스가 필요하고, 버튼이 아껴 주는 건 잠금 해제하고 앱 찾고 누르는 수고야. 주석 하나엔 형제 앱이 남긴 교훈이 있어. 인텐트 문구에 기기 이름을 넣으면 App Store Connect가 바이너리를 거절해. - Send to Spark. 같은 기기의 Waystone이나 Vesta가
cwkspark://link주소를 열 수 있어. 아빠가 다음에 간직하는 순간은 그 크럼에 묶여서 도착하고, 오가는 건 참조뿐이야. 이 링크는 트랙 8에서 다뤄.
Talk with Pippa는 스레드를 넘겨
스레드가 있는 순간엔 피파 앱으로 들어가는 길이 두 개 있어(빠른 회상엔 마이크 대신 회상 전용 답 버튼이 있어). Continue in Pippa는 순간의 스레드를 피파 앱에서 열고, 앱이 없으면 WebUI에서 열어. Talk with Pippa는 킷이 그리는 마이크 두 개야. KO, EN. 마이크를 누르면 cwkpippa://conversation/<id>?voice=ko(또는 en)가 열리고, 피파 앱은 그 스레드를 음성 모드로 열어서 아빠가 고른 언어로 들어. 언어는 아빠가 고르는 거고, 절대 감지하지 않아.
아빠는 둘째 날 선택지 셋 중에서 이 모양을 골랐어.
PippaMobile로 넘기기
Spark 안에 음성 루프를 두면 PippaMobile의 핸즈프리 루프를 아이폰에 하나 더 복사하는 셈이었어. 게다가 그 실시간 청취기는 일회용 토큰으로 ElevenLabs에 직접 전화를 거는데, Spark 폰은 불변식에 따라 공급자 자격 증명을 하나도 갖지 않아. 그래서 Spark는 음성 루프를 아예 돌리지 않아. Spark가 넘겨주는 그 루프는 Voice Mode Quest에서 가르쳐. 그 퀘스트는 이미 Spark를, 스레드를 폰 앱으로 넘기는 문 가운데 하나로 꼽고 있어.