아이폰·아이패드 앱은 스튜디오를 새로 만들지 않아. 빌드 스크립트가 웹 클라이언트를 컴파일해서 앱의 Web/ 폴더에 복사하고, 어느 커밋에서 왔는지 적은 파일을 하나 같이 넣어. 앱은 XcodeGen으로 만들고, iOS 18, 아이폰과 아이패드, Swift 6이야. 이 번들을 WKWebView에 올리고, 브라우저가 잘 못하는 걸 더해. 원본 사진 고르기, 파일 공유, 오프라인 읽기, 자체 스킴으로 클립 재생. 공용 배관은 가족 키트(CwkKitCore, CwkKitWeb, CwkKitMedia)에서 와. 키트 얘긴 Sibling Kit Quest에 있어.
문이 하나뿐인 패키지 출처
페이지는 avatars-app://client에서 열려. 커스텀 스킴 핸들러가 번들에서 꺼내 주는 거야. 내비게이션 정책이 메인 프레임에 허락하는 건 딱 하나야. 앱 자기 출처의 /나 /index.html. 피파 패널로 가는 링크는 따로 웹 뷰를 둔 시트에서 열리고, 그 원격 사이드킥은 네이티브 브리지를 절대 못 받아. Firebrand Quest의 레슨 '잠긴 웹뷰 하나, 브라우저가 아니야'가 같은 분리를 코딩 클라이언트에서 보여 줘. 작업엔 잠긴 뷰 하나, 가족 패널엔 격리된 뷰 하나.
스크립트는 메시지 핸들러 하나로만 네이티브와 얘기해. 키트의 출처 검사 핸들러는 여섯 가지가 다 맞아야 메시지를 받아. 이 웹 뷰에서 왔는지, 메인 프레임인지, 페이지 콘텐츠 월드인지, 보낸 쪽의 스킴과 호스트가 앱 자기 것인지, 웹 뷰 자신도 아직 그 출처에 있는지, 크기 예산 안인지. Avatars의 예산은 145 << 20바이트야. 100 MB 사진을 base64로 바꿔도 들어가는 크기지.
브리지가 fetch를 바꿔 끼워
bridge.js는 문서 시작 때 주입되는데, 최상위 프레임의 avatars-app://client가 아니면 아무것도 안 해. 거기서는 window.fetch를 바꿔 끼워서, 같은 출처의 /api/ 호출을 전부 메시지 핸들러로 보내. 폼 필드는 텍스트로, 파일은 base64로 가고, 엔진과 같은 100 MB 한도가 걸려. 나머지는 브라우저 원래 fetch로 그대로 가.
API 쓰기도 스킴 핸들러가 받으면 안 되냐고? WKWebView는 커스텀 스킴으로 보낸 POST의 본문을 버릴 수 있거든. 그래서 스킴은 GET 리소스와 번들만 내주고, 쓰기는 전부 검사받는 메시지를 타. StudioHost.swift 주석이 그렇게 적어 뒀어. 브리지엔 965초짜리 긴 타이머도 있는데, 그 메시지가 사실 규칙이야. 요청이 아직 처리 중이니, 다시 생성하기 전에 저장된 배치부터 확인하라고. 답이 느리다고 두 번 제출할 이유는 없어.
네이티브 쪽 API 정책은 하나
페이지가 준 경로를 네트워크 요청으로 바꾸는 곳은 Engine 하나뿐이야. 경로는 /api/로 시작해야 하고, 호스트나 스킴이 붙으면 안 돼. 역슬래시, 프래그먼트, 인코딩된 슬래시나 역슬래시, .. 조각도 다 안 돼. 풀어 낸 URL은 스튜디오 자기 호스트와 포트에 떨어져야 해. 테스트는 남의 전체 URL, 프로토콜 상대 URL, /internal/providers를 넣어 봐. ..로 빠져나가는 경로 두 가지와 인코딩된 슬래시 두 가지도 넣어. 전부 거절돼야 해. 가족 세션 토큰은 키체인에 이 기기 전용으로 살고, X-Pippa-Session 헤더에 실려 가. 401이나 403이 오면 그 자격 증명으로 저장해 둔 오프라인 읽기를 거둬들여.
공유는 더 좁아. 원본이나 납품용 내보내기로 가는 링크를 누르면 브리지가 가로채. 호스트는 파일을 받아서 실제 형식에 맞게 이름을 붙이고 공유 시트에 넘겨. 클립은 이미지가 아니라 .mp4로 나가. 다른 경로는 공유가 안 돼.
첫 프레임까지 요청 175번
단순한 길을 깬 건 클립이었어. WebKit 플레이어는 스킴으로 영상을 바이트 범위 하나씩 읽고, 한 번 읽을 때마다 끝날 때까지 기다렸다 다음을 물어. 먼저 2바이트로 떠보고, 박스 헤더를 8바이트씩 읽어. 이어서 파일 끝의 인덱스를 4에서 8바이트씩 읽고, 재생이 시작되면 샘플을 하나하나 읽어. 플레이어는 206 답에, 정확히 그 바이트의 위치를 알려 주는 Content-Range가 붙어 있어야만 재생해. 그리고 아이폰에선 인라인 재생을 허락하지 않으면 전체 화면으로 가 버려.
범위마다 엔진에 그대로 넘기면 되긴 했어. 느렸을 뿐. 엔진 요청마다 첫 바이트 전에 권한 확인으로 50 ms쯤 들었거든. 그래서 8초짜리 720p 클립이 첫 프레임까지 요청 175번, 6.2초가 걸렸어. 네트워크 왕복도 없는 엔진 호스트 자기 루프백에서 잰 거야. 그래서 ClipBlocks는 엔진에서 1 MiB 블록 단위로 읽어. 블록마다 엔진이 준 Content-Range로 자리를 맞추고, 최대 64 MiB까지 들고 있어. 다음 블록은 미리 읽어 두고, 같은 블록을 기다리는 쪽들은 읽기 하나를 나눠 써. 플레이어의 범위는 이 블록에서 잘라 내. 같은 클립이 엔진 읽기 4번으로 실시간 재생됐어. 8초 영상에 실제 시간 8.08초. 원본은 절대 안 바뀌니까 들고 있는 블록은 언제까지나 유효해. 그리고 블록 하나하나는 여전히 엔진 권한 확인을 거쳐 읽혀.
원칙: 클라이언트가 아주 작게, 하나씩 차례로 읽는다면 한 걸음마다 왕복 요금을 다 물리지 마.
Code
cwkAvatars, clients/apple/AvatarsMobile/Resources/bridge.js: 같은 출처 /api/ 호출의 fetch는 네이티브 메시지 핸들러로 가 (원문 그대로, 34-53행)·javascript
const browserFetch = window.fetch.bind(window);
window.fetch = async (input, init = {}) => {
const u = new URL(typeof input === 'string' ? input : input.url, location.href);
if (u.protocol !== location.protocol || u.host !== location.host || !u.pathname.startsWith('/api/')) return browserFetch(input, init);
const values = {path:u.pathname+u.search, method:init.method || 'GET'};
if (init.body instanceof FormData) {
values.fields = [];
for (const [name, value] of init.body.entries()) {
if (typeof value === 'string') values.fields.push({name, value});
else {
if (value.size > 100*1024*1024) throw new Error('Choose an image up to 100 MB.');
values.fields.push({name, data:await base64(value)});
}
}
} else if (init.body != null) values.body = String(init.body);
// `onUploadProgress` (not a fetch option; the page's upload passes it) hears
// an original's bytes as native sends them (cwkSiblingKit todo #545).
const reply = await call('fetch', values, init.onUploadProgress);
return new Response(reply.body, {status:reply.status, headers:{'Content-Type':'application/json'}});
};
cwkAvatars, clients/apple/AvatarsMobile/Sources/Engine.swift: 페이지와 네트워크 사이의 유일한 경로 정책 (원문 그대로, 46-55행)·swift
static func url(_ path: String) throws -> URL {
guard path.hasPrefix("/api/"), !path.contains("\\"), !path.contains("#"),
let parts = URLComponents(string: path), parts.host == nil, parts.scheme == nil,
!parts.percentEncodedPath.lowercased().contains("%2f"),
!parts.percentEncodedPath.lowercased().contains("%5c"),
!parts.path.split(separator: "/").contains(".."),
let result = URL(string: path, relativeTo: origin)?.absoluteURL,
result.host == origin.host, result.port == origin.port else { throw BridgeError.invalidPath }
return result
}
cwkAvatars, clients/apple/AvatarsMobile/Sources/StudioHost.swift: 플레이어 범위 하나를 엔진 블록에서 잘라 206으로 답해 (원문 그대로, 255-278행)·swift
private func relay(_ range: ByteRange, of url: URL, to schemeTask: WKURLSchemeTask) async throws {
await blocks.validateScope()
let path = url.path + (url.query.map { "?" + $0 } ?? "")
let size = blocks.size
if blocks.file(path) == nil {
// The first block read tells the clip's length.
if case .from(let first, _) = range { _ = try await blocks.block(path, first / size) } else { _ = try await blocks.block(path, 0) }
}
guard let file = blocks.file(path), let span = range.span(in: file.total),
let head = MediaRange.partial(url: url, span: span, total: file.total, type: file.type) else { throw BridgeError.badReply }
try Task.checkCancellation()
schemeTask.didReceive(head)
for index in (span.lowerBound / size)...(span.upperBound / size) {
let block = try await blocks.block(path, index)
blocks.prefetch(path, index + 1)
try Task.checkCancellation()
let start = index * size
let lower = max(span.lowerBound, start) - start, upper = min(span.upperBound, start + block.data.count - 1) - start
guard lower <= upper else { throw BridgeError.badReply }
schemeTask.didReceive(block.data.subdata(in: lower..<(upper + 1)))
}
try Task.checkCancellation()
schemeTask.didFinish()
}
커스텀 스킴에서 페이지를 여는 WKWebView 앱을 작게 하나 만들어. 페이지엔 소스도 같은 스킴에 있는 video 태그를 둬. 인덱스가 파일 끝에 있는 짧은 MP4를 틀고, WKURLSchemeHandler에서 플레이어가 보내는 Range 헤더를 전부 기록해. 먼저 평범한 URLResponse로 파일 전체를 답하고 어떻게 되는지 봐. 그다음 범위마다 Content-Range와 Content-Length를 단 206 HTTPURLResponse로 답해. 첫 프레임 전까지 읽기가 몇 번인지 세. 마지막으로 메모리에 1 MiB 블록을 캐시해 두고 거기서 범위를 내주면서 다시 세 봐.
Hint
구성에 allowsInlineMediaPlayback을 켜. 안 그러면 아이폰이 영상을 전체 화면으로 가져가. 인덱스를 끝에 두려면 AVAssetWriter로 클립을 쓰고 shouldOptimizeForNetworkUse를 꺼 둬. 돌고 있는 태스크를 딕셔너리에 들고 있어야 멈춤 콜백이 맞는 걸 취소할 수 있어.
Progress
Progress is local-only — sign in to sync across devices.