Compare commits

..

No commits in common. "main" and "cursor/q8-auth-logout-settings-382d" have entirely different histories.

42 changed files with 228 additions and 1892 deletions

View File

@ -27,15 +27,3 @@ PUBLIC_API_BASE=https://msn.iykyka.com
# Host port mapped to web:80 # Host port mapped to web:80
WEB_HOST_PORT=8088 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

@ -1,4 +1,4 @@
# hikikomori / 와카뷰 (Ykavu) # hikikomori / 와카뷰 (가칭)
카카오톡 대안 메신저 — "나를 대신해 남과 대화하는 AI 와카뷰". 카카오톡 대안 메신저 — "나를 대신해 남과 대화하는 AI 와카뷰".
@ -6,80 +6,35 @@
| 경로 | 역할 | | 경로 | 역할 |
|---|---| |---|---|
| `docs/` | 기획·PRD·기술설계·로드맵·배포 체크리스트·PoC | | `docs/` | 기획·PRD·기술설계·로드맵·PoC 계획 |
| `core-backend/` | Go 코어 (가입·메시지·WebSocket·자율성·안전장치) | | `core-backend/` | Go 코어 (가입·메시지·WebSocket·자율성·안전장치) |
| `ai-service/` | Python AI 내부 API (`/draft`, `/escalate/check`) | | `ai-service/` | Python AI 내부 API (`/draft`, `/escalate/check`) |
| `backend/` | 초기 Python 프로토타입 (참고용) | | `backend/` | 초기 Python 프로토타입 (참고용) |
| `poc/tone-corpus/` | PoC 실험 스크립트 | | `poc/tone-corpus/` | PoC 실험 스크립트 |
| `mobile/` | Flutter 클라이언트 (Android 우선 · Web 배포) | | `mobile/` | Flutter 클라이언트 (Android 우선) |
| `secrets/` | FCM 등 시크릿 마운트용 (git에 실파일 커밋 금지) |
## 문서 지도 ## 문서
권위 순서·에이전트 규칙: [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md). - 기획: [`docs/PLANNING.md`](./docs/PLANNING.md) · 결정: [`docs/decision-log.md`](./docs/decision-log.md) (Q1~Q7 **확정**)
- Vision / PRD / 기술설계: [`docs/vision.md`](./docs/vision.md) · [`docs/PRD.md`](./docs/PRD.md) · [`docs/tech-design.md`](./docs/tech-design.md)
### 기획 · 결정 - 로드맵 (작업 체크리스트): [`docs/roadmap.md`](./docs/roadmap.md)
- **배포·잔여 실행 트랙:** [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) (N1 스모크 → N2 Docker → … → N5 사람 PoC)
| 문서 | 용도 | - 베타 직전(C): [`docs/invite-ops.md`](./docs/invite-ops.md) · [`docs/android-release.md`](./docs/android-release.md) · [`docs/prototype.md`](./docs/prototype.md)
|------|------| - PoC 계획/준비물: [`docs/poc-plan.md`](./docs/poc-plan.md) · [`docs/poc-materials.md`](./docs/poc-materials.md)
| [`docs/PLANNING.md`](./docs/PLANNING.md) | 프로세스·산출물 가이드 |
| [`docs/decision-log.md`](./docs/decision-log.md) | Q1~**Q8 확정**, **Q9 제안** (단일 결정 기준) |
| [`docs/vision.md`](./docs/vision.md) | 문제·가치제안·성공 지표 |
| [`docs/PRD.md`](./docs/PRD.md) | P0 기능·유저 플로우 |
| [`docs/tech-design.md`](./docs/tech-design.md) | 온디바이스/서버 경계·자율성 엔진 |
| [`docs/risk-log.md`](./docs/risk-log.md) | 리스크·완화 추적 |
| [`docs/account-settings-ia.md`](./docs/account-settings-ia.md) | 계정·로그인·설정 IA (Q8) · L2 의미 갭 (Q9) |
### 실행 · 잔여 작업 (여기가 본체)
| 문서 | 용도 |
|------|------|
| [`docs/roadmap.md`](./docs/roadmap.md) | Phase 체크리스트 (`[x]` / `[~]` / `[ ]`) |
| [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) | **배포·잔여 실행 트랙** (DONE/NOW/NEXT · Track A~C · N1~N5 · N4-W) |
| [`docs/web-upgrade.md`](./docs/web-upgrade.md) | **웹 고도화** (데모/프리뷰 · W0~W7 · Web Push · PWA). Q9 비범위 |
남은 일: 웹은 `web-upgrade.md` / `deploy-checklist` §N4-W. Android/FCM 실기기·APK·SHARE_URL·PoC는 §N4·§N5.
### 배포 · 운영
| 문서 | 용도 |
|------|------|
| [`docs/deploy-docker.md`](./docs/deploy-docker.md) | Compose·이미지 |
| [`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(W4) dart-define 주입 |
| [`docs/android-release.md`](./docs/android-release.md) | 내부 release APK 서명 |
| [`docs/tester-guide.md`](./docs/tester-guide.md) | 테스터 안내 · `DEMO-YKAVU` |
### PoC · 회의
| 문서 | 용도 |
|------|------|
| [`docs/poc-plan.md`](./docs/poc-plan.md) | PoC #1/#3 실행 계획 |
| [`docs/poc-materials.md`](./docs/poc-materials.md) | 모집·역할극 자료 |
| [`docs/user-interview-guide.md`](./docs/user-interview-guide.md) | Q3 자율성 인터뷰 |
| [`docs/prototype.md`](./docs/prototype.md) | 클릭 프로토타입 앵커 · `SHARE_URL` (Master 기입) |
| [`docs/meeting-review-summary.md`](./docs/meeting-review-summary.md) | 회의 1페이지 요약 |
## AI 에이전트 규칙 ## AI 에이전트 규칙
- [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md) - [`AGENTS.md`](./AGENTS.md) · [`CLAUDE.md`](./CLAUDE.md)
- **브랜치:** 작업·머지는 항상 `main` - **브랜치:** 작업·머지는 항상 `main`
- **리모트:** GitHub `origin` + Gitea `gitea`(iykyka) — `main` 갱신 후 `./scripts/push-both.sh` - **리모트:** GitHub `origin` + Gitea `gitea`(iykyka) — `main` 갱신 후 `./scripts/push-both.sh`
- **Q9** (L2 수신 트리거 자동응대)는 Master 확정 전 구현 금지
## 현재 단계 ## 현재 단계
- Phase 1 **A~C** + Track A/B/C + **Q8**(로그인·설정·로그아웃) 코드 완료 - Phase 1 **A~C** + **N2 컷오버 + N3 안정화 완료** — 라이브: [`https://msn.iykyka.com`](https://msn.iykyka.com)
- 라이브: [`https://msn.iykyka.com`](https://msn.iykyka.com) · 데모 코드 **`DEMO-YKAVU`** - 테스터 안내: [`docs/tester-guide.md`](./docs/tester-guide.md) · 데모 코드 **`DEMO-YKAVU`**
- **웹 고도화 (W0~W7):** 구현·배포 완료 — [`docs/web-upgrade.md`](./docs/web-upgrade.md). - **다음 (선택):** N4 FCM·Android QA
**남은 것:** W4 **accept** (하드 리프레시·알림 허용·로그인 후 `/admin/push-test``sent >= 1`) - **맨 마지막:** N5 / D — 사람 PoC (`docs/roadmap.md` §3). §3 기본값 추측 금지
- **후순위 (Master):** Android N4 — `google-services.json` PC 배치 · 실기기 FCM · UI 탭 · APK · `SHARE_URL` - 프로토타입 공유 URL은 [`docs/prototype.md`](./docs/prototype.md)의 `SHARE_URL`에 Master가 기입
- **결정 대기:** Q9 — L2 의미 (카피 정렬 ± 수신 자동응대) — 웹 트랙에도 **비범위**
- **맨 마지막:** N5 / D — 사람 PoC. §3 기본값 추측 금지
상세 현황: [`docs/deploy-checklist.md`](./docs/deploy-checklist.md) §0.
## 로컬 실행 (요약) ## 로컬 실행 (요약)
@ -105,7 +60,6 @@ cd mobile && flutter run --dart-define=CORE_API_BASE=http://10.0.2.2:8080
- 구성: [`docs/deploy-docker.md`](./docs/deploy-docker.md) - 구성: [`docs/deploy-docker.md`](./docs/deploy-docker.md)
- Portainer·컷오버: [`docs/deploy-portainer.md`](./docs/deploy-portainer.md) - Portainer·컷오버: [`docs/deploy-portainer.md`](./docs/deploy-portainer.md)
- FCM: [`docs/fcm-setup.md`](./docs/fcm-setup.md)
- 서버 기동: `./scripts/server-up.sh` (호스트에서 `.env` 채운 뒤) - 서버 기동: `./scripts/server-up.sh` (호스트에서 `.env` 채운 뒤)
```bash ```bash

View File

@ -12,25 +12,7 @@ server {
# Docker Compose embedded DNS # Docker Compose embedded DNS
resolver 127.0.0.11 valid=10s ipv6=off; resolver 127.0.0.11 valid=10s ipv6=off;
# Entrypoint + service worker: always revalidate (PWA / docs/web-upgrade N4-W5). # Flutter SPA
location = /index.html {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location = /flutter_service_worker.js {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location = /manifest.json {
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 / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }

View File

@ -72,14 +72,6 @@ services:
dockerfile: mobile/Dockerfile dockerfile: mobile/Dockerfile
args: args:
CORE_API_BASE: ${PUBLIC_API_BASE:-https://msn.iykyka.com} 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: ports:
- "${WEB_HOST_PORT:-8088}:80" - "${WEB_HOST_PORT:-8088}:80"
depends_on: depends_on:

View File

@ -37,22 +37,19 @@ Phase 1 **A~C** 이후 실행 트랙. 작업 단위를 하나씩 처리한다.
- 앱 코드는 클로즈드 베타 직전 수준 - 앱 코드는 클로즈드 베타 직전 수준
- **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료** - **`https://msn.iykyka.com` 라이브 + N3 완료 + Gemini 실초안 OK + Track A/B 완료**
- **웹 고도화:** [`web-upgrade.md`](./web-upgrade.md) — W0~W7 구현·배포 **done** · **W4 accept만** (하드 리프레시·알림·push-test) - 진행 중: **N4 FCM 코드 경로** → Master 시크릿 대기 → Android UI QA
- 후순위: **N4 Android FCM/UI**`google-services.json` PC 배치·실기기 (서버 SA는 배치됨)
- UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영 - UI: **iMessage-inspired light default** + soft charcoal dark 프로덕션 반영
- **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대) - **Track C 콘텐츠 갭 A~F**: 프로덕션 반영 완료 (`95422bb`대)
- **Q8 계정·설정 IA (2026-08-03 확정·구현):** 로그인 탭 · 설정 허브 · 로그아웃 - **Q8 계정·설정 IA (2026-08-03 확정·구현):** 로그인 탭 · 설정 허브 · 로그아웃
- **문서 갭 잔여:** L2 의미 [`decision-log.md`](./decision-log.md) Q9 **제안** - **문서 갭 잔여:** L2 의미 [`decision-log.md`](./decision-log.md) Q9 **제안**
[`account-settings-ia.md`](./account-settings-ia.md) §3. Master 확정 전 Q9 구현 금지 [`account-settings-ia.md`](./account-settings-ia.md) §3. Master 확정 전 Q9 구현 금지
(**웹 트랙에도 Q9 비범위**) - 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음
- 실 FCM 기기 수신 · Android 실기기 탭 · 사람 PoC 실행은 남음 (Android 트랙)
### NEXT 순서 ### NEXT 순서
``` ```
N1 스모크 → N2 Docker(msn.iykyka.com) → N3 배포 안정화 N1 스모크 → N2 Docker(msn.iykyka.com) → N3 배포 안정화
→ N4-W 웹 고도화(W1→…→W7) 병행 가능 → N4 FCM·Android QA 등 → N5 사람 PoC(D) → 실제 베타 오픈
→ N4 Android FCM·UI QA 등 → N5 사람 PoC(D) → 실제 베타 오픈
``` ```
### LOCKED ### LOCKED
@ -207,29 +204,14 @@ N2-A 전체 확정. 다음 구현 트랙은 **N1 스모크 → N2-B (Dockerfile/
| **N4-C6b** | 리마인드 로컬 알림(본인에게만) | **done (부분 검증)** (2026-08-03) | `flutter_local_notifications`+`timezone` 실제 연동(`mobile/lib/services/snooze_notification_service_native.dart` `zonedSchedule`/`cancel`) — **단, 이 샌드박스에는 실기기/에뮬레이터가 없어 알림이 실제로 울리는지/탭 동작은 검증 불가**. 검증한 건 스케줄러를 목(mock)으로 바꿔 id·시각·페이로드가 올바른지뿐(`test/snooze_controller_test.dart`). 웹 빌드는 플러그인이 웹 미지원이라 `snooze_notification_service_web.dart` no-op 스텁. 실기기 검증 전까지는 대화 목록/채팅방의 인앱 배지·배너(C6c)가 실질적인 리마인드 경로 | | **N4-C6b** | 리마인드 로컬 알림(본인에게만) | **done (부분 검증)** (2026-08-03) | `flutter_local_notifications`+`timezone` 실제 연동(`mobile/lib/services/snooze_notification_service_native.dart` `zonedSchedule`/`cancel`) — **단, 이 샌드박스에는 실기기/에뮬레이터가 없어 알림이 실제로 울리는지/탭 동작은 검증 불가**. 검증한 건 스케줄러를 목(mock)으로 바꿔 id·시각·페이로드가 올바른지뿐(`test/snooze_controller_test.dart`). 웹 빌드는 플러그인이 웹 미지원이라 `snooze_notification_service_web.dart` no-op 스텁. 실기기 검증 전까지는 대화 목록/채팅방의 인앱 배지·배너(C6c)가 실질적인 리마인드 경로 |
| **N4-C6c** | 스누즈 표시/취소 UI | **done** (2026-08-03) | `chat_screen.dart`(앱바 아이콘 상태 + 마감 지난 스누즈 배너 + 해제 버튼), `conversation_list_screen.dart`(서브타이틀 아래 "답장 마감" 배지, 탭하여 바로 해제). 마감 판정은 순수 함수 `isSnoozePastDue()`로 분리해 고정 시각 단위 테스트(`test/snooze_service_test.dart`) | | **N4-C6c** | 스누즈 표시/취소 UI | **done** (2026-08-03) | `chat_screen.dart`(앱바 아이콘 상태 + 마감 지난 스누즈 배너 + 해제 버튼), `conversation_list_screen.dart`(서브타이틀 아래 "답장 마감" 배지, 탭하여 바로 해제). 마감 판정은 순수 함수 `isSnoozePastDue()`로 분리해 고정 시각 단위 테스트(`test/snooze_service_test.dart`) |
### N4-W — 웹 고도화 — [`web-upgrade.md`](./web-upgrade.md) ### FCM — [`fcm-setup.md`](./fcm-setup.md)
데모/프리뷰 표면 유지. **Q9 비범위.** Wi 단위 승인 후 구현. 서버 FCM SA는 Android와 공유.
| ID | 작업 | Status | 비고 |
|----|------|--------|------|
| **N4-W0** | 제품 경계 · 문서 랜딩 | **done** (2026-08-04) | 성공 정의·비범위·규율 고정 |
| **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) | **web-live** (2026-08-04) | 시크릿·`:web:`·SW 배포 done · **accept만:** 하드 리프레시·알림·실 토큰·push-test |
| **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는 후속) |
### FCM (Android) — [`fcm-setup.md`](./fcm-setup.md)
| ID | 작업 | Status | 완료 조건 | | ID | 작업 | Status | 완료 조건 |
|----|------|--------|-----------| |----|------|--------|-----------|
| **N4-1** | Firebase + `google-services.json` | doing | Master: 앱 등록됨 · JSON을 Android 빌드 PC에 배치 | | **N4-1** | Firebase + `google-services.json` | doing | Master: 앱 등록됨 · JSON을 Android 빌드 PC에 배치 |
| **N4-2** | 실 FCM registration token | done* | `PushTokenService` — Firebase 있으면 실 토큰, 없으면 `install:` (*전송은 N4-1 후) | | **N4-2** | 실 FCM registration token | done* | `PushTokenService` — Firebase 있으면 실 토큰, 없으면 `install:` (*전송은 N4-1 후) |
| **N4-3** | 서버 FCM 자격증명 (HTTP v1) | **done** (2026-08) | 프로덕션 `secrets/firebase-service-account.json` 마운트됨. 웹 W4와 공유 | | **N4-3** | 서버 FCM 자격증명 (HTTP v1) | doing | Master: `secrets/firebase-service-account.json` (레거시 서버 키 대신) |
| **N4-4** | 푸시 수신 (Android) | blocked | N4-1+N4-3 후 `/admin/push-test` + 기기 수신 | | **N4-4** | 푸시 수신 | blocked | N4-1+N4-3 후 `/admin/push-test` + 기기 수신 |
### Android UI 탭 (`mobile/README.md`) ### Android UI 탭 (`mobile/README.md`)
@ -253,7 +235,7 @@ API 계층은 프로덕션에서 검증됨 (`CORE_API_BASE=https://msn.iykyka.co
| **N4-12** | 자연스러움 피드백 UI | **done (계측만)** (2026-08-03) | vision 지표. **PoC 실행/결론이 아니라 캡처 장치** — 실제 %는 N5 이후 실 사용 데이터로. 암묵 신호: `core-backend/models.go` `Message.DraftEdited`(nullable — 사람 메시지/구버전 클라는 nil, 트윈 승인-발송 경로에서 클라이언트가 `original_draft_text`를 같이 보내면 서버가 diff해 true/false) + `mobile/lib/screens/chat_screen.dart` `_sendTwinApproved``_pendingDraft.text`(편집 전 원본)를 함께 전송. 명시 신호: `Message.NaturalnessRating`(nullable bool) + `POST /messages/:id/feedback`(트윈 메시지만, 400 아니면 재제출은 덮어씀 — 409 아님) + `chat_screen.dart`/`message_bubble.dart`의 "이 답장 나답아요?" 원탭 👍/👎(한 번 탭하면 그 세션에서 다시 안 보여줌, `_ratedMessageIds` + `message_sync.dart``ratedMessageIdsFrom()`). `/admin/metrics``draft_unedited_rate`/`naturalness_positive_rate`(둘 다 분모 0일 때 0으로 zero-guard, `peer_veto_rate`와 동일 패턴) 추가, `adminDashboardHTML`에 카드 2개 추가. 백엔드 테스트 8개(`core-backend/main_test.go`) + 모바일 테스트 5개(`mobile/test/`) 추가, 전부 통과 | | **N4-12** | 자연스러움 피드백 UI | **done (계측만)** (2026-08-03) | vision 지표. **PoC 실행/결론이 아니라 캡처 장치** — 실제 %는 N5 이후 실 사용 데이터로. 암묵 신호: `core-backend/models.go` `Message.DraftEdited`(nullable — 사람 메시지/구버전 클라는 nil, 트윈 승인-발송 경로에서 클라이언트가 `original_draft_text`를 같이 보내면 서버가 diff해 true/false) + `mobile/lib/screens/chat_screen.dart` `_sendTwinApproved``_pendingDraft.text`(편집 전 원본)를 함께 전송. 명시 신호: `Message.NaturalnessRating`(nullable bool) + `POST /messages/:id/feedback`(트윈 메시지만, 400 아니면 재제출은 덮어씀 — 409 아님) + `chat_screen.dart`/`message_bubble.dart`의 "이 답장 나답아요?" 원탭 👍/👎(한 번 탭하면 그 세션에서 다시 안 보여줌, `_ratedMessageIds` + `message_sync.dart``ratedMessageIdsFrom()`). `/admin/metrics``draft_unedited_rate`/`naturalness_positive_rate`(둘 다 분모 0일 때 0으로 zero-guard, `peer_veto_rate`와 동일 패턴) 추가, `adminDashboardHTML`에 카드 2개 추가. 백엔드 테스트 8개(`core-backend/main_test.go`) + 모바일 테스트 5개(`mobile/test/`) 추가, 전부 통과 |
| **N4-13** | `prototype.md` `SHARE_URL` | todo | Master 기입 | | **N4-13** | `prototype.md` `SHARE_URL` | todo | Master 기입 |
| **N4-14** | 내부 release APK | todo | `docs/android-release.md` | | **N4-14** | 내부 release APK | todo | `docs/android-release.md` |
| **N4-15** | roadmap/`[~]` 동기화 | **done** (2026-08-03) | `roadmap.md` §2.1 "푸시 알림 서비스 연동"(낡은 중복 표기 → §B/N4-1~4로 교차참조), §4-4번 "남은 것"(완료된 "C 배포 준비" 제거), §2.3 온보딩 뼈대(관계 티어 스텝 추가 반영), §2.5 수동 QA 항목(N4-5~10/N4-C6b/N4-11 교차참조)을 실제 코드 상태와 맞춤. 나머지 `[~]`(FCM 푸시, 멀티디바이스 동기화, 데이터 프라이버시, L2 카피/수신트리거)는 실제로 아직 Master 액션·Q9 확정 대기 중이라 그대로 유지 — 허위로 채우지 않음 | | **N4-15** | roadmap/`[~]` 동기화 | todo | 완료 시 체크 |
--- ---
@ -287,11 +269,9 @@ N1~N4(배포·품질에 필요한 최소분) 이후에만 착수. `roadmap.md` P
**2026-08-03 2차 갭 분석 배치(C4/C5/C6) 전체 구현 완료.** C6은 온디바이스 스누즈 **2026-08-03 2차 갭 분석 배치(C4/C5/C6) 전체 구현 완료.** C6은 온디바이스 스누즈
저장·UI는 완전히 검증(단위 테스트)됐지만, 실제 OS 로컬 알림이 실기기에서 울리는지는 저장·UI는 완전히 검증(단위 테스트)됐지만, 실제 OS 로컬 알림이 실기기에서 울리는지는
이 샌드박스(실기기/에뮬레이터 없음)에서 검증 불가 — Android UI QA(N4-5~10)에서 처음 이 샌드박스(실기기/에뮬레이터 없음)에서 검증 불가 — Android UI QA(N4-5~10)에서 처음
확인 필요, 그때까지는 인앱 배지·배너가 실질적 대체 경로. Track C A~F 프로덕션 확인 필요, 그때까지는 인앱 배지·배너가 실질적 대체 경로. 남은 것: **Master FCM
재배포는 **완료** (`95422bb` / docs `f363e8e`, 2026-08-03). 시크릿(N4-1/3)** → N4-4 스모크 → Android UI QA (N4-5~10, 답장 마감 알림 실기기 확인
포함). Track C A~F 프로덕션 재배포는 **완료** (`95422bb` / docs `f363e8e`, 2026-08-03)
**바로 다음:** 웹 **W4 accept**`msn.iykyka.com` 하드 리프레시·알림 허용·로그인 후 `/admin/push-test` · 또는 Android N4-1/4.
웹 W0~W7 코드·`:web:` 시크릿 배포·서버 SA **done** (브라우저 실토큰·push-test만 남음).
완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다. 완료 시 본 표의 Status를 `done`으로 바꾸고, [`roadmap.md`](./roadmap.md) §4/§5의 대응 `[~]`/`[ ]`도 같이 갱신한다.

View File

@ -1,17 +1,15 @@
# FCM 설정 (N4-1 ~ N4-4 · N4-W4) # FCM 설정 (N4-1 ~ N4-4)
서버·클라이언트의 푸시 **코드 경로는 준비됨**. 서버·클라이언트의 푸시 **코드 경로는 준비됨**.
새 Firebase 프로젝트는 **레거시 서버 키가 비활성**인 경우가 많아, **HTTP v1 + 서비스 계정 JSON**을 쓴다. 새 Firebase 프로젝트는 **레거시 서버 키가 비활성**인 경우가 많아, **HTTP v1 + 서비스 계정 JSON**을 쓴다.
## 이미 된 것 (코드 · 프로덕션) ## 이미 된 것 (코드)
| 계층 | 동작 | | 계층 | 동작 |
|------|------| |------|------|
| Flutter | `PushTokenService` — Firebase 가능하면 실 FCM 토큰, 아니면 `install:` 플레이스홀더 | | Flutter | `PushTokenService` — Firebase 가능하면 실 FCM 토큰, 아니면 `install:` 플레이스홀더 |
| Android | `google-services.json`이 있을 때만 Google Services 플러그인 적용 | | Android | `google-services.json`이 있을 때만 Google Services 플러그인 적용 |
| Web (N4-W4) | `FirebaseWebConfig` + 프로덕션 `.env` `FIREBASE_*`/`VAPID` 주입 · SW populated (`:web:` APP_ID) | | core-backend | `notifyUser` + `POST /admin/push-test`**FCM HTTP v1**(서비스 계정) 우선, 레거시 `FCM_SERVER_KEY`는 폴백 |
| core-backend | `notifyUser` + `POST /admin/push-test`**FCM HTTP v1**(서비스 계정) 우선 |
| 서버 SA (N4-3) | 프로덕션 `secrets/firebase-service-account.json` 마운트 **done** |
## Master가 할 일 ## Master가 할 일
@ -19,35 +17,44 @@
1. [Firebase Console](https://console.firebase.google.com/) → Android 앱 추가 1. [Firebase Console](https://console.firebase.google.com/) → Android 앱 추가
package: **`com.ykavu.ykavu_mobile`** package: **`com.ykavu.ykavu_mobile`**
2. `google-services.json`**Android 빌드 PC**에만 배치 (git 금지): 2. `google-services.json`로컬에만 배치 (git 금지):
```bash ```bash
cp ~/Downloads/google-services.json mobile/android/app/google-services.json cp ~/Downloads/google-services.json mobile/android/app/google-services.json
``` ```
### N4-3 — 서비스 계정 JSON (HTTP v1) — **프로덕션 done** ### N4-3 — 서비스 계정 JSON (HTTP v1)
레거시 **서버 키**가 Cloud Messaging 탭에서 `사용 중지됨`이면 정상이다. 레거시 **서버 키**가 Cloud Messaging 탭에서 `사용 중지됨`이면 정상이다. 아래를 쓴다.
프로덕션 호스트에 이미 배치됨: 1. Google Cloud → 사용자 인증 정보 → 서비스 계정 만들기
(API: Firebase Cloud Messaging API, 데이터: **애플리케이션 데이터**)
```bash 2. 역할: **Firebase Cloud Messaging Admin** (없으면 Firebase 관리자 / 임시 소유자)
# ~/project/ykavu/secrets/firebase-service-account.json 3. 키 유형 **JSON** 다운로드
# compose: ./secrets → /secrets, FCM_SERVICE_ACCOUNT_FILE=/secrets/firebase-service-account.json 4. 서버(또는 이 워크스페이스)에 배치:
```
새 환경에 다시 놓을 때:
```bash ```bash
# 파일명 고정
mkdir -p secrets mkdir -p secrets
mv ~/Downloads/iykyka-*.json secrets/firebase-service-account.json mv ~/Downloads/iykyka-*.json secrets/firebase-service-account.json
chmod 600 secrets/firebase-service-account.json chmod 600 secrets/firebase-service-account.json
```
5. 프로덕션 호스트에도 동일 파일:
```bash
# 예: scp 후
cd ~/project/ykavu
# secrets/firebase-service-account.json 존재 확인
docker compose up -d --build core-backend docker compose up -d --build core-backend
``` ```
`docker-compose.yml``./secrets` → 컨테이너 `/secrets`로 마운트하고
`FCM_SERVICE_ACCOUNT_FILE=/secrets/firebase-service-account.json`을 읽는다.
**git / 채팅에 JSON 내용을 붙여넣지 않는다.** **git / 채팅에 JSON 내용을 붙여넣지 않는다.**
### N4-2 / N4-4 — Android 실기기 스모크 ### N4-2 / N4-4 — 실기기 스모크
```bash ```bash
cd mobile cd mobile
@ -64,58 +71,8 @@ curl -sS -X POST https://msn.iykyka.com/admin/push-test \
기대: `sent >= 1`. 기대: `sent >= 1`.
`only_placeholder_tokens` → Android에 `google-services.json` 넣고 재설치. `only_placeholder_tokens` → Android에 `google-services.json` 넣고 재설치.
`fcm_not_configured` → 서비스 계정 경로 확인. `fcm_not_configured` → 서버에 서비스 계정 파일 경로 확인.
## Web (N4-W4) ## Web
설계·수락: [`web-upgrade.md`](./web-upgrade.md) §N4-W4 · 실행 표: [`deploy-checklist.md`](./deploy-checklist.md) §N4-W. Web 푸시는 별도 VAPID 설정이 필요하며 이번 N4는 **Android 실푸시** 우선.
**프로덕션:** Firebase Web config + VAPID + `:web:` APP_ID가 서버 `.env`에 있고 `web` 이미지에 빌드 주입됨.
SW(`firebase-messaging-sw.js`) populated. 서버 SA는 Android와 공유.
### 남은 accept (브라우저)
1. `https://msn.iykyka.com` **하드 리프레시** (또는 사이트 데이터 삭제)
2. 알림 **허용** → 로그인
3. device_tokens에 실 토큰(`platform=web`, `install:` 아님)
4. `/admin/push-test``sent >= 1` (탭 백그라운드에서도 알림)
로컬/스테이징에서 define이 비어 있으면 웹은 `install:`만 등록 → `only_placeholder_tokens`가 정상.
### 새 환경에 Web 시크릿을 넣을 때
배포 호스트 **`~/project/ykavu/.env`**(클라우드 에이전트 `/workspace/.env`와 별개):
```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= # must be 1:…:web:… (not :android:)
FIREBASE_VAPID_KEY=
```
```bash
docker compose up -d --build web
```
### 로컬 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`, `scripts/docker_build_web.sh`)
- `mobile/Dockerfile` / `docker-compose.yml` build-args

View File

@ -42,10 +42,7 @@
- [x] DB 스키마: users, invite_codes, contacts, conversations, messages, twin_settings, - [x] DB 스키마: users, invite_codes, contacts, conversations, messages, twin_settings,
escalation_logs, whitelist_rules — `core-backend/models.go` (GORM), `backend/app/models.py` escalation_logs, whitelist_rules — `core-backend/models.go` (GORM), `backend/app/models.py`
(Python 프로토타입)와 동일 스키마(+ invite_codes는 여기서 새로 추가) (Python 프로토타입)와 동일 스키마(+ invite_codes는 여기서 새로 추가)
- [~] 푸시 알림 서비스 연동 — **2026-08-03 동기화**: 이 항목은 오래 전에 적힌 뒤 갱신 안 된 - [ ] 푸시 알림 서비스 연동
중복 표기였음. 실제 진행 상황은 §B "FCM 푸시 연동"과 `deploy-checklist.md` N4-1~4에서 추적
중 — 서버 쪽(`notifyUser`, `/admin/push-test`, 토큰 등록)은 구현·테스트 완료, 남은 건 Master의
Firebase 시크릿 배치(N4-1/3)와 그 이후 실기기 수신 확인(N4-4)뿐
`backend/`(Python 프로토타입)는 그대로 참고용으로 남겨둔다 — `core-backend/`(Go)가 실제로 쓰는 것. `backend/`(Python 프로토타입)는 그대로 참고용으로 남겨둔다 — `core-backend/`(Go)가 실제로 쓰는 것.
@ -78,8 +75,7 @@
- [x] 기본 채팅 UI (대화 목록, 대화방) — `GET`/`POST /conversations`, 히스토리 로드, - [x] 기본 채팅 UI (대화 목록, 대화방) — `GET`/`POST /conversations`, 히스토리 로드,
WebSocket 수신·인간 메시지 전송. 연락처 화면에서 DM 시작 WebSocket 수신·인간 메시지 전송. 연락처 화면에서 DM 시작
- [~] 온보딩 플로우(5분 온보딩) 뼈대 — 가입 + 말투 샘플 입력 UI(`OnboardingToneScreen`, - [~] 온보딩 플로우(5분 온보딩) 뼈대 — 가입 + 말투 샘플 입력 UI(`OnboardingToneScreen`,
기기 로컬) + 관계 티어 기본값 선택(2.7-B, 2026-08-03 추가). 말투 학습 UX 디테일·카피는 기기 로컬). 말투 학습 UX 디테일·카피는 **사람 PoC #1 결과를 맨 마지막에 반영**
**사람 PoC #1 결과를 맨 마지막에 반영** — 이 부분만 여전히 열려 있음
- [x] 와카뷰 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는 - [x] 와카뷰 뱃지·거부권 UX — 점선+뱃지 말풍선, 대화방 거부권 버튼. 실시간 본인확인 응답 문구는
서버/AI 프롬프트 측과 이어서 다듬을 것 서버/AI 프롬프트 측과 이어서 다듬을 것
- [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외는 - [x] 자율성 설정 화면(L0~L2, 화이트리스트) — `AutonomySettingsScreen`. 상대별 예외는
@ -116,10 +112,7 @@
구현과 함께: L0 차단, L1 미승인 차단/승인 시 발송, L2 화이트리스트 매칭 자동발송/비매칭 시 승인 구현과 함께: L0 차단, L1 미승인 차단/승인 시 발송, L2 화이트리스트 매칭 자동발송/비매칭 시 승인
필요, 에스컬레이션이 레벨·화이트리스트·승인 여부와 무관하게 항상 우선한다는 것까지 6개 케이스 필요, 에스컬레이션이 레벨·화이트리스트·승인 여부와 무관하게 항상 우선한다는 것까지 6개 케이스
전부 실제 테스트로 확인함 전부 실제 테스트로 확인함
- [ ] 온보딩·채팅·설정 수동 QA — `mobile/` 골격 위에 실기기/에뮬레이터로 진행 (후속). API 계층은 - [ ] 온보딩·채팅·설정 수동 QA — `mobile/` 골격 위에 실기기/에뮬레이터로 진행 (후속)
이미 검증됨(`deploy-checklist.md` N4-5~10 "api-done") — 남은 건 화면 탭뿐. 그중 N4-C6b(스누즈
로컬 알림)·N4-11(오프라인 재연결)은 이 세션 도구엔 실기기/에뮬레이터가 없어 순수 로직만
단위테스트로 확인했고 실제 동작 확인은 이 수동 QA 라운드에서 처음 이뤄짐
**2.6 베타 배포 준비** **2.6 베타 배포 준비**
- [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라 - [x] 초대 기반 베타 가입 플로우 — **발견**: 기존 가입은 "아무 문자열이나 처음 쓰면 통과"라
@ -309,9 +302,7 @@ PoC 데이터 없이 기본값을 추측해 채우지 않는다.
3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션· 3. [x] 2.2 AI 서비스 — `ai-service/`(Python) 완료. Go 코어→AI 서비스 연동·자율성 오케스트레이션·
되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서 되돌리기 API 완료. 온디바이스 말투 이력 저장은 클라이언트와 이어서
4. [~] 2.3 Flutter 클라이언트 — A3 + B(drift/SQLCipher·데이터흐름·세션) 완료 + API E2E. 4. [~] 2.3 Flutter 클라이언트 — A3 + B(drift/SQLCipher·데이터흐름·세션) 완료 + API E2E.
**2026-08-03 동기화**: "C 배포 준비"는 6번에서 이미 완료됐으므로 여기 "남은 것"에서 제거. 남은 것: 실제 Firebase FCM 토큰, Android UI 탭(`mobile/README.md`), C 배포 준비
실제로 남은 건 Master의 Firebase FCM 시크릿 배치(N4-1/3)와 Android UI 탭(N4-5~10,
`mobile/README.md`)뿐
5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료. 5. [x] 2.4/2.5 안전장치·QA (서버 쪽) — 하드게이트·거부권·삭제·pytest·L0~L2 통합 테스트 완료.
클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 B/A3 나머지와 함께 클라이언트 쪽 온디바이스 암호화·데이터 흐름 대시보드·수동 QA는 B/A3 나머지와 함께
- 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은 - 5-1. [x] 2.6 베타 배포 준비(서버 쪽) — 초대 코드·`/admin/metrics`. **실제 베타 오픈은
@ -406,23 +397,9 @@ Master 합의 착수 순서: **A → B → C → D(맨 마지막)**. E는 Phase
단일 실행 체크리스트: **[`deploy-checklist.md`](./deploy-checklist.md)**. 단일 실행 체크리스트: **[`deploy-checklist.md`](./deploy-checklist.md)**.
순서: **N1 스모크 → N2 Docker(`msn.iykyka.com`) → N3 안정화 → N4-W 웹 고도화(병행) / 순서: **N1 스모크 → N2 Docker(`msn.iykyka.com`) → N3 안정화 → N4 FCM/Android QA → N5 사람 PoC(D)**.
N4 Android FCM·UI QA → N5 사람 PoC(D)**. Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5)는 해당 문서를 본다. 완료 시 그 문서와
Claude/Cursor 통합 DONE 목록과 항목 ID(N1-1 … N5-5, **N4-W0…W7**)는 해당 문서를 본다. 본 로드맵 §2/§5의 `[~]`/`[ ]`를 함께 갱신한다.
완료 시 그 문서와 본 로드맵 §2/§5의 `[~]`/`[ ]`를 함께 갱신한다.
##### 웹 고도화 (데모/프리뷰) — [`web-upgrade.md`](./web-upgrade.md)
- [x] **W0** 제품 경계 문서화 (2026-08-04) — 데모/프리뷰 · Q9 비범위 · Wi 규율
- [x] **W1** 웹 로컬 지속성 (prefs/idb · Drift WASM 비채택) — 2026-08-04
- [x] **W2** 데모 UX (분할·WS 상태·ID 복사) — 2026-08-04
- [x] **W3** 인앱 + Notification API — 2026-08-04
- [x] **W4** Web Push (FCM + VAPID · `:web:` APP_ID 프로덕션 주입) — **accept 대기:** 브라우저 하드 리프레시·알림·push-test
- [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는 후속
W0~W7 코드·배포 완료. W4만 브라우저 실토큰·`/admin/push-test` 확인 남음. Android N4와 서버 SA 공유.
## Phase 2 — L3 확장 + 베타 확대 ## Phase 2 — L3 확장 + 베타 확대

View File

@ -2,38 +2,25 @@
## 접속 ## 접속
- 웹: **https://msn.iykyka.com** (데모/프리뷰 1차 표면) - 웹: **https://msn.iykyka.com**
- 권장 브라우저: **Desktop Chrome**
- 공용 데모 초대 코드: **`DEMO-YKAVU`** - 공용 데모 초대 코드: **`DEMO-YKAVU`**
- 표시 이름 예시: `테스터` (원하는 이름으로 변경 가능**이미 가입** 탭 재로그인 시 같은 이름 필요) - 표시 이름 예시: `테스터` (원하는 이름으로 변경 가능)
같은 코드를 여러 명이 쓸 수 있습니다 (`ALLOW_DEMO_INVITE=1`). 같은 코드를 여러 명이 쓸 수 있습니다 (`ALLOW_DEMO_INVITE=1`).
API 메타: `GET https://msn.iykyka.com/demo``pairing_steps` / `notes` 포함. API 메타: `GET https://msn.iykyka.com/demo``pairing_steps` / `notes` 포함.
### 웹 프리뷰 팁 (N4-W)
1. **넓은 화면(≥900px)** — 왼쪽 대화 목록 · 오른쪽 채팅이 나란히 보입니다.
2. **내 ID 복사** — 목록 상단 칩 또는 설정 → 계정 행을 탭합니다.
3. **브라우저 알림** — 설정 → 「브라우저 알림」에서 허용하면 답장 마감 리마인드·(허용 시) Web Push를 받을 수 있습니다(거부해도 인앱 배지는 동작).
4. **로그인 세션 만료 시각** — 설정 → 로그인 세션의 「만료」는 **기기 로컬 시간**(`yyyy-MM-dd HH:mm`)으로 표시됩니다.
5. **홈 화면 추가(PWA)** — Chrome 주소창 옆 설치 아이콘 또는 메뉴 → 「앱 설치」.
6. **로그아웃** — 설정 → 로그아웃 → 입장 화면 「이미 가입」으로 복귀.
7. **캐시가 옛 버전일 때** — 강력 새로고침, 또는 DevTools → Application → Service Workers → Unregister 후 새로고침.
상세 설계: [`web-upgrade.md`](./web-upgrade.md).
## 권장 페어링 플로우 (5~10분) ## 권장 페어링 플로우 (5~10분)
두 명(또는 시크릿 창 두 개)으로 진행합니다. **대화는 닉네임이 아니라 숫자 사용자 ID로 연결됩니다.** 두 명(또는 시크릿 창 두 개)으로 진행합니다. **대화는 닉네임이 아니라 숫자 사용자 ID로 연결됩니다.**
1. **가입**「새로 가입」탭 · 초대 코드 `DEMO-YKAVU` (각자 다른 표시 이름 권장). 시크릿 창 두 개로 두 사용자 가능. 1. **가입** — 초대 코드 `DEMO-YKAVU` 입력 (각자 다른 표시 이름 권장)
2. **말투 샘플** — 몇 줄 적거나 스킵 (하드 리프레시 후에도 유지됨) 2. **말투 샘플** — 몇 줄 적거나 스킵
3. **내 ID 복사** — 대화 목록 상단「내 사용자 ID」칩을 탭해 복사하고 상대에게 전달 3. **내 ID 복사** — 대화 목록 상단「내 사용자 ID」칩을 탭해 복사하고 상대에게 전달
4. **연락처 추가** — 상대 표시 이름 + **상대의 숫자 ID(필수)** → 추가 → 「대화」 4. **연락처 추가** — 상대 표시 이름 + **상대의 숫자 ID(필수)** → 추가 → 「대화」
5. **메시지** — 사람 모드로 한두 줄 주고받기 · 채팅 앱바의 「실시간」상태 확인 5. **메시지** — 사람 모드로 한두 줄 주고받기
6. **와카뷰 초안**설정 → 자율성에서 **L1** → 채팅에서 ✨ 초안 → 수정/승인하고 보내기 6. **와카뷰 초안** — 메뉴 → 자율성에서 **L1** → 초안이 뜨면 수정/승인하고 보내기
7. (선택) L0에서는「입력창으로 옮기기」만 됩니다. L2·거부권·사후알림 함·이따 답장도 눌러 보세요. 7. (선택) L0에서는「입력창으로 옮기기」만 됩니다. L2·거부권·사후알림 함도 눌러 보세요.
### ID 없는 옛 연락처 ### ID 없는 옛 연락처
@ -43,9 +30,7 @@ API 메타: `GET https://msn.iykyka.com/demo` — `pairing_steps` / `notes` 포
- **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다. - **초안(AI)**: Gemini 키가 서버에 설정되어 있어 **실제 초안**이 생성됩니다.
- **L0(비서)**: 와카뷰 발송이 서버에서 막혀 있습니다. 초안 → 입력창 → 직접 전송. - **L0(비서)**: 와카뷰 발송이 서버에서 막혀 있습니다. 초안 → 입력창 → 직접 전송.
- 푸시: 웹은 프로덕션에 Firebase Web(`:web:`)·VAPID가 이미 주입되어 있습니다. - 푸시(FCM): 코드 경로는 준비됨. Master가 Firebase/`FCM_SERVER_KEY`를 넣기 전에는 플레이스홀더(`docs/fcm-setup.md`).
**하드 리프레시**(또는 사이트 데이터 삭제) → 알림 **허용** → 로그인하면 실 FCM 토큰이 등록됩니다.
Android 실기기 푸시는 `google-services.json` 배치 후 (`docs/fcm-setup.md`).
- 문제/스크린샷은 Master에게 전달해 주세요. - 문제/스크린샷은 Master에게 전달해 주세요.
- 민감 정보·실명 대화는 베타 특성상 최소화해 주세요. - 민감 정보·실명 대화는 베타 특성상 최소화해 주세요.

View File

@ -1,488 +0,0 @@
# 웹 고도화 (데모/프리뷰 + Web Push + PWA)
권위: [`decision-log.md`](./decision-log.md) · [`deploy-checklist.md`](./deploy-checklist.md) · [`AGENTS.md`](../AGENTS.md).
실행 추적 ID: **N4-W0 … N4-W7** (`deploy-checklist.md` §N4-W).
## 0. 고정 결정 (2026-08-04 Master)
| # | 결정 | 상태 |
|---|------|------|
| 1 | 웹 = **데모/프리뷰 1차 표면** (`https://msn.iykyka.com`). Android 동등 교체 아님 | 확정 |
| 2 | **Web Push**(FCM Web + VAPID) · **PWA**(설치·standalone·셸 SW) 포함 | 확정 |
| 3 | 본 문서로 Wi를 **완전 상세**히 짜고, **Wi 단위**로 차근차근 구현 | 확정 |
| 4 | **Q9**(L2 수신 트리거 자동응대) · PoC §3 기본값 추측 · L3/OS/B2B **비범위** | 확정 |
### 성공 정의 (데모/프리뷰)
두 테스터가 **브라우저만**으로: 가입(또는 로그인) → 사용자 ID 교환·연락처 → 대화 → 초안(✨) → L1 승인·와카뷰 뱃지 → 설정/로그아웃까지 **안정 시연**.
### 비범위 (전체 공통)
- Q9 수신 트리거 자동응대 및 “자리를 비우면 알아서 답함” 카피
- 사람 PoC #1/#3 실행 · 자율성/화이트리스트 **기본값** 확정
- Android N4 실기기 트랙(병행 가능, 본 문서 작업 분해에 넣지 않음)
- Drift WASM / SQLCipher-on-web / Play·App Store 배포
- 계정 삭제 UI (Q8e), 이미지·읽음·타이핑 등 일반 메신저 확장
### 구현 규율
1. **한 번에 W 전체 구현 금지.** `Wi`마다 Master 승인 → 코드 → 본 문서·`deploy-checklist` Status 동시 갱신.
2. Wi 완료 전 다음 Wi 착수하지 않는다 (의존 화살표 준수). 예외는 Master 명시.
3. 시크릿(Firebase Web config, VAPID, 서비스 계정) **git 커밋 금지**.
4. 서버 FCM HTTP v1 SA는 Android N4와 **공유**. 클라이언트 시크릿 경로만 플랫폼별 분리.
```mermaid
flowchart LR
W0[W0_Boundary] --> W1[W1_Persistence]
W1 --> W2[W2_DemoUX]
W2 --> W3[W3_InAppNotify]
W3 --> W4[W4_WebPush]
W4 --> W5[W5_PWA]
W5 --> W6[W6_TesterOps]
W6 --> W7[W7_Quality]
```
---
## 1. As-is (코드 박제, 2026-08)
| 영역 | 현황 | 근거 |
|------|------|------|
| 서빙 | Flutter web + nginx, API/WS 프록시 | `mobile/Dockerfile`, `deploy/nginx-web.conf` |
| 로컬 DB | 웹 = **메모리 스텁**, 새로고침 시 말투·온보딩·스누즈 유실 | `mobile/lib/db/app_database_web.dart` |
| 세션 | `shared_preferences`에 user/token (웹에서도 대체로 유지) | `mobile/lib/state/session_state.dart` |
| 푸시 | 웹에서 FCM 즉시 skip → `install:` 플레이스홀더 | `mobile/lib/services/push_token_service.dart` |
| 스누즈 OS 알림 | 웹 no-op | `mobile/lib/services/snooze_notification_service_web.dart` |
| PWA 골격 | `manifest.json` + icons 있음. theme `#7C6FF0`(낡은 아우로라). SW 전략 미문서화 | `mobile/web/` |
| 서버 푸시 | HTTP v1 SA 마운트됨. 플레이스홀더 토큰은 skip | `core-backend/push.go`, `/admin/push-test` |
| 테마 | iMessage-inspired light + soft charcoal dark | `mobile/lib/theme/app_theme.dart` (`#007AFF` / canvas `#F2F2F7`·`#141418`) |
---
## N4-W0 — 제품 경계
| | |
|--|--|
| **Status** | **done** (본 문서 랜딩으로 경계 고정, 2026-08-04) |
| **목표** | 웹 고도화의 제품·기술 경계를 문서에 고정해 구현 범위 논쟁을 없앤다 |
| **비범위** | 코드 변경 없음 |
### 작업 분해
- [x] 데모/프리뷰 성공 정의 기록
- [x] Q9 / PoC §3 / Android N4 / Drift WASM 비범위 명시
- [x] Wi 순서·승인 규율 명시
- [x] README · roadmap · deploy-checklist · fcm-setup · mobile README 교차 링크
### 수락 기준
- [x] 본 파일 §0이 Master 결정 4항과 일치
- [x] 실행 ID N4-W*가 `deploy-checklist.md`에 존재
### Master 액션
- 없음 (문서 승인 = W0 완료)
### 위험
- “웹도 Android만큼”으로 범위가 커지면 W1에서 Drift WASM 등으로 비대화 → §0을 인용해 거절
### 터치 파일 (문서)
- `docs/web-upgrade.md` (본 파일)
- `README.md`, `docs/roadmap.md`, `docs/deploy-checklist.md`, `docs/fcm-setup.md`, `mobile/README.md`
---
## N4-W1 — 웹 로컬 지속성
| | |
|--|--|
| **Status** | **done** (2026-08-04) |
| **목표** | 하드 리프레시 후에도 말투·온보딩·스누즈·install id가 유지되는 프리뷰 UX |
| **비범위** | SQLCipher/암호화 DB, Drift WASM, 서버 측 말투 동기화 |
### As-is
- `AppDatabase` 웹 구현이 in-memory `Map` (`app_database_web.dart`)
- 인증만 `SharedPreferences`에 남아 새로고침 후 “가입된 것처럼” 보이지만 말투 온보딩이 다시 뜰 수 있음
### To-be
- 웹 `AppDatabase`를 **durable KV**로 승격
- **기술 선택 (고정):** `shared_preferences`(또는 동일 idb 백엔드)로 KV·말투 JSON·스누즈 맵 직렬화. **Drift WASM 비채택**
- `encrypted` 플래그는 웹에서 계속 `false`; `DataFlowScreen` 카피에 “웹 프리뷰는 브라우저 저장소(비암호화)” 명시
### 작업 분해
- [x] `app_database_web.dart`: open 시 SharedPreferences 로드, mutate 시 persist
- [x] 키 네임스페이스: `ykavu_web_db_kv_*`, `ykavu_web_db_tone_samples`, `ykavu_web_db_snoozes`
- [x] `AppDatabase.memory()`는 비지속(테스트용) 유지 — `open()`만 durable
- [x] 단위 테스트: `mobile/test/web_db_persistence_test.dart` (mock prefs round-trip)
- [x] `DataFlowScreen` 웹 카피 갱신
### 수락 기준
- [x] 단위 테스트: open → 저장 → 재 open 시 말투·KV·스누즈 복원
- [ ] Chrome 라이브 하드 리프레시 스모크 (배포 후 Master/에이전트 1회)
- [x] 시크릿 창(다른 프로필)과는 저장소 격리 — 브라우저 프로필 단위 SharedPreferences (기대 동작)
### Master 액션
- 승인 후 구현 착수; 별도 시크릿 없음
### 위험
- prefs 용량·JSON 파싱 오류 시 빈 DB로 폴백 (corrupt 시 wipe + 로그)
- 여러 탭 동시 기록 → last-write-wins 허용 (프리뷰)
### 예상 터치 파일
- `mobile/lib/db/app_database_web.dart`
- `mobile/lib/screens/data_flow_screen.dart` (카피)
- `mobile/test/` (웹 persistence 테스트, 선택)
- `docs/web-upgrade.md` Status → done
---
## N4-W2 — 데모 메신저 UX
| | |
|--|--|
| **Status** | **done** (2026-08-04) |
| **목표** | 데스크톱·모바일 브라우저에서 시연 마찰을 줄인 프리뷰 UX |
| **비범위** | 디자인 시스템 전면 교체, 카드/히어로 과설계, Q9 카피 |
### As-is
- 모바일 우선 단일 컬럼 스택; 대화 목록 ↔ 채팅 push
- `MyUserIdChip` 등 ID 노출은 있으나 페어링 CTA 발견성 편차
- WS 재연결 backoff는 구현됨, UI 인디케이터는 약함
### To-be
- **넓은 뷰포트(예: ≥900px):** 목록 | 채팅 **분할**(또는 동등 반응형). 좁은 폭은 기존 스택 유지
- WS 연결 상태: connecting / live / reconnecting(backoff) 한 줄 표시
- 내 사용자 ID **원탭 복사** CTA 강화 (대화 목록·설정)
- 웹 기대치 배너(첫 진입 또는 설정): 새로고침 후 데이터(W1 이후), 알림(W3/W4), 홈 화면 추가(W5)
- 시각: 기존 iMessage-inspired 테마 유지. 신규 카드 남발·히어로 오버레이 금지
### 작업 분해
- [x] 반응형 셸: `ConversationListScreen` ≥900px 목록|채팅 분할 · 좁은 폭 Navigator push
- [x] `ConversationSocket.linkStates` + 채팅 앱바/재연결 바
- [x] ID 복사: 목록 칩 + 설정 계정 행
- [x] 웹 전용 1회 dismissible 안내 (`WebPreviewBanner`)
- [ ] 수동: 1280px·390px 폭 시연 (배포 후)
### 수락 기준
- [x] 분할·스택 경로 코드 연결 (라이브 폭 스모크는 배포 후)
- [x] 재연결 중 「다시 연결 중…」 표시
- [x] 기존 `AppTheme` 유지
### Master 액션
- UX 톤 승인 (분할 vs 스택-only 미세 조정은 구현 PR에서 스크린샷)
### 위험
- Navigator 스택과 분할 레이아웃 충돌 → 웹만 `MasterDetail` 패턴으로 격리
### 예상 터치 파일
- `mobile/lib/main.dart`
- `mobile/lib/screens/conversation_list_screen.dart`
- `mobile/lib/screens/chat_screen.dart`
- `mobile/lib/services/ws_client.dart` (상태 노출 API)
- `mobile/lib/widgets/my_user_id_chip.dart` (또는 설정)
---
## N4-W3 — 인앱·브라우저 알림 대체 (Push 전)
| | |
|--|--|
| **Status** | **done** (2026-08-04) |
| **목표** | 웹에서 스누즈/에스컬레이션을 OS 로컬알림 없이 **인지 가능**하게 |
| **비범위** | FCM Web Push 본구현(W4), Android `flutter_local_notifications` 변경 |
### As-is
- `snooze_notification_service_web.dart` = no-op
- 인앱 배지/배너(C6c)는 동작 (포그라운드·리프레시 후 W1 전제)
### To-be (고정 우선순위)
1. **항상:** 인앱 배지·배너·Inbox (기존)
2. **권한 허용 시:** 브라우저 **Notification API**로 스누즈 마감·(선택) 포그라운드 에스컬레이션 토스트성 알림
3. 권한 거부/미지원: 인앱만 — 실패로 치지 않음
스케줄: 웹은 Service Worker 알람이 약하므로 **포그라운드 타이머 + 다음 포커스 시 past-due 검사**를 1차 경로로 한다. (백그라운드 확정 전달은 W4)
### 작업 분해
- [x] `snooze_notification_service_web.dart`: Notification API + in-tab Timer
- [x] 포그라운드 복귀 시 `notifyPastDueOnFocus` → Notification (허용 시)
- [x] 설정 → 「브라우저 알림」권한 요청
- [x] 거부 시 인앱 배지/배너 유지 (기존 C6c)
- [ ] 에스컬레이션 전용 포그라운드 SnackBar — Inbox/기존 배너로 충분, W4 푸시와 연계
### 수락 기준
- [x] 단위: past-due만 `showImmediate` 호출
- [x] 권한 거부 경로 문서화 (인앱 배지)
- [ ] 라이브 Chrome에서 알림 허용 후 스누즈 마감 스모크 (배포 후)
### Master 액션
- 브라우저에서 알림 허용 클릭 (시연 시)
### 위험
- Safari/iOS 웹 알림 제약 → Chrome/Desktop을 데모 기준 브라우저로 명시 (W6)
### 예상 터치 파일
- `mobile/lib/services/snooze_notification_service_web.dart`
- `mobile/lib/services/snooze_controller.dart`
- `mobile/lib/screens/chat_screen.dart` / `conversation_list_screen.dart`
- `mobile/lib/screens/settings_screen.dart` (선택)
---
## N4-W4 — Web Push (FCM)
| | |
|--|--|
| **Status** | **web-live** (2026-08-04) — `:web:` APP_ID·VAPID·SW 프로덕션 주입 완료 · **accept 대기:** 하드 리프레시→알림→로그인→실 토큰·`/admin/push-test` |
| **목표** | 웹 탭이 백그라운드여도 에스컬레이션 등 서버 `notifyUser`가 **실 FCM 웹 토큰**으로 전달 |
| **비범위** | 레거시 `FCM_SERVER_KEY` 신규 의존, 커스텀 푸시 프로토콜 |
### Shipped (프로덕션)
- `FirebaseWebConfig` + compose/`Dockerfile` build-args · SW template 치환
- `PushTokenService` 웹 분기 · `msn.iykyka.com``:web:` APP_ID·VAPID 주입·재배포됨
- 서버 SA(`secrets/firebase-service-account.json`) 공유 · 시크릿 git 미커밋
### Master 액션 (accept만)
1. Chrome에서 `https://msn.iykyka.com` **하드 리프레시** (또는 사이트 데이터 삭제)
2. 알림 **허용** → DEMO/계정 로그인
3. device_tokens에 `platform=web` 실 토큰 확인 후 `POST /admin/push-test``sent >= 1`
### 작업 분해
- [x] 빌드 주입: `--dart-define=FIREBASE_…` + SW template 치환 (gitignore/시크릿 미커밋)
- [x] `PushTokenService` 웹 분기 + permission
- [x] Docker web 빌드에 define 전달 (`mobile/Dockerfile` / compose)
- [x] 프로덕션 `.env``FIREBASE_*` / VAPID · `:web:` APP_ID · `web` 재빌드
- [ ] `/admin/push-test`로 user 지정 → Chrome 백그라운드 수신 (**브라우저 accept**)
- [x] `docs/fcm-setup.md` Web 절 동기화
- [x] `DataFlowScreen` 푸시 문구 갱신
### 수락 기준
- [ ] device_tokens에 `install:`이 아닌 토큰 + `platform=web` (**브라우저 로그인 후**)
- [ ] `POST /admin/push-test``sent >= 1`, 브라우저 알림 표시
- [x] 시크릿이 git history에 없음 (define/env만)
- [x] 프로덕션 SW populated (`firebase.initializeApp`, appId `:web:`)
### 위험
- 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` / `docker-compose.yml` / `.env.example`
- `docs/fcm-setup.md`, `docs/web-upgrade.md`
### 서버
- **코어 코드 변경 없음.** 기존 `notifyUser` / HTTP v1. 웹 토큰도 동일 messages:send
- 클라이언트 시크릿만 host `.env` → compose build-args
---
## N4-W5 — PWA
| | |
|--|--|
| **Status** | **done** (2026-08-04) — W4와 독립 선행 |
| **목표** | 홈 화면 추가·standalone으로 데모 가능; 오프라인은 **셸만** |
| **비범위** | 오프라인 메시지 작성/전송, 풀 오프라인 메신저 |
### As-is
- `mobile/web/manifest.json`: name/icons/`display:standalone` 있음
- `theme_color` / `background_color` = `#7C6FF0` (현 테마와 불일치)
- `index.html` description = 플레이스홀더 (“A new Flutter project.”)
- Flutter 빌드가 `flutter_service_worker.js` 생성 가능 — 운영 캐시 버스트 미문서화
### To-be
| 항목 | 값 |
|------|-----|
| theme / background | light canvas `#F2F2F7` 또는 accent `#007AFF` (택1, 구현 시 `AppTheme`와 맞춤) |
| description | 제품 한 줄: “나를 대신해 답하는, 나만의 와카뷰” |
| 오프라인 | 앱 셸 캐시만. API/WS는 온라인 필수 — 오프라인 시 명확한 배너(W2와 공유) |
| install | Chrome installability (manifest + SW + HTTPS + icons) |
### 작업 분해
- [x] `manifest.json` theme `#007AFF` / background `#F2F2F7` · orientation `any`
- [x] `index.html` description / theme-color / apple meta
- [x] nginx: `index.html` · `flutter_service_worker.js` · `manifest.json``Cache-Control: no-cache`
- [x] `OfflineBanner` (navigator.onLine)
- [ ] Chrome Application 패널 수동 확인 (배포 후)
### 수락 기준
- [x] manifest·index·캐시 헤더 코드 반영
- [x] 오프라인 시 배너 문구
- [ ] standalone 설치 시연 (배포 후 Master/테스터)
### Master 액션
- 배포 파이프라인에 Web Firebase define이 있으면 W4와 동일 시크릿 유지
- 컷오버 후 한 번 hard refresh / “Unregister SW” 런북 숙지
### 위험
- SW가 오래된 main.dart.js 제공 → 배포 체크리스트에 “웹 캐시 확인” 항목
### 예상 터치 파일
- `mobile/web/manifest.json`
- `mobile/web/index.html`
- `deploy/nginx-web.conf` (캐시 헤더, 필요 시)
- `docs/deploy-checklist.md` / `docs/tester-guide.md`
---
## N4-W6 — 테스터 운영 폴리시
| | |
|--|--|
| **Status** | **done** (2026-08-04) |
| **목표** | 웹 데모 스크립트가 설치·알림·페어링·로그아웃까지 재현 가능 |
| **비범위** | PoC 인터뷰 본실행, SHARE_URL(별도 N4-13) |
### 작업 분해
- [x] [`tester-guide.md`](./tester-guide.md) 웹 프리뷰 팁 · 페어링·캐시 런북
- [x] SW unregister / 강력 새로고침 안내
### 수락 기준
- [x] 가이드에 Chrome·PWA·알림·로그아웃 경로 명시
- [ ] Master walkthrough 1회 (운영)
### Master 액션
- 가이드 문구 톤 확인
### 예상 터치 파일
- `docs/tester-guide.md`
- `docs/web-upgrade.md`
---
## N4-W7 — 품질
| | |
|--|--|
| **Status** | **done** (2026-08-04, edge smoke) — Playwright UI는 후속 선택 |
| **목표** | 웹 회귀를 자동화·수동 체크리스트로 고정 |
| **비범위** | Android 에뮬레이터 UI 탭(N4-5~10), Q9 테스트 |
### 작업 분해
- [x] [`scripts/e2e_web_smoke.sh`](../scripts/e2e_web_smoke.sh) — health/demo/manifest/login-400
- [x] 부록 A 수동 QA 표 유지
- [ ] Playwright 브라우저 UI 스모크 — 선택 후속 (에이전트 환경 제약)
### 수락 기준
- [x] `./scripts/e2e_web_smoke.sh` 한 줄 실행
- [x] 부록 A가 W1~W5와 연결됨
### Master 액션
- 라이브 대상 스모크 시 DEMO 계정 충돌 주의 (display_name)
### 예상 터치 파일
- `scripts/` 또는 `mobile/integration_test/`
- `docs/web-upgrade.md` 부록
---
## 2. Android N4와의 관계
| 항목 | Android N4 | Web N4-W |
|------|------------|----------|
| 서버 SA | 공유 (이미 배치된 경우 유지) | 동일 |
| 클라이언트 설정 | `google-services.json` | Firebase Web config + VAPID |
| 푸시 스모크 | 실기기 | Chrome + `/admin/push-test` |
| 로컬 DB | SQLCipher | prefs/idb (비암호화 프리뷰) |
| 일정 | Master 실기기 가능 시 | **문서 승인 후 웹 Wi 우선 가능** |
병행 시 서버·compose 충돌 최소화: web 이미지 빌드 args만 추가.
---
## 3. 상태 갱신 규칙
Wi를 끝낼 때마다:
1. 본 문서 해당 Wi **Status**`done` (또는 `done (부분)`) + 날짜
2. [`deploy-checklist.md`](./deploy-checklist.md) N4-W 표 Status 동기화
3. [`roadmap.md`](./roadmap.md) Web `[~]` 체크 갱신
4. 구현 커밋과 **같은 변경**에 문서 체크를 넣음 (허위 done 금지)
---
## 부록 A — 웹 수동 QA 체크리스트 (구현 후)
**환경:** Desktop Chrome · `https://msn.iykyka.com` · 필요 시 시크릿 창 2개
| # | 단계 | W 의존 | OK |
|---|------|--------|-----|
| A1 | 새로 가입 (DEMO) + 말투 저장 | W1 | [ ] |
| A2 | 하드 리프레시 후 세션·말투 유지 | W1 | [ ] |
| A3 | 두 프로필 페어링 · 메시지 · 초안 · 뱃지 | W2 | [ ] |
| A4 | WS 끊김/재연결 상태 인지 | W2 | [ ] |
| A5 | 스누즈 → 인앱 배지 (+ 알림 허용 시 Notification) | W3 | [ ] |
| A6 | 설정 → 로그아웃 → 이미 가입 로그인 | Q8 | [ ] |
| A7 | 실 FCM 웹 토큰 · push-test 수신 | W4 | [ ] |
| A8 | PWA 설치 · standalone 시연 | W5 | [ ] |
| A9 | 오프라인 시 연결 필요 안내 | W5 | [ ] |
---
## 부록 B — Master 시크릿·accept 체크리스트 (W4)
- [x] Firebase Web app 등록 (도메인 `msn.iykyka.com`, APP_ID `:web:`)
- [x] VAPID key
- [x] 서버 `~/project/ykavu/.env``FIREBASE_*` / `FIREBASE_VAPID_KEY` (git 미포함) → `web` 재빌드
- [x] 서버 `secrets/firebase-service-account.json` 유지 (Android와 공유)
- [ ] 하드 리프레시·알림 허용·로그인 후 push-test로 `sent >= 1` 확인
---
## 부록 C — 관련 문서
| 문서 | 관계 |
|------|------|
| [`deploy-checklist.md`](./deploy-checklist.md) | N4-W 실행 표 |
| [`fcm-setup.md`](./fcm-setup.md) | Android + Web 시크릿 절차 |
| [`tester-guide.md`](./tester-guide.md) | 테스터 스크립트 (W6) |
| [`account-settings-ia.md`](./account-settings-ia.md) | Q8 완료 · Q9 비범위 |
| [`roadmap.md`](./roadmap.md) | Phase 체크 |
| [`android-release.md`](./android-release.md) | 별트랙 (본 문서 비범위) |

View File

@ -1,37 +1,17 @@
# Flutter Web → nginx. Build from repository root: # Flutter Web → nginx. Build from repository root:
# docker build -f mobile/Dockerfile --build-arg CORE_API_BASE=https://msn.iykyka.com . # 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).
#
# Secrets are copied into ENV for the build stage only (not the final nginx image).
# The RUN line calls a script so values are not expanded into Docker build logs.
ARG FLUTTER_IMAGE=ghcr.io/cirruslabs/flutter:3.32.7 ARG FLUTTER_IMAGE=ghcr.io/cirruslabs/flutter:3.32.7
FROM ${FLUTTER_IMAGE} AS build FROM ${FLUTTER_IMAGE} AS build
WORKDIR /app WORKDIR /app
ARG CORE_API_BASE=https://msn.iykyka.com 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=
ENV CORE_API_BASE=${CORE_API_BASE} \
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}
COPY mobile/pubspec.yaml mobile/pubspec.lock ./ COPY mobile/pubspec.yaml mobile/pubspec.lock ./
RUN flutter pub get RUN flutter pub get
COPY mobile/ . COPY mobile/ .
RUN chmod +x scripts/docker_build_web.sh && ./scripts/docker_build_web.sh RUN flutter build web --release \
--dart-define=CORE_API_BASE=${CORE_API_BASE}
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
COPY --from=build /app/build/web /usr/share/nginx/html COPY --from=build /app/build/web /usr/share/nginx/html

View File

@ -1,6 +1,5 @@
# Optional: serve a host-built Flutter web tree (faster CI/agent smoke). # 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 . # docker build -f mobile/Dockerfile.prebuilt -t ykavu-web .
# Production/Portainer should prefer mobile/Dockerfile (multi-stage flutter build). # Production/Portainer should prefer mobile/Dockerfile (multi-stage flutter build).
FROM nginx:1.27-alpine FROM nginx:1.27-alpine

View File

@ -1,9 +1,7 @@
# 와카뷰 mobile (Flutter) # 와카뷰 mobile (Flutter)
Phase 1 클라이언트 (`docs/roadmap.md` §2.3 / A3). Phase 1 클라이언트 (`docs/roadmap.md` §2.3 / A3). **v1은 Android 빌드만** 대상으로 한다
**릴리즈 타깃(암호화 DB·OS 알림):** Android. (`docs/tech-design.md` §8).
**데모/프리뷰 1차 표면:** Flutter **Web** (`https://msn.iykyka.com`) —
고도화 설계·단계: [`docs/web-upgrade.md`](../docs/web-upgrade.md) (W0~W7).
## 지금 있는 것 ## 지금 있는 것
@ -65,29 +63,14 @@ HTTP로 검증한다.
sudo apt-get install -y libsqlite3-dev libsqlcipher1 sudo apt-get install -y libsqlite3-dev libsqlcipher1
``` ```
## 웹 (데모/프리뷰)
- 로컬 DB: `lib/db/app_database_web.dart`**W1 done**: SharedPreferences 지속성(비암호화 프리뷰)
- 스누즈 OS 알림: 웹 no-op → **W3** Notification API / 인앱
- 푸시: 프로덕션은 `FIREBASE_*` + VAPID 주입됨 · 로컬은 dart-define 없으면 `install:` ([`docs/fcm-setup.md`](../docs/fcm-setup.md) Web 절)
- PWA: **W5 done** — manifest 테마·설치·셸 캐시
- **Q9**(수신 자동응대)는 웹 트랙 비범위
```bash
# 웹 로컬 (core-backend 기동 후)
cd mobile && flutter run -d chrome --dart-define=CORE_API_BASE=http://localhost:8080
```
## 푸시 (FCM) ## 푸시 (FCM)
- 코드: `lib/services/push_token_service.dart` — Firebase 가능 시 실 토큰, 아니면 `install:` - 코드: `lib/services/push_token_service.dart` — Firebase 가능 시 실 토큰, 아니면 `install:`
- Android: [`docs/fcm-setup.md`](../docs/fcm-setup.md) (`google-services.json`) - Master 설정: [`docs/fcm-setup.md`](../docs/fcm-setup.md) (`google-services.json` + `FCM_SERVER_KEY`)
- Web: [`docs/web-upgrade.md`](../docs/web-upgrade.md) W4 + 동일 `fcm-setup` Web 절
## 아직 없는 것 ## 아직 없는 것
- 웹 W4 **accept** — 브라우저 실 FCM 토큰·`/admin/push-test` (코드·`:web:` 배포는 완료, [`web-upgrade.md`](../docs/web-upgrade.md)) - Master Firebase 시크릿 주입 후 실기기 푸시 스모크 (N4-1/3/4)
- Android: `google-services.json` PC 배치 후 실기기 푸시 스모크 (N4-1/4; 서버 SA는 N4-3 done) - 오프라인 메시지 큐 / 멀티디바이스 실시간 설정 동기화 고도화
- 멀티디바이스 실시간 설정 동기화 고도화
- 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영) - 온보딩 말투 UX 디테일 (PoC #1 결과는 맨 마지막에 반영)
- iOS 빌드 (v1 범위 밖) - iOS 빌드 (v1 범위 밖)

View File

@ -1,64 +0,0 @@
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

@ -1,125 +1,32 @@
import 'dart:convert'; /// Web/Chrome stub for [AppDatabase].
import 'package:shared_preferences/shared_preferences.dart';
/// Web/Chrome [AppDatabase] backed by [SharedPreferences] (IndexedDB under the hood).
/// ///
/// SQLCipher / dart:ffi cannot compile for Flutter Web. Persistence is /// SQLCipher / dart:ffi cannot compile for Flutter Web. Chrome runs use an
/// **not encrypted** acceptable for the demo/preview surface /// in-tab memory map so UI + API flows still work. Persistence is not encrypted
/// (`docs/web-upgrade.md` N4-W1). Drift WASM is intentionally not used. /// and is lost on refresh (web is a preview surface, not the release target).
///
/// [AppDatabase.memory] stays ephemeral (unit tests / fallbacks).
class AppDatabase { class AppDatabase {
AppDatabase({ AppDatabase({this.encrypted = false});
this.encrypted = false,
SharedPreferences? prefs,
bool persist = false,
}) : _prefs = prefs,
_persist = persist && prefs != null;
/// Always false on web there is no SQLCipher path. /// Always false on web there is no SQLCipher path.
final bool encrypted; final bool encrypted;
final SharedPreferences? _prefs;
final bool _persist;
final Map<String, String> _kv = {}; final Map<String, String> _kv = {};
List<String> _toneSamples = []; List<String> _toneSamples = [];
final Map<int, DateTime> _snoozes = {}; final Map<int, DateTime> _snoozes = {};
static const _kTone = 'ykavu_web_db_tone_samples'; factory AppDatabase.memory() => AppDatabase(encrypted: false);
static const _kSnoozes = 'ykavu_web_db_snoozes';
static const _kvPrefix = 'ykavu_web_db_kv_';
factory AppDatabase.memory() => AppDatabase(encrypted: false, persist: false); static Future<AppDatabase> open() async => AppDatabase(encrypted: false);
/// Opens a durable preview DB (survives hard refresh in the same browser profile).
static Future<AppDatabase> open() async {
final prefs = await SharedPreferences.getInstance();
final db = AppDatabase(encrypted: false, prefs: prefs, persist: true);
await db._hydrate();
return db;
}
Future<void> _hydrate() async {
final prefs = _prefs;
if (!_persist || prefs == null) return;
try {
final toneRaw = prefs.getString(_kTone);
if (toneRaw != null && toneRaw.isNotEmpty) {
final decoded = jsonDecode(toneRaw);
if (decoded is List) {
_toneSamples = decoded.map((e) => e.toString()).toList();
}
}
} catch (_) {
_toneSamples = [];
await prefs.remove(_kTone);
}
try {
final snoozeRaw = prefs.getString(_kSnoozes);
if (snoozeRaw != null && snoozeRaw.isNotEmpty) {
final decoded = jsonDecode(snoozeRaw);
if (decoded is Map) {
_snoozes
..clear()
..addEntries(
decoded.entries.map((e) {
final id = int.tryParse(e.key.toString());
final ms = e.value is int
? e.value as int
: int.tryParse(e.value.toString());
if (id == null || ms == null) return null;
return MapEntry(id, DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true));
}).whereType<MapEntry<int, DateTime>>(),
);
}
}
} catch (_) {
_snoozes.clear();
await prefs.remove(_kSnoozes);
}
for (final key in prefs.getKeys()) {
if (!key.startsWith(_kvPrefix)) continue;
final logical = key.substring(_kvPrefix.length);
final value = prefs.getString(key);
if (value != null) _kv[logical] = value;
}
}
Future<void> _persistTone() async {
if (!_persist || _prefs == null) return;
await _prefs.setString(_kTone, jsonEncode(_toneSamples));
}
Future<void> _persistSnoozes() async {
if (!_persist || _prefs == null) return;
final map = <String, int>{
for (final e in _snoozes.entries) e.key.toString(): e.value.toUtc().millisecondsSinceEpoch,
};
await _prefs.setString(_kSnoozes, jsonEncode(map));
}
Future<void> _persistKv(String key, String value) async {
if (!_persist || _prefs == null) return;
await _prefs.setString('$_kvPrefix$key', value);
}
Future<List<String>> loadToneSamples() async => List<String>.from(_toneSamples); Future<List<String>> loadToneSamples() async => List<String>.from(_toneSamples);
Future<void> replaceToneSamples(List<String> samples) async { Future<void> replaceToneSamples(List<String> samples) async {
_toneSamples = List<String>.from(samples); _toneSamples = List<String>.from(samples);
await _persistTone();
} }
Future<String?> getKv(String key) async => _kv[key]; Future<String?> getKv(String key) async => _kv[key];
Future<void> setKv(String key, String value) async { Future<void> setKv(String key, String value) async {
_kv[key] = value; _kv[key] = value;
await _persistKv(key, value);
} }
Future<bool> getBoolKv(String key, {bool defaultValue = false}) async { Future<bool> getBoolKv(String key, {bool defaultValue = false}) async {
@ -132,17 +39,16 @@ class AppDatabase {
await setKv(key, value ? '1' : '0'); await setKv(key, value ? '1' : '0');
} }
/// roadmap.md §2.7-F on-device snooze; web preview persists via SharedPreferences. // roadmap.md §2.7-F native stub과
// (in-memory, encrypted persistence web은 ).
Future<void> setSnoozedUntil(int conversationId, DateTime until) async { Future<void> setSnoozedUntil(int conversationId, DateTime until) async {
_snoozes[conversationId] = until.toUtc(); _snoozes[conversationId] = until;
await _persistSnoozes();
} }
Future<DateTime?> getSnoozedUntil(int conversationId) async => _snoozes[conversationId]; Future<DateTime?> getSnoozedUntil(int conversationId) async => _snoozes[conversationId];
Future<void> clearSnooze(int conversationId) async { Future<void> clearSnooze(int conversationId) async {
_snoozes.remove(conversationId); _snoozes.remove(conversationId);
await _persistSnoozes();
} }
Future<Map<int, DateTime>> loadAllSnoozes() async => Map<int, DateTime>.from(_snoozes); Future<Map<int, DateTime>> loadAllSnoozes() async => Map<int, DateTime>.from(_snoozes);

View File

@ -19,7 +19,6 @@ class ChatScreen extends StatefulWidget {
this.title, this.title,
this.isGroup = false, this.isGroup = false,
this.twinDisabledByFlood = false, this.twinDisabledByFlood = false,
this.embedded = false,
}); });
final int conversationId; final int conversationId;
@ -30,9 +29,6 @@ class ChatScreen extends StatefulWidget {
// . // .
final bool twinDisabledByFlood; final bool twinDisabledByFlood;
/// When true, shown inside the wide master-detail pane (N4-W2) no back affordance.
final bool embedded;
@override @override
State<ChatScreen> createState() => _ChatScreenState(); State<ChatScreen> createState() => _ChatScreenState();
} }
@ -50,8 +46,6 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
// , ( , // , ( ,
// OS가 ) QA . // OS가 ) QA .
StreamSubscription? _reconnectSub; StreamSubscription? _reconnectSub;
StreamSubscription? _linkSub;
WsLinkState _linkState = WsLinkState.connecting;
String? _banner; String? _banner;
DraftResult? _pendingDraft; DraftResult? _pendingDraft;
bool _busy = false; bool _busy = false;
@ -76,15 +70,10 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
_banner = '도배 감지로 이 대화방의 와카뷰 자동응대가 일시중단되었습니다.'; _banner = '도배 감지로 이 대화방의 와카뷰 자동응대가 일시중단되었습니다.';
} }
_socket = ConversationSocket(widget.conversationId)..connect(); _socket = ConversationSocket(widget.conversationId)..connect();
_linkState = _socket!.linkState;
_sub = _socket!.events.listen(_onEvent); _sub = _socket!.events.listen(_onEvent);
// (, hiccup ) // (, hiccup )
// REST since_id로 ws_client.dart . // REST since_id로 ws_client.dart .
_reconnectSub = _socket!.reconnects.listen((_) => _catchUp()); _reconnectSub = _socket!.reconnects.listen((_) => _catchUp());
_linkSub = _socket!.linkStates.listen((s) {
if (!mounted) return;
setState(() => _linkState = s);
});
_loadHistory(); _loadHistory();
_loadSnooze(); _loadSnooze();
} }
@ -96,9 +85,6 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
// ). OS가 QA . // ). OS가 QA .
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
_catchUp(); _catchUp();
// N4-W3 past-due snooze Notification on web focus (badges already refresh).
context.read<SessionState>().snoozeController?.notifyPastDueOnFocus();
_loadSnooze();
} }
} }
@ -313,7 +299,6 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_sub?.cancel(); _sub?.cancel();
_reconnectSub?.cancel(); _reconnectSub?.cancel();
_linkSub?.cancel();
_socket?.dispose(); _socket?.dispose();
_input.dispose(); _input.dispose();
_draftEdit.dispose(); _draftEdit.dispose();
@ -664,22 +649,7 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
final theme = Theme.of(context); final theme = Theme.of(context);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: !widget.embedded, title: Text(widget.title ?? '대화방 #${widget.conversationId}'),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.title ?? '대화방 #${widget.conversationId}'),
Text(
wsLinkStateLabel(_linkState),
style: theme.textTheme.labelSmall?.copyWith(
color: _linkState == WsLinkState.live
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
],
),
actions: [ actions: [
if (widget.isGroup) if (widget.isGroup)
IconButton( IconButton(
@ -703,22 +673,6 @@ class _ChatScreenState extends State<ChatScreen> with WidgetsBindingObserver {
), ),
body: Column( body: Column(
children: [ children: [
if (_linkState == WsLinkState.reconnecting)
Material(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.9),
child: SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Text(
'연결이 끊겼습니다. 다시 연결하는 중…',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
),
if (_banner != null) if (_banner != null)
MaterialBanner( MaterialBanner(
leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant), leading: Icon(Icons.info_outline, color: theme.colorScheme.onSurfaceVariant),

View File

@ -7,16 +7,11 @@ import '../services/snooze_service.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import '../widgets/gradient_text.dart'; import '../widgets/gradient_text.dart';
import '../widgets/my_user_id_chip.dart'; import '../widgets/my_user_id_chip.dart';
import '../widgets/offline_banner.dart';
import '../widgets/web_preview_banner.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
import 'contacts_screen.dart'; import 'contacts_screen.dart';
import 'inbox_screen.dart'; import 'inbox_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
/// Breakpoint for list | chat master-detail (docs/web-upgrade.md N4-W2).
const double kMessengerSplitBreakpoint = 900;
class ConversationListScreen extends StatefulWidget { class ConversationListScreen extends StatefulWidget {
const ConversationListScreen({super.key}); const ConversationListScreen({super.key});
@ -24,8 +19,7 @@ class ConversationListScreen extends StatefulWidget {
State<ConversationListScreen> createState() => _ConversationListScreenState(); State<ConversationListScreen> createState() => _ConversationListScreenState();
} }
class _ConversationListScreenState extends State<ConversationListScreen> class _ConversationListScreenState extends State<ConversationListScreen> {
with WidgetsBindingObserver {
List<ConversationSummary> _rooms = []; List<ConversationSummary> _rooms = [];
Map<int, String> _peerNames = {}; Map<int, String> _peerNames = {};
// (roadmap.md §2.7-F) id별 "이따 답장" . // (roadmap.md §2.7-F) id별 "이따 답장" .
@ -34,39 +28,12 @@ class _ConversationListScreenState extends State<ConversationListScreen>
bool _loading = true; bool _loading = true;
String? _error; String? _error;
/// Wide-layout selection (narrow width still uses Navigator.push).
int? _selectedId;
String? _selectedTitle;
bool _selectedIsGroup = false;
bool _selectedFlood = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
_load(); _load();
} }
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// N4-W3 tab/app focus: refresh list badges + optional web Notification.
if (state == AppLifecycleState.resumed) {
_onFocus();
}
}
Future<void> _onFocus() async {
final controller = context.read<SessionState>().snoozeController;
await controller?.notifyPastDueOnFocus();
if (mounted) await _load();
}
Future<void> _load() async { Future<void> _load() async {
final session = context.read<SessionState>(); final session = context.read<SessionState>();
setState(() { setState(() {
@ -111,39 +78,6 @@ class _ConversationListScreenState extends State<ConversationListScreen>
setState(() => _snoozes.remove(conversationId)); setState(() => _snoozes.remove(conversationId));
} }
bool get _useSplit {
final width = MediaQuery.sizeOf(context).width;
return width >= kMessengerSplitBreakpoint;
}
Future<void> _openChat({
required int conversationId,
required String title,
required bool isGroup,
bool twinDisabledByFlood = false,
}) async {
if (_useSplit) {
setState(() {
_selectedId = conversationId;
_selectedTitle = title;
_selectedIsGroup = isGroup;
_selectedFlood = twinDisabledByFlood;
});
return;
}
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatScreen(
conversationId: conversationId,
title: title,
isGroup: isGroup,
twinDisabledByFlood: twinDisabledByFlood,
),
),
);
if (mounted) await _load();
}
String _titleFor(ConversationSummary room, int? me) { String _titleFor(ConversationSummary room, int? me) {
if (me == null) return '대화방 #${room.id}'; if (me == null) return '대화방 #${room.id}';
final peers = room.userIds.where((id) => id != me).toList(); final peers = room.userIds.where((id) => id != me).toList();
@ -270,13 +204,16 @@ class _ConversationListScreenState extends State<ConversationListScreen>
isGroup: isGroup, isGroup: isGroup,
); );
if (!mounted) return; if (!mounted) return;
await _load(); await Navigator.of(context).push(
if (!mounted) return; MaterialPageRoute(
await _openChat( builder: (_) => ChatScreen(
conversationId: conv.id, conversationId: conv.id,
title: isGroup ? '그룹 #${conv.id}' : (_peerNames[peers.first] ?? '상대 #${peers.first}'), title: isGroup ? '그룹 #${conv.id}' : (_peerNames[peers.first] ?? '상대 #${peers.first}'),
isGroup: isGroup, isGroup: isGroup,
),
),
); );
await _load();
} on ApiException catch (e) { } on ApiException catch (e) {
setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}'); setState(() => _error = '대화 생성 실패 (${e.statusCode}): ${e.body}');
} }
@ -303,9 +240,8 @@ class _ConversationListScreenState extends State<ConversationListScreen>
final session = context.watch<SessionState>(); final session = context.watch<SessionState>();
final theme = Theme.of(context); final theme = Theme.of(context);
final me = session.user?.id; final me = session.user?.id;
final split = _useSplit;
final listPane = Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: GradientText('와카뷰', style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)), title: GradientText('와카뷰', style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800)),
actions: [ actions: [
@ -342,130 +278,95 @@ class _ConversationListScreenState extends State<ConversationListScreen>
tooltip: '새 대화', tooltip: '새 대화',
child: const Icon(Icons.chat_outlined), child: const Icon(Icons.chat_outlined),
), ),
body: Column( body: RefreshIndicator(
children: [ onRefresh: _load,
const OfflineBanner(), child: _loading
const WebPreviewBanner(), ? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())])
Expanded( : ListView(
child: RefreshIndicator( padding: const EdgeInsets.symmetric(vertical: 4),
onRefresh: _load, children: [
child: _loading Padding(
? ListView(children: const [SizedBox(height: 160), Center(child: CircularProgressIndicator())]) padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
: ListView( child: Text(
padding: const EdgeInsets.symmetric(vertical: 4), session.user == null ? '' : '안녕하세요, ${session.user!.displayName}',
children: [ style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant),
Padding( ),
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), ),
child: Text( if (me != null)
session.user == null ? '' : '안녕하세요, ${session.user!.displayName}', Padding(
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
), child: MyUserIdChip(userId: me),
), ),
if (me != null) if (_error != null)
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: MyUserIdChip(userId: me), child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
), ),
if (_error != null) if (_rooms.isEmpty)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.fromLTRB(32, 48, 32, 32),
child: Text(_error!, style: TextStyle(color: theme.colorScheme.error)), child: Column(
), children: [
if (_rooms.isEmpty) Text('대화방이 없습니다', style: theme.textTheme.titleMedium),
Padding( const SizedBox(height: 8),
padding: const EdgeInsets.fromLTRB(32, 48, 32, 32), Text(
child: Column( '1) 내 ID를 상대에게 알려 주세요\n'
children: [ '2) 연락처에 상대의 숫자 ID를 넣고 추가\n'
Text('대화방이 없습니다', style: theme.textTheme.titleMedium), '3) 연락처에서 「대화」또는 행을 탭하세요',
const SizedBox(height: 8), textAlign: TextAlign.center,
Text( style: theme.textTheme.bodySmall?.copyWith(
'1) 아래 「내 사용자 ID」를 탭해 복사한 뒤 상대에게 알려 주세요\n' color: theme.colorScheme.onSurfaceVariant,
'2) 연락처에 상대의 숫자 ID를 넣고 추가\n' height: 1.5,
'3) 연락처에서 「대화」또는 행을 탭하세요',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
),
const SizedBox(height: 18),
OutlinedButton(
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ContactsScreen()),
);
await _load();
},
child: const Text('연락처 열기'),
),
],
), ),
), ),
for (final room in _rooms) const SizedBox(height: 18),
Builder( OutlinedButton(
builder: (context) { onPressed: () async {
final snoozedUntil = _snoozes[room.id]; await Navigator.of(context).push(
final snoozePastDue = isSnoozePastDue(DateTime.now(), snoozedUntil); MaterialPageRoute(builder: (_) => const ContactsScreen()),
return _ConversationRow( );
room: room, await _load();
me: me, },
title: _titleFor(room, me), child: const Text('연락처 열기'),
subtitle: _subtitleFor(room, me), ),
avatarColor: _avatarColor(context, room.id), ],
onAvatarColor: _onAvatarColor(context, room.id), ),
snoozePastDue: snoozePastDue, ),
selected: split && _selectedId == room.id, for (final room in _rooms)
onClearSnooze: () => _clearSnoozeFromList(room.id), Builder(
onTap: () => _openChat( builder: (context) {
final snoozedUntil = _snoozes[room.id];
final snoozePastDue = isSnoozePastDue(DateTime.now(), snoozedUntil);
return _ConversationRow(
room: room,
me: me,
title: _titleFor(room, me),
subtitle: _subtitleFor(room, me),
avatarColor: _avatarColor(context, room.id),
onAvatarColor: _onAvatarColor(context, room.id),
snoozePastDue: snoozePastDue,
onClearSnooze: () => _clearSnoozeFromList(room.id),
onTap: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChatScreen(
conversationId: room.id, conversationId: room.id,
title: _titleFor(room, me), title: _titleFor(room, me),
isGroup: room.isGroup, isGroup: room.isGroup,
twinDisabledByFlood: room.twinDisabledByFlood, twinDisabledByFlood: room.twinDisabledByFlood,
), ),
); ),
}, );
), await _load();
const SizedBox(height: 72), },
], );
},
), ),
), const SizedBox(height: 72),
), ],
], ),
), ),
); );
if (!split) return listPane;
return Row(
children: [
SizedBox(
width: 360,
child: listPane,
),
VerticalDivider(width: 1, thickness: 1, color: theme.dividerColor),
Expanded(
child: _selectedId == null
? Scaffold(
body: Center(
child: Text(
'왼쪽에서 대화를 선택하세요',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
)
: ChatScreen(
key: ValueKey(_selectedId),
conversationId: _selectedId!,
title: _selectedTitle,
isGroup: _selectedIsGroup,
twinDisabledByFlood: _selectedFlood,
embedded: true,
),
),
],
);
} }
} }
@ -480,7 +381,6 @@ class _ConversationRow extends StatelessWidget {
required this.snoozePastDue, required this.snoozePastDue,
required this.onClearSnooze, required this.onClearSnooze,
required this.onTap, required this.onTap,
this.selected = false,
}); });
final ConversationSummary room; final ConversationSummary room;
@ -490,7 +390,6 @@ class _ConversationRow extends StatelessWidget {
final Color avatarColor; final Color avatarColor;
final Color onAvatarColor; final Color onAvatarColor;
final bool snoozePastDue; final bool snoozePastDue;
final bool selected;
final VoidCallback onClearSnooze; final VoidCallback onClearSnooze;
final VoidCallback onTap; final VoidCallback onTap;
@ -499,7 +398,7 @@ class _ConversationRow extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final blockedColor = room.twinDisabledByPeer || room.twinDisabledByFlood; final blockedColor = room.twinDisabledByPeer || room.twinDisabledByFlood;
return Material( return Material(
color: selected ? theme.colorScheme.primary.withValues(alpha: 0.08) : Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
child: Padding( child: Padding(
@ -522,10 +421,7 @@ class _ConversationRow extends StatelessWidget {
children: [ children: [
Text( Text(
title, title,
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
fontWeight: FontWeight.w600,
color: selected ? theme.colorScheme.primary : null,
),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Row( Row(

View File

@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -75,9 +74,7 @@ class DataFlowScreen extends StatelessWidget {
samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '), samples.isEmpty ? '(아직 없음 — 말투 샘플 화면에서 추가)' : samples.take(3).join(' · '),
session.localDbEncrypted session.localDbEncrypted
? '저장: drift + SQLCipher(암호화)' ? '저장: drift + SQLCipher(암호화)'
: kIsWeb : '저장: 메모리/웹 스텁(이 환경에 SQLCipher 없음 — Chrome·Linux 폴백)',
? '저장: 브라우저 저장소(비암호화 · 웹 프리뷰 — 같은 프로필에서는 새로고침 후에도 유지)'
: '저장: 메모리 폴백(이 환경에 SQLCipher 없음 — Linux CI 등)',
].join('\n'), ].join('\n'),
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
@ -93,8 +90,7 @@ class DataFlowScreen extends StatelessWidget {
body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n' body: '초안이 필요할 때: 최근 대화 몇 줄 + 말투 샘플 일부(최소 컨텍스트)\n'
'채팅 릴레이: 보낸 메시지 본문\n' '채팅 릴레이: 보낸 메시지 본문\n'
'계정: 표시 이름·초대 코드·세션 토큰\n' '계정: 표시 이름·초대 코드·세션 토큰\n'
'푸시: Android는 google-services.json, 웹은 FIREBASE_*+VAPID 빌드 주입 시 실 FCM 토큰 등록 · ' '푸시: Firebase 연결 시 실 FCM 토큰 등록 · 미연결 시 install 플레이스홀더 (docs/fcm-setup.md)',
'미연결 시 install 플레이스홀더 (docs/fcm-setup.md · web-upgrade W4)',
), ),
_section( _section(
context, context,

View File

@ -8,17 +8,6 @@ import '../state/session_state.dart';
class SessionsScreen extends StatefulWidget { class SessionsScreen extends StatefulWidget {
const SessionsScreen({super.key}); const SessionsScreen({super.key});
/// Formats API `expires_at` (UTC RFC3339) for the device local timezone.
static String formatExpiresAtLocal(Object? raw) {
if (raw == null) return '';
final parsed = DateTime.tryParse(raw.toString());
if (parsed == null) return raw.toString();
final local = parsed.toLocal();
String two(int n) => n.toString().padLeft(2, '0');
return '${local.year}-${two(local.month)}-${two(local.day)} '
'${two(local.hour)}:${two(local.minute)}';
}
@override @override
State<SessionsScreen> createState() => _SessionsScreenState(); State<SessionsScreen> createState() => _SessionsScreenState();
} }
@ -115,10 +104,7 @@ class _SessionsScreenState extends State<SessionsScreen> {
s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}', s['is_current'] == true ? '이 기기 (현재)' : '세션 #${s['id']}',
style: theme.textTheme.titleSmall, style: theme.textTheme.titleSmall,
), ),
subtitle: Text( subtitle: Text('만료: ${s['expires_at'] ?? ''}', style: theme.textTheme.bodySmall),
'만료: ${SessionsScreen.formatExpiresAtLocal(s['expires_at'])}',
style: theme.textTheme.bodySmall,
),
trailing: IconButton( trailing: IconButton(
tooltip: '세션 종료', tooltip: '세션 종료',
icon: const Icon(Icons.logout), icon: const Icon(Icons.logout),

View File

@ -1,9 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../services/snooze_notification_service.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
import 'autonomy_settings_screen.dart'; import 'autonomy_settings_screen.dart';
import 'data_flow_screen.dart'; import 'data_flow_screen.dart';
@ -68,19 +65,11 @@ class SettingsScreen extends StatelessWidget {
), ),
title: Text(user.displayName), title: Text(user.displayName),
subtitle: Text( subtitle: Text(
'user #${user.id} · 탭하여 ID 복사', 'user #${user.id}',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
), ),
trailing: const Icon(Icons.copy_rounded, size: 18),
onTap: () async {
await Clipboard.setData(ClipboardData(text: '${user.id}'));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('내 사용자 ID ${user.id} 를 복사했습니다')),
);
},
), ),
const Divider(height: 1), const Divider(height: 1),
ListTile( ListTile(
@ -129,26 +118,6 @@ class SettingsScreen extends StatelessWidget {
); );
}, },
), ),
if (kIsWeb)
ListTile(
leading: const Icon(Icons.notifications_active_outlined),
title: const Text('브라우저 알림'),
subtitle: const Text('답장 마감 리마인드 (허용 시)'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
final ok = await LocalSnoozeNotificationScheduler().ensurePermission();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
ok
? '브라우저 알림이 허용되었습니다'
: '알림이 거부되었거나 지원되지 않습니다. 인앱 배지는 그대로 동작합니다.',
),
),
);
},
),
const Divider(height: 24), const Divider(height: 24),
ListTile( ListTile(
leading: Icon(Icons.logout, color: theme.colorScheme.error), leading: Icon(Icons.logout, color: theme.colorScheme.error),

View File

@ -4,20 +4,13 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../config/firebase_web_config.dart';
/// Resolves a device push token for core-backend registration. /// Resolves a device push token for core-backend registration.
/// ///
/// Prefer a real FCM registration token when Firebase is configured /// Prefer a real FCM registration token when Firebase is configured
/// (`google-services.json` on Android, dart-define Web config + VAPID on web). /// (`google-services.json` on Android). Otherwise fall back to a stable
/// Otherwise fall back to a stable `install:` placeholder that the server /// `install:` placeholder that the server intentionally skips for delivery.
/// intentionally skips for delivery.
class PushTokenService { class PushTokenService {
PushTokenService({ PushTokenService({this.storeInstallId, this.readInstallId});
this.storeInstallId,
this.readInstallId,
FirebaseWebConfig? webConfig,
}) : webConfig = webConfig ?? FirebaseWebConfig.fromEnvironment();
/// Persist a newly generated install id (KV / secure storage). /// Persist a newly generated install id (KV / secure storage).
final Future<void> Function(String installId)? storeInstallId; final Future<void> Function(String installId)? storeInstallId;
@ -25,9 +18,6 @@ class PushTokenService {
/// Load a previously stored install id. /// Load a previously stored install id.
final Future<String?> Function()? readInstallId; final Future<String?> Function()? readInstallId;
/// Injected for tests; production uses [FirebaseWebConfig.fromEnvironment].
final FirebaseWebConfig webConfig;
Future<({String token, String platform, bool isFcm})> resolve() async { Future<({String token, String platform, bool isFcm})> resolve() async {
final platform = _platformLabel(); final platform = _platformLabel();
final fcm = await _tryFirebaseToken(); final fcm = await _tryFirebaseToken();
@ -39,9 +29,8 @@ class PushTokenService {
} }
Future<String?> _tryFirebaseToken() async { Future<String?> _tryFirebaseToken() async {
if (kIsWeb) { // Web push needs a separate Firebase web config + VAPID; skip until provided.
return _tryFirebaseWebToken(); if (kIsWeb) return null;
}
if (defaultTargetPlatform != TargetPlatform.android && if (defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS) { defaultTargetPlatform != TargetPlatform.iOS) {
return null; return null;
@ -61,31 +50,6 @@ 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 { Future<String> _ensureInstallId() async {
final existing = await readInstallId?.call(); final existing = await readInstallId?.call();
if (existing != null && existing.isNotEmpty) return existing; if (existing != null && existing.isNotEmpty) return existing;

View File

@ -1,10 +1,9 @@
import '../db/app_database.dart'; import '../db/app_database.dart';
import 'snooze_notification_service.dart'; import 'snooze_notification_service.dart';
import 'snooze_service.dart';
/// (roadmap.md §2.7-F) DB( ) /// (roadmap.md §2.7-F) DB( )
/// (OS / Notification) . /// (OS ) . `chat_screen.dart`/
/// `chat_screen.dart` / `conversation_list_screen.dart` . /// `conversation_list_screen.dart` .
/// ///
/// AGENTS.md "Tone/style learning: on-device first" /// AGENTS.md "Tone/style learning: on-device first"
/// , · /// , ·
@ -37,18 +36,4 @@ class SnoozeController {
await _db.clearSnooze(conversationId); await _db.clearSnooze(conversationId);
await _scheduler.cancelReminder(conversationId); await _scheduler.cancelReminder(conversationId);
} }
/// Web N4-W3: when the tab/app resumes, surface past-due snoozes once via
/// [SnoozeNotificationScheduler.showImmediate] (no-op on native).
Future<void> notifyPastDueOnFocus({DateTime? now}) async {
final all = await loadAllSnoozes();
final t = now ?? DateTime.now();
for (final e in all.entries) {
if (!isSnoozePastDue(t, e.value)) continue;
await _scheduler.showImmediate(
title: '답장 마감',
body: '대화 #${e.key} — 답장할 시간입니다',
);
}
}
} }

View File

@ -1,8 +1,9 @@
/// (roadmap.md §2.7-F) OS /// (roadmap.md §2.7-F) OS
/// export로 (`../db/app_database.dart` ). /// export로 (`../db/app_database.dart` ).
/// ///
/// Flutter Web은 `flutter_local_notifications` Notification API + /// Flutter Web은 `flutter_local_notifications` ,
/// in-tab Timer를 (`snooze_notification_service_web.dart`, docs/web-upgrade N4-W3). ///
/// (`app_database_web.dart` ) no-op .
library; library;
export 'snooze_notification_service_native.dart' export 'snooze_notification_service_native.dart'

View File

@ -20,11 +20,6 @@ abstract class SnoozeNotificationScheduler {
}); });
Future<void> cancelReminder(int conversationId); Future<void> cancelReminder(int conversationId);
/// Immediate notification (web past-due catch-up). Native uses OS schedule only.
Future<void> showImmediate({required String title, required String body}) async {}
Future<bool> ensurePermission() async => true;
} }
class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler { class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
@ -82,12 +77,4 @@ class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
Future<void> cancelReminder(int conversationId) async { Future<void> cancelReminder(int conversationId) async {
await _plugin.cancel(id: conversationId); await _plugin.cancel(id: conversationId);
} }
@override
Future<void> showImmediate({required String title, required String body}) async {
// Native path relies on zonedSchedule; no separate immediate channel here.
}
@override
Future<bool> ensurePermission() async => true;
} }

View File

@ -1,13 +1,7 @@
import 'dart:async'; /// Web `flutter_local_notifications` ,
// Web-only conditional import target (see snooze_notification_service.dart). /// (`app_database_web.dart` ).
// ignore: avoid_web_libraries_in_flutter, deprecated_member_use /// (`AppDatabase`) , OS no-op
import 'dart:html' as html; /// / "마감 지남" · .
/// Web reminder scheduler (docs/web-upgrade.md N4-W3).
///
/// Uses the browser Notification API when permission is granted. Scheduling is
/// in-tab [Timer]-based (no SW alarm) if the tab is closed before [at], the
/// in-app past-due badge/banner remains the source of truth after reopen.
abstract class SnoozeNotificationScheduler { abstract class SnoozeNotificationScheduler {
Future<void> scheduleReminder({ Future<void> scheduleReminder({
required int conversationId, required int conversationId,
@ -17,68 +11,17 @@ abstract class SnoozeNotificationScheduler {
}); });
Future<void> cancelReminder(int conversationId); Future<void> cancelReminder(int conversationId);
Future<void> showImmediate({required String title, required String body});
Future<bool> ensurePermission();
} }
class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler { class LocalSnoozeNotificationScheduler implements SnoozeNotificationScheduler {
final Map<int, Timer> _timers = {};
bool get permissionGranted {
try {
return html.Notification.permission == 'granted';
} catch (_) {
return false;
}
}
@override
Future<bool> ensurePermission() async {
try {
final current = html.Notification.permission;
if (current == 'granted') return true;
if (current == 'denied') return false;
final result = await html.Notification.requestPermission();
return result == 'granted';
} catch (_) {
return false;
}
}
@override
Future<void> showImmediate({required String title, required String body}) async {
try {
if (!permissionGranted) return;
html.Notification(title, body: body);
} catch (_) {
// Unsupported browser ignore.
}
}
@override @override
Future<void> scheduleReminder({ Future<void> scheduleReminder({
required int conversationId, required int conversationId,
required String title, required String title,
required String body, required String body,
required DateTime at, required DateTime at,
}) async { }) async {}
await ensurePermission();
await cancelReminder(conversationId);
final delay = at.toUtc().difference(DateTime.now().toUtc());
if (delay.isNegative || delay == Duration.zero) {
await showImmediate(title: title, body: body);
return;
}
_timers[conversationId] = Timer(delay, () {
_timers.remove(conversationId);
showImmediate(title: title, body: body);
});
}
@override @override
Future<void> cancelReminder(int conversationId) async { Future<void> cancelReminder(int conversationId) async {}
_timers.remove(conversationId)?.cancel();
}
} }

View File

@ -7,20 +7,16 @@ import '../config.dart';
import '../models/models.dart'; import '../models/models.dart';
import 'message_sync.dart'; import 'message_sync.dart';
/// UI-facing WebSocket link state for a conversation (docs/web-upgrade.md N4-W2).
enum WsLinkState {
connecting,
live,
reconnecting,
}
/// Conversation-scoped WebSocket relay (`GET /ws/conversations/:id`). /// Conversation-scoped WebSocket relay (`GET /ws/conversations/:id`).
/// ///
/// (roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11): /// (roadmap.md "멀티 디바이스 동기화" / deploy-checklist N4-11):
/// (, hiccup ) `onDone`/`onError` /// (, hiccup ) `onDone`/`onError`
/// [nextReconnectDelay] backoff . /// [nextReconnectDelay] backoff .
/// [reconnects] (chat_screen.dart) /// [reconnects] (chat_screen.dart)
/// REST `since_id` . /// REST `since_id` .
/// ( )
/// QA가 [nextReconnectDelay]
/// (mobile/test/message_sync_test.dart).
class ConversationSocket { class ConversationSocket {
ConversationSocket(this.conversationId); ConversationSocket(this.conversationId);
@ -29,12 +25,10 @@ class ConversationSocket {
StreamSubscription? _channelSub; StreamSubscription? _channelSub;
final _controller = StreamController<Map<String, dynamic>>.broadcast(); final _controller = StreamController<Map<String, dynamic>>.broadcast();
final _reconnectController = StreamController<void>.broadcast(); final _reconnectController = StreamController<void>.broadcast();
final _linkController = StreamController<WsLinkState>.broadcast();
Timer? _reconnectTimer; Timer? _reconnectTimer;
int _reconnectAttempt = 0; int _reconnectAttempt = 0;
bool _disposed = false; bool _disposed = false;
bool _everConnected = false; bool _everConnected = false;
WsLinkState _linkState = WsLinkState.connecting;
Stream<Map<String, dynamic>> get events => _controller.stream; Stream<Map<String, dynamic>> get events => _controller.stream;
@ -43,38 +37,18 @@ class ConversationSocket {
/// , "끊겼다가 다시 붙었을 때" ). /// , "끊겼다가 다시 붙었을 때" ).
Stream<void> get reconnects => _reconnectController.stream; Stream<void> get reconnects => _reconnectController.stream;
/// connecting live reconnecting live
Stream<WsLinkState> get linkStates => _linkController.stream;
WsLinkState get linkState => _linkState;
void _setLinkState(WsLinkState next) {
if (_disposed || _linkState == next) return;
_linkState = next;
if (!_linkController.isClosed) _linkController.add(next);
}
void connect() { void connect() {
final wasReconnect = _everConnected; final wasReconnect = _everConnected;
_setLinkState(wasReconnect ? WsLinkState.reconnecting : WsLinkState.connecting);
// onError로 cancelOnError:false라 // onError로 cancelOnError:false라
// leak을 . // leak을 .
_channelSub?.cancel(); _channelSub?.cancel();
final uri = Uri.parse('${AppConfig.wsBase()}/ws/conversations/$conversationId'); final uri = Uri.parse('${AppConfig.wsBase()}/ws/conversations/$conversationId');
final channel = WebSocketChannel.connect(uri); _channel = WebSocketChannel.connect(uri);
_channel = channel;
_everConnected = true; _everConnected = true;
channel.ready.then((_) { _channelSub = _channel!.stream.listen(
if (_disposed) return;
_setLinkState(WsLinkState.live);
}).catchError((Object _) {
// onError / onDone will schedule reconnect.
});
_channelSub = channel.stream.listen(
(raw) { (raw) {
// backoff를 . // backoff를 .
_reconnectAttempt = 0; _reconnectAttempt = 0;
_setLinkState(WsLinkState.live);
if (raw is! String) return; if (raw is! String) return;
final decoded = jsonDecode(raw); final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) { if (decoded is Map<String, dynamic>) {
@ -96,7 +70,6 @@ class ConversationSocket {
void _scheduleReconnect() { void _scheduleReconnect() {
if (_disposed) return; if (_disposed) return;
_setLinkState(WsLinkState.reconnecting);
_reconnectTimer?.cancel(); _reconnectTimer?.cancel();
final delay = nextReconnectDelay(_reconnectAttempt); final delay = nextReconnectDelay(_reconnectAttempt);
_reconnectAttempt++; _reconnectAttempt++;
@ -127,18 +100,5 @@ class ConversationSocket {
await _channel?.sink.close(); await _channel?.sink.close();
await _controller.close(); await _controller.close();
await _reconnectController.close(); await _reconnectController.close();
await _linkController.close();
}
}
/// Short Korean label for chat chrome (N4-W2).
String wsLinkStateLabel(WsLinkState state) {
switch (state) {
case WsLinkState.connecting:
return '연결 중…';
case WsLinkState.live:
return '실시간';
case WsLinkState.reconnecting:
return '다시 연결 중…';
} }
} }

View File

@ -75,8 +75,7 @@ class SessionState extends ChangeNotifier {
} catch (e) { } catch (e) {
// Linux CI / hosts without libsqlcipher.so fall back to memory so the // Linux CI / hosts without libsqlcipher.so fall back to memory so the
// app still boots; Android release path uses SQLCipher. // app still boots; Android release path uses SQLCipher.
// Web uses SharedPreferences-backed AppDatabase via conditional import // Web uses an in-memory stub via conditional import (no FFI).
// (docs/web-upgrade.md N4-W1); this catch is for native open failures.
debugPrint('Encrypted DB unavailable ($e); using in-memory fallback'); debugPrint('Encrypted DB unavailable ($e); using in-memory fallback');
localDbEncrypted = false; localDbEncrypted = false;
return AppDatabase.memory(); return AppDatabase.memory();

View File

@ -1,59 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'web_connectivity.dart';
/// Thin bar when the browser reports offline (docs/web-upgrade.md N4-W5).
/// API/WS still require a network this only sets expectation.
class OfflineBanner extends StatefulWidget {
const OfflineBanner({super.key});
@override
State<OfflineBanner> createState() => _OfflineBannerState();
}
class _OfflineBannerState extends State<OfflineBanner> {
bool _offline = false;
StreamSubscription? _onlineSub;
StreamSubscription? _offlineSub;
@override
void initState() {
super.initState();
if (!kIsWeb) return;
_offline = webNavigatorOffline;
_onlineSub = listenWebOnline(() {
if (mounted) setState(() => _offline = false);
});
_offlineSub = listenWebOffline(() {
if (mounted) setState(() => _offline = true);
});
}
@override
void dispose() {
_onlineSub?.cancel();
_offlineSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!kIsWeb || !_offline) return const SizedBox.shrink();
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'오프라인입니다. 메시지·로그인에는 인터넷 연결이 필요합니다.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
);
}
}

View File

@ -1,2 +0,0 @@
export 'web_connectivity_stub.dart'
if (dart.library.html) 'web_connectivity_web.dart';

View File

@ -1,7 +0,0 @@
import 'dart:async';
bool get webNavigatorOffline => false;
StreamSubscription<void>? listenWebOnline(void Function() onOnline) => null;
StreamSubscription<void>? listenWebOffline(void Function() onOffline) => null;

View File

@ -1,13 +0,0 @@
import 'dart:async';
// ignore: avoid_web_libraries_in_flutter, deprecated_member_use
import 'dart:html' as html;
bool get webNavigatorOffline => !(html.window.navigator.onLine ?? true);
StreamSubscription<void>? listenWebOnline(void Function() onOnline) {
return html.window.onOnline.listen((_) => onOnline());
}
StreamSubscription<void>? listenWebOffline(void Function() onOffline) {
return html.window.onOffline.listen((_) => onOffline());
}

View File

@ -1,85 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../state/session_state.dart';
/// One-time dismissible tips for the web demo/preview surface (N4-W2).
///
/// Hidden on non-web builds. Dismissal is stored in the local DB KV so it
/// survives hard refresh after W1.
class WebPreviewBanner extends StatefulWidget {
const WebPreviewBanner({super.key});
static const dismissKvKey = 'web_preview_tips_dismissed';
@override
State<WebPreviewBanner> createState() => _WebPreviewBannerState();
}
class _WebPreviewBannerState extends State<WebPreviewBanner> {
bool _visible = false;
bool _loaded = false;
@override
void initState() {
super.initState();
if (kIsWeb) {
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
} else {
_loaded = true;
}
}
Future<void> _load() async {
if (!mounted) return;
final session = context.read<SessionState>();
final db = session.db;
final dismissed = db == null
? false
: await db.getBoolKv(WebPreviewBanner.dismissKvKey);
if (!mounted) return;
setState(() {
_visible = !dismissed;
_loaded = true;
});
}
Future<void> _dismiss() async {
setState(() => _visible = false);
final db = context.read<SessionState>().db;
await db?.setBoolKv(WebPreviewBanner.dismissKvKey, true);
}
@override
Widget build(BuildContext context) {
if (!kIsWeb || !_loaded || !_visible) return const SizedBox.shrink();
final theme = Theme.of(context);
return Material(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.55),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.web_asset, size: 20, color: theme.colorScheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
'웹 프리뷰입니다. 말투·설정은 새로고침 후에도 이 브라우저에 남습니다. '
'브라우저 알림·홈 화면 추가는 곧 이어집니다.',
style: theme.textTheme.bodySmall?.copyWith(height: 1.4),
),
),
IconButton(
tooltip: '닫기',
onPressed: _dismiss,
icon: const Icon(Icons.close, size: 18),
visualDensity: VisualDensity.compact,
),
],
),
),
);
}
}

View File

@ -1,46 +0,0 @@
#!/usr/bin/env bash
# Runs inside the Flutter build stage. Reads FIREBASE_* from the environment
# (set via Dockerfile ENV from build-args) so secret values are not expanded
# into the Dockerfile RUN instruction / build logs.
set -euo pipefail
CORE_API_BASE="${CORE_API_BASE:-https://msn.iykyka.com}"
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:-}"
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}"
# Detect common misconfiguration: Android app id used for web build.
if [[ "${FIREBASE_APP_ID}" == *":android:"* ]]; then
echo "WARN: FIREBASE_APP_ID looks like an Android app id (:android:)."
echo "WARN: Web Push needs a Firebase Web app id (:web:). Token registration may fail."
fi
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
flutter build web --release \
--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}"

View File

@ -1,63 +0,0 @@
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

@ -1,24 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ykavu_mobile/screens/sessions_screen.dart';
void main() {
test('formatExpiresAtLocal converts UTC Z to local wall clock', () {
// Fixed offset via DateTime.parse keeps instant; toLocal() uses test TZ.
final raw = '2026-09-02T09:06:45.137172Z';
final out = SessionsScreen.formatExpiresAtLocal(raw);
final expected = DateTime.parse(raw).toLocal();
final two = (int n) => n.toString().padLeft(2, '0');
expect(
out,
'${expected.year}-${two(expected.month)}-${two(expected.day)} '
'${two(expected.hour)}:${two(expected.minute)}',
);
expect(out.contains('T'), isFalse);
expect(out.endsWith('Z'), isFalse);
});
test('formatExpiresAtLocal handles null and garbage', () {
expect(SessionsScreen.formatExpiresAtLocal(null), '');
expect(SessionsScreen.formatExpiresAtLocal('not-a-date'), 'not-a-date');
});
}

View File

@ -9,7 +9,6 @@ import 'package:ykavu_mobile/services/snooze_notification_service.dart';
class _FakeScheduler implements SnoozeNotificationScheduler { class _FakeScheduler implements SnoozeNotificationScheduler {
final scheduledCalls = <Map<String, Object?>>[]; final scheduledCalls = <Map<String, Object?>>[];
final cancelledIds = <int>[]; final cancelledIds = <int>[];
final immediateCalls = <Map<String, Object?>>[];
@override @override
Future<void> scheduleReminder({ Future<void> scheduleReminder({
@ -30,14 +29,6 @@ class _FakeScheduler implements SnoozeNotificationScheduler {
Future<void> cancelReminder(int conversationId) async { Future<void> cancelReminder(int conversationId) async {
cancelledIds.add(conversationId); cancelledIds.add(conversationId);
} }
@override
Future<void> showImmediate({required String title, required String body}) async {
immediateCalls.add({'title': title, 'body': body});
}
@override
Future<bool> ensurePermission() async => true;
} }
void main() { void main() {
@ -92,16 +83,4 @@ void main() {
expect(await controller.loadAllSnoozes(), {1: until1, 2: until2}); expect(await controller.loadAllSnoozes(), {1: until1, 2: until2});
expect(scheduler.scheduledCalls, hasLength(2)); expect(scheduler.scheduledCalls, hasLength(2));
}); });
test('notifyPastDueOnFocus shows immediate only for past-due snoozes', () async {
final past = DateTime.utc(2026, 8, 1, 12, 0);
final future = DateTime.utc(2026, 8, 10, 12, 0);
await controller.applySnooze(conversationId: 1, until: past, title: 't', body: 'b');
await controller.applySnooze(conversationId: 2, until: future, title: 't', body: 'b');
await controller.notifyPastDueOnFocus(now: DateTime.utc(2026, 8, 3, 12, 0));
expect(scheduler.immediateCalls, hasLength(1));
expect(scheduler.immediateCalls.single['body'], contains('#1'));
});
} }

View File

@ -1,56 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:ykavu_mobile/db/app_database_web.dart';
/// N4-W1 web AppDatabase durable round-trip (imports web impl directly so
/// VM tests exercise prefs persistence, not the native Drift path).
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('web open persists tone, kv, and snooze across reopen', () async {
final db1 = await AppDatabase.open();
addTearDown(db1.close);
await db1.replaceToneSamples(['웹에서도', '유지돼야 함']);
await db1.setBoolKv('tone_onboarding_done', true);
await db1.setKv('device_install_id', 'abc123deadbeef');
final until = DateTime.utc(2026, 8, 4, 15, 30);
await db1.setSnoozedUntil(7, until);
final db2 = await AppDatabase.open();
addTearDown(db2.close);
expect(await db2.loadToneSamples(), ['웹에서도', '유지돼야 함']);
expect(await db2.getBoolKv('tone_onboarding_done'), isTrue);
expect(await db2.getKv('device_install_id'), 'abc123deadbeef');
expect(await db2.getSnoozedUntil(7), until);
expect(db2.encrypted, isFalse);
});
test('web memory factory does not write prefs', () async {
final mem = AppDatabase.memory();
addTearDown(mem.close);
await mem.replaceToneSamples(['ephemeral']);
await mem.setBoolKv('tone_onboarding_done', true);
final opened = await AppDatabase.open();
addTearDown(opened.close);
expect(await opened.loadToneSamples(), isEmpty);
expect(await opened.getBoolKv('tone_onboarding_done'), isFalse);
});
test('corrupt tone JSON is wiped and open still succeeds', () async {
SharedPreferences.setMockInitialValues({
'ykavu_web_db_tone_samples': '{not-json',
});
final db = await AppDatabase.open();
addTearDown(db.close);
expect(await db.loadToneSamples(), isEmpty);
await db.replaceToneSamples(['ok']);
expect(await db.loadToneSamples(), ['ok']);
});
}

View File

@ -1,10 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ykavu_mobile/services/ws_client.dart';
void main() {
test('wsLinkStateLabel covers all states', () {
expect(wsLinkStateLabel(WsLinkState.connecting), '연결 중…');
expect(wsLinkStateLabel(WsLinkState.live), '실시간');
expect(wsLinkStateLabel(WsLinkState.reconnecting), '다시 연결 중…');
});
}

View File

@ -1,5 +0,0 @@
// 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

@ -1,15 +0,0 @@
// 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

@ -1,35 +1,38 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF"> <base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible"> <meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="나를 대신해 답하는, 나만의 와카뷰 — AI 트윈 메신저 데모"> <meta name="description" content="A new Flutter project.">
<meta name="theme-color" content="#007AFF">
<!-- iOS / install --> <!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-status-bar-style" content="default"> <meta name="apple-mobile-web-app-title" content="Ykavu">
<meta name="apple-mobile-web-app-title" content="와카뷰">
<link rel="apple-touch-icon" href="icons/Icon-192.png"> <link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/> <link rel="icon" type="image/png" href="favicon.png"/>
<title>와카뷰 (Ykavu)</title> <title>와카뷰 (Ykavu)</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
</head> </head>
<body> <body>
<!-- FCM Web SW (N4-W4). Scope matches FlutterFire messaging defaults. --> <script src="flutter_bootstrap.js" async></script>
<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> </body>
</html> </html>

View File

@ -3,10 +3,10 @@
"short_name": "와카뷰", "short_name": "와카뷰",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"background_color": "#F2F2F7", "background_color": "#7C6FF0",
"theme_color": "#007AFF", "theme_color": "#7C6FF0",
"description": "나를 대신해 답하는, 나만의 와카뷰", "description": "나를 대신해 답하는, 나만의 와카뷰",
"orientation": "any", "orientation": "portrait-primary",
"prefer_related_applications": false, "prefer_related_applications": false,
"icons": [ "icons": [
{ {

View File

@ -1,32 +0,0 @@
#!/usr/bin/env bash
# N4-W7 — minimal live smoke for the web edge (no browser automation).
# Usage: ./scripts/e2e_web_smoke.sh [BASE_URL]
set -euo pipefail
BASE="${1:-https://msn.iykyka.com}"
echo "==> GET $BASE/health"
curl -fsS "$BASE/health"
echo
echo "==> GET $BASE/demo"
curl -fsS "$BASE/demo" | head -c 400
echo
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" \
-H 'Content-Type: application/json' \
-d '{"invite_code":"DEMO-YKAVU"}')
echo "HTTP $code $(head -c 120 /tmp/ykavu_login.json)"
test "$code" = "400"
echo "OK web edge smoke ($BASE)"