diff --git a/.env.example b/.env.example index 5912766..03cc70d 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index edd2b6a..21da5f2 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/deploy/nginx-web.conf b/deploy/nginx-web.conf index daf2345..6cf7a5b 100644 --- a/deploy/nginx-web.conf +++ b/deploy/nginx-web.conf @@ -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 / { diff --git a/docker-compose.yml b/docker-compose.yml index 6f31d80..98c4144 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/deploy-checklist.md b/docs/deploy-checklist.md index 7413425..1ac8994 100644 --- a/docs/deploy-checklist.md +++ b/docs/deploy-checklist.md @@ -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의 대응 `[~]`/`[ ]`도 같이 갱신한다. diff --git a/docs/fcm-setup.md b/docs/fcm-setup.md index 5015f85..d8e2d01 100644 --- a/docs/fcm-setup.md +++ b/docs/fcm-setup.md @@ -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)은 공유하고, - 클라이언트는 `google-services.json`(Android) vs Firebase Web config + VAPID(Web)로 분리. -- 코드 as-is: `PushTokenService`가 `kIsWeb`이면 토큰을 건너뛰고 `install:`만 등록 — - W4 구현 전까지 웹 `/admin/push-test`는 `only_placeholder_tokens`가 정상. +Android N4(실기기)와 **병행 가능**. 서버 서비스 계정(HTTP v1)은 공유하고, +클라이언트는 `google-services.json`(Android) vs Firebase Web config + VAPID(Web)로 분리. -### 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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 9cdaf92..cee082a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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는 후속 diff --git a/docs/tester-guide.md b/docs/tester-guide.md index ad50f55..db3da1c 100644 --- a/docs/tester-guide.md +++ b/docs/tester-guide.md @@ -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에게 전달해 주세요. - 민감 정보·실명 대화는 베타 특성상 최소화해 주세요. diff --git a/docs/web-upgrade.md b/docs/web-upgrade.md index fde3c98..79d7bbd 100644 --- a/docs/web-upgrade.md +++ b/docs/web-upgrade.md @@ -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` 확인 diff --git a/mobile/Dockerfile b/mobile/Dockerfile index 5cf6f56..ef6fbf9 100644 --- a/mobile/Dockerfile +++ b/mobile/Dockerfile @@ -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 diff --git a/mobile/Dockerfile.prebuilt b/mobile/Dockerfile.prebuilt index ce03106..affb1f8 100644 --- a/mobile/Dockerfile.prebuilt +++ b/mobile/Dockerfile.prebuilt @@ -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 diff --git a/mobile/README.md b/mobile/README.md index e3eebe8..cd47c46 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -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**(수신 자동응대)는 웹 트랙 비범위 diff --git a/mobile/lib/config/firebase_web_config.dart b/mobile/lib/config/firebase_web_config.dart new file mode 100644 index 0000000..18698e8 --- /dev/null +++ b/mobile/lib/config/firebase_web_config.dart @@ -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, + ); + } +} diff --git a/mobile/lib/screens/data_flow_screen.dart b/mobile/lib/screens/data_flow_screen.dart index 695ed6c..8968e45 100644 --- a/mobile/lib/screens/data_flow_screen.dart +++ b/mobile/lib/screens/data_flow_screen.dart @@ -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, diff --git a/mobile/lib/services/push_token_service.dart b/mobile/lib/services/push_token_service.dart index fa9826e..e7402d3 100644 --- a/mobile/lib/services/push_token_service.dart +++ b/mobile/lib/services/push_token_service.dart @@ -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 Function(String installId)? storeInstallId; @@ -18,6 +25,9 @@ class PushTokenService { /// Load a previously stored install id. final Future 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 _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 _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 _ensureInstallId() async { final existing = await readInstallId?.call(); if (existing != null && existing.isNotEmpty) return existing; diff --git a/mobile/test/firebase_web_config_test.dart b/mobile/test/firebase_web_config_test.dart new file mode 100644 index 0000000..e2aa324 --- /dev/null +++ b/mobile/test/firebase_web_config_test.dart @@ -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); + }); +} diff --git a/mobile/web/firebase-messaging-sw.js b/mobile/web/firebase-messaging-sw.js new file mode 100644 index 0000000..9d143e2 --- /dev/null +++ b/mobile/web/firebase-messaging-sw.js @@ -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)'); diff --git a/mobile/web/firebase-messaging-sw.js.template b/mobile/web/firebase-messaging-sw.js.template new file mode 100644 index 0000000..f6f841f --- /dev/null +++ b/mobile/web/firebase-messaging-sw.js.template @@ -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(); diff --git a/mobile/web/index.html b/mobile/web/index.html index 27acc82..afbcd55 100644 --- a/mobile/web/index.html +++ b/mobile/web/index.html @@ -21,6 +21,15 @@ - + + diff --git a/scripts/e2e_web_smoke.sh b/scripts/e2e_web_smoke.sh index 716b275..59b1b26 100755 --- a/scripts/e2e_web_smoke.sh +++ b/scripts/e2e_web_smoke.sh @@ -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 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" \