Compare commits

..

2 Commits

Author SHA1 Message Date
Cursor Agent 4aa21a6099
Merge branch 'cursor/web-push-w4-382d' — W4 Web Push path
Co-authored-by: okuma <o0kuma@users.noreply.github.com>
2026-08-04 01:39:12 +00:00
Cursor Agent 40c36a20c3
feat(web): W4 Web Push path via FCM dart-define + VAPID
Wire Firebase Web config and VAPID through build-args/dart-define,
populate firebase-messaging-sw.js at image build, and soft-fallback to
install: tokens when secrets are absent. Live accept still needs Master
.env FIREBASE_* values and a web rebuild.

Co-authored-by: okuma <o0kuma@users.noreply.github.com>
2026-08-04 01:30:49 +00:00
20 changed files with 354 additions and 60 deletions

View File

@ -27,3 +27,15 @@ PUBLIC_API_BASE=https://msn.iykyka.com
# Host port mapped to web:80
WEB_HOST_PORT=8088
# --- N4-W4 Web Push (Flutter web build-args / dart-define; never commit real values) ---
# Firebase Console → Project settings → Your apps → Web app (msn.iykyka.com)
# + Cloud Messaging → Web Push certificates (VAPID key pair).
# Leave empty to keep install: placeholder tokens until secrets are ready.
FIREBASE_API_KEY=
FIREBASE_AUTH_DOMAIN=
FIREBASE_PROJECT_ID=
FIREBASE_STORAGE_BUCKET=
FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_APP_ID=
FIREBASE_VAPID_KEY=

View File

@ -48,7 +48,7 @@
| [`docs/deploy-portainer.md`](./docs/deploy-portainer.md) | Portainer·컷오버 |
| [`docs/ops-backup.md`](./docs/ops-backup.md) | 백업 |
| [`docs/invite-ops.md`](./docs/invite-ops.md) | 초대 코드 운영 |
| [`docs/fcm-setup.md`](./docs/fcm-setup.md) | FCM Android N4-1~4 · Web Push`web-upgrade` W4 |
| [`docs/fcm-setup.md`](./docs/fcm-setup.md) | FCM Android N4-1~4 · Web Push(W4) dart-define 주입 |
| [`docs/android-release.md`](./docs/android-release.md) | 내부 release APK 서명 |
| [`docs/tester-guide.md`](./docs/tester-guide.md) | 테스터 안내 · `DEMO-YKAVU` |

View File

@ -25,6 +25,10 @@ server {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location = /firebase-messaging-sw.js {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
# Flutter SPA shell — offline shell cache is SW-managed; HTML fallback for deep links.
location / {

View File

@ -72,6 +72,14 @@ services:
dockerfile: mobile/Dockerfile
args:
CORE_API_BASE: ${PUBLIC_API_BASE:-https://msn.iykyka.com}
# N4-W4 Web Push — set in host .env (never commit). Empty → install: fallback.
FIREBASE_API_KEY: ${FIREBASE_API_KEY:-}
FIREBASE_AUTH_DOMAIN: ${FIREBASE_AUTH_DOMAIN:-}
FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID:-}
FIREBASE_STORAGE_BUCKET: ${FIREBASE_STORAGE_BUCKET:-}
FIREBASE_MESSAGING_SENDER_ID: ${FIREBASE_MESSAGING_SENDER_ID:-}
FIREBASE_APP_ID: ${FIREBASE_APP_ID:-}
FIREBASE_VAPID_KEY: ${FIREBASE_VAPID_KEY:-}
ports:
- "${WEB_HOST_PORT:-8088}:80"
depends_on:

View File

@ -37,7 +37,7 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
- 앱 코드는 클로즈드 베타 직전 수준
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료**
- **웹 고도화:** [`web-upgrade.md`](./web-upgrade.md) — W0~W3·**W5~W7 done** · **W4 Web Push만 Master VAPID 대기**
- **웹 고도화:** [`web-upgrade.md`](./web-upgrade.md) — W0~W3·W5~W7 **done** · **W4 code shipped** (live: Master `.env`에 FIREBASE_*/VAPID 후 `web` 재빌드)
- 후순위: **N4 Android FCM/UI** — Master 시크릿·실기기
- UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영
- **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대)
@ -217,7 +217,7 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
| **N4-W1** | 웹 로컬 지속성 (prefs/idb) | **done** (2026-08-04) | `app_database_web.dart` SharedPreferences · Drift WASM 비채택 · 라이브 하드 리프레시 스모크는 배포 후 |
| **N4-W2** | 데모 메신저 UX | **done** (2026-08-04) | ≥900px 분할·WS 상태·ID 복사·웹 안내 배너 |
| **N4-W3** | 인앱·Notification API | **done** (2026-08-04) | 웹 Timer+Notification · 포커스 past-due · 설정 권한 |
| **N4-W4** | Web Push (FCM + VAPID) | todo | **Master 블로커:** Firebase Web config · VAPID |
| **N4-W4** | Web Push (FCM + VAPID) | **code** (2026-08-04) | dart-define+SW shipped · **live**는 Master `.env` FIREBASE_*/VAPID + `web` rebuild |
| **N4-W5** | PWA | **done** (2026-08-04) | manifest 테마 · nginx no-cache · OfflineBanner |
| **N4-W6** | 테스터 가이드 | **done** (2026-08-04) | `tester-guide.md` 웹 절 |
| **N4-W7** | 품질 | **done** (2026-08-04) | `scripts/e2e_web_smoke.sh` + 부록 QA (Playwright는 후속) |
@ -290,8 +290,8 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P
확인 필요, 그때까지는 인앱 배지·배너가 실질적 대체 경로. Track C A~F 프로덕션
재배포는 **완료** (`95422bb` / docs `f363e8e`, 2026-08-03).
**바로 다음:** 웹 **W4** (Master: Firebase Web + VAPID) 또는 Android N4-1/3.
웹 W0~W3·W5~W7 **done**.
**바로 다음:** 웹 **W4 live** — Master가 Firebase Web config + VAPID를 서버 `.env`에 넣고 `docker compose up -d --build web` · 또는 Android N4-1/3.
웹 W0~W7 코드 경로 **done** (W4 live accept만 시크릿 대기).
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.

View File

@ -1,4 +1,4 @@
# FCM 설정 (N4-1 ~ N4-4)
# FCM 설정 (N4-1 ~ N4-4 · N4-W4)
서버·클라이언트의 푸시 **코드 경로는 준비됨**.
새 Firebase 프로젝트는 **레거시 서버 키가 비활성**인 경우가 많아, **HTTP v1 + 서비스 계정 JSON**을 쓴다.
@ -9,6 +9,7 @@
|------|------|
| Flutter | `PushTokenService` — Firebase 가능하면 실 FCM 토큰, 아니면 `install:` 플레이스홀더 |
| Android | `google-services.json`이 있을 때만 Google Services 플러그인 적용 |
| Web (N4-W4) | `FirebaseWebConfig` + `FIREBASE_*`/`VAPID` dart-define · SW template · 미설정 시 `install:` |
| core-backend | `notifyUser` + `POST /admin/push-test`**FCM HTTP v1**(서비스 계정) 우선, 레거시 `FCM_SERVER_KEY`는 폴백 |
## Master가 할 일
@ -73,20 +74,55 @@ curl -sS -X POST https://msn.iykyka.com/admin/push-test \
`only_placeholder_tokens` → Android에 `google-services.json` 넣고 재설치.
`fcm_not_configured` → 서버에 서비스 계정 파일 경로 확인.
## Web
## Web (N4-W4)
Web Push(FCM Web + VAPID)는 **웹 고도화 트랙 N4-W4**에서 다룬다.
설계·수락: [`web-upgrade.md`](./web-upgrade.md) §N4-W4 · 실행 표: [`deploy-checklist.md`](./deploy-checklist.md) §N4-W.
- 설계·작업 분해·수락 기준: [`web-upgrade.md`](./web-upgrade.md) §N4-W4
- 실행 표: [`deploy-checklist.md`](./deploy-checklist.md) §N4-W
- Android N4(실기기)와 **병행 가능**. 서버 서비스 계정(HTTP v1)은 공유하고,
Android N4(실기기)와 **병행 가능**. 서버 서비스 계정(HTTP v1)은 공유하고,
클라이언트는 `google-services.json`(Android) vs Firebase Web config + VAPID(Web)로 분리.
- 코드 as-is: `PushTokenService``kIsWeb`이면 토큰을 건너뛰고 `install:`만 등록 —
W4 구현 전까지 웹 `/admin/push-test``only_placeholder_tokens`가 정상.
### W4 Master 준비물 (요약)
**코드 shipped.** define이 비어 있으면 웹은 계속 `install:`만 등록 → `/admin/push-test``only_placeholder_tokens`는 정상.
1. Firebase Console → Web 앱 (`msn.iykyka.com`)
2. Cloud Messaging → Web Push 인증서 / **VAPID key**
3. 빌드 시크릿으로 config 주입 (git 금지) — 상세는 `web-upgrade.md` 부록 B
4. 스모크: 실 웹 토큰 등록 후 `POST /admin/push-test``sent >= 1`
### Master 준비물 (live accept)
1. Firebase Console → **Web** 앱 추가 (authorized domain: `msn.iykyka.com`) — SA 프로젝트와 동일
2. Cloud Messaging → Web Push certificates / **VAPID public key**
3. 배포 호스트 `~/project/ykavu/.env`에 기입 (`.env.example` 키 이름 — **값 git 금지**):
```bash
FIREBASE_API_KEY=
FIREBASE_AUTH_DOMAIN= # 비우면 {projectId}.firebaseapp.com
FIREBASE_PROJECT_ID=
FIREBASE_STORAGE_BUCKET= # 비우면 {projectId}.appspot.com
FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_APP_ID=
FIREBASE_VAPID_KEY=
```
4. 웹 재빌드 후 하드 리프레시:
```bash
docker compose up -d --build web
```
5. 로그인 → 알림 허용 → device_tokens에 실 토큰(`platform=web`) → `/admin/push-test``sent >= 1`
### 로컬 Flutter web
```bash
cd mobile
flutter run -d chrome \
--dart-define=CORE_API_BASE=http://localhost:8080 \
--dart-define=FIREBASE_API_KEY=... \
--dart-define=FIREBASE_APP_ID=... \
--dart-define=FIREBASE_MESSAGING_SENDER_ID=... \
--dart-define=FIREBASE_PROJECT_ID=... \
--dart-define=FIREBASE_VAPID_KEY=...
```
### 구현 위치
- `mobile/lib/config/firebase_web_config.dart`
- `mobile/lib/services/push_token_service.dart` (`_tryFirebaseWebToken`)
- `mobile/web/firebase-messaging-sw.js` (+ `.template`, Dockerfile 치환)
- `mobile/Dockerfile` / `docker-compose.yml` build-args

View File

@ -417,7 +417,7 @@ Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5, **N4-W0…W7**)는
- [x] **W1** 웹 로컬 지속성 (prefs/idb · Drift WASM 비채택) — 2026-08-04
- [x] **W2** 데모 UX (분할·WS 상태·ID 복사) — 2026-08-04
- [x] **W3** 인앱 + Notification API — 2026-08-04
- [ ] **W4** Web Push (FCM + VAPID) — **Master Firebase Web/VAPID 대기**
- [x] **W4** Web Push 코드 경로 (FCM + VAPID dart-define) — **live accept:** Master `.env` 시크릿 후
- [x] **W5** PWA (manifest 테마 · 셸 캐시) — 2026-08-04 (W4와 독립 선행)
- [x] **W6** 테스터 가이드 — 2026-08-04
- [x] **W7** edge 스모크 (`e2e_web_smoke.sh`) — 2026-08-04; Playwright UI는 후속

View File

@ -42,7 +42,9 @@ API 메타: `GET https://msn.iykyka.com/demo` — `pairing_steps` / `notes` 포
- **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다.
- **L0(비서)**: 와카뷰 발송이 서버에서 막혀 있습니다. 초안 → 입력창 → 직접 전송.
- 푸시: Android·Web Push는 Master Firebase 설정 후 (`docs/fcm-setup.md`, `web-upgrade` W4). 그전엔 `install:` 플레이스홀더.
- 푸시: Android·Web Push는 Master Firebase 설정 후 (`docs/fcm-setup.md`, `web-upgrade` W4).
웹은 서버 `.env``FIREBASE_*`/`VAPID`가 있고 `web` 이미지를 그 값으로 빌드한 뒤에야 실 토큰이 등록됩니다. 그전엔 `install:` 플레이스홀더.
Web Push 배포 직후엔 **하드 리프레시**(또는 사이트 데이터 삭제)로 `firebase-messaging-sw.js`를 갱신하세요.
- 문제/스크린샷은 Master에게 전달해 주세요.
- 민감 정보·실명 대화는 베타 특성상 최소화해 주세요.

View File

@ -255,59 +255,56 @@ flowchart LR
| | |
|--|--|
| **Status** | todo (**Master Firebase Web / VAPID 블로커** — W5~W7은 선행 진행 가능) |
| **Status** | **code shipped** (2026-08-04) — **live accept**는 Master Firebase Web config + VAPID를 서버 `.env`에 넣고 `web` 재빌드 후 |
| **목표** | 웹 탭이 백그라운드여도 에스컬레이션 등 서버 `notifyUser`가 **실 FCM 웹 토큰**으로 전달 |
| **비범위** | 레거시 `FCM_SERVER_KEY` 신규 의존, 커스텀 푸시 프로토콜 |
### As-is
### As-is → shipped
- `_tryFirebaseToken()``kIsWeb`이면 즉시 `null`
- DB device_tokens는 `install:…` / `platform=web`만 → `only_placeholder_tokens`
- `FirebaseWebConfig` + `--dart-define=FIREBASE_*` / `FIREBASE_VAPID_KEY`
- `PushTokenService` 웹 분기: config 없으면 `install:` soft-fallback, 있으면 `initializeApp` + `getToken(vapidKey:)`
- `mobile/web/firebase-messaging-sw.js` (+ Dockerfile이 template로 치환) · `index.html` SW 등록
- compose `web` build-args · `.env.example` 키 이름만 · nginx no-cache for SW
### To-be
### Master 액션 (live accept 블로커)
- Firebase **Web** 앱 + Messaging + **VAPID 키**
- `Firebase.initializeApp(options: …)` + `FirebaseMessaging.instance.getToken(vapidKey: …)`
- 성공 시 `platform=web` **실토큰** 등록 (서버 v1 전송 경로 재사용)
- 실패 시 기존처럼 `install:` soft-fallback (앱 기동 막지 않음)
### Master 액션 (블로커)
1. Firebase Console에 Web 앱 추가 (`msn.iykyka.com`)
1. Firebase Console에 Web 앱 추가 (`msn.iykyka.com`) — 프로젝트 `iykyka` SA와 동일 프로젝트
2. Cloud Messaging Web Push 인증서/VAPID 키 발급
3. Web config (`apiKey`, `authDomain`, `projectId`, `messagingSenderId`, `appId`, `vapidKey`)를 **빌드 시크릿**으로 전달 (git 금지)
4. (권장) `firebase-messaging-sw.js` 요구사항 확인 — FlutterFire 문서에 맞게 `web/`에 스켈레톤
3. 서버 `~/project/ykavu/.env``FIREBASE_API_KEY``FIREBASE_VAPID_KEY` 기입 (git 금지)
4. `docker compose up -d --build web` 후 하드 리프레시 → 로그인 → `/admin/push-test`
### 작업 분해
- [ ] 빌드 주입 방식 확정: `--dart-define=FIREBASE_…` 및/또는 생성 `firebase_options_web.dart` (gitignore)
- [ ] `PushTokenService._tryFirebaseToken` 웹 분기 구현 + permission
- [ ] Docker web 빌드에 define 전달 (`mobile/Dockerfile` / compose)
- [ ] `/admin/push-test`로 user 지정 → Chrome 백그라운드 수신
- [ ] `docs/fcm-setup.md` Web 절을 본 W4와 동기화
- [ ] `DataFlowScreen` 푸시 문구 갱신
- [x] 빌드 주입: `--dart-define=FIREBASE_…` + SW template 치환 (gitignore/시크릿 미커밋)
- [x] `PushTokenService` 웹 분기 + permission
- [x] Docker web 빌드에 define 전달 (`mobile/Dockerfile` / compose)
- [ ] `/admin/push-test`로 user 지정 → Chrome 백그라운드 수신 (**Master 시크릿 후**)
- [x] `docs/fcm-setup.md` Web 절 동기화
- [x] `DataFlowScreen` 푸시 문구 갱신
### 수락 기준
- [ ] device_tokens에 `install:`이 아닌 토큰 + `platform=web`
- [ ] `POST /admin/push-test``sent >= 1`, 브라우저 알림 표시
- [ ] 시크릿이 git history에 없음
- [ ] device_tokens에 `install:`이 아닌 토큰 + `platform=web` (**시크릿 후**)
- [ ] `POST /admin/push-test``sent >= 1`, 브라우저 알림 표시 (**시크릿 후**)
- [x] 시크릿이 git history에 없음 (define/env만)
### 위험
- SW 등록 순서/캐시로 토큰 null → 배포 후 hard refresh 절차를 W6에 적음
- SW 등록 순서/캐시로 토큰 null → 배포 후 hard refresh 절차를 W6/tester-guide에 적음
- Android `google-services.json`과 Web config 혼동 → 문서에서 경로 분리
### 예상 터치 파일
### 터치 파일
- `mobile/lib/config/firebase_web_config.dart`
- `mobile/lib/services/push_token_service.dart`
- `mobile/web/` (messaging SW, index 훅)
- `mobile/Dockerfile` / `.env.example` (define 이름만)
- `mobile/Dockerfile` / `docker-compose.yml` / `.env.example`
- `docs/fcm-setup.md`, `docs/web-upgrade.md`
### 서버
- **코드 변경 최소.** 기존 `notifyUser` / HTTP v1. 웹 토큰도 동일 messages:send
- **코어 코드 변경 없음.** 기존 `notifyUser` / HTTP v1. 웹 토큰도 동일 messages:send
- 클라이언트 시크릿만 host `.env` → compose build-args
---
@ -473,7 +470,7 @@ Wi를 끝낼 때마다:
- [ ] Firebase Web app 등록 (도메인 `msn.iykyka.com`)
- [ ] VAPID key
- [ ] 빌드 환경에 define/options 주입 (git 미포함)
- [ ] 서버 `~/project/ykavu/.env``FIREBASE_*` / `FIREBASE_VAPID_KEY` (git 미포함) → `docker compose up -d --build web`
- [ ] 서버 `secrets/firebase-service-account.json` 유지 (Android와 공유)
- [ ] push-test로 `sent >= 1` 확인

View File

@ -1,17 +1,53 @@
# Flutter Web → nginx. Build from repository root:
# docker build -f mobile/Dockerfile --build-arg CORE_API_BASE=https://msn.iykyka.com .
# Optional Web Push (N4-W4) — pass FIREBASE_* / FIREBASE_VAPID_KEY as build-args
# (never commit real values; see .env.example and docs/fcm-setup.md).
ARG FLUTTER_IMAGE=ghcr.io/cirruslabs/flutter:3.32.7
FROM ${FLUTTER_IMAGE} AS build
WORKDIR /app
ARG CORE_API_BASE=https://msn.iykyka.com
ARG FIREBASE_API_KEY=
ARG FIREBASE_AUTH_DOMAIN=
ARG FIREBASE_PROJECT_ID=
ARG FIREBASE_STORAGE_BUCKET=
ARG FIREBASE_MESSAGING_SENDER_ID=
ARG FIREBASE_APP_ID=
ARG FIREBASE_VAPID_KEY=
COPY mobile/pubspec.yaml mobile/pubspec.lock ./
RUN flutter pub get
COPY mobile/ .
# Populate firebase-messaging-sw.js when Web config is present; keep stub otherwise.
RUN set -eu; \
if [ -n "${FIREBASE_API_KEY}" ] && [ -n "${FIREBASE_APP_ID}" ] && \
[ -n "${FIREBASE_MESSAGING_SENDER_ID}" ] && [ -n "${FIREBASE_PROJECT_ID}" ]; then \
AUTH_DOMAIN="${FIREBASE_AUTH_DOMAIN:-${FIREBASE_PROJECT_ID}.firebaseapp.com}"; \
STORAGE_BUCKET="${FIREBASE_STORAGE_BUCKET:-${FIREBASE_PROJECT_ID}.appspot.com}"; \
sed \
-e "s|__FIREBASE_API_KEY__|${FIREBASE_API_KEY}|g" \
-e "s|__FIREBASE_AUTH_DOMAIN__|${AUTH_DOMAIN}|g" \
-e "s|__FIREBASE_PROJECT_ID__|${FIREBASE_PROJECT_ID}|g" \
-e "s|__FIREBASE_STORAGE_BUCKET__|${STORAGE_BUCKET}|g" \
-e "s|__FIREBASE_MESSAGING_SENDER_ID__|${FIREBASE_MESSAGING_SENDER_ID}|g" \
-e "s|__FIREBASE_APP_ID__|${FIREBASE_APP_ID}|g" \
web/firebase-messaging-sw.js.template > web/firebase-messaging-sw.js; \
echo "firebase-messaging-sw.js: populated for project ${FIREBASE_PROJECT_ID}"; \
else \
echo "firebase-messaging-sw.js: stub (FIREBASE_* build-args empty)"; \
fi
RUN flutter build web --release \
--dart-define=CORE_API_BASE=${CORE_API_BASE}
--dart-define=CORE_API_BASE=${CORE_API_BASE} \
--dart-define=FIREBASE_API_KEY=${FIREBASE_API_KEY} \
--dart-define=FIREBASE_AUTH_DOMAIN=${FIREBASE_AUTH_DOMAIN} \
--dart-define=FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID} \
--dart-define=FIREBASE_STORAGE_BUCKET=${FIREBASE_STORAGE_BUCKET} \
--dart-define=FIREBASE_MESSAGING_SENDER_ID=${FIREBASE_MESSAGING_SENDER_ID} \
--dart-define=FIREBASE_APP_ID=${FIREBASE_APP_ID} \
--dart-define=FIREBASE_VAPID_KEY=${FIREBASE_VAPID_KEY}
FROM nginx:1.27-alpine
COPY --from=build /app/build/web /usr/share/nginx/html

View File

@ -1,5 +1,6 @@
# Optional: serve a host-built Flutter web tree (faster CI/agent smoke).
# cd mobile && flutter build web --release --dart-define=CORE_API_BASE=https://msn.iykyka.com
# cd mobile && flutter build web --release --dart-define=CORE_API_BASE=https://msn.iykyka.com \
# # optional W4: --dart-define=FIREBASE_API_KEY=... --dart-define=FIREBASE_VAPID_KEY=...
# docker build -f mobile/Dockerfile.prebuilt -t ykavu-web .
# Production/Portainer should prefer mobile/Dockerfile (multi-stage flutter build).
FROM nginx:1.27-alpine

View File

@ -69,7 +69,7 @@ sudo apt-get install -y libsqlite3-dev libsqlcipher1
- 로컬 DB: `lib/db/app_database_web.dart`**W1 done**: SharedPreferences 지속성(비암호화 프리뷰)
- 스누즈 OS 알림: 웹 no-op → **W3** Notification API / 인앱
- 푸시: 웹에서 FCM skip → **W4** Web Push + VAPID ([`docs/fcm-setup.md`](../docs/fcm-setup.md) Web 절)
- 푸시: 웹`FIREBASE_*` + `FIREBASE_VAPID_KEY` dart-define 시 실 FCM 토큰 · 없으면 `install:` ([`docs/fcm-setup.md`](../docs/fcm-setup.md) Web 절)
- PWA: `web/manifest.json` 골격 있음 → **W5** 테마·설치·셸 캐시
- **Q9**(수신 자동응대)는 웹 트랙 비범위

View File

@ -0,0 +1,64 @@
import 'package:firebase_core/firebase_core.dart';
/// Firebase Web + VAPID settings injected at build time via `--dart-define`.
///
/// Never commit real values. Pass them through Docker build-args / `.env`
/// (see `.env.example` and `docs/fcm-setup.md` Web section).
class FirebaseWebConfig {
const FirebaseWebConfig({
required this.apiKey,
required this.appId,
required this.messagingSenderId,
required this.projectId,
required this.vapidKey,
this.authDomain = '',
this.storageBucket = '',
});
/// Reads compile-time defines (`FIREBASE_*`).
factory FirebaseWebConfig.fromEnvironment() {
return const FirebaseWebConfig(
apiKey: String.fromEnvironment('FIREBASE_API_KEY'),
appId: String.fromEnvironment('FIREBASE_APP_ID'),
messagingSenderId: String.fromEnvironment('FIREBASE_MESSAGING_SENDER_ID'),
projectId: String.fromEnvironment('FIREBASE_PROJECT_ID'),
vapidKey: String.fromEnvironment('FIREBASE_VAPID_KEY'),
authDomain: String.fromEnvironment('FIREBASE_AUTH_DOMAIN'),
storageBucket: String.fromEnvironment('FIREBASE_STORAGE_BUCKET'),
);
}
final String apiKey;
final String appId;
final String messagingSenderId;
final String projectId;
final String vapidKey;
final String authDomain;
final String storageBucket;
/// True when the minimum set for FCM Web token registration is present.
bool get isReady =>
apiKey.isNotEmpty &&
appId.isNotEmpty &&
messagingSenderId.isNotEmpty &&
projectId.isNotEmpty &&
vapidKey.isNotEmpty;
/// [FirebaseOptions] for [Firebase.initializeApp], or null when not ready.
FirebaseOptions? get options {
if (!isReady) return null;
final domain =
authDomain.isNotEmpty ? authDomain : '$projectId.firebaseapp.com';
final bucket = storageBucket.isNotEmpty
? storageBucket
: '$projectId.appspot.com';
return FirebaseOptions(
apiKey: apiKey,
appId: appId,
messagingSenderId: messagingSenderId,
projectId: projectId,
authDomain: domain,
storageBucket: bucket,
);
}
}

View File

@ -93,7 +93,8 @@ class DataFlowScreen extends StatelessWidget {
body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n'
'채팅 릴레이: 보낸 메시지 본문\n'
'계정: 표시 이름·초대 코드·세션 토큰\n'
'푸시: Firebase 연결 시 실 FCM 토큰 등록 · 미연결 시 install 플레이스홀더 (docs/fcm-setup.md)',
'푸시: Android는 google-services.json, 웹은 FIREBASE_*+VAPID 빌드 주입 시 실 FCM 토큰 등록 · '
'미연결 시 install 플레이스홀더 (docs/fcm-setup.md · web-upgrade W4)',
),
_section(
context,

View File

@ -4,13 +4,20 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import '../config/firebase_web_config.dart';
/// Resolves a device push token for core-backend registration.
///
/// Prefer a real FCM registration token when Firebase is configured
/// (`google-services.json` on Android). Otherwise fall back to a stable
/// `install:` placeholder that the server intentionally skips for delivery.
/// (`google-services.json` on Android, dart-define Web config + VAPID on web).
/// Otherwise fall back to a stable `install:` placeholder that the server
/// intentionally skips for delivery.
class PushTokenService {
PushTokenService({this.storeInstallId, this.readInstallId});
PushTokenService({
this.storeInstallId,
this.readInstallId,
FirebaseWebConfig? webConfig,
}) : webConfig = webConfig ?? FirebaseWebConfig.fromEnvironment();
/// Persist a newly generated install id (KV / secure storage).
final Future<void> Function(String installId)? storeInstallId;
@ -18,6 +25,9 @@ class PushTokenService {
/// Load a previously stored install id.
final Future<String?> Function()? readInstallId;
/// Injected for tests; production uses [FirebaseWebConfig.fromEnvironment].
final FirebaseWebConfig webConfig;
Future<({String token, String platform, bool isFcm})> resolve() async {
final platform = _platformLabel();
final fcm = await _tryFirebaseToken();
@ -29,8 +39,9 @@ class PushTokenService {
}
Future<String?> _tryFirebaseToken() async {
// Web push needs a separate Firebase web config + VAPID; skip until provided.
if (kIsWeb) return null;
if (kIsWeb) {
return _tryFirebaseWebToken();
}
if (defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS) {
return null;
@ -50,6 +61,31 @@ class PushTokenService {
}
}
/// N4-W4 Web Push via FCM + VAPID (build-time dart-define secrets).
Future<String?> _tryFirebaseWebToken() async {
if (!webConfig.isReady) {
debugPrint(
'FCM web skipped: FIREBASE_* / VAPID dart-defines not set '
'(see docs/fcm-setup.md Web)',
);
return null;
}
try {
final options = webConfig.options!;
if (Firebase.apps.isEmpty) {
await Firebase.initializeApp(options: options);
}
final messaging = FirebaseMessaging.instance;
await messaging.requestPermission(alert: true, badge: true, sound: true);
final token = await messaging.getToken(vapidKey: webConfig.vapidKey);
if (token == null || token.isEmpty) return null;
return token;
} catch (e) {
debugPrint('FCM web token unavailable (using install placeholder): $e');
return null;
}
}
Future<String> _ensureInstallId() async {
final existing = await readInstallId?.call();
if (existing != null && existing.isNotEmpty) return existing;

View File

@ -0,0 +1,63 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ykavu_mobile/config/firebase_web_config.dart';
void main() {
test('isReady false when any required field is empty', () {
expect(
const FirebaseWebConfig(
apiKey: 'k',
appId: 'a',
messagingSenderId: '1',
projectId: 'p',
vapidKey: '',
).isReady,
isFalse,
);
expect(
const FirebaseWebConfig(
apiKey: '',
appId: 'a',
messagingSenderId: '1',
projectId: 'p',
vapidKey: 'v',
).isReady,
isFalse,
);
});
test('isReady true and options fill default domains', () {
const cfg = FirebaseWebConfig(
apiKey: 'api',
appId: '1:123:web:abc',
messagingSenderId: '123',
projectId: 'iykyka',
vapidKey: 'vapid-public',
);
expect(cfg.isReady, isTrue);
final opts = cfg.options!;
expect(opts.apiKey, 'api');
expect(opts.projectId, 'iykyka');
expect(opts.authDomain, 'iykyka.firebaseapp.com');
expect(opts.storageBucket, 'iykyka.appspot.com');
});
test('explicit authDomain and storageBucket win over defaults', () {
const cfg = FirebaseWebConfig(
apiKey: 'api',
appId: '1:123:web:abc',
messagingSenderId: '123',
projectId: 'iykyka',
vapidKey: 'vapid-public',
authDomain: 'custom.example.com',
storageBucket: 'iykyka.firebasestorage.app',
);
final opts = cfg.options!;
expect(opts.authDomain, 'custom.example.com');
expect(opts.storageBucket, 'iykyka.firebasestorage.app');
});
test('fromEnvironment without defines is not ready', () {
expect(FirebaseWebConfig.fromEnvironment().isReady, isFalse);
expect(FirebaseWebConfig.fromEnvironment().options, isNull);
});
}

View File

@ -0,0 +1,5 @@
// FCM Web service worker stub.
// When Firebase Web config is injected at Docker build time, this file is
// overwritten with a populated worker (see mobile/Dockerfile).
// An empty/stub file prevents 404s while Web Push secrets are absent.
console.log('[ykavu] firebase-messaging-sw.js stub (Web Push not configured)');

View File

@ -0,0 +1,15 @@
// Generated at Docker build from firebase-messaging-sw.js.template — do not edit on server.
// Compat SDK is required for importScripts-based FCM service workers.
importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-messaging-compat.js');
firebase.initializeApp({
apiKey: '__FIREBASE_API_KEY__',
authDomain: '__FIREBASE_AUTH_DOMAIN__',
projectId: '__FIREBASE_PROJECT_ID__',
storageBucket: '__FIREBASE_STORAGE_BUCKET__',
messagingSenderId: '__FIREBASE_MESSAGING_SENDER_ID__',
appId: '__FIREBASE_APP_ID__',
});
firebase.messaging();

View File

@ -21,6 +21,15 @@
<link rel="manifest" href="manifest.json">
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
<!-- FCM Web SW (N4-W4). Scope matches FlutterFire messaging defaults. -->
<script src="flutter_bootstrap.js" async>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('firebase-messaging-sw.js', {
scope: '/firebase-cloud-messaging-push-scope',
});
});
}
</script>
</body>
</html>

View File

@ -16,6 +16,11 @@ echo "==> GET $BASE/manifest.json (PWA)"
curl -fsS "$BASE/manifest.json" | head -c 300
echo
echo "==> GET $BASE/firebase-messaging-sw.js (W4 SW)"
code=$(curl -sS -o /tmp/ykavu_fcm_sw.js -w '%{http_code}' "$BASE/firebase-messaging-sw.js")
echo "HTTP $code ($(wc -c </tmp/ykavu_fcm_sw.js) bytes)"
test "$code" = "200"
echo "==> POST /auth/login DEMO without display_name (expect 400)"
code=$(curl -sS -o /tmp/ykavu_login.json -w '%{http_code}' \
-X POST "$BASE/auth/login" \