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,124 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { handleInbound, helloInjection, type BridgeContext } from "./router";
|
|
3
|
+
import { MSG, SSB_NS, SSB_PROTOCOL_VERSION } from "./contract";
|
|
4
|
+
|
|
5
|
+
/** 모든 콜백을 spy로 채운 BridgeContext. 테스트마다 새로 만든다. */
|
|
6
|
+
function makeCtx(): BridgeContext {
|
|
7
|
+
return {
|
|
8
|
+
appVersion: "1.0.0",
|
|
9
|
+
platform: "ios",
|
|
10
|
+
webOrigin: "https://example.com",
|
|
11
|
+
inject: vi.fn(),
|
|
12
|
+
onNeedLogin: vi.fn(),
|
|
13
|
+
onLoggedOut: vi.fn(),
|
|
14
|
+
openExternal: vi.fn(),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 봉투를 웹→앱 postMessage가 보내는 형태(JSON 문자열)로 직렬화. */
|
|
19
|
+
function wire(msg: Record<string, unknown>): string {
|
|
20
|
+
return JSON.stringify({ ns: SSB_NS, v: SSB_PROTOCOL_VERSION, ...msg });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("handleInbound", () => {
|
|
24
|
+
it("REQUEST_SESSION_INSTALL → onNeedLogin(redirectPath)", () => {
|
|
25
|
+
const ctx = makeCtx();
|
|
26
|
+
handleInbound(
|
|
27
|
+
wire({ type: MSG.REQUEST_SESSION_INSTALL, payload: { redirectPath: "/dashboard" } }),
|
|
28
|
+
ctx,
|
|
29
|
+
);
|
|
30
|
+
expect(ctx.onNeedLogin).toHaveBeenCalledTimes(1);
|
|
31
|
+
expect(ctx.onNeedLogin).toHaveBeenCalledWith("/dashboard");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("REQUEST_SESSION_INSTALL without payload → onNeedLogin(undefined)", () => {
|
|
35
|
+
const ctx = makeCtx();
|
|
36
|
+
handleInbound(wire({ type: MSG.REQUEST_SESSION_INSTALL }), ctx);
|
|
37
|
+
expect(ctx.onNeedLogin).toHaveBeenCalledTimes(1);
|
|
38
|
+
expect(ctx.onNeedLogin).toHaveBeenCalledWith(undefined);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("LOGOUT → onLoggedOut, then inject a LOGOUT_DONE envelope", () => {
|
|
42
|
+
const ctx = makeCtx();
|
|
43
|
+
handleInbound(wire({ type: MSG.LOGOUT }), ctx);
|
|
44
|
+
|
|
45
|
+
expect(ctx.onLoggedOut).toHaveBeenCalledTimes(1);
|
|
46
|
+
expect(ctx.inject).toHaveBeenCalledTimes(1);
|
|
47
|
+
|
|
48
|
+
// onLoggedOut must run BEFORE inject (clear local session, then notify web).
|
|
49
|
+
const loggedOutOrder = (ctx.onLoggedOut as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0];
|
|
50
|
+
const injectOrder = (ctx.inject as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0];
|
|
51
|
+
expect(loggedOutOrder).toBeLessThan(injectOrder);
|
|
52
|
+
|
|
53
|
+
const js = (ctx.inject as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
|
|
54
|
+
expect(js).toContain(MSG.LOGOUT_DONE);
|
|
55
|
+
expect(js.endsWith("true;")).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("OPEN_EXTERNAL → openExternal(url)", () => {
|
|
59
|
+
const ctx = makeCtx();
|
|
60
|
+
handleInbound(wire({ type: MSG.OPEN_EXTERNAL, payload: { url: "https://other.example/x" } }), ctx);
|
|
61
|
+
expect(ctx.openExternal).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(ctx.openExternal).toHaveBeenCalledWith("https://other.example/x");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("READY → noop (no callback fired)", () => {
|
|
66
|
+
const ctx = makeCtx();
|
|
67
|
+
handleInbound(wire({ type: MSG.READY }), ctx);
|
|
68
|
+
expect(ctx.onNeedLogin).not.toHaveBeenCalled();
|
|
69
|
+
expect(ctx.onLoggedOut).not.toHaveBeenCalled();
|
|
70
|
+
expect(ctx.openExternal).not.toHaveBeenCalled();
|
|
71
|
+
expect(ctx.inject).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("AUTH_STATE_CHANGED → v1 noop (no callback fired)", () => {
|
|
75
|
+
const ctx = makeCtx();
|
|
76
|
+
handleInbound(wire({ type: MSG.AUTH_STATE_CHANGED, payload: { authenticated: true } }), ctx);
|
|
77
|
+
expect(ctx.onNeedLogin).not.toHaveBeenCalled();
|
|
78
|
+
expect(ctx.onLoggedOut).not.toHaveBeenCalled();
|
|
79
|
+
expect(ctx.openExternal).not.toHaveBeenCalled();
|
|
80
|
+
expect(ctx.inject).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("ignores an unknown message type without throwing or calling any callback", () => {
|
|
84
|
+
const ctx = makeCtx();
|
|
85
|
+
expect(() =>
|
|
86
|
+
handleInbound(wire({ type: "TOTALLY_MADE_UP", payload: { x: 1 } }), ctx),
|
|
87
|
+
).not.toThrow();
|
|
88
|
+
expect(ctx.onNeedLogin).not.toHaveBeenCalled();
|
|
89
|
+
expect(ctx.onLoggedOut).not.toHaveBeenCalled();
|
|
90
|
+
expect(ctx.openExternal).not.toHaveBeenCalled();
|
|
91
|
+
expect(ctx.inject).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("ignores wrong-namespace traffic (ns mismatch → UNKNOWN)", () => {
|
|
95
|
+
const ctx = makeCtx();
|
|
96
|
+
handleInbound(
|
|
97
|
+
JSON.stringify({ ns: "some-other-bridge", v: 1, type: MSG.LOGOUT }),
|
|
98
|
+
ctx,
|
|
99
|
+
);
|
|
100
|
+
expect(ctx.onLoggedOut).not.toHaveBeenCalled();
|
|
101
|
+
expect(ctx.inject).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("ignores garbage (non-JSON string, null, number) without throwing", () => {
|
|
105
|
+
const ctx = makeCtx();
|
|
106
|
+
expect(() => handleInbound("}{not json", ctx)).not.toThrow();
|
|
107
|
+
expect(() => handleInbound(null, ctx)).not.toThrow();
|
|
108
|
+
expect(() => handleInbound(42, ctx)).not.toThrow();
|
|
109
|
+
expect(ctx.onNeedLogin).not.toHaveBeenCalled();
|
|
110
|
+
expect(ctx.onLoggedOut).not.toHaveBeenCalled();
|
|
111
|
+
expect(ctx.openExternal).not.toHaveBeenCalled();
|
|
112
|
+
expect(ctx.inject).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("helloInjection", () => {
|
|
117
|
+
it("produces injectable JS carrying a HELLO envelope, ending in \"true;\"", () => {
|
|
118
|
+
const js = helloInjection({ appVersion: "2.0.0", platform: "android" });
|
|
119
|
+
expect(js).toContain(MSG.HELLO);
|
|
120
|
+
expect(js).toContain("2.0.0");
|
|
121
|
+
expect(js).toContain("android");
|
|
122
|
+
expect(js.endsWith("true;")).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 웹→앱 메시지 라우터 — 순수 디스패처.
|
|
3
|
+
*
|
|
4
|
+
* Host(웹뷰 onMessage)가 받은 raw 문자열을 그대로 handleInbound에 넘긴다. 라우터는
|
|
5
|
+
* tolerant reader(readInbound)로 봉투를 해석한 뒤 MSG.* 별로 BridgeContext 콜백을
|
|
6
|
+
* 호출한다. 부수효과(로그인 UI·로그아웃·외부 링크·주입)는 전부 ctx 콜백으로 위임하므로
|
|
7
|
+
* 이 파일은 네이티브/expo import가 전혀 없고 Node에서 단위 테스트된다.
|
|
8
|
+
*
|
|
9
|
+
* 보안·견고성(DESIGN 결정 15):
|
|
10
|
+
* - 토큰은 이 채널을 절대 거치지 않는다(세션은 서버 Set-Cookie 핸드오프).
|
|
11
|
+
* - 모르는/깨진 메시지는 readInbound가 UNKNOWN으로 환원 → 여기서 조용히 무시(never throw).
|
|
12
|
+
*/
|
|
13
|
+
import { MSG, SSB_NS, SSB_PROTOCOL_VERSION, type OutboundMessage } from "./contract";
|
|
14
|
+
import { readInbound } from "./reader";
|
|
15
|
+
import { buildHello } from "./capabilities";
|
|
16
|
+
import { buildReceiveInjection } from "./messaging";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 라우터가 부수효과를 위임하는 콜백 묶음. 셸(웹뷰 Host)이 네이티브 구현을 채워 넘긴다.
|
|
20
|
+
* - inject : injectJavaScript 호출(앱→웹 응답 주입).
|
|
21
|
+
* - onNeedLogin : 웹이 세션 없음을 알릴 때 네이티브 로그인 흐름 시작.
|
|
22
|
+
* - onLoggedOut : 웹 로그아웃 시 네이티브 로컬 세션 사본 정리.
|
|
23
|
+
* - openExternal : URL을 시스템 브라우저로 열기.
|
|
24
|
+
*/
|
|
25
|
+
export type BridgeContext = {
|
|
26
|
+
appVersion: string;
|
|
27
|
+
platform: "ios" | "android";
|
|
28
|
+
webOrigin: string;
|
|
29
|
+
inject: (js: string) => void;
|
|
30
|
+
onNeedLogin: (redirectPath?: string) => void;
|
|
31
|
+
onLoggedOut: () => void;
|
|
32
|
+
openExternal: (url: string) => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* LOGOUT 완료 후 웹에 되돌려줄 LOGOUT_DONE 봉투. payload 없는 단순 ack(계약상 optional).
|
|
37
|
+
*/
|
|
38
|
+
const LOGOUT_DONE_MESSAGE: OutboundMessage = {
|
|
39
|
+
ns: SSB_NS,
|
|
40
|
+
v: SSB_PROTOCOL_VERSION,
|
|
41
|
+
type: MSG.LOGOUT_DONE,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* raw(웹→앱 postMessage 문자열 또는 파싱된 객체)를 해석해 적절한 콜백을 호출한다.
|
|
46
|
+
* readInbound가 tolerant하므로 절대 throw하지 않는다.
|
|
47
|
+
*/
|
|
48
|
+
export function handleInbound(raw: unknown, ctx: BridgeContext): void {
|
|
49
|
+
const msg = readInbound(raw);
|
|
50
|
+
|
|
51
|
+
switch (msg.type) {
|
|
52
|
+
case MSG.READY:
|
|
53
|
+
// 핸드셰이크 ack — 별도 동작 없음(셸이 필요 시 재시도 타임아웃을 따로 관리).
|
|
54
|
+
break;
|
|
55
|
+
|
|
56
|
+
case MSG.REQUEST_SESSION_INSTALL:
|
|
57
|
+
// 웹이 세션 없음을 알림 → 네이티브 로그인 + 세션 설치 흐름 시작.
|
|
58
|
+
ctx.onNeedLogin(msg.payload?.redirectPath);
|
|
59
|
+
break;
|
|
60
|
+
|
|
61
|
+
case MSG.AUTH_STATE_CHANGED:
|
|
62
|
+
// v1 noop 훅 — 미래에 네이티브 측 세션 캐시 동기화 등에 쓸 자리.
|
|
63
|
+
break;
|
|
64
|
+
|
|
65
|
+
case MSG.LOGOUT:
|
|
66
|
+
// 네이티브 로컬 세션 정리 후, 완료를 웹에 LOGOUT_DONE으로 통지.
|
|
67
|
+
ctx.onLoggedOut();
|
|
68
|
+
ctx.inject(buildReceiveInjection(LOGOUT_DONE_MESSAGE));
|
|
69
|
+
break;
|
|
70
|
+
|
|
71
|
+
case MSG.OPEN_EXTERNAL:
|
|
72
|
+
// 외부 URL을 시스템 브라우저로(웹뷰 in-app 내비게이션 아님).
|
|
73
|
+
ctx.openExternal(msg.payload.url);
|
|
74
|
+
break;
|
|
75
|
+
|
|
76
|
+
case MSG.UNKNOWN:
|
|
77
|
+
default:
|
|
78
|
+
// 미지·깨진 메시지는 조용히 무시(DESIGN 결정 15: never-throw tolerant).
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* HELLO 봉투를 만들어 주입용 JS 소스로 변환한다. 셸은 웹뷰 로드 직후 이 문자열을
|
|
85
|
+
* injectJavaScript에 넘겨 "나 이런 앱·능력이에요"를 웹에 알린다(DESIGN 결정 8).
|
|
86
|
+
*/
|
|
87
|
+
export function helloInjection(ctx: { appVersion: string; platform: "ios" | "android" }): string {
|
|
88
|
+
return buildReceiveInjection(buildHello(ctx));
|
|
89
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-safe configuration for the native shell.
|
|
3
|
+
*
|
|
4
|
+
* `EXPO_PUBLIC_*` variables are inlined at BUILD time (not read at runtime), so
|
|
5
|
+
* changing the URL means a rebuild — never promise "change the URL without
|
|
6
|
+
* rebuilding". See the eas-deploy-guide skill (환경 모델).
|
|
7
|
+
*
|
|
8
|
+
* Guard rationale (see the dev-loop guide): a missing or malformed web URL must
|
|
9
|
+
* FAIL LOUDLY here, at startup. An unguarded `.replace()` / silent fallback is
|
|
10
|
+
* how a release ships a blank white screen.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const rawWebUrl = process.env.EXPO_PUBLIC_WEB_URL;
|
|
14
|
+
|
|
15
|
+
if (!rawWebUrl) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
"[env] EXPO_PUBLIC_WEB_URL is not set. Copy .env.example to .env (local) or set it " +
|
|
18
|
+
"in the matching eas.json build profile, pointing at your deployed web URL. " +
|
|
19
|
+
"See the eas-deploy-guide skill.",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let parsedUrl: URL;
|
|
24
|
+
try {
|
|
25
|
+
parsedUrl = new URL(rawWebUrl);
|
|
26
|
+
} catch {
|
|
27
|
+
throw new Error(`[env] EXPO_PUBLIC_WEB_URL is not a valid URL: "${rawWebUrl}"`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (parsedUrl.protocol !== "https:" && parsedUrl.hostname !== "localhost" && parsedUrl.hostname !== "10.0.2.2") {
|
|
31
|
+
// Real devices require https (secure context): camera, Web Crypto, clipboard and
|
|
32
|
+
// Secure cookies silently break over http on a device. http is only tolerated for
|
|
33
|
+
// the simulator/emulator shell fast-loop. See the dev-loop guide.
|
|
34
|
+
throw new Error(
|
|
35
|
+
`[env] EXPO_PUBLIC_WEB_URL must be https on real devices (got "${rawWebUrl}"). ` +
|
|
36
|
+
"http is only allowed for localhost / 10.0.2.2 in the simulator.",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The full URL the WebView loads. */
|
|
41
|
+
export const WEB_URL = rawWebUrl;
|
|
42
|
+
|
|
43
|
+
/** Origin of WEB_URL, used for the link allow-list and bridge origin checks. */
|
|
44
|
+
export const WEB_ORIGIN = parsedUrl.origin;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Origins the WebView is allowed to navigate to in-app. Everything else opens in
|
|
48
|
+
* the system browser. This array is the single source of truth for the in-app link
|
|
49
|
+
* allow-list (native-app-guide §링크 경계 explains it). Same-origin is always included.
|
|
50
|
+
*/
|
|
51
|
+
export const ALLOWED_ORIGINS: readonly string[] = [WEB_ORIGIN];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny, dependency-free i18n for the handful of NATIVE strings the shell shows
|
|
3
|
+
* (loading / error / offline / the login scaffold). Everything else is the web's
|
|
4
|
+
* own copy, rendered inside the WebView.
|
|
5
|
+
*
|
|
6
|
+
* `t()` is tolerant: an unknown key falls back to English, then to the key
|
|
7
|
+
* itself — a missing translation degrades gracefully instead of crashing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type Locale = "ko" | "en";
|
|
11
|
+
|
|
12
|
+
function detectLocale(): Locale {
|
|
13
|
+
try {
|
|
14
|
+
const tag = String(new Intl.DateTimeFormat().resolvedOptions().locale ?? "");
|
|
15
|
+
return tag.toLowerCase().startsWith("ko") ? "ko" : "en";
|
|
16
|
+
} catch {
|
|
17
|
+
return "ko";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const LOCALE: Locale = detectLocale();
|
|
22
|
+
|
|
23
|
+
const MESSAGES = {
|
|
24
|
+
ko: {
|
|
25
|
+
loading: "불러오는 중…",
|
|
26
|
+
"error.title": "문제가 발생했어요",
|
|
27
|
+
"error.body": "잠시 후 다시 시도해 주세요.",
|
|
28
|
+
"error.retry": "다시 시도",
|
|
29
|
+
"offline.title": "인터넷에 연결되어 있지 않아요",
|
|
30
|
+
"offline.body": "연결 상태를 확인한 뒤 다시 시도해 주세요.",
|
|
31
|
+
"offline.retry": "다시 시도",
|
|
32
|
+
"login.title": "로그인",
|
|
33
|
+
"login.continueWithEmail": "이메일로 계속하기",
|
|
34
|
+
"login.continueWithGoogle": "Google로 계속하기",
|
|
35
|
+
"login.continueWithApple": "Apple로 계속하기",
|
|
36
|
+
"login.socialComingSoon": "소셜 로그인은 준비 중입니다",
|
|
37
|
+
"login.close": "닫기",
|
|
38
|
+
},
|
|
39
|
+
en: {
|
|
40
|
+
loading: "Loading…",
|
|
41
|
+
"error.title": "Something went wrong",
|
|
42
|
+
"error.body": "Please try again in a moment.",
|
|
43
|
+
"error.retry": "Try again",
|
|
44
|
+
"offline.title": "You're offline",
|
|
45
|
+
"offline.body": "Check your connection and try again.",
|
|
46
|
+
"offline.retry": "Try again",
|
|
47
|
+
"login.title": "Sign in",
|
|
48
|
+
"login.continueWithEmail": "Continue with email",
|
|
49
|
+
"login.continueWithGoogle": "Continue with Google",
|
|
50
|
+
"login.continueWithApple": "Continue with Apple",
|
|
51
|
+
"login.socialComingSoon": "Social sign-in is coming soon",
|
|
52
|
+
"login.close": "Close",
|
|
53
|
+
},
|
|
54
|
+
} as const;
|
|
55
|
+
|
|
56
|
+
export type I18nKey = keyof (typeof MESSAGES)["en"];
|
|
57
|
+
|
|
58
|
+
export function getLocale(): Locale {
|
|
59
|
+
return LOCALE;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function t(key: I18nKey | (string & {}), params?: Record<string, string | number>): string {
|
|
63
|
+
const table = MESSAGES[LOCALE] as Record<string, string>;
|
|
64
|
+
let out = table[key] ?? (MESSAGES.en as Record<string, string>)[key] ?? String(key);
|
|
65
|
+
if (params) {
|
|
66
|
+
for (const [k, v] of Object.entries(params)) {
|
|
67
|
+
out = out.replace(new RegExp(`\\{${k}\\}`, "g"), String(v));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure session mirror (native layer).
|
|
3
|
+
*
|
|
4
|
+
* This is a READ-ONLY MIRROR of the web's session for the native shell — NOT the
|
|
5
|
+
* source of truth. The WEB owns the session and its refresh (see DESIGN decision 5:
|
|
6
|
+
* the native shell sets `autoRefreshToken: false`; only the web refreshes, so the two
|
|
7
|
+
* never race on token rotation). The mirror's INTENDED use is to tell, locally and offline,
|
|
8
|
+
* whether a handoff has already happened — e.g. to skip the login scaffold on a cold start.
|
|
9
|
+
*
|
|
10
|
+
* v1 STATUS — scaffold: only `clearAsync()` is wired (from `requestNativeLogout` on a web LOGOUT).
|
|
11
|
+
* `getAsync`/`setAsync` have NO callers yet, and there is no cold-start session check. The
|
|
12
|
+
* write-on-handoff / read-on-cold-start behavior lands when native login is wired (DESIGN 12).
|
|
13
|
+
*
|
|
14
|
+
* Stored value: an OPAQUE marker the native shell controls (e.g. "installed" or a
|
|
15
|
+
* non-sensitive id). It is NOT the Supabase access/refresh token — tokens never live
|
|
16
|
+
* in the native layer and never cross the bridge (contract.ts invariant 5). expo-secure-store
|
|
17
|
+
* backs this with the iOS Keychain / Android Keystore.
|
|
18
|
+
*
|
|
19
|
+
* API verified against node_modules/expo-secure-store (SDK 56):
|
|
20
|
+
* getItemAsync(key, options?) → Promise<string | null> (null when absent)
|
|
21
|
+
* setItemAsync(key, value, options?) → Promise<void> (rejects on failure)
|
|
22
|
+
* deleteItemAsync(key, options?) → Promise<void>
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import * as SecureStore from "expo-secure-store";
|
|
26
|
+
|
|
27
|
+
/** Keychain/Keystore key. Alphanumerics + `.`/`-`/`_` only (SecureStore constraint). */
|
|
28
|
+
const SESSION_KEY = "ssb.session.mirror";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* iOS accessibility: readable after the first unlock, this-device-only (never migrated
|
|
32
|
+
* to a new device via backup). A session marker should not survive a device restore.
|
|
33
|
+
*/
|
|
34
|
+
const OPTIONS: SecureStore.SecureStoreOptions = {
|
|
35
|
+
keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const secureSession = {
|
|
39
|
+
/** Returns the stored marker, or `null` if none / on any read error. Never throws. */
|
|
40
|
+
async getAsync(): Promise<string | null> {
|
|
41
|
+
try {
|
|
42
|
+
return await SecureStore.getItemAsync(SESSION_KEY, OPTIONS);
|
|
43
|
+
} catch {
|
|
44
|
+
// A read failure (e.g. invalidated key) is treated as "no session" — the worst
|
|
45
|
+
// case is showing login again, which is safe. Never crash the shell over this.
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
/** Stores/overwrites the marker. */
|
|
51
|
+
async setAsync(value: string): Promise<void> {
|
|
52
|
+
await SecureStore.setItemAsync(SESSION_KEY, value, OPTIONS);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
/** Removes the marker. Idempotent — clearing an absent key is a no-op, never throws. */
|
|
56
|
+
async clearAsync(): Promise<void> {
|
|
57
|
+
try {
|
|
58
|
+
await SecureStore.deleteItemAsync(SESSION_KEY, OPTIONS);
|
|
59
|
+
} catch {
|
|
60
|
+
// Deleting a key that does not exist must not surface as an error.
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
} as const;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native → web session handoff (DESIGN decision 5).
|
|
3
|
+
*
|
|
4
|
+
* Flow (the "secure handoff"):
|
|
5
|
+
* 1. The native layer obtains a provider credential (Google/Apple `idToken`).
|
|
6
|
+
* ── ⚠️ FAST-FOLLOW: acquiring that idToken (native Google/Apple sign-in) is NOT
|
|
7
|
+
* wired in v1 (DESIGN decision 12). The plumbing below is fully implemented and
|
|
8
|
+
* tested, but has no caller yet — `LoginScreen` routes users to the web's own
|
|
9
|
+
* email/password login instead. See the store-release-guide skill for the wire-up.
|
|
10
|
+
* 2. We POST { provider, idToken } to `${WEB_ORIGIN}/auth/app-bridge`. The web verifies
|
|
11
|
+
* the credential server-side, signs the user in, and mints a ONE-TIME exchange NONCE.
|
|
12
|
+
* 3. We return a `handoffUrl` of the form `${WEB_URL}<path>?code=<nonce>`. The WebView
|
|
13
|
+
* loads it; the web route exchanges the nonce and responds with `Set-Cookie` + a 303
|
|
14
|
+
* redirect, installing a normal @supabase/ssr session cookie.
|
|
15
|
+
*
|
|
16
|
+
* Why a nonce and not the token:
|
|
17
|
+
* - The `code` is a SHORT-LIVED, SINGLE-USE exchange nonce — NOT the session token.
|
|
18
|
+
* - A `?token=<jwt>` URL would leak the actual session in history/logs/referrers
|
|
19
|
+
* (DESIGN correction 2). The token is set as a cookie server-side and NEVER appears
|
|
20
|
+
* in a URL, and NEVER crosses the app↔web message bridge (contract.ts invariant 5).
|
|
21
|
+
*
|
|
22
|
+
* The web route (`POST /auth/app-bridge` + the nonce-exchange GET) is installed into the
|
|
23
|
+
* web project from docs/web-adapter; it is not part of this app.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { WEB_ORIGIN, WEB_URL } from "../config/env";
|
|
27
|
+
|
|
28
|
+
/** Where the web mints the nonce (POST). Relative to WEB_ORIGIN. */
|
|
29
|
+
const APP_BRIDGE_PATH = "/auth/app-bridge";
|
|
30
|
+
|
|
31
|
+
/** Where the web exchanges the one-time nonce for a session cookie (GET) — see docs/web-adapter. */
|
|
32
|
+
const CONSUME_PATH = "/auth/app-bridge/consume";
|
|
33
|
+
|
|
34
|
+
/** Default landing path after the cookie is installed, if the caller gives none. */
|
|
35
|
+
const DEFAULT_REDIRECT_PATH = "/";
|
|
36
|
+
|
|
37
|
+
export type HandoffResult =
|
|
38
|
+
| { ok: true; handoffUrl: string }
|
|
39
|
+
| { ok: false; reason: string };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Exchange a verified native credential for a one-time handoff URL the WebView can load.
|
|
43
|
+
*
|
|
44
|
+
* @param args.provider Which native provider minted the idToken.
|
|
45
|
+
* @param args.idToken The OIDC id token from the native sign-in (verified server-side).
|
|
46
|
+
* @param args.redirectPath Optional in-web path to land on after the cookie is set.
|
|
47
|
+
* @returns `{ ok: true, handoffUrl }` on success, otherwise `{ ok: false, reason }`.
|
|
48
|
+
*
|
|
49
|
+
* NOTE: fully implemented, but UNCALLED in v1 (see file header — native idToken acquisition
|
|
50
|
+
* is the fast-follow). Tokens never cross the bridge; only the nonce does, in the URL.
|
|
51
|
+
*/
|
|
52
|
+
export async function requestSessionHandoff(args: {
|
|
53
|
+
provider: "google" | "apple";
|
|
54
|
+
idToken: string;
|
|
55
|
+
redirectPath?: string;
|
|
56
|
+
}): Promise<HandoffResult> {
|
|
57
|
+
const { provider, idToken, redirectPath } = args;
|
|
58
|
+
|
|
59
|
+
if (!idToken) {
|
|
60
|
+
return { ok: false, reason: "missing-id-token" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let response: Response;
|
|
64
|
+
try {
|
|
65
|
+
response = await fetch(`${WEB_ORIGIN}${APP_BRIDGE_PATH}`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
68
|
+
body: JSON.stringify({ provider, idToken }),
|
|
69
|
+
});
|
|
70
|
+
} catch {
|
|
71
|
+
// Network error / DNS / offline: surface a stable reason, never throw.
|
|
72
|
+
return { ok: false, reason: "network-error" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
return { ok: false, reason: `server-${response.status}` };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let data: unknown;
|
|
80
|
+
try {
|
|
81
|
+
data = await response.json();
|
|
82
|
+
} catch {
|
|
83
|
+
return { ok: false, reason: "invalid-response" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const handoffUrl = buildHandoffUrl(data, redirectPath ?? DEFAULT_REDIRECT_PATH);
|
|
87
|
+
if (!handoffUrl) {
|
|
88
|
+
return { ok: false, reason: "invalid-handoff" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { ok: true, handoffUrl };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Native logout: forget the local session mirror.
|
|
96
|
+
*
|
|
97
|
+
* This only clears the NATIVE copy. The authoritative web `signOut` (cookie + Supabase
|
|
98
|
+
* session) happens via the LOGOUT bridge message handled by the web page — see
|
|
99
|
+
* src/bridge/router.ts (`onLoggedOut` → LOGOUT_DONE). Logout unwinds BOTH sides
|
|
100
|
+
* (DESIGN decision 5): the web clears the cookie, the native shell clears this mirror.
|
|
101
|
+
*/
|
|
102
|
+
export async function requestNativeLogout(): Promise<void> {
|
|
103
|
+
// Imported lazily-by-path to keep this module's surface focused on handoff plumbing.
|
|
104
|
+
const { secureSession } = await import("./secureSession");
|
|
105
|
+
await secureSession.clearAsync();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Read a non-empty string field off an unknown JSON object. */
|
|
109
|
+
function pickString(data: unknown, key: string): string | null {
|
|
110
|
+
if (typeof data !== "object" || data === null) return null;
|
|
111
|
+
const value = (data as Record<string, unknown>)[key];
|
|
112
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Turn the server response into the on-origin URL the WebView should load.
|
|
117
|
+
*
|
|
118
|
+
* The web (docs/web-adapter) owns the consume route + how the nonce is carried, so we
|
|
119
|
+
* PREFER the server-returned `handoffUrl` (e.g. `/auth/app-bridge/consume?code=<nonce>`)
|
|
120
|
+
* and fall back to composing `${CONSUME_PATH}?code=<nonce>` from a bare `code`/`nonce`.
|
|
121
|
+
* The post-cookie landing path travels as `next` (path-only). Everything is resolved
|
|
122
|
+
* against WEB_ORIGIN and rejected if it would leave the web origin — only the one-time
|
|
123
|
+
* nonce is ever in the URL, never the session token (DESIGN correction 2).
|
|
124
|
+
*/
|
|
125
|
+
function buildHandoffUrl(data: unknown, redirectPath: string): string | null {
|
|
126
|
+
const next =
|
|
127
|
+
redirectPath.startsWith("/") && !redirectPath.startsWith("//") ? redirectPath : DEFAULT_REDIRECT_PATH;
|
|
128
|
+
|
|
129
|
+
let url: URL;
|
|
130
|
+
const serverHandoff = pickString(data, "handoffUrl");
|
|
131
|
+
if (serverHandoff) {
|
|
132
|
+
try {
|
|
133
|
+
url = new URL(serverHandoff, WEB_URL);
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
const code = pickString(data, "code") ?? pickString(data, "nonce");
|
|
139
|
+
if (!code) return null;
|
|
140
|
+
try {
|
|
141
|
+
url = new URL(CONSUME_PATH, WEB_URL);
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
url.searchParams.set("code", code);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (url.origin !== WEB_ORIGIN) return null;
|
|
149
|
+
if (!url.searchParams.has("next")) url.searchParams.set("next", next);
|
|
150
|
+
return url.toString();
|
|
151
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Pressable, StyleSheet, Text, View } from "react-native";
|
|
3
|
+
|
|
4
|
+
import { t } from "../i18n";
|
|
5
|
+
|
|
6
|
+
export type ErrorViewProps = {
|
|
7
|
+
/** 재시도 콜백 — 셸은 보통 웹뷰 key를 증가시켜 통째 재로드한다. */
|
|
8
|
+
onRetry: () => void;
|
|
9
|
+
/** 선택적 상세 메시지(HTTP 코드·네트워크 설명 등). 없으면 i18n 기본 본문. */
|
|
10
|
+
message?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 로드 실패(네트워크/HTTP 오류) 시 웹뷰 위에 덮는 에러 오버레이 (DESIGN 결정 21: 에러 화면).
|
|
15
|
+
*
|
|
16
|
+
* onError / onHttpError가 호출하며, "다시 시도" 버튼은 onRetry로 위임한다. 본문은 선택적
|
|
17
|
+
* message(있으면 우선) → 없으면 i18n 기본 문구. 모든 사용자 노출 문구는 t()를 거친다.
|
|
18
|
+
*/
|
|
19
|
+
export function ErrorView(props: ErrorViewProps): React.JSX.Element {
|
|
20
|
+
const { onRetry, message } = props;
|
|
21
|
+
return (
|
|
22
|
+
<View style={styles.fill}>
|
|
23
|
+
<Text style={styles.title}>{t("error.title")}</Text>
|
|
24
|
+
<Text style={styles.body}>{message ?? t("error.body")}</Text>
|
|
25
|
+
<Pressable
|
|
26
|
+
onPress={onRetry}
|
|
27
|
+
accessibilityRole="button"
|
|
28
|
+
style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}
|
|
29
|
+
>
|
|
30
|
+
<Text style={styles.buttonLabel}>{t("error.retry")}</Text>
|
|
31
|
+
</Pressable>
|
|
32
|
+
</View>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const styles = StyleSheet.create({
|
|
37
|
+
fill: {
|
|
38
|
+
position: "absolute",
|
|
39
|
+
top: 0,
|
|
40
|
+
left: 0,
|
|
41
|
+
right: 0,
|
|
42
|
+
bottom: 0,
|
|
43
|
+
backgroundColor: "#fff",
|
|
44
|
+
alignItems: "center",
|
|
45
|
+
justifyContent: "center",
|
|
46
|
+
paddingHorizontal: 24,
|
|
47
|
+
},
|
|
48
|
+
title: {
|
|
49
|
+
fontSize: 16,
|
|
50
|
+
fontWeight: "700",
|
|
51
|
+
color: "#111",
|
|
52
|
+
textAlign: "center",
|
|
53
|
+
},
|
|
54
|
+
body: {
|
|
55
|
+
marginTop: 8,
|
|
56
|
+
fontSize: 13,
|
|
57
|
+
color: "#666",
|
|
58
|
+
textAlign: "center",
|
|
59
|
+
},
|
|
60
|
+
button: {
|
|
61
|
+
marginTop: 20,
|
|
62
|
+
paddingHorizontal: 18,
|
|
63
|
+
paddingVertical: 11,
|
|
64
|
+
borderRadius: 10,
|
|
65
|
+
backgroundColor: "#111",
|
|
66
|
+
},
|
|
67
|
+
buttonPressed: {
|
|
68
|
+
opacity: 0.7,
|
|
69
|
+
},
|
|
70
|
+
buttonLabel: {
|
|
71
|
+
color: "#fff",
|
|
72
|
+
fontSize: 14,
|
|
73
|
+
fontWeight: "600",
|
|
74
|
+
},
|
|
75
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ActivityIndicator, Platform, StyleSheet, Text, View } from "react-native";
|
|
3
|
+
|
|
4
|
+
import { t } from "../i18n";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 첫 페이지가 그려지기 전까지 웹뷰 위에 덮어두는 로딩 오버레이 (DESIGN 결정 21: 로딩 화면).
|
|
8
|
+
*
|
|
9
|
+
* absolute-fill 흰 배경이라 웹뷰가 그리는 동안의 깜빡임/빈 화면을 가린다. 스피너 아래에
|
|
10
|
+
* i18n 문구를 두어 "멈춘 게 아님"을 알린다. 색은 중립 회색 — 스타터의 기본값이며 브랜드
|
|
11
|
+
* 색이 정해지면 이 한 곳만 바꾸면 된다.
|
|
12
|
+
*/
|
|
13
|
+
export function LoadingView(): React.JSX.Element {
|
|
14
|
+
return (
|
|
15
|
+
<View style={styles.fill} accessibilityRole="progressbar" accessibilityLabel={t("loading")}>
|
|
16
|
+
<ActivityIndicator size={Platform.OS === "ios" ? "large" : 40} color="#111" />
|
|
17
|
+
<Text style={styles.label}>{t("loading")}</Text>
|
|
18
|
+
</View>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const styles = StyleSheet.create({
|
|
23
|
+
fill: {
|
|
24
|
+
position: "absolute",
|
|
25
|
+
top: 0,
|
|
26
|
+
left: 0,
|
|
27
|
+
right: 0,
|
|
28
|
+
bottom: 0,
|
|
29
|
+
backgroundColor: "#fff",
|
|
30
|
+
alignItems: "center",
|
|
31
|
+
justifyContent: "center",
|
|
32
|
+
},
|
|
33
|
+
label: {
|
|
34
|
+
marginTop: 12,
|
|
35
|
+
fontSize: 13,
|
|
36
|
+
color: "#666",
|
|
37
|
+
},
|
|
38
|
+
});
|