From d7324fe184773a92d457cd5e97ab9e6891c81207 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 30 May 2026 18:02:06 +0900 Subject: [PATCH] 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 --- .../project/domain/control-plane/rules.md | 9 +- agent-ops/rules/project/domain/edge/rules.md | 9 +- .../client-integration-clone-adoption.md | 16 +- ...xo-notification-message-smoke-alignment.md | 85 +++++++++ agent-roadmap/current.md | 4 +- .../phase/control-plane-portal-ops/PHASE.md | 17 +- .../milestones/control-plane-client.md | 5 +- ...xo-notification-message-smoke-alignment.md | 82 --------- apps/client/.gitignore | 3 + apps/client/android/app/build.gradle.kts | 10 +- apps/client/android/app/google-services.json | 29 +++ .../android/app/src/main/AndroidManifest.xml | 2 + apps/client/android/build.gradle.kts | 10 + apps/client/assets/.gitkeep | 1 + apps/client/lib/main.dart | 78 +++++++- .../mattermost/mattermost_auth_service.dart | 161 ++++++++++++++++ .../mattermost/mattermost_push_client.dart | 25 +++ .../mattermost_push_host_integration.dart | 70 +++++++ .../mattermost_push_plugin_client.dart | 58 ++++++ .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 8 + apps/client/pubspec.lock | 174 +++++++++++++++++- apps/client/pubspec.yaml | 19 +- ...mattermost_push_host_integration_test.dart | 174 ++++++++++++++++++ .../flutter/generated_plugin_registrant.cc | 6 + .../windows/flutter/generated_plugins.cmake | 2 + scripts/e2e-openai-ollama.sh | 1 + 28 files changed, 944 insertions(+), 119 deletions(-) rename agent-roadmap/{ => archive}/phase/control-plane-portal-ops/milestones/client-integration-clone-adoption.md (90%) create mode 100644 agent-roadmap/archive/phase/control-plane-portal-ops/milestones/nexo-notification-message-smoke-alignment.md delete mode 100644 agent-roadmap/phase/control-plane-portal-ops/milestones/nexo-notification-message-smoke-alignment.md create mode 100644 apps/client/android/app/google-services.json create mode 100644 apps/client/assets/.gitkeep create mode 100644 apps/client/lib/src/integrations/mattermost/mattermost_auth_service.dart create mode 100644 apps/client/lib/src/integrations/mattermost/mattermost_push_client.dart create mode 100644 apps/client/lib/src/integrations/mattermost/mattermost_push_host_integration.dart create mode 100644 apps/client/lib/src/integrations/mattermost/mattermost_push_plugin_client.dart create mode 100644 apps/client/test/integrations/mattermost_push_host_integration_test.dart diff --git a/agent-ops/rules/project/domain/control-plane/rules.md b/agent-ops/rules/project/domain/control-plane/rules.md index d06b13e..e02d662 100644 --- a/agent-ops/rules/project/domain/control-plane/rules.md +++ b/agent-ops/rules/project/domain/control-plane/rules.md @@ -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에 기록하지 않는다. diff --git a/agent-ops/rules/project/domain/edge/rules.md b/agent-ops/rules/project/domain/edge/rules.md index 54b779d..407ab14 100644 --- a/agent-ops/rules/project/domain/edge/rules.md +++ b/agent-ops/rules/project/domain/edge/rules.md @@ -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 | bash -s ` 한 줄을 출력하는 경로를 유지한다. 사용자가 기본 경로에서 `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` 생성 파일을 직접 수정하지 않는다. diff --git a/agent-roadmap/phase/control-plane-portal-ops/milestones/client-integration-clone-adoption.md b/agent-roadmap/archive/phase/control-plane-portal-ops/milestones/client-integration-clone-adoption.md similarity index 90% rename from agent-roadmap/phase/control-plane-portal-ops/milestones/client-integration-clone-adoption.md rename to agent-roadmap/archive/phase/control-plane-portal-ops/milestones/client-integration-clone-adoption.md index fad38c1..ba98b12 100644 --- a/agent-roadmap/phase/control-plane-portal-ops/milestones/client-integration-clone-adoption.md +++ b/agent-roadmap/archive/phase/control-plane-portal-ops/milestones/client-integration-clone-adoption.md @@ -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을 더 이상 구현 후보로 두지 않는다. ## 범위 제외 diff --git a/agent-roadmap/archive/phase/control-plane-portal-ops/milestones/nexo-notification-message-smoke-alignment.md b/agent-roadmap/archive/phase/control-plane-portal-ops/milestones/nexo-notification-message-smoke-alignment.md new file mode 100644 index 0000000..a10482f --- /dev/null +++ b/agent-roadmap/archive/phase/control-plane-portal-ops/milestones/nexo-notification-message-smoke-alignment.md @@ -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 값으로 검증한다. diff --git a/agent-roadmap/current.md b/agent-roadmap/current.md index 6ea2d40..ef06abb 100644 --- a/agent-roadmap/current.md +++ b/agent-roadmap/current.md @@ -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` ## 선택 규칙 diff --git a/agent-roadmap/phase/control-plane-portal-ops/PHASE.md b/agent-roadmap/phase/control-plane-portal-ops/PHASE.md index 470650f..6108960 100644 --- a/agent-roadmap/phase/control-plane-portal-ops/PHASE.md +++ b/agent-roadmap/phase/control-plane-portal-ops/PHASE.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으로 반영한다. diff --git a/agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-client.md b/agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-client.md index 73fe61a..a7e6954 100644 --- a/agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-client.md +++ b/agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-client.md @@ -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 제외 기준 diff --git a/agent-roadmap/phase/control-plane-portal-ops/milestones/nexo-notification-message-smoke-alignment.md b/agent-roadmap/phase/control-plane-portal-ops/milestones/nexo-notification-message-smoke-alignment.md deleted file mode 100644 index 2ad7d19..0000000 --- a/agent-roadmap/phase/control-plane-portal-ops/milestones/nexo-notification-message-smoke-alignment.md +++ /dev/null @@ -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를 수집해야 한다. diff --git a/apps/client/.gitignore b/apps/client/.gitignore index 3820a95..7ed4e7c 100644 --- a/apps/client/.gitignore +++ b/apps/client/.gitignore @@ -43,3 +43,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Mattermost credentials +assets/mattermost_credentials.json diff --git a/apps/client/android/app/build.gradle.kts b/apps/client/android/app/build.gradle.kts index ebb790d..897e0c9 100644 --- a/apps/client/android/app/build.gradle.kts +++ b/apps/client/android/app/build.gradle.kts @@ -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") +} diff --git a/apps/client/android/app/google-services.json b/apps/client/android/app/google-services.json new file mode 100644 index 0000000..24bda37 --- /dev/null +++ b/apps/client/android/app/google-services.json @@ -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" +} \ No newline at end of file diff --git a/apps/client/android/app/src/main/AndroidManifest.xml b/apps/client/android/app/src/main/AndroidManifest.xml index 2e41363..148149d 100644 --- a/apps/client/android/app/src/main/AndroidManifest.xml +++ b/apps/client/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + main() => runIopClient(); + +Future applyFullscreenMode() async { + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + systemNavigationBarColor: Colors.transparent, + systemNavigationBarDividerColor: Colors.transparent, + ), + ); +} + +Future bootstrapIopClient() async { + await applyFullscreenMode(); + await Firebase.initializeApp(); + await _mattermostHost.initialize(); +} + +Future 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 createState() => _ClientHomePageState(); @@ -45,6 +84,7 @@ class _ClientHomePageState extends State String _wireStatus = 'Disconnected'; String _statusMessage = 'Not connected'; ClientWireClient? _client; + StreamSubscription? _notificationSubscription; @override void initState() { @@ -60,17 +100,45 @@ class _ClientHomePageState extends State // 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 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 _connectWire() async { if (!mounted) return; setState(() { diff --git a/apps/client/lib/src/integrations/mattermost/mattermost_auth_service.dart b/apps/client/lib/src/integrations/mattermost/mattermost_auth_service.dart new file mode 100644 index 0000000..f25cbe9 --- /dev/null +++ b/apps/client/lib/src/integrations/mattermost/mattermost_auth_service.dart @@ -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 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?> _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; + 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 _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; + _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 _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; + 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 _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}', + ); + } + } +} diff --git a/apps/client/lib/src/integrations/mattermost/mattermost_push_client.dart b/apps/client/lib/src/integrations/mattermost/mattermost_push_client.dart new file mode 100644 index 0000000..d160645 --- /dev/null +++ b/apps/client/lib/src/integrations/mattermost/mattermost_push_client.dart @@ -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> get onNotification; + + Future initialize(); + Future getDeviceToken(); + Future setAuthToken( + String serverUrl, + String token, { + String? identifier, + }); + Future setSigningKey(String serverUrl, String signingKey); + + set onDeviceTokenReady(Future Function(String token)? callback); + set onNavigateToChannel( + void Function(String serverUrl, String channelId)? callback, + ); + set onNavigateToThread( + void Function(String serverUrl, String rootId)? callback, + ); +} diff --git a/apps/client/lib/src/integrations/mattermost/mattermost_push_host_integration.dart b/apps/client/lib/src/integrations/mattermost/mattermost_push_host_integration.dart new file mode 100644 index 0000000..a795b5b --- /dev/null +++ b/apps/client/lib/src/integrations/mattermost/mattermost_push_host_integration.dart @@ -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> 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 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', + ); + }; + } +} diff --git a/apps/client/lib/src/integrations/mattermost/mattermost_push_plugin_client.dart b/apps/client/lib/src/integrations/mattermost/mattermost_push_plugin_client.dart new file mode 100644 index 0000000..f600f7d --- /dev/null +++ b/apps/client/lib/src/integrations/mattermost/mattermost_push_plugin_client.dart @@ -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> get onNotification => _plugin.onNotification; + + @override + Future initialize() => _plugin.initialize(); + + @override + Future getDeviceToken() => _plugin.getDeviceToken(); + + @override + Future setAuthToken( + String serverUrl, + String token, { + String? identifier, + }) => _plugin.setAuthToken(serverUrl, token, identifier: identifier); + + @override + Future setSigningKey(String serverUrl, String signingKey) => + _plugin.setSigningKey(serverUrl, signingKey); + + @override + set onDeviceTokenReady(Future 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; + } +} diff --git a/apps/client/linux/flutter/generated_plugin_registrant.cc b/apps/client/linux/flutter/generated_plugin_registrant.cc index e71a16d..f6f23bf 100644 --- a/apps/client/linux/flutter/generated_plugin_registrant.cc +++ b/apps/client/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include 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); } diff --git a/apps/client/linux/flutter/generated_plugins.cmake b/apps/client/linux/flutter/generated_plugins.cmake index 2e1de87..f16b4c3 100644 --- a/apps/client/linux/flutter/generated_plugins.cmake +++ b/apps/client/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..86be32e 100644 --- a/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/apps/client/pubspec.lock b/apps/client/pubspec.lock index ca4b607..c87d652 100644 --- a/apps/client/pubspec.lock +++ b/apps/client/pubspec.lock @@ -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" diff --git a/apps/client/pubspec.yaml b/apps/client/pubspec.yaml index 4b8efce..9c9b624 100644 --- a/apps/client/pubspec.yaml +++ b/apps/client/pubspec.yaml @@ -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 diff --git a/apps/client/test/integrations/mattermost_push_host_integration_test.dart b/apps/client/test/integrations/mattermost_push_host_integration_test.dart new file mode 100644 index 0000000..393405a --- /dev/null +++ b/apps/client/test/integrations/mattermost_push_host_integration_test.dart @@ -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> _controller = + StreamController>.broadcast(); + + void Function(String serverUrl, String channelId)? _navigateChannel; + void Function(String serverUrl, String rootId)? _navigateThread; + + @override + Stream> get onNotification => _controller.stream; + + void emit(Map data) => _controller.add(data); + + Future dispose() => _controller.close(); + + @override + Future initialize() async { + initializeCalls += 1; + } + + @override + Future getDeviceToken() async => null; + + @override + Future setAuthToken( + String serverUrl, + String token, { + String? identifier, + }) async {} + + @override + Future setSigningKey(String serverUrl, String signingKey) async {} + + @override + set onDeviceTokenReady(Future 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 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 = >[]; + final sub = host.onNotification.listen(received.add); + + push.emit(const {'type': 'message', 'message': 'hi'}); + await Future.delayed(Duration.zero); + + expect(received, hasLength(1)); + expect(received.first['message'], equals('hi')); + + await sub.cancel(); + await push.dispose(); + }); +} diff --git a/apps/client/windows/flutter/generated_plugin_registrant.cc b/apps/client/windows/flutter/generated_plugin_registrant.cc index 8b6d468..ec8e8d4 100644 --- a/apps/client/windows/flutter/generated_plugin_registrant.cc +++ b/apps/client/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,12 @@ #include "generated_plugin_registrant.h" +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/apps/client/windows/flutter/generated_plugins.cmake b/apps/client/windows/flutter/generated_plugins.cmake index b93c4c3..02d26c3 100644 --- a/apps/client/windows/flutter/generated_plugins.cmake +++ b/apps/client/windows/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + firebase_core + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/scripts/e2e-openai-ollama.sh b/scripts/e2e-openai-ollama.sh index 3366d6c..33ad5c6 100755 --- a/scripts/e2e-openai-ollama.sh +++ b/scripts/e2e-openai-ollama.sh @@ -129,6 +129,7 @@ openai: - "$MODEL" session_id: "cline" timeout_sec: 30 + strict_output: false console: adapter: mock target: mock-echo