feat: client integration and control-plane phase updates
- Update control-plane and edge domain rules - Update roadmap phase and current status - Archive client-integration-clone-adoption and nexo-notification-message-smoke-alignment milestones - Update control-plane-client milestone - Add client assets, lib/src, integrations tests - Add google-services.json for client android - Update client pubspec, build configs, and platform manifests
This commit is contained in:
parent
7b9345bc1a
commit
d7324fe184
28 changed files with 944 additions and 119 deletions
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
domain: control-plane
|
||||
last_rule_review_commit: 6e1c201d0b82661465238821edb136444eacb9d0
|
||||
last_rule_updated_at: 2026-05-28
|
||||
last_rule_review_commit: 7b9345bc1af3d7c449e07b6fc9627d984c80cebf
|
||||
last_rule_updated_at: 2026-05-30
|
||||
---
|
||||
|
||||
# control-plane
|
||||
|
|
@ -39,6 +39,8 @@ last_rule_updated_at: 2026-05-28
|
|||
## 유지할 패턴
|
||||
|
||||
- Control Plane은 Edge를 통해 시스템을 관찰/제어한다. Node를 직접 등록하거나 직접 스케줄링하는 active contract를 만들지 않는다.
|
||||
- Control Plane은 상시 운영, multi-edge 관찰/명령, 정책/감사 UX의 기본 운영면으로 확장한다. Edge-local bootstrap, config, 진단, 단일 Edge 유지보수 fallback을 대체하거나 제거하는 전제로 설계하지 않는다.
|
||||
- Control Plane-Edge wire protocol은 Edge가 소유한 operation contract를 원격 운영면으로 소비하는 형태로 설계한다. Edge CLI와 같은 행위를 노출해야 하면 Edge 쪽 표면 중립 service/operation boundary로 수렴시킨다.
|
||||
- `net/http`는 health/readiness/bootstrap 같은 보조 endpoint에 우선 사용하고, IOP 고유 운영 통신은 proto-socket 기반 wire protocol로 확장한다.
|
||||
- 브라우저/앱 경계는 WebSocket/WSS proto-socket을 사용할 수 있다. Edge-Node TCP transport 기준을 이 경계에 억지로 적용하지 않는다.
|
||||
- `ScheduleRequest`/`ScheduleResponse`는 legacy placeholder로만 취급하고, 새 orchestration 계약은 Edge-owned runtime state를 우회하지 않도록 다시 설계한다.
|
||||
|
|
@ -47,7 +49,7 @@ last_rule_updated_at: 2026-05-28
|
|||
|
||||
## 다른 도메인과의 경계
|
||||
|
||||
- **edge**: Edge는 로컬 실행 그룹 상태와 Node registry를 소유한다. Control Plane은 Edge를 통해 상태 조회, 설정 변경, 명령 전달, 이벤트 수신을 확장한다.
|
||||
- **edge**: Edge는 로컬 실행 그룹 상태와 Node registry를 소유한다. Control Plane은 Edge를 통해 상태 조회, 설정 변경, 명령 전달, 이벤트 수신을 확장한다. Control Plane 없는 bootstrap/local/field/진단 fallback은 `iop-edge` command 표면에 남긴다.
|
||||
- **platform-common**: protobuf 원본, 생성물, 공통 설정/관측성 helper는 platform-common 계약을 따른다.
|
||||
- **testing**: Control Plane command, wire endpoint, bootstrap/enrollment 사용자 흐름을 바꾸면 testing domain rule의 검증 기준을 따른다.
|
||||
|
||||
|
|
@ -55,5 +57,6 @@ last_rule_updated_at: 2026-05-28
|
|||
|
||||
- Control Plane에서 Node를 직접 연결하거나 직접 스케줄링하는 경로를 제품 기본 계약으로 만들지 않는다.
|
||||
- Edge-local registry, run dispatch, stream relay 구현을 Control Plane으로 복제하지 않는다.
|
||||
- Control Plane 도입만을 이유로 Edge-local CLI bootstrap/config/진단 fallback을 제품 기본 계약에서 제외하거나 제거하지 않는다. 축소는 별도 roadmap 결정과 대체 fallback 기준이 있을 때만 다룬다.
|
||||
- gRPC를 Control Plane 기본 wire protocol로 도입하지 않는다.
|
||||
- 실제 DB/Redis credential이나 환경별 endpoint를 tracked docs/rules에 기록하지 않는다.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
domain: edge
|
||||
last_rule_review_commit: 6e1c201d0b82661465238821edb136444eacb9d0
|
||||
last_rule_updated_at: 2026-05-28
|
||||
last_rule_review_commit: 7b9345bc1af3d7c449e07b6fc9627d984c80cebf
|
||||
last_rule_updated_at: 2026-05-30
|
||||
---
|
||||
|
||||
# edge
|
||||
|
|
@ -62,6 +62,7 @@ last_rule_updated_at: 2026-05-28
|
|||
- edge는 사전 등록된 node 정의를 검증하고 연결 상태를 registry에 반영하는 역할을 먼저 안정화한다.
|
||||
- edge는 단순 gateway가 아니라 Node registry, adapter/profile configuration, routing, stream relay의 소유자다.
|
||||
- 공식 local/field 운영 UX는 `iop-edge` command 표면에 모은다. repo helper script나 README 절차를 공식 사용자 경로로 승격하지 않는다.
|
||||
- `iop-edge` command 표면은 bootstrap, bundle-local config, env/config 진단, node 등록 command 발급, smoke, Control Plane 없는 단일 Edge 유지보수 범위로 유지한다. 후속 specialized agent enrollment command도 같은 local/field fallback 원칙을 따른다. 상시 multi-edge 운영 UI/API 역할을 CLI에 확대하지 않는다.
|
||||
- `config init`은 bundle-local `edge.yaml` template을 만들고, `env`는 서버를 기동하지 않고 effective environment를 설명해야 한다.
|
||||
- `node register`는 사용자에게 `curl -fsSL <bootstrap-url> | bash -s <token>` 한 줄을 출력하는 경로를 유지한다. 사용자가 기본 경로에서 `node.yaml`을 직접 만들거나 `iop-node serve --config ...`를 직접 실행하게 하지 않는다.
|
||||
- 외부 OpenAI-compatible HTTP API는 입력 표면일 뿐이며, 내부 실행 모델 전체를 대표하지 않는다.
|
||||
|
|
@ -76,6 +77,7 @@ last_rule_updated_at: 2026-05-28
|
|||
- OpenAI/Ollama passthrough성 옵션은 입력 표면에서 명시적으로 변환하고, node adapter의 Ollama 실행 계약을 우회하지 않는다.
|
||||
- run/node event fanout은 `events.Bus`를 통해 수행하고, transport handler에서 console/HTTP 표면으로 직접 출력하거나 응답하지 않는다.
|
||||
- 입력 서버 lifecycle은 `input.Manager`가 소유하며, bootstrap runtime이 transport server와 함께 시작/종료한다.
|
||||
- CLI, ops console, HTTP/A2A 입력 표면, 후속 Control Plane wire handler가 같은 Edge operation을 노출할 때는 `service.Service` 같은 표면 중립 application service로 수렴시킨다. registry 변경, run dispatch, command request 로직을 표면별로 복제하지 않는다.
|
||||
- `apps/edge/cmd/edge/**`, `apps/edge/internal/bootstrap/**`, `apps/edge/internal/transport/**`, `apps/edge/internal/service/**`, `apps/edge/internal/events/**`, `apps/edge/internal/input/**`, `apps/edge/internal/openai/**`, `apps/edge/internal/opsconsole/**`, `apps/edge/internal/node/**`, console/HTTP 입출력, run/node event relay를 바꾼 뒤에는 `testing` domain rule의 작업 후 검증 기준을 따른다.
|
||||
|
||||
## 다른 도메인과의 경계
|
||||
|
|
@ -83,7 +85,7 @@ last_rule_updated_at: 2026-05-28
|
|||
- **node**: edge는 node 내부 adapter를 직접 실행하지 않는다. edge는 사전 등록 정보와 연결 registry를 기반으로 요청을 보낼 대상과 실행 설정을 관리하고, TCP/protobuf로 `RunRequest`/`CancelRequest`/`NodeCommandRequest`를 보낸다.
|
||||
- **platform-common**: edge 설정, metrics, protobuf 타입은 platform-common 계약을 따른다.
|
||||
- **external input surfaces**: OpenAI-compatible HTTP와 A2A JSON-RPC는 edge inbound adapter이며, 내부 transport/protobuf 경계를 대체하지 않는다.
|
||||
- **control-plane**: control-plane은 Edge를 통해 시스템을 제어한다. Node 직접 연결/직접 스케줄링을 control-plane 책임으로 굳히지 않는다.
|
||||
- **control-plane**: control-plane은 Edge를 통해 시스템을 제어한다. 상시 운영, multi-edge 관찰, 정책/감사 UX는 control-plane으로 확장하되, Control Plane 없는 bootstrap/local/field/진단 fallback은 `iop-edge` command 표면에 남긴다. Node 직접 연결/직접 스케줄링을 control-plane 책임으로 굳히지 않는다.
|
||||
|
||||
## 금지 사항
|
||||
|
||||
|
|
@ -91,6 +93,7 @@ last_rule_updated_at: 2026-05-28
|
|||
- OpenAI-compatible HTTP API를 추가할 때 내부 TCP/protobuf 경계를 우회하지 않는다.
|
||||
- A2A/OpenAI/console 입력 표면에서 registry client에 직접 `RunRequest`를 조립·전송하지 않는다. 공유 로직은 `service.Service`에 둔다.
|
||||
- Control Plane 구현 전이라도 Node를 Control Plane에 직접 연결하는 경로를 edge 도메인에 추가하지 않는다.
|
||||
- Control Plane 도입만을 이유로 `iop-edge config`, `env`, `node register`, `nodes list`, `smoke`, `setup` 같은 local/field fallback command 경로를 제거하거나 제품 기본 계약에서 제외하지 않는다. 축소는 별도 roadmap 결정과 대체 fallback 기준이 있을 때만 다룬다.
|
||||
- `node register`와 bootstrap UX에 named environment parameter 조합을 기본 사용자 경로로 노출하지 않는다.
|
||||
- gRPC 또는 WebSocket을 기본 내부 transport로 바꾸지 않는다.
|
||||
- `proto/gen/iop/*.pb.go` 생성 파일을 직접 수정하지 않는다.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ NomadCode의 `Client Integration Standardization` 결과를 IOP Flutter client
|
|||
|
||||
## 상태
|
||||
|
||||
[계획]
|
||||
[폐기]
|
||||
|
||||
## 구현 잠금
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ NomadCode의 `Client Integration Standardization` 결과를 IOP Flutter client
|
|||
- 결정 필요: 아래 체크리스트
|
||||
- [ ] NomadCode client 표준화 산출물을 받은 뒤 IOP client에 복제할 파일/구조/설정 범위를 확정한다.
|
||||
- [x] 이전 Flutter 앱 경로는 `apps/client`로 전환하고 호환 경로는 두지 않는다.
|
||||
- [ ] Nexo 알림/메시지 Smoke 정합성 Milestone에서 valid signature 또는 scope decision과 IOP consumer 설정 차이가 정리됐는지 확인한다.
|
||||
- [x] Nexo 알림/메시지 Smoke 정합성 Milestone에서 valid signature 또는 scope decision과 IOP consumer 설정 차이가 정리됐는지 확인한다.
|
||||
- [x] IOP client의 Mattermost push routing은 내부 화면, 외부 링크, 운영 이벤트를 모두 지원한다. 세부 구현은 notification type/context별 primary route, fallback route, audit/ops event 기록 정책으로 정리한다.
|
||||
|
||||
## 범위
|
||||
|
|
@ -60,13 +60,13 @@ NomadCode 표준과 IOP 차이를 추적 가능하게 남긴다.
|
|||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 없음
|
||||
- 상태: 폐기
|
||||
- 요청일: 2026-05-30
|
||||
- 완료 근거: 사용자가 NomadCode Flutter client 복제 작업은 별도 프로젝트에서 진행 예정이므로 IOP roadmap의 해당 Milestone을 제거하라고 지시했다.
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
- 리뷰 코멘트: 없음
|
||||
- [x] 사용자가 폐기 방향을 지시했다
|
||||
- [x] archive 이동을 승인했다
|
||||
- 리뷰 코멘트: IOP active roadmap에서는 이 Milestone을 더 이상 구현 후보로 두지 않는다.
|
||||
|
||||
## 범위 제외
|
||||
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
# Milestone: Nexo 알림/메시지 Smoke 정합성
|
||||
|
||||
## 위치
|
||||
|
||||
- Roadmap: `agent-roadmap/ROADMAP.md`
|
||||
- Phase: `agent-roadmap/phase/control-plane-portal-ops/PHASE.md`
|
||||
|
||||
## 목표
|
||||
|
||||
`../nexo`의 메시징/푸시 알림 검증 상태를 IOP client integration의 선행 게이트로 정리한다. `../nomadcode`에서 `nexo_messaging`을 실제 소비 앱에 적용하고 server-generated signed push smoke까지 완료한 결과를 기준으로, IOP `apps/client`가 같은 Flutter integration skeleton을 복제하기 위해 필요한 설정 차이를 확정한다.
|
||||
|
||||
## 상태
|
||||
|
||||
[완료]
|
||||
|
||||
## 구현 잠금
|
||||
|
||||
- 상태: 해제
|
||||
- 결정 필요: 아래 체크리스트
|
||||
- [x] Nexo에서 valid signed push trigger를 제공해 notification display/opened/reply/dismiss까지 검증하는 방향으로 진행한다.
|
||||
- [x] IOP client의 Mattermost push routing은 내부 화면, 외부 링크, 운영 이벤트를 모두 지원한다. 남은 작업은 notification type/context별 기본 라우팅, fallback, 이벤트 기록 정책을 정의하는 것이다.
|
||||
|
||||
## 범위
|
||||
|
||||
- `../nomadcode`의 Mattermost server-generated signed push smoke evidence와 `../nexo/packages/messaging_flutter` host responsibility 확인
|
||||
- IOP `apps/client`의 `nexo_messaging`, Firebase, Android application id, Google Services plugin, platform guard, plugin initialization 설정 차이 정리
|
||||
- IOP client의 device token, auth token, signing key, notification opened routing callback 책임 경계 정리
|
||||
- `Client Integration Clone Adoption`을 시작하기 전 필요한 선행 게이트와 후속 분리 항목 문서화
|
||||
|
||||
## 기능
|
||||
|
||||
### Epic: [nexo-smoke] Nexo smoke gate
|
||||
|
||||
Nexo 쪽 알림/메시지 smoke 상태를 IOP client adoption의 선행 조건으로 분류한다.
|
||||
|
||||
- [x] [handoff-status] `../nomadcode/agent-test/local/mattermost-server-generated-push-smoke.md` 기준으로 server-generated signed push smoke의 PASS/N/A 항목을 최신 상태로 확인한다. 검증: 2026-05-30 remote Android emulator에서 FCM receipt, valid signature, notification display, ACK, opened event, channel navigation, inline reply, dismiss, device token prefix PASS를 확인했다.
|
||||
- [x] [signed-scope] valid signed push trigger를 확보해 notification display, opened routing, inline reply, dismiss까지 검증한 결과를 남긴다. 검증: positive path는 Mattermost REST `POST /api/v4/posts` server-generated DM이며 placeholder signature를 쓰지 않았다.
|
||||
|
||||
### Epic: [iop-consumer] IOP consumer 설정 정합성
|
||||
|
||||
IOP `apps/client`가 Nexo messaging plugin의 host app responsibility를 충족하는지 확인하고 부족한 설정을 adoption 작업 입력으로 만든다.
|
||||
|
||||
- [x] [dependency-gap] IOP `apps/client/pubspec.yaml`과 Android 설정에서 `nexo_messaging`, `firebase_core`, Google Services plugin, Firebase config, application id 차이를 정리한다. 검증: 현재 IOP client에는 해당 의존성과 Firebase Android wiring이 없으므로 clone adoption의 첫 변경 범위로 둔다.
|
||||
- [x] [bootstrap-gap] Web + Android bootstrap에서 Firebase 초기화, `NexoMessagingPlugin.instance.initialize()`, platform guard, deterministic test escape hatch 필요 여부를 정리한다. 검증: Android는 NomadCode bootstrap 패턴을 이식하고, Web/desktop은 native push runtime을 no-op 또는 guard 처리한다.
|
||||
- [x] [credential-boundary] device token, auth token, signing key 저장과 전달 책임을 IOP Control Plane/client 경계에 맞춰 정의한다. 검증: host는 auth/signing key/device token handoff만 소유하고 native FCM/ACK/reply/dismiss는 plugin에 둔다.
|
||||
- [x] [routing-boundary] notification opened event가 내부 화면, 외부 링크, 운영 이벤트를 모두 사용할 수 있도록 notification type/context별 기본 라우팅, fallback, 이벤트 기록 정책을 정리한다. 검증: primary internal route, external link fallback, ops event recording을 adoption 기본 정책으로 둔다.
|
||||
|
||||
### Epic: [adoption-gate] Clone adoption handoff
|
||||
|
||||
기존 `Client Integration Clone Adoption` Milestone이 시작할 수 있는 입력과 남은 블로커를 추적 가능하게 만든다.
|
||||
|
||||
- [x] [clone-input] `Client Integration Clone Adoption`에서 가져갈 Nexo messaging skeleton, IOP 치환 항목, 후속 분리 항목을 checklist로 남긴다. 검증: 복제 대상은 `../nomadcode/apps/client/lib/src/integrations/mattermost/*`, bootstrap pattern, tests, Android Firebase/Google Services wiring이며 NomadCode 제품 화면은 제외한다.
|
||||
- [x] [lock-sync] 이 Milestone과 `../nexo` `client-validation`의 외부 의존 잠금 상태가 workspace lock에 반영되어 있는지 확인한다. 검증: `../.agent-roadmap-sync/locks.yaml`의 `iop:nexo-notification-message-smoke-alignment` rely-on 상태가 `enable`이다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 승인됨
|
||||
- 요청일: 2026-05-30
|
||||
- 승인일: 2026-05-30
|
||||
- 완료 근거: `../nomadcode`에서 `nexo_messaging` 적용 후 server-generated signed push smoke의 주요 runtime evidence가 PASS로 정리되었다.
|
||||
- 완료 근거: IOP client의 dependency/bootstrap/credential/routing gap을 clone adoption 입력으로 분리했다.
|
||||
- 완료 근거: workspace lock의 선행 의존 상태가 `enable`로 확인되었다.
|
||||
- 리뷰 필요:
|
||||
- [x] 사용자가 완료 결과를 확인했다
|
||||
- [x] archive 이동을 승인했다
|
||||
- 리뷰 코멘트: 사용자가 완료 및 archive 이동을 승인했다. 복제 시작 가능 후보다. 단, IOP 앱 자체의 Firebase config, application id, routing adapter 구현과 Android runtime smoke는 `Client Integration Clone Adoption`에서 별도로 검증한다.
|
||||
|
||||
## 범위 제외
|
||||
|
||||
- Nexo plugin runtime 대규모 재작성
|
||||
- IOP client의 전체 제품 UI 재설계
|
||||
- iOS/macOS native push 구현
|
||||
- Control Plane-Edge wire protocol 세부 확정
|
||||
- raw FCM token, auth token, signing key, private endpoint 값을 tracked 문서에 기록하는 일
|
||||
|
||||
## 작업 컨텍스트
|
||||
|
||||
- 관련 경로: `apps/client/`, `apps/client/android/`, `../nomadcode/apps/client/`, `../nomadcode/agent-test/local/mattermost-server-generated-push-smoke.md`, `../nexo/packages/messaging_flutter/`, `../nexo/apps/flutter-test/`
|
||||
- 표준선(선택): Nexo `nexo_messaging`은 Android-first plugin runtime이고 host app은 Firebase config, plugin initialization, auth/signing key 저장, device token 전달, opened routing callback만 소유한다.
|
||||
- 표준선(선택): IOP Web target은 messaging native runtime을 직접 실행하지 않고 platform guard와 no-op 또는 후속 확장 경계를 명확히 둔다.
|
||||
- 표준선(선택): Mattermost push routing은 내부 화면, 외부 링크, 운영 이벤트를 배타적으로 고르지 않고 모두 지원한다. 구현에서는 type/context별 primary route, fallback route, audit/ops event 기록 여부를 정책으로 분리한다.
|
||||
- 외부 의존 잠금: `nexo:agent-roadmap/phase/product-foundation/milestones/client-validation.md`
|
||||
- 선행 작업: NomadCode의 `nexo_messaging` 적용과 server-generated signed push 기반 full notification smoke
|
||||
- 후속 작업: `Client Integration Clone Adoption`
|
||||
- 확인 필요: 사용자 완료 확인 후 archive 이동 여부를 결정한다. 구현 후속은 `Client Integration Clone Adoption`에서 IOP target 값으로 검증한다.
|
||||
|
|
@ -7,9 +7,9 @@
|
|||
|
||||
## 활성 Milestone
|
||||
|
||||
- [진행중] Nexo 알림/메시지 Smoke 정합성
|
||||
- [계획] Control Plane과 Client
|
||||
- Phase: `agent-roadmap/phase/control-plane-portal-ops/PHASE.md`
|
||||
- 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/nexo-notification-message-smoke-alignment.md`
|
||||
- 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-client.md`
|
||||
|
||||
## 선택 규칙
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
## Milestone 흐름
|
||||
|
||||
완료된 Milestone은 archive 경로를 가리키고, 검토중, 진행중, 계획 또는 보류 Milestone은 이 Phase 하위 `milestones/` 경로를 가리킨다.
|
||||
완료 또는 폐기된 Milestone은 archive 경로를 가리키고, 검토중, 진행중, 계획 또는 보류 Milestone은 이 Phase 하위 `milestones/` 경로를 가리킨다.
|
||||
완료, 검토중, 진행중, 계획 순서로 두어 아래로 갈수록 미래 작업에 가까워지게 정렬한다.
|
||||
|
||||
- [완료] Flutter-first Client 마이그레이션
|
||||
|
|
@ -21,13 +21,13 @@
|
|||
- 경로: `agent-roadmap/archive/phase/control-plane-portal-ops/milestones/edge-local-dev-config-runtime.md`
|
||||
- 요약: Control Plane이 없는 동안 `edge.yaml`을 테스트용 source of truth로 삼고, config 템플릿 생성, effective environment 출력, Node register/bootstrap 출력, setup, smoke test를 `iop-edge` command 중심으로 모은다.
|
||||
|
||||
- [진행중] Nexo 알림/메시지 Smoke 정합성
|
||||
- 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/nexo-notification-message-smoke-alignment.md`
|
||||
- 요약: `../nexo` client-validation의 FCM ACK smoke와 IOP `apps/client`의 Nexo messaging 소비 설정 차이를 선행 게이트로 정리한다.
|
||||
- [완료] Nexo 알림/메시지 Smoke 정합성
|
||||
- 경로: `agent-roadmap/archive/phase/control-plane-portal-ops/milestones/nexo-notification-message-smoke-alignment.md`
|
||||
- 요약: `../nomadcode`의 server-generated signed push smoke evidence와 IOP `apps/client`의 Nexo messaging 소비 설정 차이를 선행 게이트로 정리하고 archive했다.
|
||||
|
||||
- [계획] Client Integration Clone Adoption
|
||||
- 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/client-integration-clone-adoption.md`
|
||||
- 요약: Nexo 알림/메시지 smoke 정합성 확인 뒤 NomadCode client 표준을 IOP client에 복제하기 위한 모듈명, bootstrap, proto-socket, Mattermost push host integration 기준을 정리한다.
|
||||
- [폐기] Client Integration Clone Adoption
|
||||
- 경로: `agent-roadmap/archive/phase/control-plane-portal-ops/milestones/client-integration-clone-adoption.md`
|
||||
- 요약: NomadCode Flutter client clone adoption은 별도 프로젝트에서 진행하기로 하여 IOP active roadmap에서 제외했다.
|
||||
|
||||
- [계획] Control Plane과 Client
|
||||
- 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-client.md`
|
||||
|
|
@ -46,5 +46,4 @@
|
|||
- Control Plane은 Node를 직접 스케줄링하지 않고 Edge를 통해 관찰/제어한다.
|
||||
- Flutter client는 IOP 자체 운영 UI/UX이며 NomadCode 전용 shell로 만들지 않는다.
|
||||
- Multi-Edge 운영은 Edge의 로컬 상태 소유권을 침범하지 않는 범위에서 확장한다.
|
||||
- `../nomadcode`에서 정리한 client 표준을 복제할 때는 제품 feature 전체가 아니라 client skeleton, integration boundary, bootstrap convention, 문서화된 handoff 기준만 가져온다.
|
||||
- `../nexo`의 messaging/push smoke가 valid signature 또는 scope decision까지 정리되기 전에는 Mattermost push host integration을 IOP client에 확정 적용하지 않는다.
|
||||
- NomadCode Flutter client 복제/표준화 작업은 별도 프로젝트 범위로 분리하고, IOP Phase에서는 필요한 경우 확정 산출물만 후속 Milestone으로 반영한다.
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ Client는 NomadCode 전용 화면이 아니라 IOP가 독립적으로 운영되
|
|||
- [ ] Client의 첫 운영 화면 범위를 Edge/Node 상태 조회 중심으로 둘지, 명령/설정 변경까지 포함할지 결정한다.
|
||||
- [ ] Control Plane-Edge 연결에서 우선 구현할 IOP Wire Protocol 범위를 결정한다.
|
||||
- [ ] IOP client가 범용 운영 UI로 남기 위해 제외할 NomadCode 전용 UX 경계를 결정한다.
|
||||
- [ ] Client Integration Clone Adoption 완료 전 Flutter client 화면 구현을 어디까지 진행할지 결정한다.
|
||||
|
||||
## 범위
|
||||
|
||||
|
|
@ -74,6 +73,6 @@ Client는 NomadCode 전용 화면이 아니라 IOP가 독립적으로 운영되
|
|||
- 관련 경로: `apps/control-plane`, `apps/client`, `apps/control-plane/README.md`, `apps/client/README.md`, `agent-test/local/control-plane-smoke.md`, `docker-compose.yml`, `scripts/dev/web.sh`
|
||||
- 표준선(선택): Control Plane은 Node를 직접 스케줄링하지 않고 Edge를 통해 관찰/제어한다.
|
||||
- 표준선(선택): Client-Control Plane의 첫 wire surface는 `/client` WebSocket hello handshake를 기준으로 둔다.
|
||||
- 선행 작업: Flutter-first Client 마이그레이션, Client Integration Clone Adoption, Agent Bootstrap과 OTO 등록, 정책/이력/감사 일부 기준
|
||||
- 선행 작업: Flutter-first Client 마이그레이션, Agent Bootstrap과 OTO 등록, 정책/이력/감사 일부 기준
|
||||
- 후속 작업: Multi-Edge 운영
|
||||
- 확인 필요: Client 첫 화면 범위, Control Plane-Edge wire 우선순위, NomadCode 전용 UX 제외 기준, client 표준 clone 반영 범위
|
||||
- 확인 필요: Client 첫 화면 범위, Control Plane-Edge wire 우선순위, NomadCode 전용 UX 제외 기준
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
# Milestone: Nexo 알림/메시지 Smoke 정합성
|
||||
|
||||
## 위치
|
||||
|
||||
- Roadmap: `agent-roadmap/ROADMAP.md`
|
||||
- Phase: `agent-roadmap/phase/control-plane-portal-ops/PHASE.md`
|
||||
|
||||
## 목표
|
||||
|
||||
`../nexo`의 메시징/푸시 알림 검증 상태를 IOP client integration의 선행 게이트로 정리한다. Nexo `client-validation`의 FCM ACK smoke 결과와 남은 valid signed payload 결정을 확인하고, IOP `apps/client`가 `nexo_messaging` 소비 앱 조건을 만족하기 위해 필요한 설정 차이를 확정한다.
|
||||
|
||||
## 상태
|
||||
|
||||
[진행중]
|
||||
|
||||
## 구현 잠금
|
||||
|
||||
- 상태: 잠금
|
||||
- 결정 필요: 아래 체크리스트
|
||||
- [x] Nexo에서 valid signed push trigger를 제공해 notification display/opened/reply/dismiss까지 검증하는 방향으로 진행한다.
|
||||
- [x] IOP client의 Mattermost push routing은 내부 화면, 외부 링크, 운영 이벤트를 모두 지원한다. 남은 작업은 notification type/context별 기본 라우팅, fallback, 이벤트 기록 정책을 정의하는 것이다.
|
||||
|
||||
## 범위
|
||||
|
||||
- `../nexo`의 `client-validation` Milestone, `04+03_fcm_ack_smoke` handoff, `packages/messaging_flutter` host responsibility 확인
|
||||
- IOP `apps/client`의 `nexo_messaging`, Firebase, Android application id, Google Services plugin, platform guard, plugin initialization 설정 차이 정리
|
||||
- IOP client의 device token, auth token, signing key, notification opened routing callback 책임 경계 정리
|
||||
- `Client Integration Clone Adoption`을 시작하기 전 필요한 선행 게이트와 후속 분리 항목 문서화
|
||||
|
||||
## 기능
|
||||
|
||||
### Epic: [nexo-smoke] Nexo smoke gate
|
||||
|
||||
Nexo 쪽 알림/메시지 smoke 상태를 IOP client adoption의 선행 조건으로 분류한다.
|
||||
|
||||
- [ ] [handoff-status] `../nexo/HANDOFF.md`와 `../nexo/agent-roadmap/phase/product-foundation/milestones/client-validation.md` 기준으로 FCM ACK smoke의 PASS/BLOCKED 항목을 최신 상태로 확인한다.
|
||||
- [ ] [signed-scope] valid signed push trigger를 확보해 notification display, opened routing, inline reply, dismiss까지 검증한 결과를 남긴다. 진행 방향: FCM receipt + ACK까지만 인정하지 않고 full notification smoke를 완료한다.
|
||||
|
||||
### Epic: [iop-consumer] IOP consumer 설정 정합성
|
||||
|
||||
IOP `apps/client`가 Nexo messaging plugin의 host app responsibility를 충족하는지 확인하고 부족한 설정을 adoption 작업 입력으로 만든다.
|
||||
|
||||
- [ ] [dependency-gap] IOP `apps/client/pubspec.yaml`과 Android 설정에서 `nexo_messaging`, `firebase_core`, Google Services plugin, Firebase config, application id 차이를 정리한다.
|
||||
- [ ] [bootstrap-gap] Web + Android bootstrap에서 Firebase 초기화, `NexoMessagingPlugin.instance.initialize()`, platform guard, deterministic test escape hatch 필요 여부를 정리한다.
|
||||
- [ ] [credential-boundary] device token, auth token, signing key 저장과 전달 책임을 IOP Control Plane/client 경계에 맞춰 정의한다.
|
||||
- [ ] [routing-boundary] notification opened event가 내부 화면, 외부 링크, 운영 이벤트를 모두 사용할 수 있도록 notification type/context별 기본 라우팅, fallback, 이벤트 기록 정책을 정리한다.
|
||||
|
||||
### Epic: [adoption-gate] Clone adoption handoff
|
||||
|
||||
기존 `Client Integration Clone Adoption` Milestone이 시작할 수 있는 입력과 남은 블로커를 추적 가능하게 만든다.
|
||||
|
||||
- [ ] [clone-input] `Client Integration Clone Adoption`에서 가져갈 Nexo messaging skeleton, IOP 치환 항목, 후속 분리 항목을 checklist로 남긴다.
|
||||
- [ ] [lock-sync] 이 Milestone과 `../nexo` `client-validation`의 외부 의존 잠금 상태가 workspace lock에 반영되어 있는지 확인한다.
|
||||
|
||||
## 완료 리뷰
|
||||
|
||||
- 상태: 없음
|
||||
- 요청일: 없음
|
||||
- 완료 근거: 없음
|
||||
- 리뷰 필요:
|
||||
- [ ] 사용자가 완료 결과를 확인했다
|
||||
- [ ] archive 이동을 승인했다
|
||||
- 리뷰 코멘트: 없음
|
||||
|
||||
## 범위 제외
|
||||
|
||||
- Nexo plugin runtime 대규모 재작성
|
||||
- IOP client의 전체 제품 UI 재설계
|
||||
- iOS/macOS native push 구현
|
||||
- Control Plane-Edge wire protocol 세부 확정
|
||||
- raw FCM token, auth token, signing key, private endpoint 값을 tracked 문서에 기록하는 일
|
||||
|
||||
## 작업 컨텍스트
|
||||
|
||||
- 관련 경로: `apps/client/`, `apps/client/android/`, `../nexo/HANDOFF.md`, `../nexo/packages/messaging_flutter/`, `../nexo/apps/flutter-test/`, `../nexo/agent-roadmap/phase/product-foundation/milestones/client-validation.md`
|
||||
- 표준선(선택): Nexo `nexo_messaging`은 Android-first plugin runtime이고 host app은 Firebase config, plugin initialization, auth/signing key 저장, device token 전달, opened routing callback만 소유한다.
|
||||
- 표준선(선택): IOP Web target은 messaging native runtime을 직접 실행하지 않고 platform guard와 no-op 또는 후속 확장 경계를 명확히 둔다.
|
||||
- 표준선(선택): Mattermost push routing은 내부 화면, 외부 링크, 운영 이벤트를 배타적으로 고르지 않고 모두 지원한다. 구현에서는 type/context별 primary route, fallback route, audit/ops event 기록 여부를 정책으로 분리한다.
|
||||
- 외부 의존 잠금: `nexo:agent-roadmap/phase/product-foundation/milestones/client-validation.md`
|
||||
- 선행 작업: Nexo `client-validation`의 valid signed push trigger 기반 full notification smoke
|
||||
- 후속 작업: `Client Integration Clone Adoption`
|
||||
- 확인 필요: Nexo에서 valid signed push trigger를 준비하고 display/opened/reply/dismiss evidence를 수집해야 한다.
|
||||
3
apps/client/.gitignore
vendored
3
apps/client/.gitignore
vendored
|
|
@ -43,3 +43,6 @@ app.*.map.json
|
|||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Mattermost credentials
|
||||
assets/mattermost_credentials.json
|
||||
|
|
|
|||
|
|
@ -3,16 +3,18 @@ plugins {
|
|||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
id("com.google.gms.google-services")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.iop_client"
|
||||
namespace = "com.tokilabs.mattermost"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
|
|
@ -21,7 +23,7 @@ android {
|
|||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.example.iop_client"
|
||||
applicationId = "com.tokilabs.mattermost"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
|
|
@ -42,3 +44,7 @@ android {
|
|||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
}
|
||||
|
|
|
|||
29
apps/client/android/app/google-services.json
Normal file
29
apps/client/android/app/google-services.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"project_info": {
|
||||
"project_number": "1047648748539",
|
||||
"project_id": "mattermost-6ac08",
|
||||
"storage_bucket": "mattermost-6ac08.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:1047648748539:android:818bf70bfbb3f9d070415e",
|
||||
"android_client_info": {
|
||||
"package_name": "com.tokilabs.mattermost"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyAW6j_oPl9MVcm93qS3JgaEPD5ywp-TzZ0"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<application
|
||||
android:label="iop_client"
|
||||
android:name="${applicationName}"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,13 @@
|
|||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.google.gms:google-services:4.4.2")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
|
|
|
|||
1
apps/client/assets/.gitkeep
Normal file
1
apps/client/assets/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -1,14 +1,48 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'client_config.dart';
|
||||
import 'iop_wire/client_wire_client.dart';
|
||||
import 'src/integrations/mattermost/mattermost_push_host_integration.dart';
|
||||
import 'src/integrations/mattermost/mattermost_push_plugin_client.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const IopClientApp());
|
||||
final _mattermostHost = MattermostPushHostIntegration(
|
||||
pushClient: MattermostPushPluginClient(),
|
||||
);
|
||||
|
||||
Future<void> main() => runIopClient();
|
||||
|
||||
Future<void> applyFullscreenMode() async {
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> bootstrapIopClient() async {
|
||||
await applyFullscreenMode();
|
||||
await Firebase.initializeApp();
|
||||
await _mattermostHost.initialize();
|
||||
}
|
||||
|
||||
Future<void> runIopClient() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await bootstrapIopClient();
|
||||
runApp(IopClientApp(mattermostHost: _mattermostHost));
|
||||
}
|
||||
|
||||
class IopClientApp extends StatelessWidget {
|
||||
final ClientWireClient? testClient;
|
||||
const IopClientApp({super.key, this.testClient});
|
||||
final MattermostPushHostIntegration? mattermostHost;
|
||||
|
||||
const IopClientApp({super.key, this.testClient, this.mattermostHost});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -24,14 +58,19 @@ class IopClientApp extends StatelessWidget {
|
|||
),
|
||||
scaffoldBackgroundColor: const Color(0xFF0F172A),
|
||||
),
|
||||
home: ClientHomePage(testClient: testClient),
|
||||
home: ClientHomePage(
|
||||
testClient: testClient,
|
||||
mattermostHost: mattermostHost,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ClientHomePage extends StatefulWidget {
|
||||
final ClientWireClient? testClient;
|
||||
const ClientHomePage({super.key, this.testClient});
|
||||
final MattermostPushHostIntegration? mattermostHost;
|
||||
|
||||
const ClientHomePage({super.key, this.testClient, this.mattermostHost});
|
||||
|
||||
@override
|
||||
State<ClientHomePage> createState() => _ClientHomePageState();
|
||||
|
|
@ -45,6 +84,7 @@ class _ClientHomePageState extends State<ClientHomePage>
|
|||
String _wireStatus = 'Disconnected';
|
||||
String _statusMessage = 'Not connected';
|
||||
ClientWireClient? _client;
|
||||
StreamSubscription? _notificationSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -60,17 +100,45 @@ class _ClientHomePageState extends State<ClientHomePage>
|
|||
|
||||
// Auto-connect on start
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
applyFullscreenMode();
|
||||
_connectWire();
|
||||
});
|
||||
|
||||
final host = widget.mattermostHost;
|
||||
if (host != null) {
|
||||
_notificationSubscription = host.onNotification.listen(
|
||||
_showMattermostNotification,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pulseController.dispose();
|
||||
_notificationSubscription?.cancel();
|
||||
_client?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showMattermostNotification(Map<String, dynamic> data) {
|
||||
if (data['type'] != 'message' || !mounted) return;
|
||||
|
||||
final message = data['message'] as String? ?? '';
|
||||
final channel = data['channel_name'] as String? ?? '';
|
||||
final sender = data['sender_name'] as String? ?? '';
|
||||
final content = sender.isNotEmpty
|
||||
? '[$channel] $sender: $message'
|
||||
: message;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(content, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
duration: const Duration(seconds: 4),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _connectWire() async {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart' show FlutterError;
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'mattermost_push_client.dart';
|
||||
|
||||
/// Mattermost 자동 로그인 + FCM 디바이스 토큰 서버 등록 서비스.
|
||||
class MattermostAuthService {
|
||||
final MattermostPushClient _pushService;
|
||||
|
||||
String? _serverUrl;
|
||||
String? _serverIdentifier;
|
||||
String? _authToken;
|
||||
String? _userId;
|
||||
String? _sessionId;
|
||||
|
||||
String? get serverUrl => _serverUrl;
|
||||
bool get isLoggedIn => _authToken != null;
|
||||
|
||||
MattermostAuthService(this._pushService);
|
||||
|
||||
/// assets/mattermost_credentials.json 로드 → 로그인 → FCM 등록까지 자동 수행.
|
||||
Future<void> autoLoginAndRegister() async {
|
||||
final creds = await _loadCredentials();
|
||||
if (creds == null) {
|
||||
print(
|
||||
'[MattermostAuth] Credentials asset not found, skipping auto login.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
_serverUrl = creds['serverUrl']!;
|
||||
_serverIdentifier = creds['serverId'];
|
||||
|
||||
print('[MattermostAuth] Logging in to $_serverUrl ...');
|
||||
await _login(creds['loginId']!, creds['password']!);
|
||||
|
||||
// FCM 토큰이 이미 있으면 바로 등록, 없으면 콜백으로 대기
|
||||
final existingToken = await _pushService.getDeviceToken();
|
||||
if (existingToken != null && existingToken.isNotEmpty) {
|
||||
print('[MattermostAuth] FCM token already available, registering ...');
|
||||
await _registerDeviceToken(existingToken);
|
||||
} else {
|
||||
print(
|
||||
'[MattermostAuth] FCM token not ready yet, waiting for callback ...',
|
||||
);
|
||||
}
|
||||
|
||||
// FCM 토큰 발급/갱신 시 자동으로 서버에 등록
|
||||
_pushService.onDeviceTokenReady = (deviceToken) async {
|
||||
print('[MattermostAuth] FCM token ready, registering with server ...');
|
||||
await _registerDeviceToken(deviceToken);
|
||||
};
|
||||
|
||||
print('[MattermostAuth] Auto login & FCM registration complete.');
|
||||
}
|
||||
|
||||
Future<Map<String, String>?> _loadCredentials() async {
|
||||
final String jsonStr;
|
||||
try {
|
||||
jsonStr = await rootBundle.loadString(
|
||||
'assets/mattermost_credentials.json',
|
||||
);
|
||||
} on FlutterError {
|
||||
return null;
|
||||
}
|
||||
final map = json.decode(jsonStr) as Map<String, dynamic>;
|
||||
final serverId = (map['serverId'] ?? map['serverIdentifier']) as String?;
|
||||
return {
|
||||
'serverUrl': map['serverUrl'] as String,
|
||||
'loginId': map['loginId'] as String,
|
||||
'password': map['password'] as String,
|
||||
if (serverId != null && serverId.isNotEmpty) 'serverId': serverId,
|
||||
};
|
||||
}
|
||||
|
||||
/// POST /api/v4/users/login → Token 헤더에서 인증 토큰 추출.
|
||||
Future<void> _login(String loginId, String password) async {
|
||||
final url = Uri.parse('$_serverUrl/api/v4/users/login');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({'login_id': loginId, 'password': password}),
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception(
|
||||
'[MattermostAuth] Login failed (${response.statusCode}): ${response.body}',
|
||||
);
|
||||
}
|
||||
|
||||
_authToken = response.headers['token'];
|
||||
if (_authToken == null) {
|
||||
throw Exception('[MattermostAuth] Login response missing Token header');
|
||||
}
|
||||
|
||||
final body = json.decode(response.body) as Map<String, dynamic>;
|
||||
_userId = body['id'] as String?;
|
||||
_sessionId = body['session_id'] as String? ?? body['id'] as String?;
|
||||
|
||||
print('[MattermostAuth] Login OK - userId=$_userId sessionId=$_sessionId');
|
||||
|
||||
// 네이티브 측에 인증 토큰 저장 (ACK, 답장 등에 사용)
|
||||
await _pushService.setAuthToken(
|
||||
_serverUrl!,
|
||||
_authToken!,
|
||||
identifier: _serverIdentifier,
|
||||
);
|
||||
|
||||
// 서버 config에서 signing key 가져와 네이티브에 저장
|
||||
await _fetchAndStoreSigningKey();
|
||||
}
|
||||
|
||||
/// GET /api/v4/config/client?format=old → AsymmetricSigningPublicKey를 네이티브에 저장.
|
||||
Future<void> _fetchAndStoreSigningKey() async {
|
||||
try {
|
||||
final url = Uri.parse('$_serverUrl/api/v4/config/client?format=old');
|
||||
final response = await http.get(
|
||||
url,
|
||||
headers: {'Authorization': 'Bearer $_authToken'},
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
print(
|
||||
'[MattermostAuth] Failed to fetch config (${response.statusCode})',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final config = json.decode(response.body) as Map<String, dynamic>;
|
||||
final signingKey = config['AsymmetricSigningPublicKey'] as String?;
|
||||
if (signingKey != null && signingKey.isNotEmpty) {
|
||||
await _pushService.setSigningKey(_serverUrl!, signingKey);
|
||||
print('[MattermostAuth] Signing key stored.');
|
||||
} else {
|
||||
print('[MattermostAuth] No signing key in server config.');
|
||||
}
|
||||
} catch (e) {
|
||||
print('[MattermostAuth] Failed to fetch signing key: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// PUT /api/v4/users/sessions/device → device_id 등록.
|
||||
Future<void> _registerDeviceToken(String deviceToken) async {
|
||||
final url = Uri.parse('$_serverUrl/api/v4/users/sessions/device');
|
||||
final response = await http.put(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $_authToken',
|
||||
},
|
||||
body: json.encode({'device_id': deviceToken}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
print('[MattermostAuth] FCM device token registered successfully.');
|
||||
} else {
|
||||
print(
|
||||
'[MattermostAuth] FCM registration failed (${response.statusCode}): ${response.body}',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/// Host-facing interface for the Mattermost push plugin.
|
||||
///
|
||||
/// All production access to the platform plugin singleton is routed through
|
||||
/// implementations of this interface. The host integration depends on this
|
||||
/// abstraction so tests can inject fakes without booting Firebase/FCM.
|
||||
abstract interface class MattermostPushClient {
|
||||
Stream<Map<String, dynamic>> get onNotification;
|
||||
|
||||
Future<void> initialize();
|
||||
Future<String?> getDeviceToken();
|
||||
Future<void> setAuthToken(
|
||||
String serverUrl,
|
||||
String token, {
|
||||
String? identifier,
|
||||
});
|
||||
Future<void> setSigningKey(String serverUrl, String signingKey);
|
||||
|
||||
set onDeviceTokenReady(Future<void> Function(String token)? callback);
|
||||
set onNavigateToChannel(
|
||||
void Function(String serverUrl, String channelId)? callback,
|
||||
);
|
||||
set onNavigateToThread(
|
||||
void Function(String serverUrl, String rootId)? callback,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
|
||||
import 'mattermost_auth_service.dart';
|
||||
import 'mattermost_push_client.dart';
|
||||
|
||||
/// Owns the Mattermost host/plugin responsibility boundary.
|
||||
///
|
||||
/// The integration:
|
||||
/// - initializes the push client exactly once,
|
||||
/// - performs auto-login best-effort (skips silently when credentials are
|
||||
/// unavailable, so app boot is not blocked),
|
||||
/// - registers navigation callbacks in one place (not from a widget build),
|
||||
/// - exposes the notification stream for app-level consumers.
|
||||
class MattermostPushHostIntegration {
|
||||
final MattermostPushClient pushClient;
|
||||
final MattermostAuthService Function(MattermostPushClient client)
|
||||
_authServiceFactory;
|
||||
|
||||
bool _initialized = false;
|
||||
|
||||
MattermostPushHostIntegration({
|
||||
required this.pushClient,
|
||||
MattermostAuthService Function(MattermostPushClient client)?
|
||||
authServiceFactory,
|
||||
}) : _authServiceFactory =
|
||||
authServiceFactory ??
|
||||
((MattermostPushClient client) => MattermostAuthService(client));
|
||||
|
||||
Stream<Map<String, dynamic>> get onNotification => pushClient.onNotification;
|
||||
|
||||
bool get isInitialized => _initialized;
|
||||
|
||||
/// Initialize plugin, perform auth handoff, and register navigation
|
||||
/// callbacks. Safe to call once at bootstrap; subsequent calls are no-ops.
|
||||
///
|
||||
/// [onNavigateToChannel] / [onNavigateToThread] are optional. When omitted,
|
||||
/// the integration installs debug-logging fallbacks so the navigation path
|
||||
/// remains observable without forcing the caller to wire routing.
|
||||
Future<void> initialize({
|
||||
void Function(String serverUrl, String channelId)? onNavigateToChannel,
|
||||
void Function(String serverUrl, String rootId)? onNavigateToThread,
|
||||
}) async {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
|
||||
await pushClient.initialize();
|
||||
|
||||
final authService = _authServiceFactory(pushClient);
|
||||
try {
|
||||
await authService.autoLoginAndRegister();
|
||||
} catch (e) {
|
||||
debugPrint('[MattermostHost] Mattermost auto-login failed: $e');
|
||||
}
|
||||
|
||||
pushClient.onNavigateToChannel =
|
||||
onNavigateToChannel ??
|
||||
(serverUrl, channelId) {
|
||||
debugPrint(
|
||||
'[MattermostHost] Navigate to channel: $channelId on $serverUrl',
|
||||
);
|
||||
};
|
||||
pushClient.onNavigateToThread =
|
||||
onNavigateToThread ??
|
||||
(serverUrl, rootId) {
|
||||
debugPrint(
|
||||
'[MattermostHost] Navigate to thread: $rootId on $serverUrl',
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:nexo_messaging/nexo_messaging.dart';
|
||||
|
||||
import 'mattermost_push_client.dart';
|
||||
|
||||
/// Production adapter wrapping the platform-channel singleton.
|
||||
///
|
||||
/// This is the ONLY file in production that should reference
|
||||
/// `NexoMessagingPlugin.instance`. Everything else depends on
|
||||
/// [MattermostPushClient].
|
||||
class MattermostPushPluginClient implements MattermostPushClient {
|
||||
final NexoMessagingPlugin _plugin;
|
||||
|
||||
MattermostPushPluginClient({NexoMessagingPlugin? plugin})
|
||||
: _plugin = plugin ?? NexoMessagingPlugin.instance;
|
||||
|
||||
@override
|
||||
Stream<Map<String, dynamic>> get onNotification => _plugin.onNotification;
|
||||
|
||||
@override
|
||||
Future<void> initialize() => _plugin.initialize();
|
||||
|
||||
@override
|
||||
Future<String?> getDeviceToken() => _plugin.getDeviceToken();
|
||||
|
||||
@override
|
||||
Future<void> setAuthToken(
|
||||
String serverUrl,
|
||||
String token, {
|
||||
String? identifier,
|
||||
}) => _plugin.setAuthToken(serverUrl, token, identifier: identifier);
|
||||
|
||||
@override
|
||||
Future<void> setSigningKey(String serverUrl, String signingKey) =>
|
||||
_plugin.setSigningKey(serverUrl, signingKey);
|
||||
|
||||
@override
|
||||
set onDeviceTokenReady(Future<void> Function(String token)? callback) {
|
||||
_plugin.onDeviceTokenReady = callback == null
|
||||
? null
|
||||
: (token) => unawaited(callback(token));
|
||||
}
|
||||
|
||||
@override
|
||||
set onNavigateToChannel(
|
||||
void Function(String serverUrl, String channelId)? callback,
|
||||
) {
|
||||
_plugin.onNavigateToChannel = callback;
|
||||
}
|
||||
|
||||
@override
|
||||
set onNavigateToThread(
|
||||
void Function(String serverUrl, String rootId)? callback,
|
||||
) {
|
||||
_plugin.onNavigateToThread = callback;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,10 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@
|
|||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import firebase_core
|
||||
import firebase_messaging
|
||||
import nexo_messaging
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||
NexoMessagingPlugin.register(with: registry.registrar(forPlugin: "NexoMessagingPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_flutterfire_internals:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _flutterfire_internals
|
||||
sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.59"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -57,6 +65,54 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
firebase_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_core
|
||||
sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.15.2"
|
||||
firebase_core_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_platform_interface
|
||||
sha256: "0ecda14c1bfc9ed8cac303dd0f8d04a320811b479362a9a4efb14fd331a473ce"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
firebase_core_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_web
|
||||
sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.24.1"
|
||||
firebase_messaging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_messaging
|
||||
sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.10"
|
||||
firebase_messaging_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_messaging_platform_interface
|
||||
sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.10"
|
||||
firebase_messaging_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_messaging_web
|
||||
sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.10.10"
|
||||
fixnum:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -83,6 +139,27 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -139,6 +216,13 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
nexo_messaging:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../../nexo/packages/messaging_flutter"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.0.1"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -147,6 +231,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
proto_socket:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -215,6 +307,78 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.30"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -231,6 +395,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
sdks:
|
||||
dart: ">=3.11.3 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
flutter: ">=3.38.0"
|
||||
|
|
|
|||
|
|
@ -35,11 +35,23 @@ dependencies:
|
|||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
# Firebase
|
||||
firebase_core: ^3.13.0
|
||||
|
||||
# Nexo messaging / Mattermost-compatible push plugin
|
||||
nexo_messaging:
|
||||
path: ../../../nexo/packages/messaging_flutter
|
||||
|
||||
protobuf: ^6.0.0
|
||||
fixnum: ^1.1.0
|
||||
fixnum: ^1.1.1
|
||||
proto_socket:
|
||||
path: ../../../proto-socket/dart
|
||||
|
||||
# HTTP client
|
||||
http: ^1.3.0
|
||||
|
||||
url_launcher: ^6.3.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
|
@ -62,6 +74,11 @@ flutter:
|
|||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
assets:
|
||||
# Local Mattermost smoke credentials live at assets/mattermost_credentials.json
|
||||
# and are ignored by apps/client/.gitignore.
|
||||
- assets/
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:iop_client/src/integrations/mattermost/mattermost_auth_service.dart';
|
||||
import 'package:iop_client/src/integrations/mattermost/mattermost_push_client.dart';
|
||||
import 'package:iop_client/src/integrations/mattermost/mattermost_push_host_integration.dart';
|
||||
|
||||
class _FakePushClient implements MattermostPushClient {
|
||||
int initializeCalls = 0;
|
||||
int navigateChannelAssignments = 0;
|
||||
int navigateThreadAssignments = 0;
|
||||
|
||||
final StreamController<Map<String, dynamic>> _controller =
|
||||
StreamController<Map<String, dynamic>>.broadcast();
|
||||
|
||||
void Function(String serverUrl, String channelId)? _navigateChannel;
|
||||
void Function(String serverUrl, String rootId)? _navigateThread;
|
||||
|
||||
@override
|
||||
Stream<Map<String, dynamic>> get onNotification => _controller.stream;
|
||||
|
||||
void emit(Map<String, dynamic> data) => _controller.add(data);
|
||||
|
||||
Future<void> dispose() => _controller.close();
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
initializeCalls += 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getDeviceToken() async => null;
|
||||
|
||||
@override
|
||||
Future<void> setAuthToken(
|
||||
String serverUrl,
|
||||
String token, {
|
||||
String? identifier,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> setSigningKey(String serverUrl, String signingKey) async {}
|
||||
|
||||
@override
|
||||
set onDeviceTokenReady(Future<void> Function(String token)? callback) {}
|
||||
|
||||
@override
|
||||
set onNavigateToChannel(
|
||||
void Function(String serverUrl, String channelId)? callback,
|
||||
) {
|
||||
navigateChannelAssignments += 1;
|
||||
_navigateChannel = callback;
|
||||
}
|
||||
|
||||
@override
|
||||
set onNavigateToThread(
|
||||
void Function(String serverUrl, String rootId)? callback,
|
||||
) {
|
||||
navigateThreadAssignments += 1;
|
||||
_navigateThread = callback;
|
||||
}
|
||||
|
||||
void triggerNavigateChannel(String serverUrl, String channelId) {
|
||||
_navigateChannel?.call(serverUrl, channelId);
|
||||
}
|
||||
|
||||
void triggerNavigateThread(String serverUrl, String rootId) {
|
||||
_navigateThread?.call(serverUrl, rootId);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoopAuthService implements MattermostAuthService {
|
||||
int autoLoginCalls = 0;
|
||||
final bool throwOnLogin;
|
||||
|
||||
_NoopAuthService({this.throwOnLogin = false});
|
||||
|
||||
@override
|
||||
Future<void> autoLoginAndRegister() async {
|
||||
autoLoginCalls += 1;
|
||||
if (throwOnLogin) {
|
||||
throw StateError('credentials missing');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('initialize calls push client init exactly once', () async {
|
||||
final push = _FakePushClient();
|
||||
final auth = _NoopAuthService();
|
||||
final host = MattermostPushHostIntegration(
|
||||
pushClient: push,
|
||||
authServiceFactory: (_) => auth,
|
||||
);
|
||||
|
||||
await host.initialize();
|
||||
await host.initialize();
|
||||
|
||||
expect(push.initializeCalls, equals(1));
|
||||
expect(auth.autoLoginCalls, equals(1));
|
||||
expect(host.isInitialized, isTrue);
|
||||
|
||||
await push.dispose();
|
||||
});
|
||||
|
||||
test('auto-login failure does not block initialize', () async {
|
||||
final push = _FakePushClient();
|
||||
final auth = _NoopAuthService(throwOnLogin: true);
|
||||
final host = MattermostPushHostIntegration(
|
||||
pushClient: push,
|
||||
authServiceFactory: (_) => auth,
|
||||
);
|
||||
|
||||
await host.initialize();
|
||||
|
||||
expect(host.isInitialized, isTrue);
|
||||
expect(push.initializeCalls, equals(1));
|
||||
|
||||
await push.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'navigation callbacks are assigned exactly once during initialize',
|
||||
() async {
|
||||
final push = _FakePushClient();
|
||||
final host = MattermostPushHostIntegration(
|
||||
pushClient: push,
|
||||
authServiceFactory: (_) => _NoopAuthService(),
|
||||
);
|
||||
|
||||
String? capturedChannel;
|
||||
String? capturedThread;
|
||||
await host.initialize(
|
||||
onNavigateToChannel: (_, channelId) => capturedChannel = channelId,
|
||||
onNavigateToThread: (_, rootId) => capturedThread = rootId,
|
||||
);
|
||||
|
||||
expect(push.navigateChannelAssignments, equals(1));
|
||||
expect(push.navigateThreadAssignments, equals(1));
|
||||
|
||||
push.triggerNavigateChannel('https://srv', 'channel-1');
|
||||
push.triggerNavigateThread('https://srv', 'root-1');
|
||||
|
||||
expect(capturedChannel, equals('channel-1'));
|
||||
expect(capturedThread, equals('root-1'));
|
||||
|
||||
await push.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test('notification stream remains consumable through integration', () async {
|
||||
final push = _FakePushClient();
|
||||
final host = MattermostPushHostIntegration(
|
||||
pushClient: push,
|
||||
authServiceFactory: (_) => _NoopAuthService(),
|
||||
);
|
||||
await host.initialize();
|
||||
|
||||
final received = <Map<String, dynamic>>[];
|
||||
final sub = host.onNotification.listen(received.add);
|
||||
|
||||
push.emit(const {'type': 'message', 'message': 'hi'});
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(received, hasLength(1));
|
||||
expect(received.first['message'], equals('hi'));
|
||||
|
||||
await sub.cancel();
|
||||
await push.dispose();
|
||||
});
|
||||
}
|
||||
|
|
@ -6,6 +6,12 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
firebase_core
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ openai:
|
|||
- "$MODEL"
|
||||
session_id: "cline"
|
||||
timeout_sec: 30
|
||||
strict_output: false
|
||||
console:
|
||||
adapter: mock
|
||||
target: mock-echo
|
||||
|
|
|
|||
Loading…
Reference in a new issue