create-saas-starter-workspace 0.1.0
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/CHANGELOG.md +7 -0
- package/LICENSE +17 -0
- package/README.md +19 -0
- package/bin/index.mjs +201 -0
- package/package.json +25 -0
- package/src/scaffold.mjs +109 -0
- package/src/validate.mjs +22 -0
- package/templates/workspace/.claude/settings.json +44 -0
- package/templates/workspace/.claude/skills/bridge-guide/SKILL.md +71 -0
- package/templates/workspace/.claude/skills/eas-deploy-guide/SKILL.md +107 -0
- package/templates/workspace/.claude/skills/go-live/SKILL.md +66 -0
- package/templates/workspace/.claude/skills/kickoff/SKILL.md +72 -0
- package/templates/workspace/.claude/skills/launch/SKILL.md +69 -0
- package/templates/workspace/.claude/skills/native-app-guide/SKILL.md +102 -0
- package/templates/workspace/.claude/skills/preview/SKILL.md +43 -0
- package/templates/workspace/.claude/skills/probe/SKILL.md +17 -0
- package/templates/workspace/.claude/skills/release/SKILL.md +51 -0
- package/templates/workspace/.claude/skills/sketch/SKILL.md +19 -0
- package/templates/workspace/.claude/skills/store-release-guide/SKILL.md +239 -0
- package/templates/workspace/.claude/skills/vercel-cron/SKILL.md +17 -0
- package/templates/workspace/.claude/skills/warmup/SKILL.md +33 -0
- package/templates/workspace/AGENTS.md +39 -0
- package/templates/workspace/CLAUDE.md +2 -0
- package/templates/workspace/LICENSE +17 -0
- package/templates/workspace/README.md +20 -0
- package/templates/workspace/START-HERE.md +52 -0
- package/templates/workspace/gitignore +18 -0
- package/templates/workspace/mobile/.env.example +25 -0
- package/templates/workspace/mobile/AGENTS.md +69 -0
- package/templates/workspace/mobile/App.tsx +47 -0
- package/templates/workspace/mobile/CLAUDE.md +2 -0
- package/templates/workspace/mobile/LICENSE +17 -0
- package/templates/workspace/mobile/README.md +73 -0
- package/templates/workspace/mobile/app.config.ts +153 -0
- package/templates/workspace/mobile/assets/android-icon-background.png +0 -0
- package/templates/workspace/mobile/assets/android-icon-foreground.png +0 -0
- package/templates/workspace/mobile/assets/android-icon-monochrome.png +0 -0
- package/templates/workspace/mobile/assets/favicon.png +0 -0
- package/templates/workspace/mobile/assets/icon.png +0 -0
- package/templates/workspace/mobile/assets/splash-icon.png +0 -0
- package/templates/workspace/mobile/docs/web-adapter/README.md +130 -0
- package/templates/workspace/mobile/docs/web-adapter/route-app-bridge.ts +235 -0
- package/templates/workspace/mobile/eas.json +31 -0
- package/templates/workspace/mobile/gitignore +45 -0
- package/templates/workspace/mobile/index.ts +12 -0
- package/templates/workspace/mobile/package.json +38 -0
- package/templates/workspace/mobile/pnpm-lock.yaml +5201 -0
- package/templates/workspace/mobile/src/auth/LoginScreen.tsx +192 -0
- package/templates/workspace/mobile/src/bridge/capabilities.test.ts +44 -0
- package/templates/workspace/mobile/src/bridge/capabilities.ts +42 -0
- package/templates/workspace/mobile/src/bridge/contract.test.ts +49 -0
- package/templates/workspace/mobile/src/bridge/contract.ts +146 -0
- package/templates/workspace/mobile/src/bridge/messaging.test.ts +49 -0
- package/templates/workspace/mobile/src/bridge/messaging.ts +33 -0
- package/templates/workspace/mobile/src/bridge/reader.test.ts +52 -0
- package/templates/workspace/mobile/src/bridge/reader.ts +31 -0
- package/templates/workspace/mobile/src/bridge/router.test.ts +124 -0
- package/templates/workspace/mobile/src/bridge/router.ts +89 -0
- package/templates/workspace/mobile/src/config/env.ts +51 -0
- package/templates/workspace/mobile/src/i18n.ts +71 -0
- package/templates/workspace/mobile/src/session/secureSession.ts +63 -0
- package/templates/workspace/mobile/src/session/sessionHandoff.ts +151 -0
- package/templates/workspace/mobile/src/ui/ErrorView.tsx +75 -0
- package/templates/workspace/mobile/src/ui/LoadingView.tsx +38 -0
- package/templates/workspace/mobile/src/ui/OfflineView.tsx +73 -0
- package/templates/workspace/mobile/src/webview/Host.tsx +353 -0
- package/templates/workspace/mobile/src/webview/linkBoundary.test.ts +57 -0
- package/templates/workspace/mobile/src/webview/linkBoundary.ts +58 -0
- package/templates/workspace/mobile/tsconfig.json +8 -0
- package/templates/workspace/mobile/vitest.config.ts +14 -0
- package/templates/workspace/package.json +9 -0
- package/templates/workspace/scripts/doctor.mjs +291 -0
- package/templates/workspace/ssb/README.md +10 -0
- package/templates/workspace/ssb/contract.ts +146 -0
- package/templates/workspace/ssb/reader.ts +31 -0
- package/templates/workspace/web/.env.example +39 -0
- package/templates/workspace/web/.gitattributes +1 -0
- package/templates/workspace/web/.github/workflows/ci.yml +61 -0
- package/templates/workspace/web/.vscode/settings.json +8 -0
- package/templates/workspace/web/AGENTS.md +103 -0
- package/templates/workspace/web/CLAUDE.md +2 -0
- package/templates/workspace/web/DESIGN.md +18 -0
- package/templates/workspace/web/LICENSE +17 -0
- package/templates/workspace/web/README.md +48 -0
- package/templates/workspace/web/app/error.tsx +28 -0
- package/templates/workspace/web/app/favicon.ico +0 -0
- package/templates/workspace/web/app/global-error.tsx +19 -0
- package/templates/workspace/web/app/globals.css +130 -0
- package/templates/workspace/web/app/layout.tsx +33 -0
- package/templates/workspace/web/app/not-found.tsx +12 -0
- package/templates/workspace/web/app/page.tsx +11 -0
- package/templates/workspace/web/components/ui/button.tsx +58 -0
- package/templates/workspace/web/components.json +25 -0
- package/templates/workspace/web/docs/ENVIRONMENTS.md +102 -0
- package/templates/workspace/web/docs/LIMITS.md +54 -0
- package/templates/workspace/web/eslint.config.mjs +46 -0
- package/templates/workspace/web/features/.gitkeep +0 -0
- package/templates/workspace/web/gitignore +51 -0
- package/templates/workspace/web/instrumentation-client.ts +16 -0
- package/templates/workspace/web/instrumentation.ts +12 -0
- package/templates/workspace/web/lib/app-env.ts +12 -0
- package/templates/workspace/web/lib/bridge/contract.ts +146 -0
- package/templates/workspace/web/lib/bridge/reader.ts +31 -0
- package/templates/workspace/web/lib/env.server.ts +33 -0
- package/templates/workspace/web/lib/env.ts +21 -0
- package/templates/workspace/web/lib/logger.ts +32 -0
- package/templates/workspace/web/lib/supabase/admin.ts +14 -0
- package/templates/workspace/web/lib/supabase/client.ts +9 -0
- package/templates/workspace/web/lib/supabase/server.ts +24 -0
- package/templates/workspace/web/lib/utils.ts +6 -0
- package/templates/workspace/web/next.config.ts +16 -0
- package/templates/workspace/web/npmrc +14 -0
- package/templates/workspace/web/package.json +60 -0
- package/templates/workspace/web/pnpm-lock.yaml +9155 -0
- package/templates/workspace/web/postcss.config.mjs +7 -0
- package/templates/workspace/web/sentry.edge.config.ts +9 -0
- package/templates/workspace/web/sentry.server.config.ts +9 -0
- package/templates/workspace/web/supabase/migrations/.gitkeep +0 -0
- package/templates/workspace/web/tests/setup.ts +1 -0
- package/templates/workspace/web/tests/utils.test.ts +12 -0
- package/templates/workspace/web/tsconfig.json +35 -0
- package/templates/workspace/web/vercel.json +6 -0
- package/templates/workspace/web/vitest.config.ts +15 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Pressable, StyleSheet, Text, View } from "react-native";
|
|
3
|
+
|
|
4
|
+
import { t } from "../i18n";
|
|
5
|
+
|
|
6
|
+
export type OfflineViewProps = {
|
|
7
|
+
/** 재시도 콜백 — 셸은 연결 상태를 다시 확인하고(NetInfo) 필요 시 웹뷰를 재로드한다. */
|
|
8
|
+
onRetry: () => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 기기가 오프라인일 때 웹뷰 위에 덮는 오버레이 (DESIGN 결정 21: 오프라인 화면).
|
|
13
|
+
*
|
|
14
|
+
* onError는 "로드 실패"만 잡고 로드 후 라이브 연결 끊김은 못 잡으므로, 셸은 NetInfo를
|
|
15
|
+
* 구독해 연결이 끊기면 이 화면을 띄운다. 에러 화면과 시각적으로 구분되는 별도 문구를 쓴다.
|
|
16
|
+
*/
|
|
17
|
+
export function OfflineView(props: OfflineViewProps): React.JSX.Element {
|
|
18
|
+
const { onRetry } = props;
|
|
19
|
+
return (
|
|
20
|
+
<View style={styles.fill}>
|
|
21
|
+
<Text style={styles.title}>{t("offline.title")}</Text>
|
|
22
|
+
<Text style={styles.body}>{t("offline.body")}</Text>
|
|
23
|
+
<Pressable
|
|
24
|
+
onPress={onRetry}
|
|
25
|
+
accessibilityRole="button"
|
|
26
|
+
style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}
|
|
27
|
+
>
|
|
28
|
+
<Text style={styles.buttonLabel}>{t("offline.retry")}</Text>
|
|
29
|
+
</Pressable>
|
|
30
|
+
</View>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const styles = StyleSheet.create({
|
|
35
|
+
fill: {
|
|
36
|
+
position: "absolute",
|
|
37
|
+
top: 0,
|
|
38
|
+
left: 0,
|
|
39
|
+
right: 0,
|
|
40
|
+
bottom: 0,
|
|
41
|
+
backgroundColor: "#fff",
|
|
42
|
+
alignItems: "center",
|
|
43
|
+
justifyContent: "center",
|
|
44
|
+
paddingHorizontal: 24,
|
|
45
|
+
},
|
|
46
|
+
title: {
|
|
47
|
+
fontSize: 16,
|
|
48
|
+
fontWeight: "700",
|
|
49
|
+
color: "#111",
|
|
50
|
+
textAlign: "center",
|
|
51
|
+
},
|
|
52
|
+
body: {
|
|
53
|
+
marginTop: 8,
|
|
54
|
+
fontSize: 13,
|
|
55
|
+
color: "#666",
|
|
56
|
+
textAlign: "center",
|
|
57
|
+
},
|
|
58
|
+
button: {
|
|
59
|
+
marginTop: 20,
|
|
60
|
+
paddingHorizontal: 18,
|
|
61
|
+
paddingVertical: 11,
|
|
62
|
+
borderRadius: 10,
|
|
63
|
+
backgroundColor: "#111",
|
|
64
|
+
},
|
|
65
|
+
buttonPressed: {
|
|
66
|
+
opacity: 0.7,
|
|
67
|
+
},
|
|
68
|
+
buttonLabel: {
|
|
69
|
+
color: "#fff",
|
|
70
|
+
fontSize: 14,
|
|
71
|
+
fontWeight: "600",
|
|
72
|
+
},
|
|
73
|
+
});
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { BackHandler, Platform, StyleSheet, View } from "react-native";
|
|
3
|
+
import { WebView } from "react-native-webview";
|
|
4
|
+
import type {
|
|
5
|
+
ShouldStartLoadRequest,
|
|
6
|
+
WebViewErrorEvent,
|
|
7
|
+
WebViewHttpErrorEvent,
|
|
8
|
+
WebViewMessageEvent,
|
|
9
|
+
WebViewNavigation,
|
|
10
|
+
WebViewOpenWindowEvent,
|
|
11
|
+
} from "react-native-webview/lib/WebViewTypes";
|
|
12
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
13
|
+
import NetInfo, { type NetInfoState } from "@react-native-community/netinfo";
|
|
14
|
+
import * as Linking from "expo-linking";
|
|
15
|
+
import Constants from "expo-constants";
|
|
16
|
+
|
|
17
|
+
import { handleInbound, helloInjection, type BridgeContext } from "../bridge/router";
|
|
18
|
+
import { ALLOWED_ORIGINS, WEB_ORIGIN } from "../config/env";
|
|
19
|
+
import { classifyLink, toOrigin } from "./linkBoundary";
|
|
20
|
+
import { LoadingView } from "../ui/LoadingView";
|
|
21
|
+
import { ErrorView } from "../ui/ErrorView";
|
|
22
|
+
import { OfflineView } from "../ui/OfflineView";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 네이티브 셸 호스트 — 웹뷰를 감싸 셸 레시피를 구현한다
|
|
26
|
+
* (DESIGN 결정 21 셸 레시피 전부 + 결정 18 개발 루프). 4.2는 이 셸만으론 부족하다(출발점) — store-release-guide 참고.
|
|
27
|
+
*
|
|
28
|
+
* 책임:
|
|
29
|
+
* - 안전영역(노치) 여백을 네이티브가 측정해 CSS 커스텀 변수로 웹에 주입 (env() 금지).
|
|
30
|
+
* - Android 하드웨어 뒤로가기: 웹뷰 history 우선, 없으면 OS에 위임.
|
|
31
|
+
* - 외부 링크 경계: 단일 분류자 classifyLink(linkBoundary)를 navigation·window.open·OPEN_EXTERNAL이 공유.
|
|
32
|
+
* - 크래시 복구: key 증가로 통째 재마운트(reload 아님), lastKnownUrl 복원.
|
|
33
|
+
* - 오프라인/에러/로딩 오버레이.
|
|
34
|
+
* - 브릿지: onMessage → handleInbound, 페이지 도달 후 helloInjection 1회.
|
|
35
|
+
*
|
|
36
|
+
* 토큰은 이 채널을 절대 거치지 않는다 — 세션은 서버 Set-Cookie 핸드오프(결정 15·session 슬라이스).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export type WebViewHostProps = {
|
|
40
|
+
/**
|
|
41
|
+
* 웹뷰가 로드할 URL. App이 config/env의 WEB_URL을 넘긴다 — 여기서 하드코딩 금지.
|
|
42
|
+
*
|
|
43
|
+
* 앱은 배포된 prod 웹 https URL을 로드한다(실기기는 localhost 도달 불가 + http LAN은
|
|
44
|
+
* secure-context가 아니라 카메라·Web Crypto·Secure 쿠키가 조용히 깨짐). localhost는
|
|
45
|
+
* 시뮬레이터에서 셸을 고칠 때만 쓰는 예외이며, 그 분기는 env.ts 가드가 책임진다.
|
|
46
|
+
*/
|
|
47
|
+
url: string;
|
|
48
|
+
/** 웹이 세션 없음을 알릴 때(REQUEST_SESSION_INSTALL) 네이티브 로그인 흐름을 시작. */
|
|
49
|
+
onNeedLogin?: (redirectPath?: string) => void;
|
|
50
|
+
/** 웹 로그아웃(LOGOUT) 시 네이티브 로컬 세션 사본을 정리. */
|
|
51
|
+
onLoggedOut?: () => void;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const APP_VERSION = Constants.expoConfig?.version ?? "unknown";
|
|
55
|
+
const PLATFORM: "ios" | "android" = Platform.OS === "ios" ? "ios" : "android";
|
|
56
|
+
|
|
57
|
+
/** 로드 실패 상태(네트워크 vs HTTP). 오프라인은 별도 NetInfo 상태로 다룬다. */
|
|
58
|
+
type WebError = { kind: "network" | "http"; message?: string };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 안전영역 inset(dp)을 :root 의 CSS 커스텀 변수로 박는 JS 소스를 만든다.
|
|
62
|
+
*
|
|
63
|
+
* env(safe-area-inset-*)는 양 플랫폼 다 못 쓴다(Android WebView 138+는 0px, iOS는 첫
|
|
64
|
+
* 페인트 지연 점프) → 네이티브가 useSafeAreaInsets로 측정한 값을 --ssb-inset-* 변수로
|
|
65
|
+
* 직접 주입하고, 웹은 그 변수로 패딩한다(결정 21 safe-area: 네이티브=값, 웹=레이아웃).
|
|
66
|
+
*
|
|
67
|
+
* inset은 이미 dp(=CSS px와 1:1) 단위이므로 그대로 px로 쓴다. viewport-fit=cover는
|
|
68
|
+
* 콘텐츠가 노치 영역까지 펼쳐지도록 보장(이 변수 패딩이 의미를 가지려면 필요).
|
|
69
|
+
*/
|
|
70
|
+
function buildInsetInjection(insets: { top: number; right: number; bottom: number; left: number }): string {
|
|
71
|
+
const { top, right, bottom, left } = insets;
|
|
72
|
+
return (
|
|
73
|
+
"try{" +
|
|
74
|
+
"var d=document.documentElement;" +
|
|
75
|
+
`d.style.setProperty('--ssb-inset-top','${top}px');` +
|
|
76
|
+
`d.style.setProperty('--ssb-inset-right','${right}px');` +
|
|
77
|
+
`d.style.setProperty('--ssb-inset-bottom','${bottom}px');` +
|
|
78
|
+
`d.style.setProperty('--ssb-inset-left','${left}px');` +
|
|
79
|
+
// viewport-fit=cover 를 보장(meta가 없으면 추가).
|
|
80
|
+
"var m=document.querySelector('meta[name=viewport]');" +
|
|
81
|
+
"if(m){if(m.content.indexOf('viewport-fit')===-1){m.content+=', viewport-fit=cover';}}" +
|
|
82
|
+
"else{m=document.createElement('meta');m.name='viewport';" +
|
|
83
|
+
"m.content='width=device-width, initial-scale=1, viewport-fit=cover';" +
|
|
84
|
+
"document.head.appendChild(m);}" +
|
|
85
|
+
"}catch(e){}" +
|
|
86
|
+
" true;"
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function WebViewHost(props: WebViewHostProps): React.JSX.Element {
|
|
91
|
+
const { url, onNeedLogin, onLoggedOut } = props;
|
|
92
|
+
const insets = useSafeAreaInsets();
|
|
93
|
+
|
|
94
|
+
const webRef = React.useRef<WebView | null>(null);
|
|
95
|
+
// 크래시 복구용: key를 올리면 웹뷰가 통째 재마운트된다(Android는 reload로 불충분 —
|
|
96
|
+
// 죽은 렌더 프로세스 인스턴스를 폐기하고 새로 만들어야 한다. 결정 21 크래시 복구).
|
|
97
|
+
const [webKey, setWebKey] = React.useState(0);
|
|
98
|
+
// 첫 로드 완료까지만 로딩 오버레이.
|
|
99
|
+
const [loading, setLoading] = React.useState(true);
|
|
100
|
+
const [webError, setWebError] = React.useState<WebError | null>(null);
|
|
101
|
+
// 오프라인 오버레이. onError는 라이브 연결 끊김을 못 잡으므로 NetInfo로 별도 추적.
|
|
102
|
+
const [offline, setOffline] = React.useState(false);
|
|
103
|
+
// Android 뒤로가기 판단용. 최신 값을 ref로 들고 있어야 BackHandler 콜백이 stale을 안 읽는다.
|
|
104
|
+
const canGoBackRef = React.useRef(false);
|
|
105
|
+
// 크래시 복구 시 맹목 reload 대신 복원할 마지막 정상 URL.
|
|
106
|
+
const lastKnownUrlRef = React.useRef<string>(url);
|
|
107
|
+
// helloInjection 1회 가드 — onLoadEnd가 SPA에서 여러 번 와도 한 번만 보낸다.
|
|
108
|
+
const helloSentRef = React.useRef(false);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 내비게이션 경계 — 순수 분류자(classifyLink)의 판정을 웹뷰 boolean으로 옮기고,
|
|
112
|
+
* "external"이면 시스템 브라우저로 위임한다. onShouldStartLoadWithRequest 와
|
|
113
|
+
* onOpenWindow 가 공유하므로 규칙은 linkBoundary 한 곳에만 있다 (결정 21 링크 경계).
|
|
114
|
+
*
|
|
115
|
+
* 반환 true = 웹뷰가 in-app으로 직접 로드.
|
|
116
|
+
* 반환 false = 웹뷰 로드 차단("external"은 OS 위임 후, "block"은 아무 데도 안 넘김).
|
|
117
|
+
*/
|
|
118
|
+
const routeNavigation = React.useCallback((targetUrl: string): boolean => {
|
|
119
|
+
const decision = classifyLink(targetUrl, ALLOWED_ORIGINS);
|
|
120
|
+
if (decision === "in-app") {
|
|
121
|
+
// 허용 origin → in-app. (동일 origin 필수 true: Android #2819 유령 리로드 회피)
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
if (decision === "external") {
|
|
125
|
+
// mailto:/tel:/geo:/intent: + 허용 외 https → OS 위임. 실패는 조용히 무시(앱 미설치 등).
|
|
126
|
+
Linking.openURL(targetUrl).catch(() => {});
|
|
127
|
+
}
|
|
128
|
+
// "external"·"block" 모두 웹뷰 로드는 막는다.
|
|
129
|
+
return false;
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 라우터에 넘길 BridgeContext. inject/openExternal은 네이티브 구현으로 채운다.
|
|
134
|
+
* webRef·콜백 props가 바뀔 때만 재생성.
|
|
135
|
+
*/
|
|
136
|
+
const bridgeCtx = React.useMemo<BridgeContext>(
|
|
137
|
+
() => ({
|
|
138
|
+
appVersion: APP_VERSION,
|
|
139
|
+
platform: PLATFORM,
|
|
140
|
+
webOrigin: WEB_ORIGIN,
|
|
141
|
+
inject: (js: string) => {
|
|
142
|
+
webRef.current?.injectJavaScript(js);
|
|
143
|
+
},
|
|
144
|
+
onNeedLogin: (redirectPath?: string) => {
|
|
145
|
+
onNeedLogin?.(redirectPath);
|
|
146
|
+
},
|
|
147
|
+
onLoggedOut: () => {
|
|
148
|
+
onLoggedOut?.();
|
|
149
|
+
},
|
|
150
|
+
openExternal: (externalUrl: string) => {
|
|
151
|
+
// OPEN_EXTERNAL = "웹뷰 밖으로"라는 웹의 명시적 명령. 내비게이션과 같은 단일 분류자
|
|
152
|
+
// (classifyLink)를 '안전 게이트'로 쓴다: 파싱 실패("block")만 막고(깨진 URL을 OS에 안 넘김),
|
|
153
|
+
// 나머지는 — 허용 origin이라도 — 시스템에 위임한다(웹뷰 escape가 이 메시지의 존재 이유이므로
|
|
154
|
+
// origin 허용목록이 명시적 명령을 막지 않는다. 결정 21). url은 origin-게이트된 onMessage로만
|
|
155
|
+
// 들어오고, Linking은 OS에 넘길 뿐 웹뷰에서 실행하지 않는다(스킴 안전성은 이 둘에 기댄다).
|
|
156
|
+
if (classifyLink(externalUrl, ALLOWED_ORIGINS) !== "block") {
|
|
157
|
+
Linking.openURL(externalUrl).catch(() => {});
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
[onNeedLogin, onLoggedOut],
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// ── 오프라인 감지 (결정 21 오프라인) ─────────────────────────────
|
|
165
|
+
// onError는 "로드 실패"만 잡고 로드 후 라이브 연결 끊김은 못 잡으므로 NetInfo를 구독한다.
|
|
166
|
+
// NetInfo.addEventListener는 해제 함수를 반환한다(이 라이브러리는 .remove()가 아니라 호출형).
|
|
167
|
+
React.useEffect(() => {
|
|
168
|
+
const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
|
|
169
|
+
// isConnected === false 면 확실한 오프라인. isInternetReachable === false 도 오프라인 취급.
|
|
170
|
+
// null/unknown(아직 모름)은 온라인으로 둬서 거짓 오프라인 깜빡임을 피한다.
|
|
171
|
+
const disconnected = state.isConnected === false || state.isInternetReachable === false;
|
|
172
|
+
setOffline(disconnected);
|
|
173
|
+
});
|
|
174
|
+
return () => {
|
|
175
|
+
unsubscribe();
|
|
176
|
+
};
|
|
177
|
+
}, []);
|
|
178
|
+
|
|
179
|
+
// ── Android 하드웨어 뒤로가기 (결정 21) ──────────────────────────
|
|
180
|
+
// 웹뷰 history가 있으면 goBack()+true(소비), 없으면 false로 OS에 위임(예측-뒤로/종료).
|
|
181
|
+
// RN 0.85: 구독 해제는 subscription.remove() (removeEventListener 제거됨).
|
|
182
|
+
React.useEffect(() => {
|
|
183
|
+
if (Platform.OS !== "android") return;
|
|
184
|
+
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
|
|
185
|
+
if (canGoBackRef.current) {
|
|
186
|
+
webRef.current?.goBack();
|
|
187
|
+
return true; // 우리가 소비.
|
|
188
|
+
}
|
|
189
|
+
return false; // OS 기본 동작(예측-뒤로/앱 종료)에 위임.
|
|
190
|
+
});
|
|
191
|
+
return () => {
|
|
192
|
+
subscription.remove();
|
|
193
|
+
};
|
|
194
|
+
}, []);
|
|
195
|
+
|
|
196
|
+
// 크래시/에러 복구: key를 올려 통째 재마운트하고 오버레이를 닫는다.
|
|
197
|
+
const remountWebView = React.useCallback(() => {
|
|
198
|
+
helloSentRef.current = false;
|
|
199
|
+
setWebError(null);
|
|
200
|
+
setLoading(true);
|
|
201
|
+
setWebKey((prev) => prev + 1);
|
|
202
|
+
}, []);
|
|
203
|
+
|
|
204
|
+
// 오프라인 오버레이의 "다시 시도": 연결을 재확인하고, 회복됐으면 웹뷰를 재로드한다.
|
|
205
|
+
const retryFromOffline = React.useCallback(() => {
|
|
206
|
+
NetInfo.fetch().then((state) => {
|
|
207
|
+
const disconnected = state.isConnected === false || state.isInternetReachable === false;
|
|
208
|
+
setOffline(disconnected);
|
|
209
|
+
if (!disconnected) {
|
|
210
|
+
webRef.current?.reload();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}, []);
|
|
214
|
+
|
|
215
|
+
// ── 이벤트 핸들러 ───────────────────────────────────────────────
|
|
216
|
+
const onMessage = React.useCallback(
|
|
217
|
+
(event: WebViewMessageEvent) => {
|
|
218
|
+
// 1차 방어: 메시지를 보낸 프레임의 origin이 허용 목록일 때만 처리한다. ns 게이트(reader/router)는
|
|
219
|
+
// 'ssb' 봉투인지'만' 거르지 origin은 못 거른다(postMessage는 어떤 프레임에서도 호출 가능). 링크
|
|
220
|
+
// 경계가 메인 프레임을 허용 origin에 묶어두므로 정상 경로에선 항상 통과하고, 벗어나면 차단한다.
|
|
221
|
+
const origin = toOrigin(event.nativeEvent.url);
|
|
222
|
+
if (origin === null || !ALLOWED_ORIGINS.includes(origin)) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// ns·형식 게이트는 reader/router가 담당(SSB_NS 아닌·깨진 트래픽 → UNKNOWN → 무시, never-throw).
|
|
226
|
+
handleInbound(event.nativeEvent.data, bridgeCtx);
|
|
227
|
+
},
|
|
228
|
+
[bridgeCtx],
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const onNavigationStateChange = React.useCallback((nav: WebViewNavigation) => {
|
|
232
|
+
canGoBackRef.current = nav.canGoBack;
|
|
233
|
+
// 크래시 복구용 마지막 정상 URL 추적(허용 origin인 실제 페이지만; about:blank/오류 제외).
|
|
234
|
+
const origin = toOrigin(nav.url);
|
|
235
|
+
if (origin !== null && ALLOWED_ORIGINS.includes(origin)) {
|
|
236
|
+
lastKnownUrlRef.current = nav.url;
|
|
237
|
+
}
|
|
238
|
+
}, []);
|
|
239
|
+
|
|
240
|
+
// onLoadEnd는 성공·실패 로드 모두에서 불린다 → 스피너만 내린다(실패면 ErrorView가 위에 뜬다).
|
|
241
|
+
const onLoadEnd = React.useCallback(() => {
|
|
242
|
+
setLoading(false);
|
|
243
|
+
}, []);
|
|
244
|
+
|
|
245
|
+
// onLoad는 성공 로드에서만 불린다 → HELLO는 여기서만 1회 주입한다(실패 페이지엔 능력을 광고하지 않음).
|
|
246
|
+
// (결정 8: 앱이 능력을 광고 → 웹이 feature-detect.)
|
|
247
|
+
const onLoad = React.useCallback(() => {
|
|
248
|
+
if (!helloSentRef.current) {
|
|
249
|
+
helloSentRef.current = true;
|
|
250
|
+
webRef.current?.injectJavaScript(helloInjection({ appVersion: APP_VERSION, platform: PLATFORM }));
|
|
251
|
+
}
|
|
252
|
+
}, []);
|
|
253
|
+
|
|
254
|
+
const onError = React.useCallback((event: WebViewErrorEvent) => {
|
|
255
|
+
setWebError({ kind: "network", message: event.nativeEvent.description });
|
|
256
|
+
}, []);
|
|
257
|
+
|
|
258
|
+
const onHttpError = React.useCallback((event: WebViewHttpErrorEvent) => {
|
|
259
|
+
const code = event.nativeEvent.statusCode;
|
|
260
|
+
setWebError({ kind: "http", message: `HTTP ${code}` });
|
|
261
|
+
}, []);
|
|
262
|
+
|
|
263
|
+
const onShouldStartLoadWithRequest = React.useCallback(
|
|
264
|
+
(req: ShouldStartLoadRequest): boolean => routeNavigation(req.url),
|
|
265
|
+
[routeNavigation],
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
// _blank/window.open 으로 새 창을 열려는 시도. onOpenWindow가 없으면 빈 페이지가 뜨므로 필수.
|
|
269
|
+
// 같은 링크 경계로 보내 외부 URL은 시스템 브라우저로 연다(in-app 새 창은 만들지 않음).
|
|
270
|
+
const onOpenWindow = React.useCallback(
|
|
271
|
+
(event: WebViewOpenWindowEvent) => {
|
|
272
|
+
routeNavigation(event.nativeEvent.targetUrl);
|
|
273
|
+
},
|
|
274
|
+
[routeNavigation],
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// 안전영역 inset 주입 JS. inset 값이 바뀌면 새 문자열이 되고, key로 묶어 재로드 시 재적용.
|
|
278
|
+
// injectedJavaScriptBeforeContentLoaded는 콘텐츠 로드 전에 실행되어 첫 페인트 점프를 막는다.
|
|
279
|
+
const insetInjection = React.useMemo(
|
|
280
|
+
() => buildInsetInjection({ top: insets.top, right: insets.right, bottom: insets.bottom, left: insets.left }),
|
|
281
|
+
[insets.top, insets.right, insets.bottom, insets.left],
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
// inset 값이 런타임에 바뀌면(회전·split view 등) 이미 로드된 페이지에도 다시 주입한다.
|
|
285
|
+
// 로드 전이면 injectJavaScript는 무해한 no-op이고 첫 적용은 injectedJavaScriptBeforeContentLoaded가
|
|
286
|
+
// 담당하므로, helloSent 가드 없이 항상 시도한다(첫 onLoad 이전 회전 누락 방지).
|
|
287
|
+
React.useEffect(() => {
|
|
288
|
+
webRef.current?.injectJavaScript(insetInjection);
|
|
289
|
+
}, [insetInjection]);
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<View style={styles.fill}>
|
|
293
|
+
<WebView
|
|
294
|
+
// key를 올리면 통째 재마운트 → 크래시 복구. 동시에 inset 변경 시 BeforeContentLoaded 재적용.
|
|
295
|
+
key={webKey}
|
|
296
|
+
ref={webRef}
|
|
297
|
+
// 첫 마운트 = 진입 url(lastKnownUrlRef 초기값). webKey가 올라 재마운트되는 크래시
|
|
298
|
+
// 복구 시엔 마지막 정상 url로 복원한다(맨 처음으로 재진입이 아니라 있던 자리 복원).
|
|
299
|
+
// `|| url`은 lastKnownUrl이 비어 있을 때의 폴백(정상 경로에선 발생하지 않음).
|
|
300
|
+
source={{ uri: lastKnownUrlRef.current || url }}
|
|
301
|
+
// 안전영역 변수를 콘텐츠 로드 전에 :root에 박는다(env() 아님 — 결정 21).
|
|
302
|
+
injectedJavaScriptBeforeContentLoaded={insetInjection}
|
|
303
|
+
// 위 주입 스크립트는 메인 프레임에만(서드파티 iframe에 새지 않도록; Android 필수).
|
|
304
|
+
injectedJavaScriptBeforeContentLoadedForMainFrameOnly
|
|
305
|
+
injectedJavaScriptForMainFrameOnly
|
|
306
|
+
// 브릿지: 웹→앱 메시지 수신. ns 게이트는 reader/router가 담당.
|
|
307
|
+
onMessage={onMessage}
|
|
308
|
+
onLoad={onLoad}
|
|
309
|
+
onLoadEnd={onLoadEnd}
|
|
310
|
+
onError={onError}
|
|
311
|
+
onHttpError={onHttpError}
|
|
312
|
+
onNavigationStateChange={onNavigationStateChange}
|
|
313
|
+
// 외부 링크 경계(단일 판단을 양쪽에 공유).
|
|
314
|
+
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
|
|
315
|
+
onOpenWindow={onOpenWindow}
|
|
316
|
+
// 크래시 복구: iOS=프로세스 종료, Android=렌더 프로세스 사망(핸들러 return true 필수).
|
|
317
|
+
onContentProcessDidTerminate={remountWebView}
|
|
318
|
+
onRenderProcessGone={() => {
|
|
319
|
+
remountWebView();
|
|
320
|
+
return true;
|
|
321
|
+
}}
|
|
322
|
+
// 파일 업로드(<input type=file>)와 카메라/미디어 캡처가 동작하도록 (결정 21 파일 업로드).
|
|
323
|
+
// iOS usage string은 app.config.ts(다른 슬라이스)에 둔다.
|
|
324
|
+
allowsInlineMediaPlayback
|
|
325
|
+
mediaCapturePermissionGrantType="grant"
|
|
326
|
+
// 당겨서 새로고침: iOS는 prop 한 줄(기본 false). Android는 iOS 전용이라 무시되며
|
|
327
|
+
// RefreshControl 별도 배선이 필요하다(fast-follow; 결정 21 당겨새로고침).
|
|
328
|
+
pullToRefreshEnabled={Platform.OS === "ios"}
|
|
329
|
+
// 표준 웹 기능: JS·DOM 저장·쿠키. 세션은 서버 Set-Cookie 핸드오프라 쿠키 공유가 핵심.
|
|
330
|
+
javaScriptEnabled
|
|
331
|
+
domStorageEnabled
|
|
332
|
+
sharedCookiesEnabled
|
|
333
|
+
thirdPartyCookiesEnabled
|
|
334
|
+
// iOS 스와이프 뒤로/앞으로 제스처(웹 history 탐색).
|
|
335
|
+
allowsBackForwardNavigationGestures={Platform.OS === "ios"}
|
|
336
|
+
// iOS: 웹 폼 입력칸의 자동 포커스·키보드가 막히지 않도록(WKWebView 기본 true가 막음). 결정 21.
|
|
337
|
+
keyboardDisplayRequiresUserAction={false}
|
|
338
|
+
style={styles.fill}
|
|
339
|
+
/>
|
|
340
|
+
{loading ? <LoadingView /> : null}
|
|
341
|
+
{webError ? <ErrorView message={webError.message} onRetry={remountWebView} /> : null}
|
|
342
|
+
{/* 오프라인은 가장 위 — 연결이 끊기면 에러/로딩과 무관하게 최우선으로 알린다. */}
|
|
343
|
+
{offline ? <OfflineView onRetry={retryFromOffline} /> : null}
|
|
344
|
+
</View>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const styles = StyleSheet.create({
|
|
349
|
+
fill: {
|
|
350
|
+
flex: 1,
|
|
351
|
+
backgroundColor: "#fff",
|
|
352
|
+
},
|
|
353
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { classifyLink, toOrigin } from "./linkBoundary";
|
|
3
|
+
|
|
4
|
+
const ALLOWED = ["https://app.example.com"] as const;
|
|
5
|
+
|
|
6
|
+
describe("classifyLink — 단일 링크 경계", () => {
|
|
7
|
+
it("허용 origin의 https는 in-app", () => {
|
|
8
|
+
expect(classifyLink("https://app.example.com/path?q=1", ALLOWED)).toBe("in-app");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("허용 origin이라도 http(평문)는 origin이 달라 external", () => {
|
|
12
|
+
// origin은 scheme을 포함한다 → http://… ≠ https://app.example.com.
|
|
13
|
+
expect(classifyLink("http://app.example.com/x", ALLOWED)).toBe("external");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("허용 외 https origin은 external", () => {
|
|
17
|
+
expect(classifyLink("https://other.com/x", ALLOWED)).toBe("external");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("서브도메인 prefix 위장은 다른 origin이라 external (startsWith 아님)", () => {
|
|
21
|
+
expect(classifyLink("https://app.example.com.evil.com/x", ALLOWED)).toBe("external");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("비-http(s) 안전 스킴(mailto/tel/geo/sms/intent)은 external", () => {
|
|
25
|
+
for (const u of [
|
|
26
|
+
"mailto:a@b.com",
|
|
27
|
+
"tel:+8210",
|
|
28
|
+
"geo:37.5,127.0",
|
|
29
|
+
"sms:+8210",
|
|
30
|
+
"intent://scan/#Intent;scheme=zxing;end",
|
|
31
|
+
]) {
|
|
32
|
+
expect(classifyLink(u, ALLOWED)).toBe("external");
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("파싱 실패(빈 문자열·상대경로·쓰레기)는 block", () => {
|
|
37
|
+
for (const u of ["", " ", "/relative/path", "not a url", "::::"]) {
|
|
38
|
+
expect(classifyLink(u, ALLOWED)).toBe("block");
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("허용목록이 비면 모든 http(s)는 external", () => {
|
|
43
|
+
expect(classifyLink("https://app.example.com/x", [])).toBe("external");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("toOrigin", () => {
|
|
48
|
+
it("scheme+host+port를 origin으로 뽑는다", () => {
|
|
49
|
+
expect(toOrigin("https://app.example.com:443/a/b?c#d")).toBe("https://app.example.com");
|
|
50
|
+
expect(toOrigin("http://h:8080/x")).toBe("http://h:8080");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("파싱 실패는 null", () => {
|
|
54
|
+
expect(toOrigin("nonsense")).toBeNull();
|
|
55
|
+
expect(toOrigin("")).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 외부 링크 경계 — 순수(부수효과 없음) 분류자 (DESIGN 결정 21).
|
|
3
|
+
*
|
|
4
|
+
* 셸의 모든 링크 판단이 이 한 곳의 규칙을 공유한다: 내비게이션
|
|
5
|
+
* (onShouldStartLoadWithRequest·onOpenWindow)과 브릿지 OPEN_EXTERNAL. 네이티브/expo
|
|
6
|
+
* import가 없어 Node(vitest)에서 단위 테스트된다 — origin 허용목록·스킴 게이트는 보안
|
|
7
|
+
* 경계라(🔴) 테스트로 못 박는다.
|
|
8
|
+
*
|
|
9
|
+
* 부수효과(Linking.openURL)는 호출자(Host)가 판정을 받아 수행한다 — 여기선 URL을 열지 않는다.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* url에서 origin만 안전하게 뽑는다(파싱 실패 시 null).
|
|
14
|
+
* 동일 origin 비교는 반드시 origin 등가비교 — startsWith 금지(서브도메인 prefix 위장 회피).
|
|
15
|
+
*/
|
|
16
|
+
export function toOrigin(url: string): string | null {
|
|
17
|
+
try {
|
|
18
|
+
return new URL(url).origin;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** url의 scheme(소문자, 콜론 제거). 파싱 실패 시 null. */
|
|
25
|
+
function getScheme(url: string): string | null {
|
|
26
|
+
try {
|
|
27
|
+
return new URL(url).protocol.replace(/:$/, "").toLowerCase();
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 링크 경계의 세 가지 판정.
|
|
35
|
+
* - "in-app" : 허용 origin의 http(s) → 웹뷰가 직접 로드.
|
|
36
|
+
* - "external" : 그 외 파싱되는 URL(허용 외 http(s) + mailto:/tel:/geo:/intent: 등 비-http(s) 스킴)
|
|
37
|
+
* → 시스템에 위임. 이건 파싱·origin 게이트이지 스킴 안전성 필터가 아니다
|
|
38
|
+
* (javascript:/data:도 파싱되면 external) — 호출자가 OS에 위임할 뿐 웹뷰에서 실행하지 않는다.
|
|
39
|
+
* - "block" : 파싱 실패 → 아무 데도(웹뷰에도, OS에도) 넘기지 않는다.
|
|
40
|
+
*/
|
|
41
|
+
export type LinkDecision = "in-app" | "external" | "block";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 단일 링크 분류자. 부수효과 없음 — 판정만 반환한다.
|
|
45
|
+
* - 파싱 실패 → "block".
|
|
46
|
+
* - http(s) + origin ∈ allowedOrigins → "in-app"(동일 origin 필수: Android #2819 유령 리로드 회피).
|
|
47
|
+
* - http(s) + 허용 외 origin → "external".
|
|
48
|
+
* - 비-http(s) 스킴(mailto:/tel:/geo:/intent: 등) → "external".
|
|
49
|
+
*/
|
|
50
|
+
export function classifyLink(url: string, allowedOrigins: readonly string[]): LinkDecision {
|
|
51
|
+
const scheme = getScheme(url);
|
|
52
|
+
if (scheme === null) return "block";
|
|
53
|
+
if (scheme === "http" || scheme === "https") {
|
|
54
|
+
const origin = toOrigin(url);
|
|
55
|
+
return origin !== null && allowedOrigins.includes(origin) ? "in-app" : "external";
|
|
56
|
+
}
|
|
57
|
+
return "external";
|
|
58
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "expo/tsconfig.base",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"strict": true
|
|
5
|
+
},
|
|
6
|
+
"//": "docs/web-adapter holds WEB-side template code (Next.js / @supabase/ssr) that is NOT part of this app's build — exclude it from typechecking.",
|
|
7
|
+
"exclude": ["node_modules", "docs"]
|
|
8
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tests cover the pure, native-free logic: the bridge contract and tolerant
|
|
5
|
+
* reader. These run in plain Node — they import no React Native modules.
|
|
6
|
+
* (Native shell behavior is verified by hand on a dev build / device, per the
|
|
7
|
+
* dev-loop guide.)
|
|
8
|
+
*/
|
|
9
|
+
export default defineConfig({
|
|
10
|
+
test: {
|
|
11
|
+
environment: "node",
|
|
12
|
+
include: ["src/**/*.test.ts"],
|
|
13
|
+
},
|
|
14
|
+
});
|