create-saas-starter-workspace 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/workspace/.claude/skills/app-launch/SKILL.md +82 -0
- package/dist/workspace/.claude/skills/{release → app-update}/SKILL.md +5 -5
- package/dist/workspace/.claude/skills/bridge-guide/SKILL.md +43 -26
- package/dist/workspace/.claude/skills/eas-deploy-guide/SKILL.md +37 -79
- package/dist/workspace/.claude/skills/native-app-guide/SKILL.md +43 -9
- package/dist/workspace/.claude/skills/probe/SKILL.md +14 -2
- package/dist/workspace/.claude/skills/sketch/SKILL.md +14 -8
- package/dist/workspace/.claude/skills/store-release-guide/SKILL.md +54 -57
- package/dist/workspace/.claude/skills/web-launch/SKILL.md +59 -0
- package/dist/workspace/AGENTS.md +1 -1
- package/dist/workspace/START-HERE.md +3 -6
- package/dist/workspace/mobile/.env.example +3 -1
- package/dist/workspace/mobile/AGENTS.md +9 -1
- package/dist/workspace/mobile/README.md +1 -1
- package/dist/workspace/mobile/eas.json +2 -2
- package/dist/workspace/scripts/connect-repos.mjs +1 -1
- package/dist/workspace/web/.env.example +1 -1
- package/dist/workspace/web/AGENTS.md +8 -8
- package/dist/workspace/web/README.md +6 -7
- package/dist/workspace/web/docs/ENVIRONMENTS.md +8 -4
- package/dist/workspace/web/instrumentation-client.ts +4 -2
- package/package.json +1 -1
- package/src/scaffold.mjs +1 -1
- package/dist/workspace/.claude/skills/go-live/SKILL.md +0 -68
- package/dist/workspace/.claude/skills/kickoff/SKILL.md +0 -72
- package/dist/workspace/.claude/skills/launch/SKILL.md +0 -69
- package/dist/workspace/.claude/skills/preview/SKILL.md +0 -43
- package/dist/workspace/.claude/skills/vercel-cron/SKILL.md +0 -17
- package/dist/workspace/.claude/skills/warmup/SKILL.md +0 -33
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: app-launch
|
|
3
|
+
description: Set up App Store Connect and Play Console for the first release — developer accounts, app records, signing keys, store listing, privacy declarations — then the first manual submit-for-review. Use when the Owner is ready to ship to the stores for the first time, or invokes /app-launch.
|
|
4
|
+
user-invocable: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# /app-launch
|
|
8
|
+
|
|
9
|
+
앱을 앱스토어·플레이스토어에 처음 올리기 위한 1회 출시 준비다. 개발자 계정·앱 레코드·서명 키·스토어 리스팅·프라이버시 선언을 갖추고 첫 수동 심사 제출까지 데려간다. 비개발자에게 가장 큰 난관이라, web-launch처럼 단계별로 확인받으며 진행한다.
|
|
10
|
+
|
|
11
|
+
이 스킬은 순서와 게이트만 잡는다. 가격·정책·콘솔 절차·심사 통과 전략의 정본은 `store-release-guide`이고, EAS 빌드·제출 명령의 정본은 `eas-deploy-guide`다. 둘 다 드리프트하므로 본문에 박지 말고 그때 가이드에서 읽는다.
|
|
12
|
+
|
|
13
|
+
## 빠른 참조 (값은 여기서 읽는다)
|
|
14
|
+
|
|
15
|
+
- 개발자 계정 비용·계정 유형, Play 비공개 테스트 게이트의 인원·기간: `store-release-guide` §1 (정책 SSOT — 본문에 다시 적지 않는다).
|
|
16
|
+
- 빌드·제출 명령, EAS 무료 티어 한도: `eas-deploy-guide`.
|
|
17
|
+
- 앱 식별자·서명·SDK 버전: `mobile/app.config.ts`·`mobile/eas.json`·`mobile/package.json` (메모리를 믿지 않는다).
|
|
18
|
+
|
|
19
|
+
## 진행 방식 (3단계)
|
|
20
|
+
|
|
21
|
+
1. **읽기전용 조사**: 계정 유무, 기존 자격증명, 배포된 웹의 컴플라이언스 표면(계정 삭제·트래커)을 먼저 살핀다.
|
|
22
|
+
2. **일괄 플랜**: AskUserQuestion으로 결정과 비용을 한 번에 묻는다.
|
|
23
|
+
3. **confirm 실행**: 비가역·돈·2FA가 걸린 일만 Owner confirm 뒤 진행한다.
|
|
24
|
+
|
|
25
|
+
## 누가 무엇을 하나 (가역성)
|
|
26
|
+
|
|
27
|
+
- 🟢 AI / CLI: 자격증명 파일 작업, EAS 빌드·제출, `eas.json`의 public 값.
|
|
28
|
+
- 🟡 Owner 대시보드 (AI가 정확히 안내): 첫 Apple 로그인과 2FA, 스크린샷, App Privacy·Data Safety 라벨, Play 서비스 계정 JSON.
|
|
29
|
+
- 🔴 Owner 직접 (대리 불가): 카드 결제, 신원 검증, 멤버십 가입.
|
|
30
|
+
|
|
31
|
+
## 절차 (순서가 게이트다)
|
|
32
|
+
|
|
33
|
+
### 1. 개발자 계정 — 가장 먼저 (🔴 Owner)
|
|
34
|
+
|
|
35
|
+
두 계정 가입이 출시 전체의 리드타임을 지배하므로, 코드 작업 전에 둘 다 시작한다. 비용·계정 유형은 `store-release-guide` §1에서 읽는다(유료·환불 불가라 한 줄 confirm을 받는다). Individual/Personal을 권장하는 이유는 D-U-N-S를 피하고 신원검증만 거치기 때문이며, 개인 법적 이름이 공개 판매자명이 된다.
|
|
36
|
+
|
|
37
|
+
🔴 리드타임 트랩: Google 신규 개인계정은 production 전에 비공개(closed) 테스트가 강제고(인원·기간은 `store-release-guide` §1; 내부 테스트는 카운트하지 않는다), 이게 출시에서 가장 긴 지연이다. 그래서 **Google Play Console 가입을 가장 먼저** 해 둔다. Apple도 결제 후 멤버십 활성화에 시간이 걸린다.
|
|
38
|
+
|
|
39
|
+
### 2. 앱 레코드·서명 자격증명 (🟢 대부분 자동)
|
|
40
|
+
|
|
41
|
+
EAS가 iOS 인증서·프로비저닝·APNs 키와 Android 업로드 키스토어를 자동 생성하므로 `.cer`/`.jks`를 손대지 않는다. iOS ASC API 키도 EAS 자동 생성이 기본이며, Account Holder의 1회 'Request Access' 약관 동의가 선행이다. iOS는 첫 제출이 ASC 앱 레코드를 자동 생성해 TestFlight까지 간다.
|
|
42
|
+
|
|
43
|
+
🔴 트랩: Android 첫 AAB는 Play Console에 **수동으로 1회 업로드**해야 한다(Play API는 첫 릴리스를 만들지 못한다). 첫 자동 제출 실패는 정상이니 선고지한다. 이후부터 `eas submit -p android`가 동작한다. Play 제출용 서비스 계정 JSON은 항상 수동(🟡)이며 EAS 서버에 저장한다(`eas.json`에 시크릿 경로를 넣지 않는다). 절차는 `store-release-guide`.
|
|
44
|
+
|
|
45
|
+
### 3. 푸시 자격증명 (앱에 푸시를 켰을 때만)
|
|
46
|
+
|
|
47
|
+
앱은 Expo Push Token만 쓴다. iOS APNs 키는 EAS 자동(🟢)이고, Android FCM v1 서비스 계정은 Firebase Console에서 수동 생성한 뒤 EAS에 업로드한다(🟡). `google-services.json`(public·커밋), FCM SA JSON(시크릿), Play 제출 SA JSON은 각각 별개 파일이니 혼동하지 않는다. 매핑은 `store-release-guide`.
|
|
48
|
+
|
|
49
|
+
### 4. 소셜 로그인 콘솔 (소셜을 켰을 때만, 🟡)
|
|
50
|
+
|
|
51
|
+
Google OAuth 클라이언트는 Web·iOS·Android 3종이고, 코드에는 webClientId(Web)만 넣는다. Apple은 게이트로 네이티브 idToken(기본·bundle ID만)을 권장한다. 한국 개발자는 웹 OAuth용 Services ID에 server-to-server 엔드포인트 요건이 걸리므로(시행 시점은 `store-release-guide`), 네이티브 방식이 그 부담을 피한다.
|
|
52
|
+
|
|
53
|
+
🟡 콘솔 트랩 — 자동 계산이 아니라 손으로 맞춰야 조용히 안 깨진다:
|
|
54
|
+
- iOS reversed URL scheme은 플러그인에 **수동으로 붙여넣는다**(누락 시 iOS 소셜 로그인이 무한 로딩).
|
|
55
|
+
- SHA-1 지문은 **모든** 서명 키를 등록한다(EAS 빌드 키 포함). 일부만 등록하면 `DEVELOPER_ERROR`로 막힌다.
|
|
56
|
+
- 소셜을 켜면 Apple 4.8에 따라 Sign in with Apple도 동등하게 들어가므로, Supabase Apple provider 실배선까지 점검한다("Google만 켜고 Apple은 나중에"는 불가능하다). 절차는 `store-release-guide`.
|
|
57
|
+
|
|
58
|
+
### 5. 스토어 리스팅·컴플라이언스 (🟡 수동)
|
|
59
|
+
|
|
60
|
+
스크린샷, 연령등급(IARC), App Privacy(iOS)·Data Safety(Android) 라벨은 수동이다. 세 프라이버시 산출물(ASC 라벨·Data Safety·`PrivacyInfo.xcprivacy`)은 taxonomy가 달라 복붙이 안 되므로, 단일 데이터 인벤토리에서 각각 매핑한다.
|
|
61
|
+
|
|
62
|
+
게이트로 점검할 항목 — 둘 다 누락이 심사에서 거절을 부른다:
|
|
63
|
+
- **계정 삭제 (5.1.1(v))**: 배포된 웹에 인앱 삭제 경로가 있고, 네이티브 소셜 포함 모든 로그인에서 도달 가능한지 확인한다(웹뷰 렌더는 인앱으로 인정된다).
|
|
64
|
+
- **ATT**: 기본은 No다. 단, 웹뷰가 로드하는 배포된 웹의 트래커(GA4·Pixel 등)는 개발자 책임이므로, 배포 웹 스크립트를 실점검한다.
|
|
65
|
+
|
|
66
|
+
세부 기준은 `store-release-guide`.
|
|
67
|
+
|
|
68
|
+
### 6. 첫 제출 → 공개 심사 (🔴 트랩 + confirm)
|
|
69
|
+
|
|
70
|
+
production 빌드 하나를 만들어 두 스토어 테스트 채널로 보낸다(iOS는 TestFlight, Android는 내부 트랙). 빌드·제출 명령은 `eas-deploy-guide`. 같은 production 빌드를 그대로 공개로 승급하므로 전용 preview 빌드를 따로 만들지 않는다.
|
|
71
|
+
|
|
72
|
+
🔴 트랩: `eas submit`은 **공개 심사를 제출하지 않는다** — 테스트 채널까지만 올린다. 공개 출시는 수동 + 명시 confirm이다:
|
|
73
|
+
- **iOS**: App Store Connect에서 "Submit for Review".
|
|
74
|
+
- **Android**: Play 비공개 테스트 게이트(인원·기간은 `store-release-guide` §1)를 통과한 뒤 production으로 승급.
|
|
75
|
+
|
|
76
|
+
## confirm 경계
|
|
77
|
+
|
|
78
|
+
개발자 계정 가입·결제(1, 🔴), 공개 심사 제출과 production 승급(6, 🔴)은 Owner confirm을 받는다. 자격증명 파일 작업과 테스트 채널 제출은 자동(🟢)이다. 첫 Apple 로그인과 2FA·라벨·SA JSON은 Owner가 대시보드에서 진행한다(🟡, AI가 안내).
|
|
79
|
+
|
|
80
|
+
## 검증
|
|
81
|
+
|
|
82
|
+
성공은 두 가지가 참일 때다: production 빌드가 두 테스트 채널(TestFlight·Play 내부 트랙)에 올라 실기기에서 prod 웹을 띄우고, 컴플라이언스 게이트(계정 삭제 도달 가능·미사용 권한 없음·소셜을 켰다면 Sign in with Apple 동등)가 채워진다. 공개 심사 제출과 production 승급은 그 뒤 Owner confirm으로만 일어난다 — `eas submit`이 성공했다고 앱이 공개된 게 아니다(조용한 오해를 가르는 지점이다).
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
|
-
name:
|
|
3
|
-
description: Ship an app update the right way
|
|
2
|
+
name: app-update
|
|
3
|
+
description: Ship an app update the right way every time after the first launch — the agent reads what changed and routes it to a plain web deploy, an instant OTA update, or a native rebuild with a store resubmit. Use when the Owner wants to release an update once the app is already live, or invokes /app-update.
|
|
4
4
|
user-invocable: true
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
# /
|
|
7
|
+
# /app-update
|
|
8
8
|
|
|
9
|
-
출시 뒤로 계속 반복하는 흐름이다. 변경할 때마다 부른다.
|
|
9
|
+
출시 뒤로 계속 반복하는 흐름이다. 변경할 때마다 부른다. 웹은 언제나 SSOT이고, 앱은 배포된 웹을 다시 띄우는 얇은 셸이다. 그래서 대부분의 변경은 스토어를 건드리지 않고 끝난다. 변경의 성격에 따라 길이 셋으로 갈린다.
|
|
10
10
|
|
|
11
11
|
## 1. 무엇이 바뀌었나 — 길 가르기
|
|
12
12
|
|
|
@@ -42,7 +42,7 @@ user-invocable: true
|
|
|
42
42
|
`eas submit`은 테스트 채널까지만 올린다. 공개 심사 제출은 하지 않는다. 공개 출시는 심사·공개가 걸려 되돌리기 어렵다. 그래서 Owner가 콘솔에서 직접 + 명시 confirm으로 한다.
|
|
43
43
|
|
|
44
44
|
- **iOS** — App Store Connect에서 "Submit for Review".
|
|
45
|
-
- **Android** — production 트랙으로 승급. 신규 개인계정은 비공개 테스트
|
|
45
|
+
- **Android** — production 트랙으로 승급. 신규 개인계정은 비공개 테스트 게이트를 통과해야 production이 열린다(테스터 수·기간·면제 조건은 `store-release-guide`).
|
|
46
46
|
|
|
47
47
|
콘솔 절차·테스트 게이트·심사 통과 전략·컴플라이언스는 `store-release-guide`.
|
|
48
48
|
|
|
@@ -6,66 +6,83 @@ user-invocable: false
|
|
|
6
6
|
|
|
7
7
|
# 앱↔웹 대화 규격 (ssb 계약)
|
|
8
8
|
|
|
9
|
-
앱과 웹은 정해진 형식의 메시지로만 대화한다.
|
|
9
|
+
앱과 웹은 `ns: "ssb"`를 단 정해진 형식의 메시지로만 대화한다. 계약의 원본은 `ssb/`에 있고, 같은 내용이 `web/lib/bridge/`와 `mobile/src/bridge/`에 바이트 단위로 복사된다. 세 사본이 어긋나면 워크스페이스 루트의 `pnpm run doctor`가 잡는다. 메시지의 네이티브 측 처리·셸 배선은 `native-app-guide`가 맡는다.
|
|
10
|
+
|
|
11
|
+
## 빠른 참조 (값은 여기서 읽는다)
|
|
12
|
+
|
|
13
|
+
- 봉투·메시지 타입(`MSG`)·zod 스키마·불변식 헤더: `ssb/contract.ts`(SSOT). 메시지 목록을 외워 쓰지 말고 여기서 읽는다.
|
|
14
|
+
- tolerant 파서: `ssb/reader.ts`. 라우팅·핸드셰이크: `mobile/src/bridge/{router,capabilities,messaging}.ts`.
|
|
15
|
+
- 앱이 광고하는 capability 집합: `mobile/src/bridge/capabilities.ts`의 `APP_CAPABILITIES`. v1 토큰을 본문에 박지 않는다.
|
|
16
|
+
- 세션 핸드오프 nonce 규칙: `mobile/AGENTS.md` 불변식 2. 웹 어댑터 라우트: `mobile/docs/web-adapter/`.
|
|
10
17
|
|
|
11
18
|
## 봉투 & 전달 방식
|
|
12
19
|
|
|
13
20
|
봉투: `{ ns: "ssb", v: number, type: string, id?: string, payload?: object }`
|
|
14
21
|
|
|
15
|
-
- `ns: "ssb"`로 네임스페이스를 잡아, 페이지의 무관한 postMessage 트래픽을 무시한다. hotdeal의 `"hotdeal-bridge/1"`이
|
|
22
|
+
- `ns: "ssb"`로 네임스페이스를 잡아, 페이지의 무관한 postMessage 트래픽을 무시한다. `ssb`가 이 템플릿의 SSOT다(hotdeal의 `"hotdeal-bridge/1"`이 아니다).
|
|
16
23
|
- 웹 → 앱: 웹이 `window.ReactNativeWebView.postMessage(JSON.stringify(envelope))`를 호출하면, `Host.onMessage`가 raw 문자열을 `router.handleInbound(raw, ctx)`에 넘긴다.
|
|
17
24
|
- 앱 → 웹: `ctx.inject(buildReceiveInjection(msg))`가 `window.__ssbBridge?.receive(<json>)`를 호출하고 `CustomEvent("ssb:message", {detail})`를 디스패치한다(try/catch, 문 끝 `true;`).
|
|
18
25
|
- `ReactNativeWebView.postMessage`를 쓰므로 CSP와 무관하다. `injectedJavaScriptForMainFrameOnly={true}`로 메인 프레임만 대상으로 한다.
|
|
19
|
-
- 가드레일: WebView에 `limitsNavigationsToAppBoundDomains` / iOS `WKAppBoundDomains`를 설정하지 말 것. onMessage·injectedJavaScript가 소리 없이 죽어 브릿지 전체가 먹통이 된다.
|
|
20
26
|
|
|
21
|
-
|
|
27
|
+
`BridgeContext` = `{ appVersion, platform, webOrigin, inject(js), onNeedLogin(redirectPath?), onLoggedOut(), openExternal(url) }`.
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
2. 추가만 허용(append-only). 타입과 옵셔널 필드만 추가한다. 기존 타입 제거·이름변경·기존 필드 필수화는 금지한다. 구버전 앱이 최신 웹과, 최신 앱이 구버전 웹과 계속 동작해야 한다.
|
|
25
|
-
3. tolerant reader. `reader.ts`의 `readInbound`는 예외를 던지지 않는다. ns 불일치·非JSON·미지 type·필드 깨짐은 모두 `{ type: "UNKNOWN", raw }`로 받는다. 라우터는 UNKNOWN을 소리 없이 무시한다.
|
|
26
|
-
4. 버전 숫자 대신 **capabilities**로 분기한다. 앱이 HELLO로 `capabilities`(문자열 배열)를 알리고 웹이 feature-detect한다. v1 = `["session.v1", "external-links.v1"]`.
|
|
27
|
-
5. origin 화이트리스트. inbound는 신뢰 origin(`ALLOWED_ORIGINS`)에서만 받는다. 경계에서 `safeParse`한다.
|
|
29
|
+
라우팅 요약: READY→noop · REQUEST_SESSION_INSTALL→`onNeedLogin(payload?.redirectPath)` · AUTH_STATE_CHANGED→v1 noop 훅 · LOGOUT→`onLoggedOut()` 후 `inject(buildReceiveInjection(LOGOUT_DONE))` · OPEN_EXTERNAL→`openExternal(payload.url)` · UNKNOWN→무시.
|
|
28
30
|
|
|
29
|
-
##
|
|
31
|
+
## 불변식 (어기면 호환이 조용히 깨진다)
|
|
32
|
+
|
|
33
|
+
1. **토큰은 이 채널로 보내지 않는다.** 세션은 서버 Set-Cookie로 다룬다(아래 "세션"). `postMessage`·inject로 토큰을 넘기지 않는다 — 브릿지 페이로드는 신뢰 경계 밖이라 토큰이 새면 그대로 탈취된다.
|
|
34
|
+
2. **추가만 허용(additive-only).** 타입과 옵셔널 필드만 추가한다. 기존 타입 제거·이름변경·기존 필드 필수화는 금지한다. 구버전 앱이 최신 웹과, 최신 앱이 구버전 웹과 계속 동작해야 하기 때문이다(앱은 스토어 심사를 거쳐 천천히 깔린다).
|
|
35
|
+
3. **tolerant reader.** `reader.ts`의 `readInbound`는 예외를 던지지 않는다. ns 불일치·非JSON·미지 type·필드 깨짐은 모두 `{ type: "UNKNOWN", raw }`로 받고, 라우터는 UNKNOWN을 소리 없이 무시한다. 그래서 신버전 웹이 보낸 모르는 메시지가 구버전 앱을 깨뜨리지 않는다.
|
|
36
|
+
4. **버전 숫자 대신 capabilities로 분기한다.** 앱이 HELLO로 `capabilities`(문자열 배열)를 알리고 웹이 feature-detect한다. 토큰 목록은 `capabilities.ts`에서 읽는다.
|
|
37
|
+
5. **origin 화이트리스트.** inbound는 신뢰 origin(`ALLOWED_ORIGINS`)에서만 받고, 경계에서 `safeParse`한다.
|
|
38
|
+
|
|
39
|
+
## 모듈 지도 (`mobile/src/bridge/`)
|
|
30
40
|
|
|
31
41
|
| 파일 | 역할 | 상태 |
|
|
32
42
|
|------|------|------|
|
|
33
|
-
| `contract.ts` | 봉투·`MSG`·zod 스키마·타입
|
|
34
|
-
| `reader.ts` | `readInbound(raw)` tolerant 파서 | FIXED |
|
|
43
|
+
| `contract.ts` | 봉투·`MSG`·zod 스키마·타입 | FIXED, `ssb/`와 바이트 단위 동일 |
|
|
44
|
+
| `reader.ts` | `readInbound(raw)` tolerant 파서 | FIXED, `ssb/`와 동일 |
|
|
35
45
|
| `capabilities.ts` | `APP_CAPABILITIES`, `buildHello(...)` | 확장 |
|
|
36
46
|
| `messaging.ts` | `buildReceiveInjection(msg)` → inject용 JS | 확장 |
|
|
37
47
|
| `router.ts` | `handleInbound(raw, ctx)`, `helloInjection(ctx)` | 확장 |
|
|
38
48
|
|
|
39
|
-
`BridgeContext` = `{ appVersion, platform, webOrigin, inject(js), onNeedLogin(redirectPath?), onLoggedOut(), openExternal(url) }`.
|
|
40
|
-
|
|
41
49
|
코어 메시지: HELLO/READY · REQUEST_SESSION_INSTALL · AUTH_STATE_CHANGED · LOGOUT/LOGOUT_DONE · OPEN_EXTERNAL · UNKNOWN.
|
|
42
50
|
|
|
43
|
-
전방호환(forward-compat) 타입은 계약엔
|
|
44
|
-
|
|
45
|
-
라우팅 요약: READY→noop · REQUEST_SESSION_INSTALL→`onNeedLogin(payload?.redirectPath)` · AUTH_STATE_CHANGED→v1 noop 훅 · LOGOUT→`onLoggedOut()` 후 `inject(buildReceiveInjection(LOGOUT_DONE))` · OPEN_EXTERNAL→`openExternal(payload.url)` · UNKNOWN→무시.
|
|
51
|
+
전방호환(forward-compat) 타입은 계약엔 스키마만 있고 v1에서 빌드·송출하는 코드가 없다. `SESSION_INSTALLED`는 세션 핸드오프 완료 통지용이고, `PUSH_TOKEN_UPDATED`는 푸시가 v1에 없어(`expo-notifications` 없음) 타입만 선점한 것이다. 푸시를 살리려면 `expo-notifications` 추가, 토큰 획득·송출, `push.v1` capability append가 필요하다(앱 재빌드 — `eas-deploy-guide`).
|
|
46
52
|
|
|
47
53
|
## 새 메시지 타입 추가하는 법
|
|
48
54
|
|
|
49
55
|
1. `contract.ts`의 `MSG`에 새 상수를 append한다(제거·재정렬 금지).
|
|
50
|
-
2. 같은 파일에 해당
|
|
51
|
-
3. `reader.ts`는 그대로 둔다. tolerant라 새 inbound를 자동으로
|
|
52
|
-
4. inbound라면 `router.ts`의 `handleInbound` switch에 case를 추가한다. UNKNOWN
|
|
53
|
-
5. 새 능력이면 `capabilities.ts`의 `APP_CAPABILITIES`에 capability 문자열을
|
|
56
|
+
2. 같은 파일에 해당 zod 스키마를 추가한다. inbound면 `InboundSchema` union에, outbound면 `OutboundSchema` union에 넣고, 새 필드는 `.optional()`로 둔다.
|
|
57
|
+
3. `reader.ts`는 그대로 둔다. tolerant라 새 inbound를 자동으로 파싱한다.
|
|
58
|
+
4. inbound라면 `router.ts`의 `handleInbound` switch에 case를 추가한다. 빠뜨려도 UNKNOWN으로 무시되니 깨지진 않는다.
|
|
59
|
+
5. 새 능력이면 `capabilities.ts`의 `APP_CAPABILITIES`에 capability 문자열을 append한다. 웹이 HELLO로 감지한다.
|
|
54
60
|
6. 네이티브 변경이므로 앱을 재빌드한다(OTA 아님 — `eas-deploy-guide`).
|
|
55
|
-
7.
|
|
61
|
+
7. 같은 변경을 `ssb/`(원본)·`web/lib/bridge/`·`mobile/src/bridge/` 세 곳에 바이트 단위로 동일하게 반영하고, 루트에서 `pnpm run doctor`로 동일성을 확인한다.
|
|
56
62
|
|
|
57
63
|
> 새 기능은 앱에 먼저 들어가고(HELLO capability), 웹이 나중에 감지해 쓴다. 순서가 어긋나도 웹 코드가 capability 게이트로 막혀 있어 안전하다.
|
|
58
64
|
|
|
59
65
|
## 세션 (토큰 미경유)
|
|
60
66
|
|
|
61
|
-
> 기본값은 **웹 로그인**이다. 웹뷰 안에서 웹 자체 로그인 UI가 그대로 뜨고(쿠키·JS·storage 켜짐, `@supabase/ssr` 동작), 앱은 로그인에 아무것도 하지 않는다. 아래 네이티브 핸드오프 경로는 옵트인 fast-follow
|
|
67
|
+
> 기본값은 **웹 로그인**이다. 웹뷰 안에서 웹 자체 로그인 UI가 그대로 뜨고(쿠키·JS·storage 켜짐, `@supabase/ssr` 동작), 앱은 로그인에 아무것도 하지 않는다. 아래 네이티브 핸드오프 경로는 옵트인 fast-follow다 — `requestSessionHandoff`는 v1에서 호출자가 없다(네이티브 소셜 로그인을 도입할 때 쓰는 길).
|
|
62
68
|
|
|
63
|
-
핸드오프
|
|
69
|
+
핸드오프 메커니즘: 네이티브 로그인 → 서버 POST `/auth/app-bridge`(idToken은 body에) → short-TTL 1회용·단일사용 nonce → 서버 Set-Cookie + 303 → 이후 웹이 세션 갱신을 단독으로 담당한다. nonce 상세 규칙은 `mobile/AGENTS.md` 불변식 2가 SSOT다.
|
|
64
70
|
|
|
65
|
-
-
|
|
71
|
+
- **URL `?token=`은 금지한다.** 쿼리스트링은 로그·리퍼러·히스토리에 남아 유출되므로, 세션은 body의 nonce → Set-Cookie로만 넘긴다.
|
|
72
|
+
- **네이티브 `autoRefreshToken:false`는 필수다.** 갱신기가 둘이면 Supabase refresh-token reuse-detection이 트리거되어 세션 전체가 revoke된다. 핸드오프 뒤 갱신기는 웹 하나여야 한다.
|
|
66
73
|
- 네이티브 `secureSession`의 get/set은 호출자가 없다. 웹에서 발생한 LOGOUT 시 `clearAsync`만 실행된다.
|
|
67
74
|
- 로그아웃은 LOGOUT→웹 signOut→LOGOUT_DONE 양방향이다. 셸 측 배선(reload 불충분·쿠키 클리어)은 `native-app-guide`를 본다.
|
|
68
75
|
|
|
69
76
|
## 어댑터 (웹 측)
|
|
70
77
|
|
|
71
|
-
계약(`contract.ts`·`reader.ts`)은 이미 `web/lib/bridge/`에 설치돼 있다. 옵트인은 세션 핸드오프 레이어뿐이다 — 네이티브 소셜 로그인을 켤 때만 `docs/web-adapter/`의 POST 라우트(`route-app-bridge.ts`)·nonce·CSP를 웹 repo에 추가한다. 웹이
|
|
78
|
+
계약(`contract.ts`·`reader.ts`)은 이미 `web/lib/bridge/`에 설치돼 있다. 옵트인은 세션 핸드오프 레이어뿐이다 — 네이티브 소셜 로그인을 켤 때만 `docs/web-adapter/`의 POST 라우트(`route-app-bridge.ts`)·nonce·CSP를 웹 repo에 추가한다. 웹이 더하는 의존성은 `zod` 하나뿐이고, 네이티브·webview 라이브러리는 웹에 들어가지 않는다(웹은 `window.ReactNativeWebView` 전역으로만 앱에 닿는다). gronxb 등 라이브러리는 기각한다 — 네이티브-API-소유·monorepo 전제가 web=SSOT와 충돌한다.
|
|
79
|
+
|
|
80
|
+
## 함정 (조용히 깨지는 것)
|
|
81
|
+
|
|
82
|
+
- **WebView에 `limitsNavigationsToAppBoundDomains` / iOS `WKAppBoundDomains`를 설정하지 말 것.** 설정하면 onMessage·injectedJavaScript가 소리 없이 죽어 브릿지 전체가 먹통이 되는데, 에러가 안 나서 원인을 찾기 어렵다.
|
|
83
|
+
- **세 사본을 다 고치지 않으면** `ssb/`·`web/lib/bridge/`·`mobile/src/bridge/`가 어긋나 앱·웹이 서로 다른 계약을 들고 동작한다. 한 곳만 고치고 나머지를 잊는 게 가장 흔한 실수다 — doctor가 막아 준다.
|
|
84
|
+
- **기존 타입·필드를 바꾸면**(이름변경·필수화·제거) 그 순간 컴파일은 통과해도, 아직 그 변경을 모르는 구버전 앱(스토어에 깔린 것)에서 런타임에 깨진다. additive-only인 이유다.
|
|
85
|
+
|
|
86
|
+
## 검증
|
|
87
|
+
|
|
88
|
+
성공 = 세 사본이 동일해 `pnpm run doctor`의 브릿지 점검이 ✓이고, 추가한 메시지가 양방향으로 흐른다(앱이 HELLO로 capability를 광고하면 웹이 feature-detect로 켜고, inbound는 라우터 case로 처리된다). 조용한 실패의 신호: 보낸 메시지가 상대편에서 아무 반응 없이 사라지면 ns 불일치·미동기화·미등록 case로 UNKNOWN 처리된 것이다 — reader는 던지지 않으므로 콘솔 에러가 아니라 "무반응"으로 나타난다.
|
|
@@ -1,107 +1,65 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: eas-deploy-guide
|
|
3
|
-
description: EAS 빌드·배포
|
|
3
|
+
description: EAS 빌드·배포 메커니즘과 "앱을 폰에서 보기". 앱을 폰에 설치해 보기(실폰·시뮬레이터·TestFlight·Play 내부 테스트), EAS 빌드 프로파일(production/development)·dev build·EXPO_PUBLIC_WEB_URL·테스트 채널 송출(eas build --auto-submit)·OTA vs 네이티브 재빌드 판단·환경 모델(앱=prod 웹 래퍼) 작업 시 자동 로드된다. Owner가 "앱을 폰에서 보고 싶어 / 미리보기"라고 할 때도. (개발자 계정·심사·콘솔 출시는 store-release-guide.)
|
|
4
4
|
user-invocable: false
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
# EAS
|
|
7
|
+
# EAS 빌드 · 폰에서 보기 · 배포
|
|
8
8
|
|
|
9
|
-
EAS CLI는 글로벌로 설치하지 않는다. 항상 `npx eas-cli`를 쓰고,
|
|
9
|
+
EAS CLI는 글로벌로 설치하지 않는다. 항상 `npx eas-cli`를 쓰고, 앱 repo 루트에서 실행한다. 스토어 계정·인증서·심사는 `store-release-guide`가, 네이티브 셸 레시피는 `native-app-guide`가 맡는다.
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## 빠른 참조 (값은 여기서 읽는다)
|
|
12
|
+
|
|
13
|
+
- SDK·RN·React 버전과 브레이킹 변경: `mobile/package.json` + v56 문서(<https://docs.expo.dev/versions/v56.0.0/>). 메모리를 믿지 않는다.
|
|
14
|
+
- 빌드 프로파일·`EXPO_PUBLIC_*`: `mobile/eas.json`. 무료 티어 한도(월 빌드 수·동시성·빌드 시간): <https://expo.dev/pricing>.
|
|
15
|
+
- URL 가드·허용 origin: `mobile/src/config/env.ts`(SSOT).
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
## 환경 모델: 앱은 배포된 prod 웹 래퍼
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
- 별도의 dev 웹 staging은 불필요하다. 브릿지 계약이 additive·gated이므로(`bridge-guide`) 앱 연동 웹 코드를 prod에 올려도 구버전 앱·브라우저에서 안전하다.
|
|
17
|
-
- 링크가 앱 안에 머물지 시스템 브라우저로 나갈지의 경계(`ALLOWED_ORIGINS`)는 `native-app-guide` §링크 경계와 `src/config/env.ts`(SSOT)를 따른다.
|
|
19
|
+
앱엔 자체 백엔드 환경이 없다. 배포된 prod 웹 https 하나를 띄울 뿐이고, dev/prod 분기(Supabase·`VERCEL_ENV`)는 웹이 소유한다. 그래서 "앱 환경"은 *어느 웹 URL을 띄우나*와 *어떻게 서명·배포하나*로만 갈린다. 별도 dev 웹 staging은 불필요하다 — 브릿지 계약이 additive·gated라(`bridge-guide`) 앱 연동 웹 코드를 prod에 올려도 구버전 앱·브라우저에서 안전하다.
|
|
18
20
|
|
|
19
|
-
##
|
|
21
|
+
## 폰에서 보기 (= 빌드를 테스트 채널로)
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
"앱을 테스트한다 / 폰에서 본다"는 production 빌드 하나를 스토어 테스트 채널(iOS TestFlight, Android Play 내부 테스트)로 보내 실폰에 설치한다는 뜻이다. 그 빌드를 나중에 그대로 공개로 승급하므로 전용 preview 빌드를 따로 만들지 않는다 — EAS 빌드 수를 아끼고 실제 출시 아티팩트를 그대로 검증한다.
|
|
22
24
|
|
|
23
25
|
```bash
|
|
24
|
-
npx eas-cli build --profile production --platform all --auto-submit
|
|
26
|
+
npx eas-cli build --profile production --platform all --auto-submit --no-wait
|
|
25
27
|
# iOS → TestFlight, Android → Play 내부 테스트. 폰에서 TestFlight 앱 / 초대 링크로 설치.
|
|
26
28
|
```
|
|
27
29
|
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
|
|
32
|
-
## 실기기: prod 웹 https (localhost 함정)
|
|
33
|
-
|
|
34
|
-
앱은 배포된 prod 웹 https를 로드한다. 실기기는 Owner 컴퓨터의 `localhost`에 도달하지 못하고, http LAN은 secure-context가 아니라서 카메라·마이크·Web Crypto·클립보드·Secure 쿠키가 실기기에서 경고 없이 깨진다(Android cleartext, iOS ATS). 그러니 빌드의 `EXPO_PUBLIC_WEB_URL`은 항상 prod https로 둔다.
|
|
35
|
-
|
|
36
|
-
`src/config/env.ts` 가드는 protocol/hostname만 본다. https 또는 localhost면 통과한다. 이 가드는 실기기와 시뮬레이터를 구분하지 못한다. localhost URL은 가드를 통과하고도 실기기에서는 깨진다. "실기기 = prod https"는 코드가 강제하는 불변식이 아니라 Owner와 AI가 지키는 규칙이다.
|
|
37
|
-
|
|
38
|
-
### 고급·선택: 셸을 직접 고칠 때만
|
|
39
|
-
|
|
40
|
-
네이티브 셸(`Host.tsx` 등)을 빠르게 반복할 때만 `development` 프로파일(dev client)과 `npx expo start`(Metro Fast Refresh)를 쓴다. dev build는 폰이든 시뮬레이터든 어디서나 돈다.
|
|
41
|
-
|
|
42
|
-
- 시뮬레이터가 있다면(Mac=Xcode/iOS, 전 OS=Android 에뮬) 웹뷰를 `localhost`(iOS 시뮬)·`10.0.2.2`(Android 에뮬)로 가리켜 로컬 웹과 함께 고칠 수 있다. 실기기에서는 금지한다(위 secure-context 함정).
|
|
43
|
-
- `eas.json`의 `development` 프로파일은 기본적으로 https 플레이스홀더에 cleartext/ATS·localhost가 배선되지 않은 상태로 배송된다. 로컬 dev 루프는 AI가 요청 시 세팅하는 레시피다. localhost·`10.0.2.2`와 ATS/cleartext 예외를 `development` 프로파일에만 추가한다(`production`에는 절대 넣지 않는다). 사전 구성된 프로파일이 아니다.
|
|
44
|
-
- 대부분은 여기까지 가지 않는다. 웹 UI는 그냥 브라우저에서 고친다.
|
|
45
|
-
|
|
46
|
-
## 두 속도 (무엇이 어떻게 반영되나)
|
|
30
|
+
- **첫 빌드 준비는 Owner 몫이다.** Expo 계정 로그인·2FA는 Owner가 직접 한다(AI 대리 불가). 이후 빌드는 AI가 자동으로 돌린다.
|
|
31
|
+
- **개발자 계정이 아직 없으면**(app-launch 전) 스토어 테스트 채널을 못 쓴다. 그땐 EAS 내부배포로 먼저 설치한다(iOS는 UDID 등록 — `store-release-guide`). 계정·채널 셋업은 `app-launch`가 맡는다.
|
|
32
|
+
- 첫 빌드는 큐를 포함해 십수 분 ~ 20분쯤 걸린다(멈춘 게 아니다). 백그라운드는 항상 `--no-wait`로 두고, 진행은 Expo 대시보드 빌드 페이지에서 본다.
|
|
47
33
|
|
|
48
|
-
|
|
49
|
-
|------|-----------|
|
|
50
|
-
| 네이티브 셸 JS/TSX | Metro Fast Refresh — 즉시 |
|
|
51
|
-
| 웹 변경 | 웹 재배포(or 웹뷰 새로고침) — 앱은 그대로 |
|
|
52
|
-
| 네이티브/config(`app.config.ts`·플러그인·scheme·권한·SDK) | dev build 재빌드 |
|
|
34
|
+
### 셸만 빠르게 고칠 때 (선택·고급)
|
|
53
35
|
|
|
54
|
-
`
|
|
36
|
+
네이티브 셸(`Host.tsx` 등)만 반복할 땐 `development` 프로파일(dev client) + `npx expo start`(Metro Fast Refresh)를 쓴다. 시뮬레이터가 있으면(Mac=iOS, 전 OS=Android 에뮬) 웹뷰를 `localhost`(iOS 시뮬)·`10.0.2.2`(Android 에뮬)로 가리켜 로컬 웹과 함께 고칠 수 있다 — 실기기에선 금지(아래 함정). 대부분 여기까지 안 간다. 웹 UI는 그냥 브라우저에서 고친다.
|
|
55
37
|
|
|
56
|
-
##
|
|
38
|
+
## 두 속도: 무엇이 어떻게 반영되나
|
|
57
39
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
| 변경 | 조치 |
|
|
40
|
+
| 변경 | 반영 |
|
|
61
41
|
|------|------|
|
|
62
|
-
| 웹/UI
|
|
63
|
-
|
|
|
64
|
-
| 네이티브 모듈·`app.config.ts`·plugins·scheme·권한·SDK
|
|
65
|
-
|
|
66
|
-
- `runtimeVersion.policy: "appVersion"`이므로 같은 version 빌드끼리만 OTA가 가능하다.
|
|
67
|
-
- 재제출 시 marketing version(`app.config.ts`의 `version`) 올림이 필수다(ITMS-90186). autoIncrement는 buildNumber/versionCode만 올린다.
|
|
68
|
-
|
|
69
|
-
## eas build / submit
|
|
70
|
-
|
|
71
|
-
```bash
|
|
72
|
-
# 스토어 빌드 + 자동 제출 (iOS=TestFlight, Android=내부 트랙까지)
|
|
73
|
-
npx eas-cli build --profile production --platform ios \
|
|
74
|
-
--auto-submit --non-interactive --no-wait --message "<짧은 설명>"
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
- 백그라운드 진행은 항상 `--no-wait`로 둔다.
|
|
78
|
-
- `cli.appVersionSource: "remote"`와 `autoIncrement: true`로 buildNumber/versionCode를 EAS가 자동으로 +1 한다.
|
|
79
|
-
- `eas submit`은 공개 심사 제출까지 하지 않는다. iOS는 TestFlight, Android은 internal 트랙까지만 간다. 공개 출시(ASC "Submit for Review", Play production 승급)는 Owner가 수동으로, 명시적 confirm을 받아 한다. Android 첫 AAB는 Play Console에서 수동으로 1회 업로드해야 한다. 첫 자동 제출 실패는 정상이다([Expo: first Android submission](https://github.com/expo/fyi/blob/main/first-android-submission.md)).
|
|
80
|
-
- 자격증명(iOS 인증서·프로비저닝·APNs, Android 키스토어)은 EAS-managed로 자동 생성된다. `eas.json`에는 public 값만 커밋하고, 시크릿은 EAS 서버에 둔다.
|
|
81
|
-
|
|
82
|
-
## EAS 무료 티어 한도
|
|
83
|
-
|
|
84
|
-
iOS 15 + Android 15 빌드/월, 동시성 1, 우선순위 Low, 빌드당 45분([Expo 요금](https://expo.dev/pricing)). JS 변경은 빌드 풀을 쓰지 않지만, 초기 네이티브 셋업에는 iOS 빌드를 수 개 소모할 수 있다. "첫 빌드 10–20분 + 큐는 멈춘 게 아님"을 미리 알린다.
|
|
85
|
-
|
|
86
|
-
## SDK 56 핀 (브레이킹)
|
|
42
|
+
| 웹/UI(제품 기능·화면·로직) | 웹 재배포 — 앱은 그대로, 다음 로드에 새 웹을 띄운다 |
|
|
43
|
+
| 네이티브 셸의 순수 JS/TSX/이미지/스타일 | OTA: `npx eas-cli update` (즉시) |
|
|
44
|
+
| 네이티브 모듈·`app.config.ts`·plugins·scheme·권한·SDK·계약 메시지 추가 | 재빌드 + 재제출 |
|
|
87
45
|
|
|
88
|
-
-
|
|
89
|
-
- `
|
|
90
|
-
- iOS
|
|
91
|
-
- 이벤트 구독 정리는 `subscription.remove()`로 한다(`removeEventListener` 제거).
|
|
46
|
+
- `runtimeVersion.policy: "appVersion"`이라 같은 marketing version으로 나간 빌드끼리만 OTA가 도달한다. 네이티브가 한 줄이라도 바뀌면 OTA로 못 보낸다.
|
|
47
|
+
- 재빌드·재제출 땐 marketing version(`app.config.ts`의 `version`) 올림이 필수다 — 안 올리면 ITMS-90186으로 거절된다(`autoIncrement`는 buildNumber/versionCode만 올린다).
|
|
48
|
+
- `eas submit`은 공개 심사까지 가지 않는다 — iOS는 TestFlight, Android은 internal 트랙까지만. 공개 출시(ASC "Submit for Review", Play production 승급)는 Owner가 콘솔에서 수동 + 명시 confirm으로 한다(`app-launch`·`store-release-guide`). Android 첫 AAB는 Play Console에 수동으로 1회 업로드해야 한다(첫 자동 제출 실패는 정상).
|
|
92
49
|
|
|
93
|
-
##
|
|
50
|
+
## 함정 (조용히 깨지는 것)
|
|
94
51
|
|
|
95
|
-
-
|
|
96
|
-
-
|
|
97
|
-
-
|
|
52
|
+
- **실기기 = prod 웹 https.** 실기기는 Owner 컴퓨터의 `localhost`에 닿지 못하고, http LAN은 secure-context가 아니라 카메라·마이크·Web Crypto·클립보드·Secure 쿠키가 실기기에서 경고 없이 깨진다(Android cleartext·iOS ATS). 그래서 빌드의 `EXPO_PUBLIC_WEB_URL`은 항상 prod https다.
|
|
53
|
+
- **URL 가드는 protocol·hostname만 본다**(`src/config/env.ts`). https거나 localhost면 통과하므로 `localhost` URL은 가드를 통과하고도 실기기에선 깨진다 — "실기기=prod https"는 코드가 강제하는 불변식이 아니라 Owner·AI가 지키는 규칙이다. localhost·`10.0.2.2`와 ATS/cleartext 예외는 `development` 프로파일에만 추가한다(`production`엔 절대 넣지 않는다).
|
|
54
|
+
- **Expo Go로는 안 뜬다**(네이티브 모듈). 비개발자 환경엔 시뮬레이터도 없다고 가정한다 — 포장된 길은 production 빌드를 실폰에 설치하는 것이다.
|
|
55
|
+
- **무료 티어 빌드 수는 제한적이다.** 초기 네이티브 셋업이 iOS 빌드를 여러 개 소모할 수 있으니 ad-hoc preview로 낭비하지 않는다(정확한 한도는 위 Expo 요금 페이지).
|
|
56
|
+
- **SDK 버전을 추측하지 않는다.** 작성 전 `mobile/package.json`·v56 문서를 읽는다. 행동에 영향을 주는 v56 브레이킹: 이벤트 구독 정리는 `subscription.remove()`(`removeEventListener` 제거), `@expo/vector-icons` deprecated → `@react-native-vector-icons/*`, edge-to-edge 강제 기본, `expo-status-bar`의 `backgroundColor`·`translucent` props 제거.
|
|
98
57
|
|
|
99
|
-
##
|
|
58
|
+
## 자격증명 · 플랫폼 (짧게)
|
|
100
59
|
|
|
101
|
-
-
|
|
102
|
-
-
|
|
103
|
-
- 테스트와 출시 모두 production(store) 프로파일 하나를 쓴다. TestFlight(iOS), Play 내부 테스트(Android). ad-hoc 사이드로드가 필요할 때만 `distribution: internal` 프로파일을 따로 추가한다(UDID 한정, 출시로 승급되지 않음).
|
|
60
|
+
- 자격증명(iOS 인증서·프로비저닝·APNs, Android 키스토어)은 EAS-managed로 자동 생성된다. `eas.json`엔 public 값만, 시크릿은 EAS 서버에 둔다. `.env`는 커밋하지 않는다(`.env.example`만, 플레이스홀더만).
|
|
61
|
+
- iOS 시뮬은 Mac/Xcode 전용이다. Windows·Linux는 본인 iPhone + EAS 클라우드 빌드로 한다(Mac 불필요). Android 에뮬은 전 OS에서 되되 Google Play Services 포함 이미지여야 한다.
|
|
104
62
|
|
|
105
|
-
##
|
|
63
|
+
## 검증
|
|
106
64
|
|
|
107
|
-
|
|
65
|
+
폰에서 보기의 성공 = 실기기에 설치한 앱이 **prod 웹 https를 띄우고 화면이 돈다**. 로그인은 웹뷰 안의 웹 로그인 UI로 뜬다(셸 기능이 아니다). 푸시는 v1엔 없고, 추가했을 때만 실기기에서 검증한다(시뮬 푸시는 sandbox라 prod 검증이 아니다).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: native-app-guide
|
|
3
|
-
description: 네이티브 셸(WebView 래퍼) 작업 시 참고. 앱 껍데기·WebView 호스트·로딩/에러/오프라인 화면·Android 뒤로가기·외부
|
|
3
|
+
description: 네이티브 셸(WebView 래퍼) 작업 시 참고. 앱 껍데기·WebView 호스트·로딩/에러/오프라인 화면·Android 뒤로가기·외부 링크의 시스템 브라우저 라우팅·origin 경계(ALLOWED_ORIGINS)·딥링크 열기(URL 라우팅)·safe-area(노치)·파일 업로드 권한 배선·크래시 복구 작업 시 자동 로드됩니다. (앱↔웹 메시지/세션 핸드오프는 bridge-guide.)
|
|
4
4
|
user-invocable: false
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -8,31 +8,40 @@ user-invocable: false
|
|
|
8
8
|
|
|
9
9
|
배포된 prod 웹 https를 로드하는 얇은 WebView 셸이다. 모든 화면·기능·로그인은 웹이 가지고, 셸은 WebView가 막거나 못 하는 네이티브 공백만 메운다. 셸이 진짜 네이티브로 제공하는 건 다음이다. 오프라인 화면(유일하게 분명한 네이티브 전용 화면), 첫/하드 로드와 크래시 재마운트에 한정한 로딩·에러 오버레이, Android 하드웨어 뒤로가기, 외부 링크의 시스템 브라우저 라우팅, safe-area inset 주입, 카메라/마이크/파일 업로드 권한 배선(능력 자체는 웹·라이브러리의 것). 구현은 `src/webview/`에 모이고, 링크 경계는 `src/config/env.ts`의 `ALLOWED_ORIGINS`를 SSOT로 쓴다.
|
|
10
10
|
|
|
11
|
-
셸은 4.2(최소기능, [App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/))를 통과하도록 설계된 게 아니라 출발점이다. 4.2를 넘으려면 Owner가 진짜 네이티브 차별점을 더해야 한다. 흔한 다음 단계는 네이티브 푸시인데 v1엔 없다(contract.ts에 forward-compat `PUSH_TOKEN_UPDATED` 상수와 `push.v1` capability 자리만 있고, `expo-notifications`·토큰 취득·핸들러는 전무하다). 켜려면 AI가 expo-notifications를 추가하고, 권한·토큰을 등록하고, `PUSH_TOKEN_UPDATED`로 웹에 전달하고, cold-start는 딥링크 섹션의 가드를 배선한다.
|
|
11
|
+
셸은 4.2(최소기능, [App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/))를 통과하도록 설계된 게 아니라 출발점이다. 4.2를 넘으려면 Owner가 진짜 네이티브 차별점을 더해야 한다. 흔한 다음 단계는 네이티브 푸시인데 v1엔 없다(contract.ts에 forward-compat `PUSH_TOKEN_UPDATED` 상수와 `push.v1` capability 자리만 있고, `expo-notifications`·토큰 취득·핸들러는 전무하다). 켜려면 AI가 expo-notifications를 추가하고, 권한·토큰을 등록하고, `PUSH_TOKEN_UPDATED`로 웹에 전달하고, cold-start는 딥링크 섹션의 가드를 배선한다. 4.2·스토어 심사·개발자 계정 가격은 `store-release-guide`(SSOT).
|
|
12
|
+
|
|
13
|
+
## 빠른 참조 (값은 여기서 읽는다)
|
|
14
|
+
|
|
15
|
+
- SDK·RN·React·라이브러리 버전과 브레이킹 변경: `mobile/package.json` + v56 문서(<https://docs.expo.dev/versions/v56.0.0/>). 학습 데이터와 다르니 메모리를 믿지 않는다.
|
|
16
|
+
- 설치된 네이티브 API의 실제 시그니처: `node_modules/react-native-webview/lib/*.d.ts` 등 타입 정의. docs에만 있고 코드엔 없는 이름이 흔하다(아래 함정).
|
|
17
|
+
- 링크 경계·허용 origin: `src/config/env.ts`의 `ALLOWED_ORIGINS`(SSOT). URL 가드 동작도 여기.
|
|
18
|
+
- `EXPO_PUBLIC_WEB_URL`의 정확한 값: `mobile/.env.example`(플레이스홀더만 커밋된다).
|
|
19
|
+
- 셸 구현 본체: `src/webview/`(`Host.tsx`·`linkBoundary.ts`·`linkBoundary.test.ts`).
|
|
12
20
|
|
|
13
21
|
## 먼저: 추측하지 말고 확인
|
|
14
22
|
|
|
15
|
-
SDK
|
|
23
|
+
이 SDK는 학습 데이터와 다르다. 네이티브 API를 쓰기 전에 설치된 타입이나 v56 docs를 읽어 시그니처를 확인한다 — 기억으로 쓴 prop·메서드가 조용히 사라졌거나 이름만 같고 동작이 다른 경우가 많다. 행동에 영향을 주는 v56 브레이킹은 함정 섹션에 모았다.
|
|
16
24
|
|
|
17
|
-
- 이벤트 구독은 `subscription.remove()`로 정리한다(RN 0.85에서 `removeEventListener` 제거됨).
|
|
18
25
|
- 컴포넌트는 `React.JSX.Element`를 반환한다. strict TS를 쓰고 `any`를 회피한다.
|
|
19
26
|
- `react-native-url-polyfill/auto`가 `index.ts`에서 먼저 로드돼야 `new URL().origin`이 Hermes에서 동작한다(링크 allow-list의 전제).
|
|
20
27
|
- `EXPO_PUBLIC_WEB_URL`은 빌드 시점에 인라인된다(바꾸려면 재빌드). 인라인 출처는 클라우드 빌드=`eas.json` `build.<profile>.env`, 로컬 `expo start`=Owner가 만든 `.env`다. `.env`는 커밋하지 않고(`.gitignore`), 커밋되는 `.env.example`은 플레이스홀더만 담는다(정확한 값은 `mobile/.env.example`을 직접 본다). `env.ts` 가드는 protocol/hostname만 본다. 실기기인지 시뮬레이터인지 못 구분하므로 localhost URL도 가드는 통과하나 실기기에선 실패한다. "실기기=prod https"는 코드가 강제하는 불변식이 아니라 Owner/AI 규칙이다.
|
|
21
28
|
|
|
22
29
|
## safe-area (노치 여백)
|
|
23
30
|
|
|
24
|
-
`env(safe-area-inset-*)`는 양 플랫폼 다 못 믿는다(Android WebView
|
|
31
|
+
`env(safe-area-inset-*)`는 양 플랫폼 다 못 믿는다(Android WebView가 0px로 보고, iOS WKWebView는 첫 페인트에 점프한다). 그래서 책임을 분리한다: 네이티브는 inset 값을 측정하고, 웹은 레이아웃을 맡는다.
|
|
25
32
|
|
|
26
33
|
1. 네이티브가 `useSafeAreaInsets()`로 inset을 측정한다.
|
|
27
34
|
2. WebView `<meta viewport>`에 `viewport-fit=cover`를 둔다.
|
|
28
35
|
3. `injectedJavaScriptBeforeContentLoaded`로 그 값을 커스텀 CSS 변수(`--ssb-inset-top/right/bottom/left`, `env()`가 아님)에 주입한다.
|
|
29
36
|
4. 웹이 그 변수로 패딩한다.
|
|
30
37
|
|
|
31
|
-
`SafeAreaProvider`에 `initialMetrics={initialWindowMetrics}`를 줘 첫 프레임 flash를 방지한다. px→dp→CSS px 변환에 주의한다.
|
|
38
|
+
`SafeAreaProvider`에 `initialMetrics={initialWindowMetrics}`를 줘 첫 프레임 flash를 방지한다. px→dp→CSS px 변환에 주의한다.
|
|
39
|
+
|
|
40
|
+
edge-to-edge가 이 SDK에선 강제 기본이다(Android의 최신 targetSdk가 화면을 시스템 바 뒤까지 그리도록 강제한다 — 이게 위 safe-area 측정이 필요한 이유다). 그래서 상태바·내비바 아이콘 스타일은 `expo-status-bar`가 아니라 `react-native-edge-to-edge`의 `SystemBars` 한 컴포넌트로 제어한다(App.tsx `<SystemBars style="dark" />`). `expo-status-bar`의 `backgroundColor`·`translucent` props는 이 SDK에서 제거됐다 — edge-to-edge에선 의미가 없어 Expo가 SystemBars를 권하기 때문이다.
|
|
32
41
|
|
|
33
42
|
## Android 뒤로가기
|
|
34
43
|
|
|
35
|
-
`canGoBack()`이면 `webView.goBack()` + `return true`. 아니면 `return false`로 OS가 종료·예측-뒤로를 처리하게 한다. 종료확인 Alert는 선택적 레거시다(Android 13/14 예측-뒤로와 충돌). cleanup은 `BackHandler.addEventListener(...).remove()`.
|
|
44
|
+
`canGoBack()`이면 `webView.goBack()` + `return true`. 아니면 `return false`로 OS가 종료·예측-뒤로를 처리하게 한다. 종료확인 Alert는 선택적 레거시다(Android 13/14 예측-뒤로와 충돌). cleanup은 `BackHandler.addEventListener(...).remove()` — 이 SDK에서 `removeEventListener`가 제거됐기 때문이다(이벤트 정리는 항상 구독 객체의 `.remove()`).
|
|
36
45
|
|
|
37
46
|
## 링크 경계 (외부 링크)
|
|
38
47
|
|
|
@@ -79,7 +88,7 @@ WebView 프로세스가 죽으면 `key`를 증가시켜 통째 재마운트한
|
|
|
79
88
|
|
|
80
89
|
네이티브 로딩·에러 오버레이는 첫/하드 WebView 로드(빈 WebView)와 크래시 재마운트에만 덮는다. 인앱 Next.js SPA(클라이언트) 내비게이션은 네이티브 WebView에 보이지 않으므로, 페이지 전환 로딩과 인페이지/SPA 에러는 웹 자체 UI가 처리한다(셸이 중복 제공하지 않는다).
|
|
81
90
|
|
|
82
|
-
- 로딩: `startInLoadingState`/`renderLoading`을 쓰지 않는다(그 이름은 docs에만 있고 코드엔 없다). 직접 관리하는 `loading` state를 첫 `onLoadEnd`에서 해제한다(`loading` i18n).
|
|
91
|
+
- 로딩: `startInLoadingState`/`renderLoading`을 쓰지 않는다(그 이름은 docs에만 있고 코드엔 없다 — 추측하지 말고 확인하라는 이유다). 직접 관리하는 `loading` state를 첫 `onLoadEnd`에서 해제한다(`loading` i18n).
|
|
83
92
|
- 에러: `onError`(네트워크/전송) + `onHttpError`(메인 프레임 에러 상태)만으로 에러 화면을 띄운다(`error.*` i18n + 재시도 버튼). 재시도 = `key` 증가 통째 재마운트(크래시 복구와 동일).
|
|
84
93
|
- 사용자 문자열은 전부 `src/i18n.ts` `t()`를 경유한다.
|
|
85
94
|
|
|
@@ -91,6 +100,10 @@ WebView 프로세스가 죽으면 `key`를 증가시켜 통째 재마운트한
|
|
|
91
100
|
- Android: `CAMERA` 권한과 `<queries>` IMAGE_CAPTURE 인텐트 가시성(app.config.ts `withImageCaptureQuery` 플러그인). Android 11+ 패키지 가시성 때문에 둘은 별개 요구사항이다. 권한=카메라 사용, queries=카메라 앱 조회(`resolveActivity`). queries가 누락되면 갤러리·파일 선택은 되나 `<input type=file capture>`의 '카메라 촬영' 옵션이 안 뜬다(크래시는 아니다).
|
|
92
101
|
- 키보드 focus: `app.config.ts` `android.softwareKeyboardLayoutMode: "resize"` · iOS는 WebView prop `keyboardDisplayRequiresUserAction={false}`(Host.tsx).
|
|
93
102
|
|
|
103
|
+
## 아이콘 (벡터 아이콘)
|
|
104
|
+
|
|
105
|
+
아이콘 라이브러리가 바뀌었다. `@expo/vector-icons`는 이 SDK에서 deprecated이므로 `@react-native-vector-icons/*`(scoped 패키지)를 쓴다. 기억으로 `@expo/vector-icons`를 import하면 타입은 통과해도 deprecated 경로라 점진적으로 깨지므로, 추가 전 `mobile/package.json`에서 실제 설치된 아이콘 패키지를 확인한다.
|
|
106
|
+
|
|
94
107
|
## 로그인 · 소셜(FAST-FOLLOW)
|
|
95
108
|
|
|
96
109
|
로그인은 웹이 담당한다. 기본 상태에서 앱은 로그인에 아무것도 하지 않는다. 웹 로그인 화면이 웹뷰 안에 그대로 뜨고 쿠키·세션도 웹의 `@supabase/ssr`가 처리한다. 네이티브 로그인 모달(`src/auth/LoginScreen.tsx`)은 웹이 `REQUEST_SESSION_INSTALL`을 보낼 때만 뜨는 옵트인 스캐폴드이며, 유일하게 동작하는 버튼('이메일로 계속')은 모달을 닫아 웹 로그인으로 되돌릴 뿐이다(인증·재로드·네비게이션 없음). 소셜 원클릭(Google/Apple)은 disabled 'coming soon' 스텁이다(fast-follow).
|
|
@@ -99,4 +112,25 @@ WebView 프로세스가 죽으면 `key`를 증가시켜 통째 재마운트한
|
|
|
99
112
|
|
|
100
113
|
## 세션 핸드오프 (요약 — 상세는 bridge-guide)
|
|
101
114
|
|
|
102
|
-
서버 Set-Cookie + 303(네이티브 쿠키 주입이 아니다). URL `?token=`은 금지하고 1회용 nonce 교환을 쓴다. 로그아웃은 reload로 불충분하므로 `key` 증가 + iOS WebKit 쿠키/데이터스토어 클리어를 한다. ready 핸드셰이크는 웹 ack 단독으로 두지 않는다(`onMessage`는 fire-and-forget이라 iOS에서 드롭된다). 네이티브 타임아웃+재시도 fallback으로 흰 화면 hang을 방지한다.
|
|
115
|
+
서버 Set-Cookie + 303(네이티브 쿠키 주입이 아니다). URL `?token=`은 금지하고 1회용 nonce 교환을 쓴다. 로그아웃은 reload로 불충분하므로 `key` 증가 + iOS WebKit 쿠키/데이터스토어 클리어를 한다. ready 핸드셰이크는 웹 ack 단독으로 두지 않는다(`onMessage`는 fire-and-forget이라 iOS에서 드롭된다). 네이티브 타임아웃+재시도 fallback으로 흰 화면 hang을 방지한다.
|
|
116
|
+
|
|
117
|
+
## 함정 (조용히 깨지는 것)
|
|
118
|
+
|
|
119
|
+
대부분 타입은 통과하고 런타임에만 조용히 깨지는 것들이다 — 그래서 기억이 아니라 확인이 기본값이다.
|
|
120
|
+
|
|
121
|
+
- **이벤트 정리에 `removeEventListener`를 쓰면 안 정리된다.** 이 SDK에서 그 메서드는 제거됐다. `BackHandler`·`NetInfo`·`Linking`·딥링크 리스너 전부 구독 객체의 `.remove()`로 푼다. 옛 패턴으로 쓰면 에러 없이 리스너가 누수돼 재마운트마다 중복 핸들러가 쌓인다.
|
|
122
|
+
- **`expo-status-bar`의 `backgroundColor`·`translucent` props는 제거됐다.** edge-to-edge 강제 기본에선 의미가 없어졌다. 상태바·내비바는 `react-native-edge-to-edge`의 `SystemBars`로만 제어한다. 옛 props는 무시되거나 타입 에러를 낸다.
|
|
123
|
+
- **아이콘을 `@expo/vector-icons`에서 import하면 deprecated 경로다.** `@react-native-vector-icons/*`로 옮긴다(빠른 참조의 설치 패키지 확인).
|
|
124
|
+
- **URL 가드는 protocol·hostname만 본다.** https거나 localhost면 통과하므로 localhost URL도 가드를 통과하고 실기기에선 깨진다. "실기기=prod https"는 코드가 강제하지 못하는, Owner·AI가 지키는 규칙이다.
|
|
125
|
+
- **링크 경계에서 `startsWith`로 origin을 비교하면 서브도메인 prefix 위장이 뚫린다.** `new URL().origin` 등가 비교만 쓴다. 동일 origin을 external로 보내면 Android #2819 유령 리로드가 난다.
|
|
126
|
+
- **`onOpenWindow` 누락 → `_blank` 링크가 백지로 뜬다.** `setSupportMultipleWindows`는 기본 TRUE라 prop을 안 건드려야 `onOpenWindow`가 동작한다.
|
|
127
|
+
- **`onRenderProcessGone`에서 `return false` → 앱이 WebView와 함께 죽는다.** 크래시 복구는 `reload()`가 아니라 `key` 증가 재마운트여야 Android가 인스턴스를 폐기·재생성한다.
|
|
128
|
+
- **라이브 연결 끊김을 `onError`로 잡으려 하면 안 잡힌다**(로드 실패만 잡는다). 끊김 감지는 `netinfo` 구독이 필수다.
|
|
129
|
+
- **iOS usage string 누락 시 Apple이 런타임에 앱을 종료시킨다**(크래시가 아니라 강제 종료). Android queries 누락은 더 조용하다 — '카메라 촬영' 옵션만 안 뜨고 갤러리는 멀쩡하다.
|
|
130
|
+
- **`startInLoadingState`/`renderLoading`은 코드에 없다**(docs에만 있는 이름). 직접 관리하는 `loading` state를 쓴다.
|
|
131
|
+
|
|
132
|
+
## 검증
|
|
133
|
+
|
|
134
|
+
성공은 실기기에 설치한 앱이 prod 웹 https를 띄우고, 네이티브 공백이 메워질 때다: 비행기 모드에서 오프라인 화면이 뜨고(연결 복구 시 자동 회복), 외부 도메인 링크가 시스템 브라우저로 빠지고 허용 origin은 in-app으로 남고, 노치 기기에서 상단·하단 콘텐츠가 시스템 바에 가리지 않고, Android 하드웨어 뒤로가기가 웹 히스토리를 따라간다.
|
|
135
|
+
|
|
136
|
+
조용한 실패의 신호: 재마운트를 반복할수록 핸들러가 중복돼 동작이 누적되면(이벤트 정리 미흡), `_blank` 링크가 백지로 뜨면(`onOpenWindow` 누락), 실기기에서 카메라·쿠키·클립보드가 경고 없이 안 되면(localhost URL이 가드를 통과). 링크 경계는 `linkBoundary.test.ts`(vitest)로, 전체 빌드 건전성은 `mobile/`에서 `pnpm run typecheck`로 가른다.
|
|
@@ -6,7 +6,7 @@ description: Adaptive interview to probe an idea, plan, feature, data model, or
|
|
|
6
6
|
# /probe
|
|
7
7
|
|
|
8
8
|
Owner와 공유된 이해에 도달할 때까지 집요하게 조사하라.
|
|
9
|
-
회색지대를
|
|
9
|
+
회색지대를 없애야 한다.
|
|
10
10
|
|
|
11
11
|
결정들을 하나씩 짚어 내려가며, 서로 얽힌 의존 관계를 풀어라.
|
|
12
12
|
|
|
@@ -14,4 +14,16 @@ Owner의 인지부하를 줄이기 위해,
|
|
|
14
14
|
AskUserQuestion을 사용하여 한 번에 하나씩 질문하라.
|
|
15
15
|
각 질문마다 네가 추천하는 답안도 표시하라.
|
|
16
16
|
|
|
17
|
-
만약 어떤 질문이 현재 디렉토리를 탐색해서 답할 수 있다면,
|
|
17
|
+
만약 어떤 질문이 현재 디렉토리를 탐색해서 답할 수 있다면, Owner에게 묻지 말고 디렉토리를 탐색하라.
|
|
18
|
+
|
|
19
|
+
## 끝내는 법
|
|
20
|
+
|
|
21
|
+
모든 회색지대가 탐색되면, 합의된 결정을 짧은 기록(plan)으로 남기거나 남길지 묻는다.
|
|
22
|
+
이 기록이 이후 build 단계의 입력이 된다 — Owner가 다시 설명하지 않아도 되고, 무엇이 왜 그렇게 정해졌는지가 다음 작업으로 이어진다.
|
|
23
|
+
대화로 끝나 버리면 합의가 휘발해 같은 결정을 다시 캐물어야 한다.
|
|
24
|
+
|
|
25
|
+
## 검증
|
|
26
|
+
|
|
27
|
+
성공은 결정들이 Owner의 머릿속이 아니라 *공유된 형태*로 닫힐 때다.
|
|
28
|
+
남은 회색지대 없이 합의에 도달했고, 그 합의가 다음 단계가 집어 들 수 있는 기록으로 남았으면(또는 Owner가 남기지 않기로 명시했으면) 완료다.
|
|
29
|
+
열린 가정이 그대로 남아 있거나 합의가 대화에만 있고 어디에도 적히지 않았다면 아직 끝나지 않은 것이다.
|
|
@@ -5,15 +5,21 @@ description: Make working pages in the dev server so Owner can pick from design
|
|
|
5
5
|
|
|
6
6
|
# /sketch
|
|
7
7
|
|
|
8
|
-
sketch
|
|
9
|
-
Owner는 dev 서버
|
|
8
|
+
`app/sketch/<feature>/`에 Owner의 의도에 맞춰 동작하는 페이지를 스케치한다.
|
|
9
|
+
Owner는 dev 서버 브라우저로 결과물을 보고 피드백·확정할 수 있어야 한다.
|
|
10
10
|
|
|
11
|
-
디자인 시안은 서로 다른
|
|
12
|
-
시나리오는 데이터·상태 케이스(Best / Common / Worst 등)를 충분히
|
|
11
|
+
디자인 시안은 서로 다른 안을 나란히 펼쳐, Owner가 어느 룩으로 갈지 고르게 한다.
|
|
12
|
+
시나리오는 데이터·상태 케이스(Best / Common / Worst 등)를 충분히 표현해 의사결정을 돕는다.
|
|
13
13
|
|
|
14
|
-
프로젝트의 디자인
|
|
14
|
+
프로젝트의 디자인 시스템(shadcn + 디자인 컨벤션)을 탐색해 따른다.
|
|
15
15
|
업데이트가 필요한 source가 보이면 Owner 확인 후 반영한다.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
## 정리 (확정 후)
|
|
18
|
+
|
|
19
|
+
`app/sketch/`는 *현재 검토 중인 시안*만 사는 임시 공간이다. 확정되면 채택안만 이 경로에 남기고 나머지 변형은 비워, 다음 스케치가 죽은 안 위에서 시작하지 않게 한다.
|
|
20
|
+
|
|
21
|
+
비운 안은 버리지 말고 디자인 레퍼런스로 옮겨 둔다 — 탈락한 룩·상태 케이스는 다음 디자인 작업에서 다시 꺼내 보는 자산이다. 즉 정리는 *삭제*가 아니라 *라이브 경로에서 빼서 보관*이다. 어디에·어떻게 보관할지 모호하면 Owner에게 묻는다.
|
|
22
|
+
|
|
23
|
+
## 검증
|
|
24
|
+
|
|
25
|
+
`app/sketch/<feature>/`에 채택안 하나만 남아 dev 서버에서 돌고, 탈락 안들은 사라진 게 아니라 레퍼런스로 옮겨져 있다.
|