feat(push): 네이티브 앱 자동 등록 — 로그인 직후 권한 prompt + FCM 토큰 등록

설치자 액션 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:
- 인증된 사용자 화면에 <NativePushAutoRegister /> 마운트
- 일반 브라우저에선 즉시 return null — 동작 0

수정:
- src/components/push-optin.tsx: declare global Window 제거 (Capacitor SDK 와 충돌)
  → getCap() helper 로 통일

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chpark
2026-05-31 00:16:10 +09:00
parent 5f1983b0f6
commit 2be9792263
3 changed files with 103 additions and 14 deletions
+3
View File
@@ -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 (
<div className="flex h-screen overflow-hidden">
{/* 로그인 직후 자동으로 안드로이드 알림 권한 + FCM 토큰 등록 (Capacitor 앱만, UI 없음) */}
<NativePushAutoRegister />
{/* 사이드바 — 데스크탑은 정상, 모바일은 오버레이로 등장 */}
<div
className={
@@ -0,0 +1,90 @@
"use client";
// Capacitor 네이티브 앱 자동 푸시 등록 — UI 없음, 백그라운드 동작.
// 로그인 후 첫 페이지 로드 시 자동으로:
// 1. 안드로이드 시스템 알림 권한 prompt (POST_NOTIFICATIONS)
// 2. 권한 허용되면 FCM 토큰 발급
// 3. 백엔드 /api/m/push/fcm-token 에 등록
// 4. 새 상품/공지 알림이 자동 도착 시작
//
// 일반 웹 브라우저(Capacitor 아님)에서는 아무것도 안 함 — 회원정보의 PushOptIn 카드 사용.
import { useEffect, useRef } from "react";
// Capacitor 글로벌 타입은 push-optin.tsx 의 declare global 과 동일 — 한 쪽만 선언해도 충분.
// 여기서는 import 한 PushOptIn 없으므로 별도 type 만 정의.
interface PN {
requestPermissions: () => Promise<{ receive: "granted" | "denied" | "prompt" }>;
register: () => Promise<void>;
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<string | null>((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;
}
+10 -14
View File
@@ -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<void>;
addListener: (event: string, cb: (data: { value?: string; error?: string }) => void) => Promise<{ remove: () => void }>;
removeAllListeners?: () => Promise<void>;
};
};
// @capacitor/core 가 Window.Capacitor 를 자체 정의하므로 declare global 안 함.
interface CapPN {
requestPermissions: () => Promise<{ receive: "granted" | "denied" | "prompt" }>;
register: () => Promise<void>;
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<boolean> => {
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<boolean> => {
// Capacitor 환경이면 native 분기 우선
if (typeof window !== "undefined" && window.Capacitor?.isNativePlatform?.()) {
if (typeof window !== "undefined" && getCap()?.isNativePlatform?.()) {
return await turnOnNative();
}
try {