From 2be9792263b6108bc65beb10297dc7a1cff246c5 Mon Sep 17 00:00:00 2001 From: chpark Date: Sun, 31 May 2026 00:16:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(push):=20=EB=84=A4=EC=9D=B4=ED=8B=B0?= =?UTF-8?q?=EB=B8=8C=20=EC=95=B1=20=EC=9E=90=EB=8F=99=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=20=E2=80=94=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=A7=81=ED=9B=84?= =?UTF-8?q?=20=EA=B6=8C=ED=95=9C=20prompt=20+=20FCM=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설치자 액션 0: 회원정보 안 가도 로그인만 하면 자동으로 알림 받기 시작. src/components/native-push-auto-register.tsx (신규, UI 없음): - Capacitor 환경 감지 - requestPermissions() 자동 호출 (안드로이드 13+ 시스템 prompt) - 허용되면 FCM register → registration 이벤트로 토큰 받기 - /api/m/push/fcm-token 로 토큰 등록 - sessionStorage 로 같은 세션 안 중복 방지 - 401 (로그인 전) 이면 키 제거 → 로그인 후 자동 재시도 src/app/(main)/layout.tsx: - 인증된 사용자 화면에 마운트 - 일반 브라우저에선 즉시 return null — 동작 0 수정: - src/components/push-optin.tsx: declare global Window 제거 (Capacitor SDK 와 충돌) → getCap() helper 로 통일 Co-Authored-By: Claude Opus 4.7 --- src/app/(main)/layout.tsx | 3 + src/components/native-push-auto-register.tsx | 90 ++++++++++++++++++++ src/components/push-optin.tsx | 24 +++--- 3 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 src/components/native-push-auto-register.tsx diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index 3f9bda4..a987057 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -7,6 +7,7 @@ import { useMenuStore } from "@/store/menu-store"; import { Sidebar } from "@/components/layout/sidebar"; import { Header } from "@/components/layout/header"; import { Loading } from "@/components/ui/loading"; +import { NativePushAutoRegister } from "@/components/native-push-auto-register"; export default function MainLayout({ children }: { children: React.ReactNode }) { const router = useRouter(); @@ -24,6 +25,8 @@ export default function MainLayout({ children }: { children: React.ReactNode }) return (
+ {/* 로그인 직후 자동으로 안드로이드 알림 권한 + FCM 토큰 등록 (Capacitor 앱만, UI 없음) */} + {/* 사이드바 — 데스크탑은 정상, 모바일은 오버레이로 등장 */}
Promise<{ receive: "granted" | "denied" | "prompt" }>; + register: () => Promise; + addListener: (event: string, cb: (data: { value?: string; error?: string }) => void) => Promise<{ remove: () => void }>; +} +function getCap(): { isNativePlatform?: () => boolean; Plugins?: { PushNotifications?: PN } } | undefined { + if (typeof window === "undefined") return undefined; + return (window as unknown as { Capacitor?: { isNativePlatform?: () => boolean; Plugins?: { PushNotifications?: PN } } }).Capacitor; +} + +const SESSION_KEY = "momo-native-push-tried"; + +export function NativePushAutoRegister() { + const ranRef = useRef(false); + + useEffect(() => { + if (ranRef.current) return; + ranRef.current = true; + + const cap = getCap(); + const PN = cap?.Plugins?.PushNotifications; + if (!cap?.isNativePlatform?.() || !PN) return; + + // 같은 세션 내 중복 호출 방지 (페이지 이동 마다 다시 묻지 않음) + try { if (sessionStorage.getItem(SESSION_KEY)) return; } catch { /* ignore */ } + try { sessionStorage.setItem(SESSION_KEY, "1"); } catch { /* ignore */ } + + (async () => { + try { + // 1) 권한 요청 (이미 granted 면 즉시 granted 반환) + const perm = await PN.requestPermissions(); + if (perm.receive !== "granted") { + console.log("[native-push] 권한 거부:", perm.receive); + return; + } + // 2) FCM 토큰 받기 (10초 안에) + const token = await new Promise((resolve) => { + let resolved = false; + const timeout = setTimeout(() => { if (!resolved) { resolved = true; resolve(null); } }, 10000); + PN.addListener("registration", (d) => { + if (resolved) return; resolved = true; clearTimeout(timeout); + resolve(d.value || null); + }); + PN.addListener("registrationError", (d) => { + if (resolved) return; resolved = true; clearTimeout(timeout); + console.error("[native-push] registrationError:", d); + resolve(null); + }); + PN.register(); + }); + if (!token) { + console.warn("[native-push] FCM 토큰 발급 실패 — 다음 로그인에서 재시도"); + return; + } + // 3) 백엔드 등록 — 401 이면 로그인 안 됐다는 뜻, 다음 로그인 시 재시도 + const r = await fetch("/api/m/push/fcm-token", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, userAgent: navigator.userAgent }), + }); + if (r.ok) { + console.log("[native-push] ✔ FCM 토큰 등록 완료"); + } else if (r.status === 401) { + // 로그인 전이면 세션 키 제거 → 로그인 후 다시 시도되도록 + try { sessionStorage.removeItem(SESSION_KEY); } catch { /* ignore */ } + } else { + console.warn(`[native-push] 백엔드 등록 실패 (HTTP ${r.status})`); + } + } catch (err) { + console.error("[native-push] 자동 등록 예외:", err); + } + })(); + }, []); + + return null; +} diff --git a/src/components/push-optin.tsx b/src/components/push-optin.tsx index e1c2ccf..f128150 100644 --- a/src/components/push-optin.tsx +++ b/src/components/push-optin.tsx @@ -5,19 +5,15 @@ import { Bell, BellOff, AlertCircle } from "lucide-react"; import Swal from "sweetalert2"; // Capacitor 글로벌 — APK 안의 WebView 에 자동 주입됨. 일반 브라우저에선 undefined. -interface CapacitorGlobal { - isNativePlatform?: () => boolean; - Plugins?: { - PushNotifications?: { - requestPermissions: () => Promise<{ receive: "granted" | "denied" | "prompt" }>; - register: () => Promise; - addListener: (event: string, cb: (data: { value?: string; error?: string }) => void) => Promise<{ remove: () => void }>; - removeAllListeners?: () => Promise; - }; - }; +// @capacitor/core 가 Window.Capacitor 를 자체 정의하므로 declare global 안 함. +interface CapPN { + requestPermissions: () => Promise<{ receive: "granted" | "denied" | "prompt" }>; + register: () => Promise; + addListener: (event: string, cb: (data: { value?: string; error?: string }) => void) => Promise<{ remove: () => void }>; } -declare global { - interface Window { Capacitor?: CapacitorGlobal } +function getCap(): { isNativePlatform?: () => boolean; Plugins?: { PushNotifications?: CapPN } } | undefined { + if (typeof window === "undefined") return undefined; + return (window as unknown as { Capacitor?: { isNativePlatform?: () => boolean; Plugins?: { PushNotifications?: CapPN } } }).Capacitor; } // VAPID 공개키(base64url) → Uint8Array @@ -186,7 +182,7 @@ export function PushOptIn({ variant = "compact" }: PushOptInProps) { // Capacitor native APK 분기 — FCM 토큰 등록 흐름 const turnOnNative = useCallback(async (): Promise => { - const cap = typeof window !== "undefined" ? window.Capacitor : undefined; + const cap = getCap(); const PN = cap?.Plugins?.PushNotifications; if (!PN) return false; const perm = await PN.requestPermissions(); @@ -237,7 +233,7 @@ export function PushOptIn({ variant = "compact" }: PushOptInProps) { // 사용자가 OS 설정에서 권한을 풀고 돌아온 케이스에 React state 가 stale 해도 동작하도록. const turnOn = useCallback(async (): Promise => { // Capacitor 환경이면 native 분기 우선 - if (typeof window !== "undefined" && window.Capacitor?.isNativePlatform?.()) { + if (typeof window !== "undefined" && getCap()?.isNativePlatform?.()) { return await turnOnNative(); } try {