diff --git a/.gitignore b/.gitignore index 743b28c..ecfbad5 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ app-example google-services.json GoogleService-Info.plist ios/**/GoogleService-Info.plist + +# agent tooling +.omc/ diff --git a/app.json b/app.json index a4116c6..a20dee2 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "모아동", "slug": "moadong-app", - "version": "1.6.0", + "version": "1.7.0", "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "moadongapp", @@ -10,7 +10,7 @@ "newArchEnabled": true, "ios": { "supportsTablet": false, - "buildNumber": "16", + "buildNumber": "17", "googleServicesFile": "./GoogleService-Info.plist", "bundleIdentifier": "com.moadong.moadong", "associatedDomains": [ @@ -26,7 +26,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 16, + "versionCode": 17, "adaptiveIcon": { "backgroundColor": "#E6F4FE", "foregroundImage": "./assets/images/android-icon-foreground.png", diff --git a/ios/app.xcodeproj/project.pbxproj b/ios/app.xcodeproj/project.pbxproj index 98d23bc..82e08c2 100644 --- a/ios/app.xcodeproj/project.pbxproj +++ b/ios/app.xcodeproj/project.pbxproj @@ -417,7 +417,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = app/app.entitlements; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 17; DEVELOPMENT_TEAM = 2QMK9GBWN6; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -430,7 +430,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.6.0; + MARKETING_VERSION = 1.7.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -454,7 +454,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = app/app.entitlements; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 17; DEVELOPMENT_TEAM = 2QMK9GBWN6; INFOPLIST_FILE = app/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -462,7 +462,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.6.0; + MARKETING_VERSION = 1.7.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/ios/app/Info.plist b/ios/app/Info.plist index 71a5bfa..bde57a7 100644 --- a/ios/app/Info.plist +++ b/ios/app/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.6.0 + 1.7.0 CFBundleSignature ???? CFBundleURLTypes @@ -39,7 +39,7 @@ CFBundleVersion - 16 + 17 LSMinimumSystemVersion 12.0 LSRequiresIPhoneOS diff --git a/services/auth-token.service.ts b/services/auth-token.service.ts index 254b401..922f69c 100644 --- a/services/auth-token.service.ts +++ b/services/auth-token.service.ts @@ -61,11 +61,21 @@ export async function issueAccessToken(): Promise { return token; } +let issuePromise: Promise | null = null; + export async function ensureAccessToken(): Promise { const storedToken = await getStoredAccessToken(); if (storedToken) { return storedToken; } - return issueAccessToken(); + // 첫 실행 시 부트스트랩과 웹뷰가 동시에 호출하면 서로 다른 sub/토큰이 발급되어 + // 앱 신원과 웹뷰 신원이 갈린다. 발급은 항상 한 번만 수행한다. + if (!issuePromise) { + issuePromise = issueAccessToken().finally(() => { + issuePromise = null; + }); + } + + return issuePromise; } diff --git a/ui/home/home-webview-screen.tsx b/ui/home/home-webview-screen.tsx index fd26ad0..be1bac7 100644 --- a/ui/home/home-webview-screen.tsx +++ b/ui/home/home-webview-screen.tsx @@ -1,6 +1,7 @@ import { useHomeWebViewPreloadContext } from '@/contexts/home-webview-preload-context'; import { useMixpanelContext } from '@/contexts/mixpanel-context'; import { useSubscribedClubsContext } from '@/contexts/subscribed-clubs-context'; +import { ensureAccessToken } from '@/services/auth-token.service'; import { appendSessionId, getWebViewUserAgent } from '@/utils/webview'; import Constants from 'expo-constants'; import { useRouter } from 'expo-router'; @@ -30,12 +31,50 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { const canGoBackRef = useRef(false); const loadFailedRef = useRef(false); const [loaded, setLoaded] = useState(false); + const [studentToken, setStudentToken] = useState(null); + const [tokenResolved, setTokenResolved] = useState(false); const { markLoading, markReady, markFailed } = useHomeWebViewPreloadContext(); const { sessionId, isLoading: sessionLoading } = useMixpanelContext(); const { subscribedClubIds, toggleSubscribe } = useSubscribedClubsContext(); - const url = sessionLoading ? null : appendSessionId(BASE_URL, sessionId); + // 웹의 첫 API 호출 전에 토큰이 준비돼 있어야 하므로, 조회가 끝난 뒤에 웹뷰를 렌더한다. + // 발급에 실패하면 주입 없이 렌더하고 웹이 자체 토큰으로 폴백한다. + useEffect(() => { + let cancelled = false; + ensureAccessToken() + .then((token) => { + if (!cancelled) setStudentToken(token); + }) + .catch(() => { + if (!cancelled) setStudentToken(null); + }) + .finally(() => { + if (!cancelled) setTokenResolved(true); + }); + + return () => { + cancelled = true; + }; + }, []); + + const url = + sessionLoading || !tokenResolved ? null : appendSessionId(BASE_URL, sessionId); + + // 주입 스크립트는 웹뷰가 로드하는 모든 문서에서 실행되므로, + // origin 가드 없이는 외부 사이트로 이동했을 때 베어러 토큰이 노출된다. + // origin 비교는 웹뷰 안에서 한다. RN 의 URL 폴리필은 호스트 대소문자와 기본 포트를 + // 정규화하지 않아 window.location.origin 과 어긋날 수 있다. 파싱에 실패하면 주입하지 않는다. + const injectedToken = studentToken + ? `(function(){ + try { + if (new URL(${JSON.stringify(BASE_URL)}).origin !== window.location.origin) return; + } catch (e) { + return; + } + window.__MOADONG_STUDENT_TOKEN__ = ${JSON.stringify(studentToken)}; + })(); true;` + : undefined; useEffect(() => { if (url) { @@ -195,6 +234,7 @@ export function HomeWebViewScreen({ onError }: HomeWebViewScreenProps) { style={{ flex: 1 }} source={{ uri: url }} userAgent={USER_AGENT} + injectedJavaScriptBeforeContentLoaded={injectedToken} onMessage={handleMessage} onLoadEnd={handleLoadEnd} onNavigationStateChange={handleNavigationStateChange}