Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ios_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ on:
- development
- preview
- production
- stage
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 근데 expo 빌드 타입에 stage는 없지 않나요
그래서

development -> stage
production, preview -> production

으로 만들었습니다.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 추가하셨군요
근데 그냥 preview를 써도 되지 않나요...?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

development: 개발자용. developmentClient가 활성화되어 Expo Dev Client로 실행, 별도 패키지명/구글서비스로 분리됨
preview: QA/테스트 내부 배포용. 스토어 제출 없이 APK로 내부 팀에게 배포
stage: TestFlight(iOS) 용도로 특수하게 사용. 앱 자체는 production 빌드이나 API는 개발 서버(API_ENV: development)를 바라봄 → 앱 이름이 "KONECT S"로 표시
production: 실제 스토어 배포용. Android는 AAB(Play Store 업로드 형식), iOS는 App Store Connect 제출용
이렇게 생각하고 만들었습니다

preview는 internal distribution 이라 testflight 업로드가 안된다고 해서 일단 임의로 stage를 만들었는데 이번 테스트에서만 사용될 것 같아 불필요하다면 푸시 알림 해결되면 제거해도 괜찮을것 같아요

jobs:
build:
runs-on: macos-26
Expand Down
25 changes: 14 additions & 11 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { AppState } from 'react-native';
import { getForceUpdate, appVersion, versionToNumber } from '../services/forceupdate';
import * as Notifications from 'expo-notifications';
import { registerForPushNotificationsAsync, shouldRecheckPermission } from '../services/notifications';
import CookieManager from '@react-native-cookies/cookies';
import { storePushToken } from '../utils/pushTokenStore';

Notifications.setNotificationHandler({
Expand All @@ -16,14 +15,6 @@ Notifications.setNotificationHandler({
}),
});

function addTokenToCookie(token: string) {
CookieManager.set('https://agit.gg', {
name: 'EXPO_PUSH_TOKEN',
value: token,
domain: '.agit.gg',
path: '/',
});
}

export default function RootLayout() {
const { replace } = useRouter();
Expand All @@ -34,11 +25,19 @@ export default function RootLayout() {
}, []);

useEffect(() => {
let tokenObtained = false;
// 권한 거부로 토큰 취득 불가한 경우 (네트워크 오류와 구분)
// registerForPushNotificationsAsync가 undefined를 반환하면 권한 거부
let permissionDenied = false;

const handleToken = (token?: string) => {
if (token) {
addTokenToCookie(token);
tokenObtained = true;
permissionDenied = false;
storePushToken(token);
console.log('Expo Push Token:', token);
} else {
permissionDenied = true;
}
};

Expand All @@ -47,7 +46,11 @@ export default function RootLayout() {
.catch((error: any) => console.error(error));

const subscription = AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'active' && shouldRecheckPermission()) {
const fromSettings = shouldRecheckPermission();
// 설정에서 돌아온 경우: 항상 재시도
// 권한 거부가 아닌데 토큰이 없는 경우(네트워크 오류 등): 재시도
// 권한 거부 상태에서 그냥 포그라운드 복귀: 재시도 안 함 (alert 무한 반복 방지)
if (nextAppState === 'active' && (fromSettings || (!tokenObtained && !permissionDenied))) {
registerForPushNotificationsAsync()
Comment on lines +62 to 64
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid clearing wentToSettings on non-active transitions.
Line 49 calls shouldRecheckPermission() on every AppState change; that resets the flag (see services/notifications.ts) before the app becomes active, so the retry can be skipped after returning from settings. Move the call inside the nextAppState === 'active' branch.

Suggested fix
-    const fromSettings = shouldRecheckPermission();
-    if (nextAppState === 'active' && (fromSettings || (!tokenObtained && !permissionDenied))) {
-      registerForPushNotificationsAsync()
-        .then(handleToken)
-        .catch((error: any) => console.error(error));
-    }
+    if (nextAppState === 'active') {
+      const fromSettings = shouldRecheckPermission();
+      if (fromSettings || (!tokenObtained && !permissionDenied)) {
+        registerForPushNotificationsAsync()
+          .then(handleToken)
+          .catch((error: any) => console.error(error));
+      }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/_layout.tsx` around lines 49 - 54, The issue is that
shouldRecheckPermission() is being called on every AppState change which clears
the wentToSettings flag too early; move the call into the active-state branch so
the flag is only consumed when nextAppState === 'active'. Concretely, inside the
AppState change handler call shouldRecheckPermission() only when nextAppState
=== 'active' and then use its result to decide whether to call
registerForPushNotificationsAsync(); this keeps wentToSettings in
services/notifications.ts intact during non-active transitions and preserves the
intended retry behavior.

.then(handleToken)
.catch((error: any) => console.error(error));
Expand Down
43 changes: 20 additions & 23 deletions app/webview/[path].tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef, useEffect, useCallback } from 'react';
import { useRef, useEffect, useCallback, useState } from 'react';
import {
BackHandler,
Platform,
Expand All @@ -16,9 +16,14 @@ import CookieManager from '@react-native-cookies/cookies';
import { generateUserAgent } from '../../utils/userAgent';
import { ShouldStartLoadRequest } from 'react-native-webview/lib/WebViewTypes';
import { webUrl } from '../../constants/constants';
import { onPushToken } from '../../utils/pushTokenStore';
import { onPushToken, getStoredToken } from '../../utils/pushTokenStore';

const ALLOWED_URL_SCHEMES = ['kakaotalk', 'nidlogin'];

function buildPushTokenScript(token: string): string {
const safeToken = JSON.stringify(token);
return `(function(){window.dispatchEvent(new MessageEvent('message',{data:JSON.stringify({type:'PUSH_TOKEN',token:${safeToken}})}));}());true;`;
}
const userAgent = generateUserAgent();

const handleOnShouldStartLoadWithRequest = ({ url }: ShouldStartLoadRequest) => {
Expand All @@ -43,36 +48,27 @@ export default function Index() {
const webViewRef = useRef<WebView>(null);
const canGoBackRef = useRef(false);
const pageLoadedRef = useRef(false);
const pendingTokenRef = useRef<string | null>(null);
const [pushToken, setPushToken] = useState<string | null>(getStoredToken);
const local = useLocalSearchParams();

useEffect(() => {
return onPushToken(setPushToken);
}, []);

const injectPushToken = useCallback((token: string) => {
const safeToken = JSON.stringify(token);
webViewRef.current?.injectJavaScript(`
window.dispatchEvent(new MessageEvent('message', {
data: JSON.stringify({ type: 'PUSH_TOKEN', token: ${safeToken} })
}));
true;
`);
webViewRef.current?.injectJavaScript(buildPushTokenScript(token));
}, []);

// 페이지 로드 후 토큰이 도착한 경우 직접 주입
useEffect(() => {
return onPushToken((token) => {
if (pageLoadedRef.current) {
injectPushToken(token);
} else {
pendingTokenRef.current = token;
}
});
}, [injectPushToken]);
if (pushToken && pageLoadedRef.current) {
injectPushToken(pushToken);
}
}, [pushToken, injectPushToken]);

const handleLoadEnd = useCallback(() => {
pageLoadedRef.current = true;
if (pendingTokenRef.current) {
injectPushToken(pendingTokenRef.current);
pendingTokenRef.current = null;
}
}, [injectPushToken]);
}, []);
Comment on lines +51 to +105
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -name "[path].tsx" -o -name "*webview*" | grep -i path | head -20

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 53


🏁 Script executed:

fd -t f "path\].tsx"

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 93


🏁 Script executed:

fd -t f "\[path\]\.tsx"

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 93


🏁 Script executed:

git ls-files | grep -i webview

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 93


🏁 Script executed:

cat -n app/webview/[path].tsx | head -120

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 4852


🏁 Script executed:

rg "webUrl|WebView" app/webview/[path].tsx -A 3 -B 3

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 1514


🏁 Script executed:

rg "onMessage|event.nativeEvent" app/webview/[path].tsx -B 5 -A 5

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 705


🏁 Script executed:

cat -n app/webview/[path].tsx | grep -A 20 "WebView"

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 3216


🏁 Script executed:

cat app/constants/constants.ts

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 129


🏁 Script executed:

web_search
react-native-webview WebViewMessageEvent nativeEvent.url property

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 180


🏁 Script executed:

fd -type f "constants" app/

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 241


🏁 Script executed:

rg "webUrl\s*=" --type ts --type tsx -A 2

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 98


🏁 Script executed:

npm list react-native-webview 2>/dev/null || echo "npm unavailable, checking package files"

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 168


🏁 Script executed:

cat package.json | rg "react-native-webview"

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 109


🏁 Script executed:

fd "constants" -e ts -e tsx

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 93


🏁 Script executed:

find . -name "constants*" -type f

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 13406


🏁 Script executed:

rg "export.*webUrl" --type ts

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 188


🏁 Script executed:

cat package.json | head -50

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 1624


🏁 Script executed:

rg "WebViewMessageEvent\|nativeEvent" --no-filename -A 5 -B 5

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 53


🏁 Script executed:

find . -path ./node_modules -prune -o -name "*.ts" -o -name "*.tsx" -exec grep -l "postMessage\|onMessage" {} \;

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 95


🏁 Script executed:

cat ./node_modules/.pnpm/react-native-webview*/node_modules/react-native-webview/lib/WebViewTypes.d.ts 2>/dev/null | head -100

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 4257


🏁 Script executed:

find ./node_modules -path "*react-native-webview*" -name "*.d.ts" -exec cat {} \; | head -200

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 7357


🏁 Script executed:

cat ./node_modules/.pnpm/react-native-webview@13.15.0/node_modules/react-native-webview/lib/WebViewTypes.d.ts | grep -A 20 "WebViewMessage"

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 208


🏁 Script executed:

find ./node_modules -path "*react-native-webview*13.15*" -name "WebViewTypes.d.ts"

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 263


🏁 Script executed:

find ./node_modules -path "*react-native-webview*" -name "WebViewTypes.d.ts" 2>/dev/null | head -1 | xargs cat

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 43378


🏁 Script executed:

ls -la ./node_modules/.pnpm/ | grep react-native-webview

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 239


🏁 Script executed:

cat ./node_modules/.pnpm/react-native-webview@13.15.0_react-native@0.81.5_@babel+core@7.29.0_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-webview/lib/WebViewTypes.d.ts | grep -A 10 "interface WebViewMessage"

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 338


🏁 Script executed:

cat ./node_modules/.pnpm/react-native-webview@13.15.0_react-native@0.81.5_@babel+core@7.29.0_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-webview/lib/WebViewTypes.d.ts | grep -B 5 "interface WebViewMessage"

Repository: BCSDLab/KONECT_REACT_NATIVE

Length of output: 250


Validate WebView message origin before handling auth/token actions.

handleMessage currently trusts all posted messages. Any loaded page can send LOGIN_COMPLETE, TOKEN_REFRESH, or LOGOUT payloads and mutate native token state (save/clear access tokens, register/unregister push tokens).

Suggested change
   const handleMessage = useCallback(async (event: WebViewMessageEvent) => {
     try {
+      const trustedOrigin = new URL(webUrl).origin;
+      const messageUrl = event.nativeEvent.url;
+      if (!messageUrl) return;
+      if (new URL(messageUrl).origin !== trustedOrigin) return;
+
       const data = JSON.parse(event.nativeEvent.data);
       const { type } = data;
+      if (!['LOGIN_COMPLETE', 'TOKEN_REFRESH', 'LOGOUT'].includes(type)) return;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleMessage = useCallback(async (event: WebViewMessageEvent) => {
try {
const data = JSON.parse(event.nativeEvent.data);
const { type } = data;
useEffect(() => {
return onPushToken((token) => {
if (pageLoadedRef.current) {
injectPushToken(token);
} else {
pendingTokenRef.current = token;
}
});
}, [injectPushToken]);
if (type === 'LOGIN_COMPLETE') {
const { accessToken } = data;
if (!accessToken) return;
const handleLoadEnd = useCallback(() => {
pageLoadedRef.current = true;
if (pendingTokenRef.current) {
injectPushToken(pendingTokenRef.current);
pendingTokenRef.current = null;
await saveAccessToken(accessToken);
console.log('LOGIN_COMPLETE: accessToken 저장 완료');
const pushToken = getStoredToken();
if (pushToken) {
try {
await registerPushToken(pushToken);
console.log('푸시 토큰 백엔드 등록 완료');
webViewRef.current?.injectJavaScript(
`window.dispatchEvent(new CustomEvent('NOTIFICATION_STATUS', { detail: { registered: true } }));true;`
);
} catch (e) {
console.error('푸시 토큰 등록 실패:', e);
webViewRef.current?.injectJavaScript(
`window.dispatchEvent(new CustomEvent('NOTIFICATION_STATUS', { detail: { registered: false } }));true;`
);
}
}
} else if (type === 'TOKEN_REFRESH') {
const { accessToken } = data;
if (accessToken) {
await saveAccessToken(accessToken);
console.log('TOKEN_REFRESH: accessToken 갱신 완료');
}
} else if (type === 'LOGOUT') {
const pushToken = getStoredToken();
if (pushToken) {
try {
await unregisterPushToken(pushToken);
console.log('푸시 토큰 백엔드 삭제 완료');
} catch (e) {
console.error('푸시 토큰 삭제 실패:', e);
}
}
await clearAccessToken();
console.log('LOGOUT: accessToken 삭제 완료');
}
} catch {
// JSON 파싱 실패 등 무시
}
}, [injectPushToken]);
}, []);
const handleMessage = useCallback(async (event: WebViewMessageEvent) => {
try {
const trustedOrigin = new URL(webUrl).origin;
const messageUrl = event.nativeEvent.url;
if (!messageUrl) return;
if (new URL(messageUrl).origin !== trustedOrigin) return;
const data = JSON.parse(event.nativeEvent.data);
const { type } = data;
if (!['LOGIN_COMPLETE', 'TOKEN_REFRESH', 'LOGOUT'].includes(type)) return;
if (type === 'LOGIN_COMPLETE') {
const { accessToken } = data;
if (!accessToken) return;
await saveAccessToken(accessToken);
console.log('LOGIN_COMPLETE: accessToken 저장 완료');
const pushToken = getStoredToken();
if (pushToken) {
try {
await registerPushToken(pushToken);
console.log('푸시 토큰 백엔드 등록 완료');
webViewRef.current?.injectJavaScript(
`window.dispatchEvent(new CustomEvent('NOTIFICATION_STATUS', { detail: { registered: true } }));true;`
);
} catch (e) {
console.error('푸시 토큰 등록 실패:', e);
webViewRef.current?.injectJavaScript(
`window.dispatchEvent(new CustomEvent('NOTIFICATION_STATUS', { detail: { registered: false } }));true;`
);
}
}
} else if (type === 'TOKEN_REFRESH') {
const { accessToken } = data;
if (accessToken) {
await saveAccessToken(accessToken);
console.log('TOKEN_REFRESH: accessToken 갱신 완료');
}
} else if (type === 'LOGOUT') {
const pushToken = getStoredToken();
if (pushToken) {
try {
await unregisterPushToken(pushToken);
console.log('푸시 토큰 백엔드 삭제 완료');
} catch (e) {
console.error('푸시 토큰 삭제 실패:', e);
}
}
await clearAccessToken();
console.log('LOGOUT: accessToken 삭제 완료');
}
} catch {
// JSON 파싱 실패 등 무시
}
}, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/webview/`[path].tsx around lines 50 - 99, handleMessage currently
processes any posted message; restrict it by validating the message origin
before acting on auth/token flows. Add an allowlist (e.g., allowedOrigins or
expectedOrigin) and check event.nativeEvent.origin (or event.nativeEvent.url if
origin is not present) against that allowlist at the top of handleMessage; if
the origin is not allowed, return immediately and do not call JSON.parse or any
actions. Keep the rest of the logic (calls to saveAccessToken, clearAccessToken,
registerPushToken, unregisterPushToken, getStoredToken, and
webViewRef.injectJavaScript) unchanged but only execute them after the origin
check passes.


useEffect(() => {
if (Platform.OS === 'android') {
Expand Down Expand Up @@ -122,6 +118,7 @@ export default function Index() {
originWhitelist={['*']}
startInLoadingState
onLoadEnd={handleLoadEnd}
injectedJavaScript={pushToken ? buildPushTokenScript(pushToken) : undefined}
/>
</SafeAreaView>
);
Expand Down
9 changes: 9 additions & 0 deletions eas.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@
"resourceClass": "m-medium"
}
},
"stage": {
"distribution": "store",
"env": {
"EXPO_PUBLIC_APP_ENV": "development"
},
"ios": {
"resourceClass": "m-medium"
}
},
Comment on lines +16 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Confirm EXPO_PUBLIC_APP_ENV: "development" is intentional for a store-distributed build.

distribution: "store" targets TestFlight / App Store channels, but EXPO_PUBLIC_APP_ENV is set to "development" — the same value used by the development profile. If runtime code branches on this variable to select API base URLs, feature flags, or log verbosity, a TestFlight artifact will behave identically to a development build.

If the intent is to have a dedicated staging environment, consider using a distinct value (e.g., "stage") and gating on it appropriately.

🔧 Suggested change if a distinct stage env is intended
     "stage": {
       "distribution": "store",
       "env": {
-        "EXPO_PUBLIC_APP_ENV": "development"
+        "EXPO_PUBLIC_APP_ENV": "stage"
       },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@eas.json` around lines 16 - 24, The eas.json "stage" profile sets
EXPO_PUBLIC_APP_ENV to "development" while using distribution "store"; update
EXPO_PUBLIC_APP_ENV to a distinct value (e.g., "stage") if this profile should
behave differently from the "development" profile, and then ensure runtime
checks that reference EXPO_PUBLIC_APP_ENV (e.g., code paths that select API base
URLs, feature flags, or logging) explicitly handle the new "stage" value; if the
current behavior is intentional, document that EXPO_PUBLIC_APP_ENV="development"
is expected for the "stage" profile.

"preview": {
"distribution": "internal",
"android": {
Expand Down
2 changes: 2 additions & 0 deletions utils/pushTokenStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export const storePushToken = (token: string) => {
_callbacks.length = 0;
};

export const getStoredToken = (): string | null => _token;

export const onPushToken = (cb: (token: string) => void): (() => void) => {
if (_token) {
cb(_token);
Expand Down
Loading