Skip to content

Native Bridge Contract

웹 앱과 네이티브 셸(iOS·Android)은 버전 1의 얇은 브리지를 공유한다. 메시지 형식, 이벤트, capability는 두 플랫폼이 같고 전송층만 다르다.

  • 웹 → iOS: window.webkit.messageHandlers.barbelicNative.postMessage({ v: 1, ... })
  • 웹 → Android: window.barbelicNative.postMessage(JSON.stringify({ v: 1, ... })) (androidx.webkit WebMessageListener는 문자열만 받는다)
  • 네이티브 → 웹: barbelic:native-authbarbelic:native-auth-complete 등 CustomEvent
  • capability: socialAuthV1, secureAuthSessionV1, securePkceVerifierV1가 모두 true, 스플래시 소유권 splashOwnerV1

웹(barbelicNav.notifyNative)은 WebKit 핸들러가 있으면 객체를, 없고 Android 객체가 있으면 JSON 문자열을 보낸다. 두 호스트 모두 __barbelicNativeCapabilities를 document-start에 주입한다.

iOS는 main frame이면서 현재 WebView URL과 message security origin이 정확한 BarbelicServerOrigin일 때만 메시지를 처리한다. Android는 WebMessageListener의 허용 origin을 서버 origin 하나로 제한하고 isMainFramesourceOrigin을 다시 검증한다. 아래 본문의 "iOS"는 Android에도 같은 의미로 읽는다(예외는 android-setup.md "iOS와 다른 점").

startSocialLogin

웹이 Supabase PKCE 요청을 만든 뒤 iOS에 외부 인증 세션 시작을 요청한다.

json
{
  "v": 1,
  "type": "startSocialLogin",
  "action": "sign-in",
  "provider": "google",
  "authorizationUrl": "https://YOUR_PROJECT.supabase.co/auth/v1/authorize?...",
  "transactionId": "43-or-more-base64url-characters"
}

actionsign-in 또는 link이고 providerkakao, google, apple 중 하나다. 메시지 type은 브랜드 중립적인 동작 계약이며 iOS는 다음을 모두 검증한다.

  • authorize URL의 scheme, host, port가 BarbelicSupabaseAuthOrigin과 정확히 일치
  • sign-in은 Supabase origin의 /auth/v1/authorize
  • link는 provider별 공식 endpoint(kauth.kakao.com/oauth/authorize, accounts.google.com/o/oauth2/v2/auth, appleid.apple.com/auth/authorize)이며 redirect_uri는 정확한 Supabase /auth/v1/callback
  • query provider가 메시지 provider와 일치
  • code_challenge_method=s256이며 challenge가 유효한 길이
  • redirect_tobarbelic://auth/callback
  • 로그인 redirect action, provider, transaction ID가 메시지와 일치
  • 연결 provider URL에는 비어 있지 않은 state, client_id, code response type이 존재

검증 후 action, provider, transaction ID를 10분 TTL로 저장하고 ASWebAuthenticationSession을 연다. 동시 로그인은 허용하지 않는다.

Supabase SDK가 생성한 PKCE verifier는 nativePkceStorage 요청으로 iOS Keychain에 최대 10분만 저장한다. access token과 전체 auth session은 WebView 메모리에만 두며, verifier의 get/set/remove 결과는 barbelic:native-pkce-storage 이벤트로 돌려준다. 이 분리 덕분에 WebView 또는 앱이 callback 도중 다시 로드되어도 code 교환을 이어가며, access token이나 전체 session을 Web Storage에 지속하지 않는다.

barbelic:native-auth

iOS가 callback의 scheme/host/path, provider, transaction ID, TTL을 검증하고 거래를 1회 소비한 뒤 발신한다.

js
window.dispatchEvent(new CustomEvent("barbelic:native-auth", {
  detail: {
    provider: "google",
    callbackUrl: "barbelic://auth/callback?auth_action=sign-in&provider=google&transaction_id=...&code=..."
  }
}));

웹은 callback의 일회성 code를 exchangeCodeForSession(code)로 교환한다. custom scheme에는 access token, refresh token, provider ID token을 포함하지 않는다.

iOS 네이티브 Sign in with Apple(action sign-in, provider apple)은 callback URL 대신 시스템 시트가 돌려준 identity token과 원문 nonce를 같은 이벤트로 보낸다. 웹은 signInWithIdToken({ provider: "apple", token, nonce })로 세션을 만들고, 이후 (barbelic:native-session-persistbarbelic:native-auth-complete)는 동일하다.

js
window.dispatchEvent(new CustomEvent("barbelic:native-auth", {
  detail: { provider: "apple", idToken: "<Apple identity token JWT>", nonce: "<원문 nonce>" }
}));

barbelic:native-session-persist

code 교환이 성공하면 웹은 persistNativeAuthSession 메시지에 일회성 requestId와 refresh token을 담아 iOS Keychain 저장을 요청한다. iOS는 저장이 끝난 뒤 같은 requestId를 가진 barbelic:native-session-persist 이벤트로 stored, invalid, unavailable 중 하나를 돌려준다. 웹은 stored ACK를 받은 경우에만 barbelic:native-auth-complete 성공을 알리므로 WebView 재로드 전에 refresh session이 반드시 보존된다. 일반 token refresh 동기화는 UI를 막지 않는 비동기 저장을 유지한다.

barbelic:native-auth-complete

웹은 교환 결과를 이벤트로 알리고, iOS는 성공 시 WebView를 새 세션으로 다시 연다.

js
window.dispatchEvent(new CustomEvent("barbelic:native-auth-complete", {
  detail: { ok: true, action: "sign-in", provider: "google", error: "" }
}));

iOS가 인증 창을 열지 못했거나 사용자가 취소한 경우에도 ok: false 이벤트를 보내 UI의 pending 상태를 해제한다.

Apple account-deletion re-auth (appleAccountDeletionReauthV1 · appleAccountDeletionReauth)

계정 삭제(#663 Phase 3): 앱은 Apple refresh token을 보관하지 않으므로, App Store 5.1.1(v)의 토큰 revoke는 삭제 시점에 Sign in with Apple을 한 번 더 돌려 authorization code를 받는 표준 우회로 이행한다. iOS 셸이 capability appleAccountDeletionReauthV1: true를 주입하면:

  • 웹 → 셸 { v: 1, type: "appleAccountDeletionReauth", requestId } — Apple 계정 연결이 있는 사용자가 삭제를 확정했을 때만 보낸다(accountDeletionClient.ts).
  • 셸은 시스템 시트로 ASAuthorizationAppleIDRequest(scope 없음)를 수행하고 결과를 requestId 상관 이벤트로 돌려준다:
js
window.dispatchEvent(new CustomEvent("barbelic:apple-account-deletion-reauth", {
  detail: { requestId, status: "ok" | "cancelled" | "failed", authorizationCode: "…" }
}));
  • status: "ok"일 때만 authorizationCode가 담기고, 웹은 이를 /api/account/delete 본문(appleAuthorizationCode)으로 전달한다 — 서버가 client secret(ES256)으로 교환해 /auth/revoke 한다(api/account/_apple.js).
  • 취소·실패·타임아웃(120초)·capability 부재(웹/Android)에서는 code 없이 삭제를 진행하고 서버가 apple_revoke_skipped_* 경고를 남긴다 — 연결 해제 실패가 파기 의무 이행을 막지 않는다.

Other messages

persistNativeAuthSession, restoreNativeAuthSession, clearNativeAuthSession은 iOS Keychain의 refresh session을 관리한다. 로그인 완료 시 persistence는 request-bound ACK를 요구하고, nativePkceStorage는 별도 Keychain item의 짧은 수명 verifier만 관리한다. workoutActive는 화면 자동 잠금을 제어하고, sessionDownloadImage는 세션 이미지 저장을 요청한다. 모든 메시지는 v: 1을 포함하며 지원하지 않는 type은 무시한다.

Share file (shareFileV1 · shareFile)

데이터 내보내기(앱스토어 심사 #665 Phase 8, 2026-09-02). 웹의 내보내기는 다운로드 링크(<a download> + blob URL)인데 WKWebView는 다운로드 델리게이트 없이는 파일을 저장하지 않아 iOS 앱에서는 아무 일도 일어나지 않았다.

  • 셸이 __barbelicNativeCapabilities.shareFileV1 = true를 주입하면 웹은 다운로드 링크 대신 { v: 1, type: "shareFile", filename, mimeType, content }를 보낸다. content는 파일 본문 문자열(UTF-8, 20MB 이하), filename은 영문·숫자·.·_·-만(120자 이하, .으로 시작 금지). 조건을 어기면 셸이 실패 알림만 띄운다.
  • 셸은 임시 파일로 쓴 뒤 iOS 공유 창(UIActivityViewController — 파일에 저장·AirDrop· 메일 등)을 열고, 공유 창이 닫히면 임시 파일을 지운다. 웹은 토스트를 띄우지 않는다 — 공유 창 자체가 결과 안내이고, 취소했는데 "내보냈어요"가 뜨면 안 된다.
  • 플래그가 없으면(브라우저, Android 셸) 웹이 종전대로 다운로드 링크를 쓴다.

Offline page (server.errorPath · __barbelicNativeEnvironment)

오프라인 안내(앱스토어 심사 #665 Phase 8, 2026-09-02). 앱 진입 로드가 네트워크 오류로 실패하면 Capacitor가 capacitor.config.jsonserver.errorPath(offline.html, 번들 public/에서 제공 — 저장소 public/offline.html)를 대신 연다. 종전엔 오류 페이지 설정이 없어 부팅 커버가 20초 뒤 내려가고 검은 화면만 남았다.

  • 셸은 WKNavigationDelegate.didFailProvisionalNavigation을 가로채 NSURLErrorDomain의 취소(-999) 아닌 오류이면서 실패 URL이 커버 대상(앱 진입점)일 때 커버를 즉시 내린다. 그 뒤 Capacitor 핸들러가 오류 페이지를 연다(로컬 capacitor://localhost 경로라 커버 대상이 아니다).
  • 셸은 문서 시작 시점에 window.__barbelicNativeEnvironment = { homeUrl }를 주입한다. 오류 페이지의 "다시 시도"와 online 이벤트는 이 주소로 location.replace한다 — 앱 진입점 로드이므로 다시 커버 → 성공(ready) 또는 실패(오류 페이지) 경로를 탄다. 주입이 없으면(브라우저) 정규 주소 https://www.barbelic.com/을 쓴다.

Splash ownership (splashOwnerV1 · splashState)

스플래시를 그리는 주체는 환경당 하나다 (2026-08-23). 셸이 문서 시작 시점(user script, atDocumentStart)에 window.__barbelicNativeCapabilities.splashOwnerV1 = true를 주입하면:

  • 셸은 LaunchScreen.storyboard와 동일한 뷰를 커버로 띄워 앱이 그려질 때까지 유지한다. 최초 로드뿐 아니라 앱 진입점(루트 경로)·about:blank로 향하는 모든 메인 프레임 새 문서 로드 (재인증 리셋, 웹 자체 location.reload(), 웹프로세스 종료 복구)에 같은 커버를 다시 띄운다. 트리거는 WKNavigationDelegate.didStartProvisionalNavigation이다 — 같은 문서 안의 히스토리 이동(pushState 레이어의 뒤로가기·스와이프 백)은 provisional navigation을 만들지 않으므로 커버 대상이 아니다. webView.isLoading은 UI 프로세스 주도 히스토리 이동에도 true가 되므로 트리거로 쓰지 않는다 (2026-08-24, BUG-009).
  • 웹은 스플래시를 한 장도 그리지 않는다 — index.htmlhtml.gym-app-native-splash를 붙여 정적 스플래시와 React 호스트 스플래시를 숨긴다. 검은 호스트 층과 15초 watchdog의 복구 카드는 그대로 그린다.
  • 웹 → 셸 { v: 1, type: "splashState", state: "ready" | "stalled" }: ready는 호스트 로딩이 끝나고 프레임 두 번 뒤(페인트 보장)에 1회, stalled는 watchdog이 복구 카드를 띄울 때 1회. 셸은 둘 다 커버를 내리는 신호로 취급한다.
  • 신호가 전혀 없으면 셸은 20초 타임아웃으로 커버를 내린다 (오프라인·구버전 웹·로드 실패).

플래그가 없으면(브라우저, 구버전 셸) 웹이 종전대로 스플래시를 그리고 splashState를 보내지 않는다. 새 셸이 구버전 웹을 열면 ready가 오지 않으므로 타임아웃으로 내려간다 — 어느 조합도 빈 화면이 되지 않는다.

WebView 표면과 부팅 표면 (#1344)

  • WebView의 로드 사이 바탕은 capacitor.config.jsonbackgroundColor 한 곳에서 공급한다. 현재 값 #ffffff는 모바일 .screen의 기본 --surface(흰색)에 맞춘 것이다. 홈의 어두운 히어로, body/host의 다른 지면까지 같은 색이라는 뜻은 아니다.
  • iOS는 Capacitor가 WebView·scrollView 배경을 설정하고 최초 로드의 opacity를 복구하도록 둔다. viewDidLoad에서 검정 배경이나 투명값을 덮어쓰지 않는다. Android의 custom config builder도 CapConfig.loadDefault(this).backgroundColor를 사용하고 별도 WebView 배경 덮어쓰기는 하지 않는다.
  • 검정 부팅 커버·웹 부팅 층과 Android 창·시스템바의 기존 색은 각자의 계약으로 유지한다. 일반적인 foreground 복귀만으로 새 커버를 추가하지 않는다.
  • 배경 정리는 검정 빈 프레임의 완화이며, 콘텐츠가 비는 원인의 제거를 보장하지 않는다. 설정 검사와 CI 통과를 iPhone의 깜빡임 해결 증거로 대체하지 않는다.

복귀 화면 관측 (#1344, iOS Debug)

Debug 빌드의 [barbelic surface] 로그는 monotonic uptime, 이벤트명, 현재 opacity·loading·커버 유무만 남긴다. URL·사용자·세션·토큰·JavaScript 원문은 기록하지 않으며 Release에서는 출력하지 않는다.

  • background / active: 앱 전환 시점.
  • navigation_start / navigation_finished / navigation_failed: 새 문서 로드 흐름. 완료 로그는 Capacitor의 didFinish를 먼저 전달하여 opacity 복구 뒤에 남긴다.
  • process_terminated: Capacitor의 기존 프로세스 종료 복구(reload)로 넘기기 직전.
  • cover_show / cover_hide:<reason>: 커버 표시와 해제 애니메이션 시작. 해제 시작 로그에서는 커버가 아직 있으므로 covered=true가 정상이며, 실제 애니메이션 완료 시각을 뜻하지 않는다.

기기·iOS·앱 빌드·웹 버전과 화면 녹화를 함께 남기고, 같은 날 짧은 복귀/긴 복귀 및 메인·기록·리포트·피드를 비교한다. Safari Web Inspector에서 웹 gym-app-booting 클래스와 인증 이벤트·React commit을 함께 관측하여 배경, 부팅 커버, 웹 로딩 층을 구분한다. 오프라인 복귀·재인증·새 문서 reload·프로세스 종료 복구에서도 커버가 고착하거나 입력을 가리지 않는지 확인한다.