From 41bafa57cc78864a605f2a49a4c8954d33b2e061 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Tue, 26 May 2026 19:38:16 +0200 Subject: [PATCH] chore: auto-commit (17 archivos) - app.md - applog.go - frontend/package.json - frontend/package.json.md5 - frontend/vite.config.ts - go.mod - main.go - matrix_service.go - sqlite_driver.go - .wails_dev.log - ... Co-Authored-By: Claude Opus 4.7 (1M context) --- .wails_dev.log | 44 ++ .wails_dev.pid | 1 + app.md | 6 + applog.go | 6 +- docs/matrix_protocol.md | 927 ++++++++++++++++++++++++ e2e_server.go | 377 ++++++++++ frontend/e2e_cdp/entry_flow.spec.ts | 67 ++ frontend/package.json | 1 + frontend/package.json.md5 | 2 +- frontend/playwright.cdp.config.ts | 32 + frontend/src/shims/MatrixServiceShim.ts | 130 ++++ frontend/src/shims/RuntimeShim.ts | 71 ++ frontend/vite.config.ts | 47 +- go.mod | 2 +- main.go | 7 + matrix_service.go | 192 ++++- scripts/check_e2e.sh | 24 + scripts/check_wails_dev.sh | 30 + scripts/launch_e2e.sh | 31 + scripts/launch_wails_dev.sh | 37 + sqlite_driver.go | 5 + 21 files changed, 1995 insertions(+), 44 deletions(-) create mode 100644 .wails_dev.log create mode 100644 .wails_dev.pid create mode 100644 docs/matrix_protocol.md create mode 100644 e2e_server.go create mode 100644 frontend/e2e_cdp/entry_flow.spec.ts create mode 100644 frontend/playwright.cdp.config.ts create mode 100644 frontend/src/shims/MatrixServiceShim.ts create mode 100644 frontend/src/shims/RuntimeShim.ts create mode 100755 scripts/check_e2e.sh create mode 100755 scripts/check_wails_dev.sh create mode 100755 scripts/launch_e2e.sh create mode 100755 scripts/launch_wails_dev.sh diff --git a/.wails_dev.log b/.wails_dev.log new file mode 100644 index 0000000..5f3795b --- /dev/null +++ b/.wails_dev.log @@ -0,0 +1,44 @@ +Wails CLI v2.11.0 + +Executing: go mod tidy + โ€ข Generating bindings: Done. + โ€ข Installing frontend dependencies: Done. + โ€ข Compiling frontend: Done. + +> matrix_client_pc-frontend@0.1.0 dev /home/lucas/fn_registry/projects/element_agents/apps/matrix_client_pc/frontend +> vite + +Port 5173 is in use, trying another one... + + VITE v5.4.21 ready in 142 ms + +Running frontend DevWatcher command: 'pnpm dev' +Building application for development... +Vite Server URL: http://localhost:5174/ + โžœ Local: http://localhost:5174/ + โžœ Network: use --host to expose + โ€ข Generating bindings: Done. + โ€ข Compiling application: Done. + โ€ข Packaging application: Done. + +Using DevServer URL: http://localhost:34115 +Using Frontend DevServer URL: http://localhost:5173 +Using reload debounce setting of 100 milliseconds +INF | Serving assets from frontend DevServer URL: http://localhost:5173 +Overriding existing handler for signal 10. Set JSC_SIGNAL_FOR_GC if you want WebKit to use a different signal +libEGL warning: failed to open /dev/dri/renderD128: Permission denied + +libEGL warning: failed to open /dev/dri/renderD128: Permission denied + +Watching (sub)/directory: /home/lucas/fn_registry/projects/element_agents/apps/matrix_client_pc + + +To develop in the browser and call your bound Go methods from Javascript, navigate to: http://localhost:34115 +ERR | [ExternalAssetHandler] Proxy error: context canceled +ERR | [ExternalAssetHandler] Proxy error: read tcp 127.0.0.1:48699->127.0.0.1:5173: read: connection reset by peer +ERR | [ExternalAssetHandler] Proxy error: context canceled + +Caught quit +Development mode exited + โ™ฅ  If Wails is useful to you or your company, please consider sponsoring the project: +https://github.com/sponsors/leaanthony diff --git a/.wails_dev.pid b/.wails_dev.pid new file mode 100644 index 0000000..dea2da0 --- /dev/null +++ b/.wails_dev.pid @@ -0,0 +1 @@ +1325685 diff --git a/app.md b/app.md index e081979..5409155 100644 --- a/app.md +++ b/app.md @@ -43,8 +43,14 @@ frontend/ React+Vite+TS+Mantine+@fn_library Login.tsx boton "Sign in with Matrix" -> abre browser Home.tsx muestra perfil + boton Logout fn_library/ symlink a frontend/functions/ui del registry +docs/ + matrix_protocol.md referencia endpoints + payloads + mapeo Goโ†”TS ``` +## Protocolo Matrix + +Comunicacion con homeserver (Synapse + MAS) documentada en `docs/matrix_protocol.md`. Cubre: flujo OIDC, /sync, /messages, send (texto/markdown/reply/edit/reaction), E2EE (Olm/Megolm + cross-signing), media (mxc://), profile, errores canonicos, mapeo Goโ†”TS de cada struct bound a Wails, y apendices con endpoints consumidos hoy + gap roadmap. Mantener al aรฑadir capabilities nuevas. + ## Capability growth log - v0.1.0 (2026-05-24) โ€” baseline scaffold (issue 0147): Wails skeleton, login MAS OIDC, token persistence keyring SO. diff --git a/applog.go b/applog.go index 7eba494..e3de450 100644 --- a/applog.go +++ b/applog.go @@ -2,7 +2,6 @@ package main import ( "fmt" - "io" "log/slog" "os" "path/filepath" @@ -35,8 +34,9 @@ func InitLogger() (*Logger, error) { return nil, fmt.Errorf("open log file: %w", err) } - multi := io.MultiWriter(os.Stderr, f) - handler := slog.NewTextHandler(multi, &slog.HandlerOptions{ + // File only โ€” Wails GUI apps on Windows have closed stderr handle, which + // breaks MultiWriter (one failing writer aborts the chain in some Go versions). + handler := slog.NewTextHandler(f, &slog.HandlerOptions{ Level: slog.LevelDebug, }) l := &Logger{ diff --git a/docs/matrix_protocol.md b/docs/matrix_protocol.md new file mode 100644 index 0000000..8304cae --- /dev/null +++ b/docs/matrix_protocol.md @@ -0,0 +1,927 @@ +# Matrix Client-Server Protocol โ€” referencia para matrix_client_pc + +Documentacion del trafico real entre `matrix_client_pc` y el homeserver (Synapse + MAS). +Foco: que endpoint se llama, con que payload, que vuelve, y como mapea al tipo Go bound a Wails (frontend Mantine). + +**Fuentes:** +- Matrix Client-Server API v1.11 โ€” https://spec.matrix.org/v1.11/client-server-api/ +- MSC3861 (OAuth2 + MAS) โ€” https://github.com/matrix-org/matrix-spec-proposals/pull/3861 +- mautrix-go v0.23 โ€” capa que consumimos (no llamamos endpoints crudos en Go salvo casos puntuales) +- element-web (`sources/element-web/`) โ€” referencia de comportamiento UI; usa `matrix-js-sdk` que ataca los mismos endpoints + +**Convencion:** +- Base homeserver: `https://matrix.organic-machine.com` +- Base MAS: `https://mas.organic-machine.com` +- Todas las requests autenticadas llevan `Authorization: Bearer ` salvo login. +- Todas las respuestas son JSON UTF-8. + +--- + +## Indice + +1. [Flujo de login OIDC (MAS)](#1-flujo-de-login-oidc-mas) +2. [Bootstrap de sesion](#2-bootstrap-de-sesion) +3. [Sync loop](#3-sync-loop) +4. [Listado de rooms + state](#4-listado-de-rooms--state) +5. [Timeline (messages history)](#5-timeline-messages-history) +6. [Envio de mensajes](#6-envio-de-mensajes) +7. [E2EE (Olm/Megolm + cross-signing)](#7-e2ee-olmmegolm--cross-signing) +8. [Media (mxc://)](#8-media-mxc) +9. [Profile + presence](#9-profile--presence) +10. [Mapeo Go (bound) โ†” TS (frontend)](#10-mapeo-go-bound--ts-frontend) +11. [Errores: matriz canonica](#11-errores-matriz-canonica) + +--- + +## 1. Flujo de login OIDC (MAS) + +Cubierto por `mas_oidc_loopback_go_infra` (registry). Flow OAuth2 Authorization Code + PKCE. + +### 1.1 Discovery del issuer + +``` +GET https://matrix.organic-machine.com/.well-known/matrix/client +``` + +Respuesta: +```json +{ + "m.homeserver": { "base_url": "https://matrix.organic-machine.com" }, + "org.matrix.msc2965.authentication": { + "issuer": "https://mas.organic-machine.com/", + "account": "https://mas.organic-machine.com/account" + }, + "org.matrix.msc4143.rtc_foci": [ { "type": "livekit", "livekit_service_url": "..." } ] +} +``` + +`org.matrix.msc2965.authentication.issuer` indica que el HS delega auth a MAS. + +### 1.2 OIDC discovery + +``` +GET https://mas.organic-machine.com/.well-known/openid-configuration +``` + +Respuesta (claves consumidas): +```json +{ + "issuer": "https://mas.organic-machine.com/", + "authorization_endpoint": "https://mas.organic-machine.com/authorize", + "token_endpoint": "https://mas.organic-machine.com/oauth2/token", + "registration_endpoint": "https://mas.organic-machine.com/oauth2/registration", + "scopes_supported": ["openid", "urn:matrix:org.matrix.msc2967.client:api:*", "urn:matrix:org.matrix.msc2967.client:device:*"], + "code_challenge_methods_supported": ["S256"] +} +``` + +### 1.3 Dynamic client registration (RFC 7591) + +``` +POST https://mas.organic-machine.com/oauth2/registration +Content-Type: application/json + +{ + "client_name": "matrix_client_pc", + "application_type": "native", + "redirect_uris": ["http://127.0.0.1:/callback"], + "token_endpoint_auth_method": "none", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "scope": "openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:" +} +``` + +Respuesta: +```json +{ "client_id": "01HEXXXX...", "client_id_issued_at": 1716565000 } +``` + +`device_id` lo genera el cliente (16 chars hex). Se reutiliza en cada login para preservar las Olm sessions. + +### 1.4 Authorization request (browser) + +mautrix-go abre browser SO con: +``` +GET https://mas.organic-machine.com/authorize? + response_type=code& + client_id=& + redirect_uri=http://127.0.0.1:/callback& + scope=openid+urn:matrix:org.matrix.msc2967.client:api:*+urn:matrix:org.matrix.msc2967.client:device:& + state=& + code_challenge=& + code_challenge_method=S256 +``` + +Usuario aprueba en MAS UI. MAS redirige a: +``` +http://127.0.0.1:/callback?code=&state= +``` + +### 1.5 Token exchange + +``` +POST https://mas.organic-machine.com/oauth2/token +Content-Type: application/x-www-form-urlencoded + +grant_type=authorization_code& +code=& +redirect_uri=http://127.0.0.1:/callback& +client_id=& +code_verifier= +``` + +Respuesta: +```json +{ + "access_token": "mat_xxx...", + "token_type": "Bearer", + "expires_in": 300, + "refresh_token": "mar_xxx...", + "scope": "openid urn:matrix:org.matrix.msc2967.client:api:* urn:matrix:org.matrix.msc2967.client:device:" +} +``` + +Persistido por `keyring_token_store_go_infra`. + +### 1.6 Refresh + +`expires_in` corto (5 min). mautrix refresca automaticamente con: +``` +POST https://mas.organic-machine.com/oauth2/token +Content-Type: application/x-www-form-urlencoded + +grant_type=refresh_token& +refresh_token=& +client_id= +``` + +Misma respuesta. Si MAS rota refresh_token (`rotate_refresh_tokens: true`), el viejo deja de servir tras un grace period. + +### 1.7 Logout + +``` +POST https://mas.organic-machine.com/oauth2/revoke +Content-Type: application/x-www-form-urlencoded + +token=& +token_type_hint=refresh_token& +client_id= +``` + +Mas Logout local: borrar token de keyring + `client.Logout()` de mautrix (envia `POST /_matrix/client/v3/logout`). + +--- + +## 2. Bootstrap de sesion + +Tras obtener token, el cliente arranca con `matrix_client_init_go_infra`. + +### 2.1 /whoami โ€” validar token + descubrir DeviceID + +``` +GET /_matrix/client/v3/account/whoami +Authorization: Bearer +``` + +Respuesta: +```json +{ + "user_id": "@lucas:organic-machine.com", + "device_id": "QWERTYUIOP", + "is_guest": false +} +``` + +Usado para rellenar `SessionView` (`UserID`, `DeviceID`). + +### 2.2 /versions โ€” capacidades del HS (opcional) + +``` +GET /_matrix/client/versions +``` + +Respuesta: +```json +{ + "versions": ["v1.1", "v1.2", ..., "v1.11"], + "unstable_features": { + "org.matrix.msc3861": true, + "org.matrix.msc4108": true + } +} +``` + +Util si la app condiciona features por capability. + +--- + +## 3. Sync loop + +Cubierto por `matrix_sync_service_go_infra`. Long-poll continuo. + +### 3.1 Initial sync + +``` +GET /_matrix/client/v3/sync? + filter=& + timeout=0 +Authorization: Bearer +``` + +Respuesta (esquema simplificado): +```json +{ + "next_batch": "s12345_67_8_9_10_11_12_13", + "rooms": { + "join": { + "!roomId:server": { + "summary": { "m.heroes": ["@bob:server"], "m.joined_member_count": 2, "m.invited_member_count": 0 }, + "state": { "events": [ ] }, + "timeline": { + "events": [ ], + "limited": true, + "prev_batch": "t12-34_5_6" + }, + "ephemeral": { "events": [ ] }, + "account_data": { "events": [ ] }, + "unread_notifications": { "highlight_count": 0, "notification_count": 1 } + } + }, + "invite": { "!roomId:server": { "invite_state": { "events": [...] } } }, + "leave": { ... } + }, + "presence": { "events": [...] }, + "account_data": { "events": [ ] }, + "to_device": { "events": [ ] }, + "device_lists": { "changed": ["@bob:server"], "left": [] }, + "device_one_time_keys_count": { "signed_curve25519": 50 } +} +``` + +### 3.2 Incremental sync (long-poll) + +``` +GET /_matrix/client/v3/sync?since=&timeout=30000 +``` + +mautrix re-llama con el `next_batch` previo. timeout 30s. Si nada cambia โ†’ respuesta vacia tras 30s. Si hay evento nuevo โ†’ respuesta inmediata. + +### 3.3 Eventos clave que matrix_client_pc procesa + +| Event type | Donde aparece | Que hacer | +|---|---|---| +| `m.room.message` | `rooms.join.X.timeline.events` | Render en timeline | +| `m.room.encrypted` | `rooms.join.X.timeline.events` | Descifrar via Crypto.Decrypt โ†’ re-render como `m.room.message` | +| `m.room.member` | state + timeline | Update memberlist | +| `m.room.name` / `m.room.topic` / `m.room.avatar` | state | Update room header | +| `m.room.encryption` | state | Marcar room como E2EE | +| `m.room.redaction` | timeline | Tachar evento referido | +| `m.reaction` (m.annotation) | timeline | Agregar al evento target | +| `m.typing` (ephemeral) | `rooms.join.X.ephemeral` | Indicador "X is typing" | +| `m.receipt` (ephemeral) | `rooms.join.X.ephemeral` | Read markers de otros users | +| `m.direct` (account_data global) | `account_data.events` | Marcar rooms como DM | +| `m.tag` (account_data por room) | `rooms.join.X.account_data` | Favoritos / low priority | +| `m.room_key` (to_device) | `to_device.events` | Inbox group session โ€” guardar para descifrar mensajes futuros | + +### 3.4 Filters + +Para acotar el sync (perf). Pre-creado con: +``` +POST /_matrix/client/v3/user//filter +Authorization: Bearer + +{ + "room": { + "timeline": { "limit": 50 }, + "state": { "lazy_load_members": true } + }, + "presence": { "not_types": ["m.presence"] } +} +``` + +Respuesta: `{"filter_id": "1"}`. Se pasa como `?filter=1` en /sync. mautrix lo gestiona internamente. + +--- + +## 4. Listado de rooms + state + +Cubierto por `matrix_room_list_go_infra`. Lee del store del SyncStore (no requiere request extra si el sync ya corrio). + +Si se quiere fetch directo: + +### 4.1 Joined rooms + +``` +GET /_matrix/client/v3/joined_rooms +``` + +Respuesta: +```json +{ "joined_rooms": ["!a:server", "!b:server", ...] } +``` + +### 4.2 Room state (todo el estado actual) + +``` +GET /_matrix/client/v3/rooms//state +``` + +Respuesta: array de state events: +```json +[ + { "type": "m.room.name", "state_key": "", "content": { "name": "Equipo" }, "sender": "@admin:server", ... }, + { "type": "m.room.topic", "state_key": "", "content": { "topic": "..." }, ... }, + { "type": "m.room.encryption", "state_key": "", "content": { "algorithm": "m.megolm.v1.aes-sha2" }, ... }, + { "type": "m.room.member", "state_key": "@lucas:server", "content": { "membership": "join", "displayname": "Lucas" }, ... } +] +``` + +### 4.3 State event puntual + +``` +GET /_matrix/client/v3/rooms//state/[/] +``` + +Ejemplo nombre: +``` +GET /_matrix/client/v3/rooms/!a:server/state/m.room.name +``` + +Respuesta: `content` del evento directo: +```json +{ "name": "Equipo" } +``` + +### 4.4 Mapeo a `infra.RoomSummary` + +| Campo Go | Origen | +|---|---| +| `RoomID` | key del map `rooms.join` | +| `Name` | `m.room.name` state event โ†’ `content.name`. Fallback: `summary.m.heroes` + display names | +| `CanonicalAlias` | `m.room.canonical_alias` state event โ†’ `content.alias` | +| `AvatarMxc` | `m.room.avatar` โ†’ `content.url` | +| `Topic` | `m.room.topic` โ†’ `content.topic` | +| `IsDirect` | `account_data` global `m.direct` lista al roomID | +| `IsSpace` | `m.room.create` โ†’ `content.type == "m.space"` | +| `IsEncrypted` | existencia de state event `m.room.encryption` | +| `Tags` | `rooms.join.X.account_data` event `m.tag` โ†’ keys del map | +| `LastEventTs` | `timeline.events[-1].origin_server_ts` | + +--- + +## 5. Timeline (messages history) + +Cubierto por `MatrixService.LoadTimeline` โ†’ mautrix `client.Messages(...)`. + +### 5.1 /messages โ€” paginacion historica + +``` +GET /_matrix/client/v3/rooms//messages? + from=& # prev_batch del sync o de respuesta anterior + dir=b& # b=backward (mas antiguo), f=forward + limit=50& + filter= +Authorization: Bearer +``` + +Respuesta: +```json +{ + "start": "", + "end": "", + "chunk": [ + { "type": "m.room.message", "event_id": "$abc", "sender": "@x:s", "origin_server_ts": 1716..., "content": { "msgtype": "m.text", "body": "hola" } }, + ... + ], + "state": [ ] +} +``` + +### 5.2 /context โ€” un evento + vecinos + +Para ir a un permalink: +``` +GET /_matrix/client/v3/rooms//context/?limit=10 +``` + +Respuesta: +```json +{ + "event": { ... }, + "events_before": [ ... ], + "events_after": [ ... ], + "start": "", + "end": "", + "state": [ ... ] +} +``` + +### 5.3 Mapeo a `MatrixEvent` + +| Campo Go | Origen | +|---|---| +| `EventID` | `event_id` | +| `RoomID` | path param (no esta en el body de /messages) | +| `Sender` | `sender` | +| `Type` | `type` | +| `Ts` | `origin_server_ts` | +| `Body` | `content.body` (si `type=m.room.message`) | +| `EncryptedRaw` | `true` si `type=m.room.encrypted` y no se pudo descifrar | + +--- + +## 6. Envio de mensajes + +Cubierto por `matrix_message_send_go_infra` โ†’ `MatrixService.SendText` / `SendMarkdown`. + +### 6.1 PUT /send/{type}/{txnId} + +``` +PUT /_matrix/client/v3/rooms//send// +Authorization: Bearer +Content-Type: application/json + + +``` + +`txnId` = string unico por request (mautrix usa nanosecond timestamp). Idempotente: re-PUT del mismo txnId NO duplica. + +Respuesta: +```json +{ "event_id": "$xyz..." } +``` + +### 6.2 Variantes de `content` + +**Texto plano:** +```json +{ "msgtype": "m.text", "body": "hola mundo" } +``` + +**Markdown con HTML formateado:** +```json +{ + "msgtype": "m.text", + "body": "hola **mundo**", + "format": "org.matrix.custom.html", + "formatted_body": "hola mundo" +} +``` + +**Reply (MSC2781 fallback + m.in_reply_to):** +```json +{ + "msgtype": "m.text", + "body": "> <@alice:s> hola\n\nrespuesta", + "format": "org.matrix.custom.html", + "formatted_body": "
In reply to @alice:s
hola
respuesta", + "m.relates_to": { "m.in_reply_to": { "event_id": "$abc" } } +} +``` + +**Edit (m.replace):** +```json +{ + "msgtype": "m.text", + "body": "* nuevo body", + "m.new_content": { "msgtype": "m.text", "body": "nuevo body" }, + "m.relates_to": { "rel_type": "m.replace", "event_id": "$abc" } +} +``` + +**Reaction (m.annotation):** type=`m.reaction`, no `m.room.message`: +```json +{ + "m.relates_to": { "rel_type": "m.annotation", "event_id": "$abc", "key": "๐Ÿ‘" } +} +``` + +PUT a `/send/m.reaction/`. + +### 6.3 En rooms E2EE + +Si `client.Crypto != nil` (configurado via `matrix_crypto_init_go_infra`), mautrix: +1. Toma el `content` plano. +2. Lo cifra con la outbound Megolm session del room. +3. Sustituye el body por: +```json +{ + "algorithm": "m.megolm.v1.aes-sha2", + "ciphertext": "AwgBE...", + "session_id": "abc...", + "sender_key": "curve25519...", + "device_id": "QWERTYUIOP" +} +``` +4. PUT a `/send/m.room.encrypted/` (tipo cambia a `m.room.encrypted`). + +El frontend NO ve esta diferencia โ€” `SendText` retorna el `event_id` igual. + +### 6.4 Redact (delete event) + +``` +PUT /_matrix/client/v3/rooms//redact// +{ "reason": "spam" } +``` + +Respuesta: `{ "event_id": "$redact..." }`. + +--- + +## 7. E2EE (Olm/Megolm + cross-signing) + +Cubierto por `matrix_crypto_init_go_infra` + mautrix cryptohelper. + +### 7.1 Upload device keys + one-time keys + +``` +POST /_matrix/client/v3/keys/upload +{ + "device_keys": { + "user_id": "@lucas:s", + "device_id": "QWERTYUIOP", + "algorithms": ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"], + "keys": { + "curve25519:QWERTYUIOP": "", + "ed25519:QWERTYUIOP": "" + }, + "signatures": { "@lucas:s": { "ed25519:QWERTYUIOP": "" } } + }, + "one_time_keys": { + "signed_curve25519:AAAAAA": { "key": "", "signatures": { ... } }, + ... + } +} +``` + +Respuesta: +```json +{ "one_time_key_counts": { "signed_curve25519": 50 } } +``` + +mautrix mantiene siempre โ‰ฅ50 OTKs disponibles. Cuando /sync devuelve `device_one_time_keys_count.signed_curve25519 < 50`, sube mas. + +### 7.2 Query device keys (de otros) + +``` +POST /_matrix/client/v3/keys/query +{ "device_keys": { "@bob:s": [] } } # array vacio = todos los devices +``` + +Respuesta: +```json +{ + "device_keys": { + "@bob:s": { + "DEVICE1": { "user_id": "@bob:s", "device_id": "DEVICE1", "algorithms": [...], "keys": {...}, "signatures": {...} } + } + }, + "master_keys": { "@bob:s": { ... cross-signing master key ... } }, + "self_signing_keys": { ... }, + "user_signing_keys": { ... } +} +``` + +### 7.3 Claim OTK (para iniciar Olm session) + +``` +POST /_matrix/client/v3/keys/claim +{ "one_time_keys": { "@bob:s": { "DEVICE1": "signed_curve25519" } } } +``` + +Respuesta: +```json +{ + "one_time_keys": { + "@bob:s": { "DEVICE1": { "signed_curve25519:AAAA": { "key": "...", "signatures": {...} } } } + } +} +``` + +### 7.4 Send-to-device (compartir Megolm room key) + +``` +PUT /_matrix/client/v3/sendToDevice/m.room.encrypted/ +{ + "messages": { + "@bob:s": { + "DEVICE1": { + "algorithm": "m.olm.v1.curve25519-aes-sha2", + "ciphertext": { "": { "type": 0|1, "body": "..." } }, + "sender_key": "" + } + } + } +} +``` + +Una vez Bob descifra ese to-device con Olm, obtiene el `m.room_key` con la inbound Megolm session que le permite descifrar todos los mensajes posteriores del room. + +### 7.5 Cross-signing keys (4S, secret storage) + +``` +POST /_matrix/client/v3/keys/device_signing/upload +POST /_matrix/client/v3/keys/signatures/upload +GET /_matrix/client/v3/user//account_data/m.cross_signing.{master,self_signing,user_signing} +GET /_matrix/client/v3/user//account_data/m.secret_storage.default_key +GET /_matrix/client/v3/user//account_data/m.secret_storage.key. +``` + +mautrix cryptohelper gestiona todo. matrix_client_pc solo expone API "verify device" / "recover from passphrase" โ€” ambos traducen a estas requests. + +--- + +## 8. Media (mxc://) + +### 8.1 Download + +``` +GET /_matrix/client/v1/media/download// +Authorization: Bearer # v1 require auth (v3 sin auth deprecado) +``` + +Respuesta: bytes binarios. `Content-Type` indica el mime. + +### 8.2 Thumbnail + +``` +GET /_matrix/client/v1/media/thumbnail//? + width=96&height=96&method=scale&animated=false +``` + +### 8.3 Upload + +``` +POST /_matrix/media/v3/upload +Content-Type: image/png +Authorization: Bearer + + +``` + +Respuesta: +```json +{ "content_uri": "mxc://organic-machine.com/abc123def" } +``` + +### 8.4 mxc:// โ†’ URL http + +Helper canonico (no es endpoint, es transformacion): +``` +mxc://organic-machine.com/abc123def + โ†’ /_matrix/client/v1/media/download/organic-machine.com/abc123def +``` + +Para avatares en `AvatarMxc` del `RoomSummary`, el frontend resuelve con un wrapper que appende el Bearer token. NO se puede meter en `` directo (necesita auth header). + +--- + +## 9. Profile + presence + +### 9.1 Displayname + avatar + +``` +GET /_matrix/client/v3/profile/ +``` + +Respuesta: +```json +{ "displayname": "Lucas", "avatar_url": "mxc://..." } +``` + +### 9.2 Set displayname + +``` +PUT /_matrix/client/v3/profile//displayname +{ "displayname": "Nuevo nombre" } +``` + +### 9.3 Presence + +``` +PUT /_matrix/client/v3/presence//status +{ "presence": "online", "status_msg": "trabajando" } +``` + +Synapse desactiva presence por defecto. matrix_client_pc no debe asumir que esta disponible. + +--- + +## 10. Mapeo Go (bound) โ†” TS (frontend) + +Wails serializa structs Go โ†’ objetos JS via JSON tags. Mantener sincronia con tipos TS. + +### 10.1 SessionView + +```go +type SessionView struct { + UserID string `json:"user_id"` + DeviceID string `json:"device_id"` + HomeserverURL string `json:"homeserver_url"` + HasToken bool `json:"has_token"` + ExpiresAt string `json:"expires_at,omitempty"` +} +``` + +```ts +export interface SessionView { + user_id: string; + device_id: string; + homeserver_url: string; + has_token: boolean; + expires_at?: string; +} +``` + +### 10.2 RoomSummary + +```go +type RoomSummary struct { + RoomID string `json:"room_id"` + Name string `json:"name,omitempty"` + CanonicalAlias string `json:"canonical_alias,omitempty"` + AvatarMxc string `json:"avatar_mxc,omitempty"` + Topic string `json:"topic,omitempty"` + IsDirect bool `json:"is_direct"` + IsSpace bool `json:"is_space"` + IsEncrypted bool `json:"is_encrypted"` + Tags []string `json:"tags,omitempty"` + LastEventTs int64 `json:"last_event_ts,omitempty"` +} +``` + +```ts +export interface RoomSummary { + room_id: string; + name?: string; + canonical_alias?: string; + avatar_mxc?: string; + topic?: string; + is_direct: boolean; + is_space: boolean; + is_encrypted: boolean; + tags?: string[]; + last_event_ts?: number; +} +``` + +### 10.3 MatrixEvent (historico timeline) + +```go +type MatrixEvent struct { + EventID string `json:"event_id"` + RoomID string `json:"room_id"` + Sender string `json:"sender"` + Type string `json:"type"` + Ts int64 `json:"ts"` + Body string `json:"body,omitempty"` + EncryptedRaw bool `json:"encrypted_raw"` +} +``` + +```ts +export interface MatrixEvent { + event_id: string; + room_id: string; + sender: string; + type: string; // "m.room.message" | "m.room.encrypted" | "m.reaction" | ... + ts: number; // ms desde epoch + body?: string; // plain text (puede ser fallback del m.room.encrypted descifrado) + encrypted_raw: boolean; // true = no se pudo descifrar, mostrar placeholder +} +``` + +### 10.4 SyncEventView (push en vivo via Wails event) + +```go +type SyncEventView struct { + Type string `json:"type"` + RoomID string `json:"room_id"` + EventID string `json:"event_id"` + Sender string `json:"sender"` + Ts int64 `json:"ts"` + Body string `json:"body,omitempty"` +} +``` + +Emit Wails: `runtime.EventsEmit(ctx, "matrix:event", view)`. + +Listener TS: +```ts +EventsOn("matrix:event", (ev: SyncEventView) => { ... }); +``` + +### 10.5 Diagnostics + +```go +type Diagnostics struct { + SyncRunning bool `json:"sync_running"` + LastSyncAt string `json:"last_sync_at,omitempty"` + CryptoEnabled bool `json:"crypto_enabled"` + HomeserverURL string `json:"homeserver_url"` + NextBatchToken string `json:"next_batch_token,omitempty"` + PendingEvents int `json:"pending_events"` +} +``` + +--- + +## 11. Errores: matriz canonica + +Synapse devuelve `application/json` con shape estandar Matrix: + +```json +{ + "errcode": "M_FORBIDDEN", + "error": "You do not have permission to send a message in this room" +} +``` + +HTTP status varia. Tabla minima: + +| HTTP | errcode | Significado | Accion en matrix_client_pc | +|---|---|---|---| +| 401 | `M_UNKNOWN_TOKEN` | Token revocado / expirado | Trigger refresh OIDC; si falla โ†’ forzar re-login | +| 401 | `M_MISSING_TOKEN` | Sin Authorization | Bug โ€” siempre debe ir Bearer | +| 403 | `M_FORBIDDEN` | Permission denied | Mostrar toast, no retry | +| 403 | `M_USER_DEACTIVATED` | Cuenta deshabilitada | Logout + mensaje claro | +| 404 | `M_NOT_FOUND` | Room/event no existe | Refrescar lista | +| 429 | `M_LIMIT_EXCEEDED` | Rate limit | Backoff exponencial (mautrix lo hace solo) | +| 400 | `M_INVALID_PARAM` | Body malformado | Bug del cliente | +| 400 | `M_UNKNOWN` | Generico | Log + reportar | +| 502/503/504 | (no JSON) | HS caido | Reintentar con backoff | + +**Errores E2EE (descifrado):** + +| Sintoma | Causa | Mitigacion | +|---|---|---| +| `m.room.encrypted` recibido pero no se descifra | Falta `m.room_key` inbound (llego antes de unirnos) | Mostrar placeholder; key backup recovery (si configurado) | +| `Olm: OUTBOUND_GROUP_SESSION_NOT_FOUND` al enviar | Crypto store corrupto | Wipe + re-bootstrap (ver memoria `agents-e2ee-unblock-pattern`) | +| `M_FORBIDDEN` al PUT /send/m.room.encrypted | El user no esta en room o no tiene power level | Bug de UI; chequear membership antes | + +--- + +## Apendice A โ€” Endpoints que matrix_client_pc consume HOY (auditoria) + +| MatrixService method | mautrix call | Endpoint HTTP | +|---|---|---| +| `Login()` | `mas_oidc_loopback.Run()` | OIDC flow (ยง1) | +| `Start(userID)` | `client.SyncWithContext()` | GET /sync (ยง3) | +| `StartNoCrypto(userID)` | igual sin crypto store | GET /sync (ยง3) | +| `ListRooms()` | lee del SyncStore en memoria | (ninguno extra) | +| `LoadTimeline(roomID, limit)` | `client.Messages(roomID, "", "b", filter, limit)` | GET /rooms/{id}/messages (ยง5.1) | +| `SendText(roomID, body)` | `client.SendMessageEvent(roomID, "m.room.message", content)` | PUT /rooms/{id}/send/m.room.message/{txn} (ยง6.1) | +| `SendMarkdown(roomID, md)` | parse markdown โ†’ mismo SendMessageEvent | PUT /rooms/{id}/send/m.room.message/{txn} (ยง6.1) | +| `Logout(userID)` | `client.Logout()` + revoke MAS | POST /_matrix/client/v3/logout + POST /oauth2/revoke | + +## Apendice B โ€” Endpoints pendientes (gap del cliente) + +Estos NO estan envueltos todavia y son el roadmap natural: + +| Capability | Endpoint | Funcion registry candidata | +|---|---|---| +| Read receipts | `POST /rooms/{id}/receipt/m.read/{eventId}` | `matrix_receipt_send_go_infra` | +| Typing notifications | `PUT /rooms/{id}/typing/{userId}` | `matrix_typing_set_go_infra` | +| Redact (delete) | `PUT /rooms/{id}/redact/{eventId}/{txn}` | `matrix_redact_event_go_infra` | +| Reply / edit / reaction | ya cubierto por `matrix_message_send` (variantes) | โ€” (anadir wrappers en MatrixService) | +| Upload media | `POST /_matrix/media/v3/upload` | `matrix_media_upload_go_infra` | +| Download mxc | `GET /_matrix/client/v1/media/download/...` | `matrix_media_download_go_infra` | +| Resolve room alias | `GET /_matrix/client/v3/directory/room/{alias}` | `matrix_resolve_alias_go_infra` | +| Join room | `POST /_matrix/client/v3/join/{idOrAlias}` | `matrix_room_join_go_infra` | +| Leave room | `POST /_matrix/client/v3/rooms/{id}/leave` | `matrix_room_leave_go_infra` | +| Invite | `POST /_matrix/client/v3/rooms/{id}/invite` | `matrix_room_invite_go_infra` | +| Create room | `POST /_matrix/client/v3/createRoom` | `matrix_room_create_go_infra` | +| Set displayname | `PUT /profile/{userId}/displayname` | `matrix_profile_set_go_infra` | +| Verify device (SAS) | flujo to-device `m.key.verification.*` | `matrix_verify_sas_go_infra` | +| Key backup (recovery) | `GET/PUT /room_keys/...` + 4S | `matrix_key_backup_go_infra` | + +Cada gap โ†’ issue + spawn `fn-constructor`. + +--- + +## Apendice C โ€” Referencias element-web (solo lectura, AGPL โ€” NO copiar codigo) + +| Tema | Fichero element-web | Concepto a portar | +|---|---|---| +| Reply fallback parse | `apps/web/src/utils/Reply.ts` | Stripping `` antes de render | +| Composer markdown | `apps/web/src/editor/serialize.ts` | Pipeline markdown โ†’ html sanitizado | +| Permalink parsing | `apps/web/src/utils/permalinks/` | `matrix.to/#/!r:s/$e` โ†’ roomId + eventId | +| Reaction aggregation | `apps/web/src/utils/Reactions.ts` | Agrupar por key + contar | +| Room list sort | `apps/web/src/stores/room-list/algorithms/` | Recency / favorites / DM tiers | +| Verification UX state | `apps/web/src/verification.ts` | SAS state machine: started โ†’ key_received โ†’ confirmed | +| Notifier (push rules) | `apps/web/src/Notifier.ts` | Eval push rules โ†’ tray/sound/badge | + +Patron: leer fichero element-web como referencia algoritmica, reescribir limpio en Go o TS, citar en `source_file` del frontmatter. + +--- + +## Mantenimiento de este documento + +Cada vez que se anade una capability nueva a `MatrixService`: +1. Documentar endpoint(s) reales que mautrix golpea (capturar con `MAUTRIX_LOG_LEVEL=trace` si dudas). +2. Anadir request/response shape al apartado correspondiente. +3. Anadir mapeo Go โ†” TS si es nuevo tipo bound. +4. Mover la fila del Apendice B al Apendice A. + +Ultima revision: 2026-05-25. diff --git a/e2e_server.go b/e2e_server.go new file mode 100644 index 0000000..9b371d6 --- /dev/null +++ b/e2e_server.go @@ -0,0 +1,377 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strconv" + "time" + + "fn-registry/projects/element_agents/apps/matrix_client_pc/internal/infra" +) + +// E2EServer exposes the MatrixService methods as a localhost HTTP API for +// automated testing. Only starts when env var MATRIX_CLIENT_PC_E2E=1. +// Default port 8767, override with MATRIX_CLIENT_PC_E2E_PORT. +// +// SECURITY: this binds 127.0.0.1 only and the env var gate prevents accidental +// production exposure. Tokens are accepted via POST body and written to keyring. +type E2EServer struct { + svc *MatrixService +} + +func MaybeStartE2EServer(svc *MatrixService) { + if os.Getenv("MATRIX_CLIENT_PC_E2E") != "1" { + return + } + port := os.Getenv("MATRIX_CLIENT_PC_E2E_PORT") + if port == "" { + port = "8767" + } + s := &E2EServer{svc: svc} + mux := http.NewServeMux() + mux.HandleFunc("/inject_token", s.handleInjectToken) + mux.HandleFunc("/signin_admin", s.handleSigninAdmin) + mux.HandleFunc("/wipe_session", s.handleWipeSession) + mux.HandleFunc("/last_user", s.handleLastUser) + mux.HandleFunc("/start", s.handleStart) + mux.HandleFunc("/stop", s.handleStop) + mux.HandleFunc("/wipe_crypto", s.handleWipeCrypto) + mux.HandleFunc("/diagnostics", s.handleDiagnostics) + mux.HandleFunc("/rooms", s.handleRooms) + mux.HandleFunc("/timeline", s.handleTimeline) + mux.HandleFunc("/send", s.handleSend) + mux.HandleFunc("/logs", s.handleLogs) + mux.HandleFunc("/ping", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, 200, map[string]any{"ok": true, "ts": time.Now().Unix()}) + }) + + host := "127.0.0.1" + if os.Getenv("MATRIX_CLIENT_PC_E2E_BIND_ALL") == "1" { + host = "0.0.0.0" + } + addr := host + ":" + port + logInfo("E2E server starting", "addr", addr) + handler := corsMiddleware(mux) + go func() { + if err := http.ListenAndServe(addr, handler); err != nil { + logError("E2E server died", "err", err) + } + }() +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +// corsMiddleware allows any origin (gated by MATRIX_CLIENT_PC_E2E=1 so it's +// only active in dev/test mode) and handles preflight OPTIONS requests. +func corsMiddleware(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusNoContent) + return + } + h.ServeHTTP(w, r) + }) +} + +type injectReq struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + UserID string `json:"user_id"` + DeviceID string `json:"device_id"` + HomeserverURL string `json:"homeserver_url"` + PickleKeyHex string `json:"pickle_key_hex,omitempty"` +} + +func (s *E2EServer) handleInjectToken(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeJSON(w, 405, map[string]string{"error": "POST only"}) + return + } + var req injectReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, 400, map[string]string{"error": err.Error()}) + return + } + if req.UserID == "" || req.AccessToken == "" || req.DeviceID == "" { + writeJSON(w, 400, map[string]string{"error": "user_id, access_token, device_id required"}) + return + } + if req.HomeserverURL == "" { + req.HomeserverURL = homeserverURL + } + tok := infra.Token{ + AccessToken: req.AccessToken, + RefreshToken: req.RefreshToken, + UserID: req.UserID, + DeviceID: req.DeviceID, + HomeserverURL: req.HomeserverURL, + Issuer: masIssuer, + ClientID: masClientID, + PickleKeyHex: req.PickleKeyHex, + } + if err := s.svc.store.Save(req.UserID, tok); err != nil { + writeJSON(w, 500, map[string]string{"error": "keyring save: " + err.Error()}) + return + } + if err := writeLastUser(req.UserID); err != nil { + writeJSON(w, 500, map[string]string{"error": "last_user: " + err.Error()}) + return + } + logInfo("E2E inject_token OK", "user_id", req.UserID, "device_id", req.DeviceID) + writeJSON(w, 200, map[string]string{"status": "ok", "user_id": req.UserID}) +} + +type signinAdminReq struct { + AdminToken string `json:"admin_token"` + UserID string `json:"user_id"` +} + +// handleSigninAdmin takes an existing Matrix access token (admin or otherwise) +// and resolves the user_id + device_id via whoami, then persists to keyring. +// +// With MAS enabled, the Synapse admin login endpoint is disabled. So this +// helper does NOT mint a fresh token โ€” it just bootstraps the app with a +// token that already exists (e.g. from `pass matrix/synapse-admin-token`). +// Token + device_id can also be provided via env (MATRIX_SYNAPSE_ADMIN_TOKEN). +func (s *E2EServer) handleSigninAdmin(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeJSON(w, 405, map[string]string{"error": "POST only"}) + return + } + var req signinAdminReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, 400, map[string]string{"error": err.Error()}) + return + } + admin := req.AdminToken + if admin == "" { + admin = os.Getenv("MATRIX_SYNAPSE_ADMIN_TOKEN") + } + if admin == "" { + writeJSON(w, 400, map[string]string{"error": "admin_token required (body or env MATRIX_SYNAPSE_ADMIN_TOKEN)"}) + return + } + + whoamiReq, _ := http.NewRequest("GET", homeserverURL+"/_matrix/client/v3/account/whoami", nil) + whoamiReq.Header.Set("Authorization", "Bearer "+admin) + hc := &http.Client{Timeout: 15 * time.Second} + wresp, err := hc.Do(whoamiReq) + if err != nil { + writeJSON(w, 502, map[string]string{"error": "whoami http: " + err.Error()}) + return + } + defer wresp.Body.Close() + wbody, _ := io.ReadAll(wresp.Body) + if wresp.StatusCode != 200 { + writeJSON(w, wresp.StatusCode, map[string]string{"error": "whoami non-200", "status": strconv.Itoa(wresp.StatusCode), "body": string(wbody)}) + return + } + var who struct { + UserID string `json:"user_id"` + DeviceID string `json:"device_id"` + } + if err := json.Unmarshal(wbody, &who); err != nil { + writeJSON(w, 500, map[string]string{"error": "parse whoami", "body": string(wbody)}) + return + } + if who.UserID == "" || who.DeviceID == "" { + writeJSON(w, 500, map[string]string{"error": "user_id or device_id empty from whoami", "body": string(wbody)}) + return + } + // Optional sanity: if caller passed user_id, verify it matches. + if req.UserID != "" && req.UserID != who.UserID { + writeJSON(w, 400, map[string]string{"error": fmt.Sprintf("user_id mismatch: passed %s, whoami returned %s", req.UserID, who.UserID)}) + return + } + + tok := infra.Token{ + AccessToken: admin, + UserID: who.UserID, + DeviceID: who.DeviceID, + HomeserverURL: homeserverURL, + Issuer: masIssuer, + ClientID: masClientID, + } + if err := s.svc.store.Save(who.UserID, tok); err != nil { + writeJSON(w, 500, map[string]string{"error": "keyring save: " + err.Error()}) + return + } + if err := writeLastUser(who.UserID); err != nil { + writeJSON(w, 500, map[string]string{"error": "last_user: " + err.Error()}) + return + } + logInfo("E2E signin_admin OK", "user_id", who.UserID, "device_id", who.DeviceID) + writeJSON(w, 200, map[string]string{ + "status": "ok", + "user_id": who.UserID, + "device_id": who.DeviceID, + }) +} + +type startReq struct { + UserID string `json:"user_id"` +} + +func (s *E2EServer) handleStart(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeJSON(w, 405, map[string]string{"error": "POST only"}) + return + } + var req startReq + _ = json.NewDecoder(r.Body).Decode(&req) + if req.UserID == "" { + req.UserID = readLastUser() + } + if req.UserID == "" { + writeJSON(w, 400, map[string]string{"error": "user_id required (or set last_user.txt)"}) + return + } + skipCrypto := r.URL.Query().Get("skip_crypto") == "true" + var err error + if skipCrypto { + err = s.svc.StartNoCrypto(req.UserID) + } else { + err = s.svc.Start(req.UserID) + } + if err != nil { + writeJSON(w, 500, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, 200, map[string]any{"status": "ok", "user_id": req.UserID, "skip_crypto": skipCrypto}) +} + +func (s *E2EServer) handleWipeCrypto(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeJSON(w, 405, map[string]string{"error": "POST only"}) + return + } + user := r.URL.Query().Get("user_id") + if user == "" { + user = readLastUser() + } + if user == "" { + writeJSON(w, 400, map[string]string{"error": "user_id required"}) + return + } + dir := userStoreDir(user) + cryptoDB := dir + "/crypto.db" + _ = os.Remove(cryptoDB) + _ = os.Remove(cryptoDB + "-shm") + _ = os.Remove(cryptoDB + "-wal") + logInfo("E2E wipe_crypto", "dir", dir) + writeJSON(w, 200, map[string]string{"status": "ok", "wiped": cryptoDB}) +} + +func (s *E2EServer) handleStop(w http.ResponseWriter, _ *http.Request) { + s.svc.Stop() + writeJSON(w, 200, map[string]string{"status": "ok"}) +} + +// handleLastUser returns the persisted last user_id from last_user.txt, or +// empty if the file is missing. Used by the shim's GetLastUserID so the +// frontend lands on LoginScreen after /wipe_session even when a session is +// still active in memory. +func (s *E2EServer) handleLastUser(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, 200, map[string]string{"user_id": readLastUser()}) +} + +// handleWipeSession clears last_user.txt so the frontend lands on LoginScreen +// on next load. Keyring entries are kept (use /wipe_crypto to also drop the +// olm store). Does NOT call svc.Stop() because the matrix sync loop can be +// blocked on HTTP and cause this handler to hang indefinitely. +func (s *E2EServer) handleWipeSession(w http.ResponseWriter, _ *http.Request) { + path := lastUserFilePath() + _ = clearLastUser() + logInfo("E2E wipe_session", "path", path) + writeJSON(w, 200, map[string]string{"status": "ok", "wiped": path}) +} + +func (s *E2EServer) handleDiagnostics(w http.ResponseWriter, _ *http.Request) { + d := s.svc.GetDiagnostics() + writeJSON(w, 200, d) +} + +func (s *E2EServer) handleRooms(w http.ResponseWriter, _ *http.Request) { + rooms, err := s.svc.ListRooms() + if err != nil { + writeJSON(w, 500, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, 200, map[string]any{"rooms": rooms, "count": len(rooms)}) +} + +func (s *E2EServer) handleTimeline(w http.ResponseWriter, r *http.Request) { + roomID := r.URL.Query().Get("room_id") + if roomID == "" { + writeJSON(w, 400, map[string]string{"error": "room_id query param required"}) + return + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 { + limit = 50 + } + evs, err := s.svc.LoadTimeline(roomID, limit) + if err != nil { + writeJSON(w, 500, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, 200, map[string]any{"events": evs, "count": len(evs)}) +} + +type sendReq struct { + RoomID string `json:"room_id"` + Body string `json:"body"` + Markdown bool `json:"markdown,omitempty"` +} + +func (s *E2EServer) handleSend(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeJSON(w, 405, map[string]string{"error": "POST only"}) + return + } + var req sendReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, 400, map[string]string{"error": err.Error()}) + return + } + if req.RoomID == "" || req.Body == "" { + writeJSON(w, 400, map[string]string{"error": "room_id and body required"}) + return + } + var evID string + var err error + if req.Markdown { + evID, err = s.svc.SendMarkdown(req.RoomID, req.Body) + } else { + evID, err = s.svc.SendText(req.RoomID, req.Body) + } + if err != nil { + writeJSON(w, 500, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, 200, map[string]string{"event_id": evID}) +} + +func (s *E2EServer) handleLogs(w http.ResponseWriter, r *http.Request) { + n, _ := strconv.Atoi(r.URL.Query().Get("n")) + if n <= 0 { + n = 200 + } + lines, err := TailLog(n) + if err != nil { + writeJSON(w, 500, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, 200, map[string]any{"lines": lines, "count": len(lines), "path": fmt.Sprintf("%s", s.svc.GetLogPath())}) +} diff --git a/frontend/e2e_cdp/entry_flow.spec.ts b/frontend/e2e_cdp/entry_flow.spec.ts new file mode 100644 index 0000000..60ed972 --- /dev/null +++ b/frontend/e2e_cdp/entry_flow.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from "@playwright/test"; + +const E2E_API = process.env.MATRIX_CLIENT_PC_E2E_API || "http://127.0.0.1:8767"; + +async function api(path: string, init: RequestInit = {}): Promise { + const res = await fetch(E2E_API + path, init); + if (!res.ok) { + const body = await res.text(); + throw new Error(`E2E API ${path} -> ${res.status}: ${body}`); + } + return res.json(); +} + +test.describe("Entry flow โ€” LoginScreen โ†’ click Sign in โ†’ HomeScreen with rooms", () => { + test.beforeEach(async () => { + // Reset backend state: wipe last_user so frontend lands on LoginScreen. + await api("/wipe_session", { method: "POST" }); + }); + + test("user can sign in and see their rooms", async ({ page }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + + // LoginScreen visible + await expect(page.getByRole("heading", { name: "matrix_client_pc" })).toBeVisible({ + timeout: 10_000, + }); + const signInBtn = page.getByRole("button", { name: /Sign in with Matrix/i }); + await expect(signInBtn).toBeVisible(); + + // Click Sign in โ†’ shim calls /signin_admin โ†’ returns user_id + await signInBtn.click(); + + // HomeScreen header buttons appear once authenticated. + await expect(page.getByRole("button", { name: /Health/i })).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByRole("button", { name: /Logs/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /Logout/i })).toBeVisible(); + + // Sidebar has at least one room (rooms are fetched after Start triggers sync). + const firstRoom = page.locator('nav a, [role="navigation"] a').first(); + await expect(firstRoom).toBeVisible({ timeout: 30_000 }); + + // Backend sanity: diagnostics says we're synced with rooms. + const diag = await api("/diagnostics"); + expect(diag.started).toBe(true); + expect(diag.rooms_count).toBeGreaterThan(0); + }); + + test("Logout returns to LoginScreen", async ({ page }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + + // Sign in first + await page.getByRole("button", { name: /Sign in with Matrix/i }).click(); + await expect(page.getByRole("button", { name: /Logout/i })).toBeVisible({ + timeout: 15_000, + }); + + // Logout + await page.getByRole("button", { name: /Logout/i }).click(); + + // Back to LoginScreen + await expect(page.getByRole("button", { name: /Sign in with Matrix/i })).toBeVisible({ + timeout: 10_000, + }); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 13c98df..ee366c6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "test:ui": "vitest --ui", "e2e": "playwright test", "e2e:ui": "playwright test --ui", + "e2e:wails": "playwright test --config playwright.cdp.config.ts", "e2e:report": "playwright show-report" }, "dependencies": { diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 6dfa72a..c6ac2c3 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -9a66d5a5186912b91bb20602c66c7f8e \ No newline at end of file +68223a3dca6c9351bad4d13d8f189cf0 \ No newline at end of file diff --git a/frontend/playwright.cdp.config.ts b/frontend/playwright.cdp.config.ts new file mode 100644 index 0000000..6ce26f8 --- /dev/null +++ b/frontend/playwright.cdp.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from "@playwright/test"; + +// Drives the Vite dev server (frontend with HTTP-shim bindings) against a real +// Windows-side matrix_client_pc.exe running with MATRIX_CLIENT_PC_E2E=1 + +// BIND_ALL=1 (E2E HTTP API reachable on 127.0.0.1:8767 cross-WSL). +// +// Prerequisites: +// 1. bash scripts/launch_e2e.sh (Windows .exe up, :8767 listening) +// 2. VITE_E2E_API=http://localhost:8767 pnpm dev --host 0.0.0.0 +// 3. pnpm e2e:wails +// +// Default URL targets the Vite dev port. Set BASE_URL env if vite picked a +// different port (5173 if free, otherwise 5174 etc.). + +const BASE_URL = process.env.BASE_URL || "http://localhost:5174"; + +export default defineConfig({ + testDir: "./e2e_cdp", + timeout: 60_000, + fullyParallel: false, + workers: 1, + retries: 0, + reporter: "list", + use: { + baseURL: BASE_URL, + headless: process.env.HEADED ? false : true, + trace: "retain-on-failure", + screenshot: "only-on-failure", + viewport: { width: 1280, height: 800 }, + actionTimeout: 10_000, + }, +}); diff --git a/frontend/src/shims/MatrixServiceShim.ts b/frontend/src/shims/MatrixServiceShim.ts new file mode 100644 index 0000000..08d8f4a --- /dev/null +++ b/frontend/src/shims/MatrixServiceShim.ts @@ -0,0 +1,130 @@ +// HTTP shim of the Wails MatrixService bindings โ€” used in dev/E2E mode when +// running the frontend via `pnpm dev` against a real Wails app's E2E HTTP +// server (default http://127.0.0.1:8767). Vite resolve.alias swaps the +// `wailsjs/go/main/MatrixService` import to this file when VITE_E2E_API is set. + +const API: string = + (import.meta as any).env?.VITE_E2E_API || "http://127.0.0.1:8767"; + +async function callJSON( + path: string, + init: RequestInit = {}, +): Promise { + const res = await fetch(API + path, { + ...init, + headers: { + "Content-Type": "application/json", + ...(init.headers || {}), + }, + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`E2E API ${path} -> ${res.status}: ${body}`); + } + return res.json() as Promise; +} + +// --- read endpoints --- +export async function GetLastUserID(): Promise { + // /last_user reads last_user.txt directly so /wipe_session causes the + // frontend to fall back to LoginScreen even if sync is still active. + const r: any = await callJSON("/last_user"); + return r.user_id || ""; +} + +export async function GetSession(user_id: string): Promise { + // E2E server does not expose per-user session. Approximate with diagnostics. + const d: any = await callJSON("/diagnostics"); + return { + user_id, + has_token: !!d.user_id, + homeserver_url: d.homeserver_url || "", + }; +} + +export async function GetDiagnostics(): Promise { + return callJSON("/diagnostics"); +} + +export async function GetLogPath(): Promise { + const r: any = await callJSON("/logs?n=1"); + return r.path || ""; +} + +export async function GetLogTail(n: number): Promise { + const r: any = await callJSON(`/logs?n=${encodeURIComponent(n)}`); + return r.lines || []; +} + +export async function ListRooms(): Promise { + const r: any = await callJSON("/rooms"); + return r.rooms || []; +} + +export async function LoadTimeline(room_id: string, limit: number): Promise { + const r: any = await callJSON( + `/timeline?room_id=${encodeURIComponent(room_id)}&limit=${limit}`, + ); + return r.events || []; +} + +// --- write endpoints --- +// Dev mode forces skip_crypto: MatrixCryptoInit hangs indefinitely when MAS is +// active (the in-flight UIA roundtrip does not respect ctx cancellation). The +// frontend still sees rooms + can send plaintext to non-E2EE rooms; encrypted +// timelines render the wrapper events with "Encrypted" placeholders. +export async function Start(user_id: string): Promise { + await callJSON("/start?skip_crypto=true", { + method: "POST", + body: JSON.stringify({ user_id }), + }); +} + +export async function StartNoCrypto(user_id: string): Promise { + await callJSON("/start?skip_crypto=true", { + method: "POST", + body: JSON.stringify({ user_id }), + }); +} + +export async function Stop(): Promise { + await fetch(API + "/stop", { method: "POST" }); +} + +export async function SendText(room_id: string, body: string): Promise { + const r: any = await callJSON("/send", { + method: "POST", + body: JSON.stringify({ room_id, body }), + }); + return r.event_id || ""; +} + +export async function SendMarkdown(room_id: string, body: string): Promise { + const r: any = await callJSON("/send", { + method: "POST", + body: JSON.stringify({ room_id, body, markdown: true }), + }); + return r.event_id || ""; +} + +// --- login flow --- +// In real Wails the Login() call opens the OIDC loopback flow. In dev/E2E we +// short-circuit via /signin_admin: the backend uses its env-stored +// MATRIX_SYNAPSE_ADMIN_TOKEN to whoami and persist the token. user_id is +// resolved server-side โ€” no frontend prompt needed. +export async function Login(): Promise { + const r: any = await callJSON("/signin_admin", { + method: "POST", + body: JSON.stringify({}), + }); + if (!r.user_id) throw new Error("signin_admin returned no user_id"); + return r.user_id; +} + +export async function Logout(_user_id: string): Promise { + await fetch(API + "/wipe_session", { method: "POST" }); +} + +export async function SetContext(_arg: any): Promise { + // no-op in dev +} diff --git a/frontend/src/shims/RuntimeShim.ts b/frontend/src/shims/RuntimeShim.ts new file mode 100644 index 0000000..159d60d --- /dev/null +++ b/frontend/src/shims/RuntimeShim.ts @@ -0,0 +1,71 @@ +// HTTP shim of `wailsjs/runtime/runtime` โ€” only the event APIs the frontend +// uses are exposed. `EventsOn` polls /diagnostics every 1.5s and emits stub +// events with the new room/timeline counts so consumers see updates without +// needing the real Wails event bus. + +type Handler = (...args: any[]) => void; +const handlers = new Map>(); +let pollTimer: ReturnType | null = null; +let lastDiag: any = {}; + +const API: string = + (import.meta as any).env?.VITE_E2E_API || "http://127.0.0.1:8767"; + +async function pollOnce() { + try { + const r = await fetch(API + "/diagnostics"); + if (!r.ok) return; + const d = await r.json(); + if (d.rooms_count !== lastDiag.rooms_count) { + emit("matrix:rooms_changed", d); + } + if (d.sync_active !== lastDiag.sync_active) { + emit("matrix:sync_changed", d); + } + lastDiag = d; + } catch { + /* ignore */ + } +} + +function emit(event: string, ...args: any[]) { + const set = handlers.get(event); + if (!set) return; + for (const h of set) { + try { + h(...args); + } catch { + /* ignore */ + } + } +} + +function ensurePolling() { + if (pollTimer) return; + pollTimer = setInterval(pollOnce, 1500); +} + +export function EventsOn(event: string, handler: Handler): () => void { + if (!handlers.has(event)) handlers.set(event, new Set()); + handlers.get(event)!.add(handler); + ensurePolling(); + return () => { + handlers.get(event)?.delete(handler); + }; +} + +export function EventsOnce(event: string, handler: Handler): () => void { + const off = EventsOn(event, (...args) => { + off(); + handler(...args); + }); + return off; +} + +export function EventsEmit(_event: string, ..._args: any[]): void { + // no-op in dev +} + +export function EventsOff(event: string, ..._handlers: Handler[]): void { + handlers.delete(event); +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4955065..1467a78 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,42 @@ -import {defineConfig} from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig, loadEnv } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "node:path"; -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react()] -}) +// E2E/dev mode: when VITE_E2E_API is set, swap Wails bindings for HTTP shims +// that hit the matrix_client_pc E2E HTTP server. The production `wails build` +// run does NOT set VITE_E2E_API -> bindings resolve to the real generated files. +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const useShim = !!env.VITE_E2E_API; + + const aliases = useShim + ? { + "../wailsjs/go/main/MatrixService": path.resolve( + __dirname, + "src/shims/MatrixServiceShim.ts", + ), + "../../wailsjs/go/main/MatrixService": path.resolve( + __dirname, + "src/shims/MatrixServiceShim.ts", + ), + "../wailsjs/runtime/runtime": path.resolve( + __dirname, + "src/shims/RuntimeShim.ts", + ), + "../../wailsjs/runtime/runtime": path.resolve( + __dirname, + "src/shims/RuntimeShim.ts", + ), + } + : {}; + + return { + plugins: [react()], + resolve: { alias: aliases }, + server: { + host: "0.0.0.0", + port: 5173, + strictPort: false, + }, + }; +}); diff --git a/go.mod b/go.mod index b3130fd..000a7a6 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/wailsapp/wails/v2 v2.11.0 github.com/yuin/goldmark v1.8.2 github.com/zalando/go-keyring v0.2.8 + go.mau.fi/util v0.9.9 maunium.net/go/mautrix v0.28.0 ) @@ -45,7 +46,6 @@ require ( github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect - go.mau.fi/util v0.9.9 // indirect golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect golang.org/x/net v0.54.0 // indirect diff --git a/main.go b/main.go index 9dce8a1..90f4212 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,12 @@ func main() { defer logger.Close() logger.Info("starting matrix_client_pc", "version", "0.1.0") + // NOTE: WebView2 strips --remote-debugging-port from + // WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS for security, so CDP attach + // to the production WebView2 is not feasible. Frontend automation + // uses `wails dev -browser` (Chrome with CDP) instead. The E2E HTTP + // server on :8767 remains the canonical driver for headless tests. + ms := NewMatrixService() err = wails.Run(&options.App{ @@ -34,6 +40,7 @@ func main() { OnStartup: func(ctx context.Context) { ms.SetContext(ctx) logger.Info("wails ctx ready") + MaybeStartE2EServer(ms) }, OnShutdown: func(ctx context.Context) { logger.Info("shutdown") diff --git a/matrix_service.go b/matrix_service.go index bc07959..e8aa8cb 100644 --- a/matrix_service.go +++ b/matrix_service.go @@ -35,13 +35,15 @@ var defaultScopes = []string{ // MatrixService is bound to the Wails frontend. type MatrixService struct { - ctx context.Context - mu sync.Mutex - store *infra.KeyringTokenStore - client *mautrix.Client - sync *infra.MatrixSyncServiceHandle - crypto *infra.MatrixCryptoInitResult - userID string + ctx context.Context + mu sync.Mutex + store *infra.KeyringTokenStore + client *mautrix.Client + sync *infra.MatrixSyncServiceHandle + crypto *infra.MatrixCryptoInitResult + userID string + lastError string // last surfaced error message (for diagnostics panel) + errorTs time.Time } func NewMatrixService() *MatrixService { @@ -91,19 +93,27 @@ func (s *MatrixService) Login() (string, error) { s.mu.Lock() defer s.mu.Unlock() - logInfo("Login start", "client_id", masClientID, "loopback", loopbackPort, "issuer", masIssuer) + // Generate a fresh client-side device_id and request a device-bound scope + // so MAS issues a token tied to this device. Without this scope MAS does + // NOT bind the token to a device, whoami returns empty device_id, and + // MatrixCryptoInit hangs because device-keys upload has nowhere to land. + // MSC2967: urn:matrix:org.matrix.msc2967.client:device:<10-char-id> + deviceIDForLogin := generateDeviceID() + scopes := append([]string{}, defaultScopes...) + scopes = append(scopes, "urn:matrix:org.matrix.msc2967.client:device:"+deviceIDForLogin) + logInfo("Login start", "client_id", masClientID, "loopback", loopbackPort, "issuer", masIssuer, "device_id", deviceIDForLogin) cfg := infra.MasOidcLoopbackConfig{ Issuer: masIssuer, ClientID: masClientID, - Scopes: defaultScopes, + Scopes: scopes, LoopbackPort: loopbackPort, OpenBrowser: true, TimeoutSeconds: oidcTimeoutSeconds, } res, err := infra.MasOidcLoopback(cfg) if err != nil { - logError("oidc loopback failed", "err", err) + s.recordError(fmt.Errorf("oidc loopback: %w", err)) return "", fmt.Errorf("oidc: %w", err) } logInfo("oidc loopback OK", "token_type", res.TokenType, "expires_in", res.ExpiresIn, "scope", res.Scope) @@ -111,9 +121,16 @@ func (s *MatrixService) Login() (string, error) { // Pre-fetch user_id by hitting /whoami directly (mautrix requires UserID at NewClient). userID, deviceID, err := whoami(s.ctx, homeserverURL, res.AccessToken) if err != nil { - logError("whoami failed", "err", err, "homeserver", homeserverURL) + s.recordError(fmt.Errorf("whoami after oidc: %w", err)) return "", fmt.Errorf("whoami: %w", err) } + // Fallback: some MAS deployments don't echo device_id in /whoami even when + // the token IS device-bound. We requested a specific device: scope, so + // the binding exists โ€” use that id as the canonical device_id. + if deviceID == "" { + deviceID = deviceIDForLogin + logWarn("whoami returned empty device_id โ€” using client-generated id from device-scope", "device_id", deviceID) + } logInfo("whoami OK", "user_id", userID, "device_id", deviceID) clientCfg := infra.MatrixClientInitConfig{ @@ -216,6 +233,19 @@ type Diagnostics struct { LastError string `json:"last_error,omitempty"` } +// recordError stores the last error surfaced by Login/Start/Send/etc. for the +// diagnostics panel + E2E server. +func (s *MatrixService) recordError(err error) { + if err == nil { + return + } + s.mu.Lock() + s.lastError = err.Error() + s.errorTs = time.Now() + s.mu.Unlock() + logError("recorded error", "err", err) +} + // GetDiagnostics returns a live snapshot of service state + a fresh ListRooms count. func (s *MatrixService) GetDiagnostics() Diagnostics { s.mu.Lock() @@ -226,6 +256,7 @@ func (s *MatrixService) GetDiagnostics() Diagnostics { ClientReady: s.client != nil, CryptoInitialized: s.crypto != nil, SyncActive: s.sync != nil, + LastError: s.lastError, } client := s.client s.mu.Unlock() @@ -261,10 +292,22 @@ func (s *MatrixService) Stop() { } } +// StartNoCrypto initializes the Matrix client + sync loop WITHOUT E2EE. +// Useful for E2E tests + admin tokens (which lack MAS OAuth session and can't +// complete the cryptohelper upload). Encrypted rooms will show as "Encrypted" +// placeholder bubbles; unencrypted rooms work normally. +func (s *MatrixService) StartNoCrypto(userID string) error { + return s.startInternal(userID, true) +} + // Start initializes the Matrix client + crypto + sync loop for the given user. // Must be called after Login() or after a successful GetSession() for a returning user. // Idempotent: safe to call multiple times for the same user. func (s *MatrixService) Start(userID string) error { + return s.startInternal(userID, false) +} + +func (s *MatrixService) startInternal(userID string, skipCrypto bool) error { s.mu.Lock() defer s.mu.Unlock() @@ -349,46 +392,113 @@ func (s *MatrixService) Start(userID string) error { cryptoStorePath := filepath.Join(storeDir, "crypto.db") - // Wrap MatrixCryptoInit in 60s timeout โ€” hang here is the canonical MAS-UIA-rejection signal. - cryptoCtx, cancel := context.WithTimeout(s.ctx, 60*time.Second) - defer cancel() - - cryptoRes, err := infra.MatrixCryptoInit(cryptoCtx, infra.MatrixCryptoInitConfig{ - Client: clientRes.Client, - StorePath: cryptoStorePath, - PickleKey: pickleKey, - }) - if err != nil { - logError("crypto init failed", - "err", err, - "crypto_store", cryptoStorePath, - "hint", "If hang: MAS rejected UIA. WIPE crypto.db + relogin.", - ) - return fmt.Errorf("matrix crypto init: %w", err) + if skipCrypto { + logWarn("crypto init SKIPPED โ€” encrypted rooms wont decrypt", "user_id", userID) + syncRes, err := infra.MatrixSyncService(s.ctx, infra.MatrixSyncServiceConfig{ + Client: clientRes.Client, + }) + if err != nil { + s.recordError(fmt.Errorf("sync service start (no crypto): %w", err)) + return fmt.Errorf("matrix sync: %w", err) + } + s.client = clientRes.Client + s.sync = syncRes + s.userID = userID + go s.fanout() + logInfo("StartNoCrypto complete", "user_id", userID) + return nil } - logInfo("crypto init OK", "store", cryptoStorePath) + // Start sync FIRST so the app is usable immediately. Crypto runs best-effort + // in background โ€” if it hangs/fails, encrypted rooms show placeholder but + // the app remains responsive. Plain rooms work fully either way. syncRes, err := infra.MatrixSyncService(s.ctx, infra.MatrixSyncServiceConfig{ Client: clientRes.Client, }) if err != nil { - logError("sync service start failed", "err", err) + s.recordError(fmt.Errorf("sync service start: %w", err)) return fmt.Errorf("matrix sync: %w", err) } logInfo("sync service started") s.client = clientRes.Client - s.crypto = cryptoRes s.sync = syncRes s.userID = userID - - // Fan events out via Wails runtime. go s.fanout() - logInfo("Start complete", "user_id", userID) + // Crypto best-effort with heartbeat + timeout. Runs OUTSIDE s.mu so a hang + // here does NOT block subsequent service calls. The 45s ceiling matches + // 3x the longest observed cryptohelper handshake on warm MAS. + go s.tryCryptoInit(clientRes.Client, cryptoStorePath, pickleKey) + + logInfo("Start complete (crypto initializing in background)", "user_id", userID) return nil } +// tryCryptoInit runs MatrixCryptoInit out-of-band with progress heartbeats. +// Logs every 5s while pending. On success: attaches helper to client.Crypto so +// future SendMessageEvent encrypts automatically. On error/timeout: logs and +// proceeds โ€” app continues to work on plain rooms; encrypted rooms show as +// EncryptedRaw=true and Send to them returns M_FORBIDDEN until crypto recovers. +func (s *MatrixService) tryCryptoInit(client *mautrix.Client, storePath string, pickleKey []byte) { + const initTimeout = 45 * time.Second + const beatEvery = 5 * time.Second + + logInfo("calling MatrixCryptoInit (best-effort, background)", "store", storePath, "timeout_s", int(initTimeout/time.Second)) + + cryptoCtx, cancel := context.WithTimeout(s.ctx, initTimeout) + defer cancel() + + done := make(chan struct{}) + var ( + cryptoRes *infra.MatrixCryptoInitResult + cryptoErr error + ) + go func() { + defer close(done) + cryptoRes, cryptoErr = infra.MatrixCryptoInit(cryptoCtx, infra.MatrixCryptoInitConfig{ + Client: client, + StorePath: storePath, + PickleKey: pickleKey, + }) + }() + + start := time.Now() + tick := time.NewTicker(beatEvery) + defer tick.Stop() + + for { + select { + case <-done: + if cryptoErr != nil { + s.recordError(fmt.Errorf("crypto init: %w (store=%s)", cryptoErr, storePath)) + logError("crypto init failed โ€” continuing without E2EE", + "err", cryptoErr, + "elapsed_s", int(time.Since(start)/time.Second), + "crypto_store", storePath, + "hint", "encrypted rooms show placeholder; plain rooms work; investigate MAS UIA on /keys/upload") + return + } + s.mu.Lock() + s.crypto = cryptoRes + s.mu.Unlock() + logInfo("crypto init OK", "store", storePath, "elapsed_s", int(time.Since(start)/time.Second)) + return + case <-tick.C: + logInfo("crypto init still running", "elapsed_s", int(time.Since(start)/time.Second), "store", storePath) + case <-cryptoCtx.Done(): + // Timeout fires before goroutine returns; wait for goroutine to observe ctx + // cancellation (usually immediate). If mautrix ignores ctx, goroutine leaks + // but the app stays usable. + logWarn("crypto init exceeded timeout โ€” app continues without E2EE", + "elapsed_s", int(time.Since(start)/time.Second), + "store", storePath, + "hint", "goroutine may continue draining if mautrix ignores ctx; encrypted rooms wont decrypt this session") + return + } + } +} + // GetLogTail returns the last n lines of the app log file for the diagnostics UI. func (s *MatrixService) GetLogTail(n int) ([]string, error) { if n <= 0 { @@ -524,6 +634,22 @@ func (s *MatrixService) SendMarkdown(roomID, md string) (string, error) { return string(evID), nil } +// generateDeviceID produces a 10-character uppercase alphanumeric device_id +// suitable for MAS MSC2967 device-scope. Matches the format Element clients +// use (e.g. RZXAYCAWAY). +func generateDeviceID() string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + buf := make([]byte, 10) + if _, err := rand.Read(buf); err != nil { + // Fallback to time-based id; rand.Read on Windows is reliable so this is rare. + return fmt.Sprintf("DEV%07d", time.Now().UnixNano()%10000000) + } + for i, b := range buf { + buf[i] = alphabet[int(b)%len(alphabet)] + } + return string(buf) +} + // loadOrCreatePickleKey returns the 32-byte pickle key for the user. // If absent in keyring, generates fresh random bytes, hex-encodes them, persists, and returns. func (s *MatrixService) loadOrCreatePickleKey(tok *infra.Token) ([]byte, error) { diff --git a/scripts/check_e2e.sh b/scripts/check_e2e.sh new file mode 100755 index 0000000..1ed0609 --- /dev/null +++ b/scripts/check_e2e.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Single-shot health check for E2E endpoints. Returns 0 if both up. +set -u + +PID=$(tasklist.exe 2>/dev/null | awk '/matrix_client_pc\.exe/ {print $2; exit}') +echo "[e2e] PID: ${PID:-(not running)}" + +if [ -z "$PID" ]; then + echo "[e2e] app not running" + exit 1 +fi + +E2E_OK=0 +CDP_OK=0 +PING=$(curl -sS --max-time 1 http://127.0.0.1:8767/ping 2>/dev/null) && E2E_OK=1 +CDP=$(curl -sS --max-time 1 http://127.0.0.1:9222/json/version 2>/dev/null) && CDP_OK=1 + +echo "[e2e] :8767 (E2E API) -> ${E2E_OK} ${PING:-no response}" +echo "[e2e] :9222 (CDP) -> ${CDP_OK} $(echo "$CDP" | head -c 120)" + +if [ $E2E_OK -eq 1 ] && [ $CDP_OK -eq 1 ]; then + exit 0 +fi +exit 1 diff --git a/scripts/check_wails_dev.sh b/scripts/check_wails_dev.sh new file mode 100755 index 0000000..4b20f1f --- /dev/null +++ b/scripts/check_wails_dev.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Single-shot health check for wails dev mode. +set -u + +APP_DIR="$(cd "$(dirname "$0")"/.. && pwd)" +PID_FILE="${APP_DIR}/.wails_dev.pid" +LOG="${APP_DIR}/.wails_dev.log" + +if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE") + if kill -0 "$PID" 2>/dev/null; then + echo "[wails-dev] PID $PID alive" + else + echo "[wails-dev] PID file stale (PID $PID dead)" + fi +else + echo "[wails-dev] no PID file" +fi + +echo "[wails-dev] last log:" +tail -10 "$LOG" 2>/dev/null || echo "(no log)" + +echo "---" +for port in 5173 34115; do + if curl -sS --max-time 1 "http://localhost:$port" -o /dev/null -w "[wails-dev] :$port -> HTTP %{http_code}\n" 2>/dev/null; then + : + else + echo "[wails-dev] :$port -> not responding" + fi +done diff --git a/scripts/launch_e2e.sh b/scripts/launch_e2e.sh new file mode 100755 index 0000000..df3022e --- /dev/null +++ b/scripts/launch_e2e.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Launch matrix_client_pc.exe on Windows with E2E mode enabled. +# Fire-and-forget โ€” does NOT block waiting for confirmation. +# Use ./scripts/check_e2e.sh afterwards to verify endpoints. +set -euo pipefail + +APP_DIR_WIN='C:\Users\lucas\Desktop\apps\matrix_client_pc' + +echo "[e2e] killing existing matrix_client_pc.exe" +taskkill.exe /IM matrix_client_pc.exe /F 2>/dev/null || true + +LAST_USER="/mnt/c/Users/lucas/AppData/Roaming/matrix_client_pc/last_user.txt" +if [ -f "$LAST_USER" ]; then + echo "[e2e] wiping last_user.txt" + rm -f "$LAST_USER" +fi + +ADMIN_TOKEN=$(pass matrix/synapse-admin-token 2>/dev/null | head -n1 || true) +if [ -z "$ADMIN_TOKEN" ]; then + echo "[e2e] WARN: pass matrix/synapse-admin-token failed โ€” /signin_admin will require body admin_token" +fi + +echo "[e2e] launching via powershell with MATRIX_CLIENT_PC_E2E=1 + BIND_ALL=1 + admin token" +# MATRIX_CLIENT_PC_E2E_BIND_ALL=1 โ†’ E2E HTTP server binds 0.0.0.0 so WSL can +# curl it directly (Vite shim + tests). Without it, server only listens on +# 127.0.0.1 (production / single-machine testing). +# MATRIX_SYNAPSE_ADMIN_TOKEN โ†’ /signin_admin uses it as the default access +# token (resolves user_id + device_id via whoami). +powershell.exe -NoProfile -Command "\$env:MATRIX_CLIENT_PC_E2E='1'; \$env:MATRIX_CLIENT_PC_E2E_BIND_ALL='1'; \$env:MATRIX_SYNAPSE_ADMIN_TOKEN='$ADMIN_TOKEN'; Start-Process -FilePath '$APP_DIR_WIN\\matrix_client_pc.exe' -WorkingDirectory '$APP_DIR_WIN'" >/dev/null 2>&1 & +disown +echo "[e2e] fired. Use scripts/check_e2e.sh to verify endpoints." diff --git a/scripts/launch_wails_dev.sh b/scripts/launch_wails_dev.sh new file mode 100755 index 0000000..329b5b2 --- /dev/null +++ b/scripts/launch_wails_dev.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Launch `wails dev -browser=false` in background so Playwright can drive the +# real Wails frontend via its dev HTTP server. +# +# Wails dev exposes: +# - http://localhost:34115/ -> frontend with bindings injected (Wails proxy) +# - http://localhost:5173/ -> raw Vite dev server (no bindings) +# Playwright tests should target :34115 so bindings work. +# +# This launcher is fire-and-forget. Use scripts/check_wails_dev.sh to verify. +set -euo pipefail + +APP_DIR="$(cd "$(dirname "$0")"/.. && pwd)" +LOG_FILE="${APP_DIR}/.wails_dev.log" +PID_FILE="${APP_DIR}/.wails_dev.pid" + +# Kill any previous wails dev. +if [ -f "$PID_FILE" ]; then + OLD=$(cat "$PID_FILE") + if kill -0 "$OLD" 2>/dev/null; then + echo "[wails-dev] killing old PID $OLD" + kill "$OLD" 2>/dev/null || true + sleep 1 + fi + rm -f "$PID_FILE" +fi +pkill -f 'wails dev' 2>/dev/null || true +# Also kill any matrix_client_pc.exe left over (wails dev launches one). +taskkill.exe /IM matrix_client_pc.exe /F 2>/dev/null || true + +cd "$APP_DIR" + +echo "[wails-dev] starting wails dev (logs: $LOG_FILE)" +nohup wails dev -browser=false -frontenddevserverurl=http://localhost:5173 > "$LOG_FILE" 2>&1 & +echo $! > "$PID_FILE" +echo "[wails-dev] PID $(cat "$PID_FILE")" +echo "[wails-dev] fire-and-forget. Frontend will be at http://localhost:34115" diff --git a/sqlite_driver.go b/sqlite_driver.go index 2f71373..1ba7846 100644 --- a/sqlite_driver.go +++ b/sqlite_driver.go @@ -12,4 +12,9 @@ package main import ( _ "github.com/mattn/go-sqlite3" + + // mautrix-go v0.28+ cryptohelper calls sql.Open("sqlite3-fk-wal", ...). + // This variant is registered by go.mau.fi/util/dbutil/litestream init(). + // Without it: panic "sql: unknown driver \"sqlite3-fk-wal\"". + _ "go.mau.fi/util/dbutil/litestream" )