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) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 19:38:16 +02:00
parent 23c933bfa2
commit 41bafa57cc
21 changed files with 1995 additions and 44 deletions
+44
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
1325685
+6
View File
@@ -43,8 +43,14 @@ frontend/ React+Vite+TS+Mantine+@fn_library
Login.tsx boton "Sign in with Matrix" -> abre browser Login.tsx boton "Sign in with Matrix" -> abre browser
Home.tsx muestra perfil + boton Logout Home.tsx muestra perfil + boton Logout
fn_library/ symlink a frontend/functions/ui del registry 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 ## Capability growth log
- v0.1.0 (2026-05-24) — baseline scaffold (issue 0147): Wails skeleton, login MAS OIDC, token persistence keyring SO. - v0.1.0 (2026-05-24) — baseline scaffold (issue 0147): Wails skeleton, login MAS OIDC, token persistence keyring SO.
+3 -3
View File
@@ -2,7 +2,6 @@ package main
import ( import (
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
@@ -35,8 +34,9 @@ func InitLogger() (*Logger, error) {
return nil, fmt.Errorf("open log file: %w", err) return nil, fmt.Errorf("open log file: %w", err)
} }
multi := io.MultiWriter(os.Stderr, f) // File only — Wails GUI apps on Windows have closed stderr handle, which
handler := slog.NewTextHandler(multi, &slog.HandlerOptions{ // breaks MultiWriter (one failing writer aborts the chain in some Go versions).
handler := slog.NewTextHandler(f, &slog.HandlerOptions{
Level: slog.LevelDebug, Level: slog.LevelDebug,
}) })
l := &Logger{ l := &Logger{
+927
View File
@@ -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 <access_token>` 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:<loopback_port>/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:<device_id>"
}
```
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=<client_id>&
redirect_uri=http://127.0.0.1:<port>/callback&
scope=openid+urn:matrix:org.matrix.msc2967.client:api:*+urn:matrix:org.matrix.msc2967.client:device:<device_id>&
state=<random>&
code_challenge=<base64url(sha256(verifier))>&
code_challenge_method=S256
```
Usuario aprueba en MAS UI. MAS redirige a:
```
http://127.0.0.1:<port>/callback?code=<auth_code>&state=<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=<auth_code>&
redirect_uri=http://127.0.0.1:<port>/callback&
client_id=<client_id>&
code_verifier=<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:<device_id>"
}
```
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=<rt>&
client_id=<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=<refresh_token>&
token_type_hint=refresh_token&
client_id=<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 <access_token>
```
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=<filter_id_o_json>&
timeout=0
Authorization: Bearer <access_token>
```
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": [ <state events> ] },
"timeline": {
"events": [ <message events> ],
"limited": true,
"prev_batch": "t12-34_5_6"
},
"ephemeral": { "events": [ <typing, receipts> ] },
"account_data": { "events": [ <m.tag, etc> ] },
"unread_notifications": { "highlight_count": 0, "notification_count": 1 }
}
},
"invite": { "!roomId:server": { "invite_state": { "events": [...] } } },
"leave": { ... }
},
"presence": { "events": [...] },
"account_data": { "events": [ <m.direct, m.push_rules, etc> ] },
"to_device": { "events": [ <m.room.encrypted, m.room_key, etc> ] },
"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=<next_batch>&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/<user_id>/filter
Authorization: Bearer <token>
{
"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/<roomId>/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/<roomId>/state/<eventType>[/<stateKey>]
```
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/<roomId>/messages?
from=<token>& # prev_batch del sync o de respuesta anterior
dir=b& # b=backward (mas antiguo), f=forward
limit=50&
filter=<json_opcional>
Authorization: Bearer <token>
```
Respuesta:
```json
{
"start": "<token_from>",
"end": "<token_para_siguiente_pagina>",
"chunk": [
{ "type": "m.room.message", "event_id": "$abc", "sender": "@x:s", "origin_server_ts": 1716..., "content": { "msgtype": "m.text", "body": "hola" } },
...
],
"state": [ <state events relevantes en este rango> ]
}
```
### 5.2 /context — un evento + vecinos
Para ir a un permalink:
```
GET /_matrix/client/v3/rooms/<roomId>/context/<eventId>?limit=10
```
Respuesta:
```json
{
"event": { ... },
"events_before": [ ... ],
"events_after": [ ... ],
"start": "<token>",
"end": "<token>",
"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/<roomId>/send/<eventType>/<txnId>
Authorization: Bearer <token>
Content-Type: application/json
<content>
```
`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 <strong>mundo</strong>"
}
```
**Reply (MSC2781 fallback + m.in_reply_to):**
```json
{
"msgtype": "m.text",
"body": "> <@alice:s> hola\n\nrespuesta",
"format": "org.matrix.custom.html",
"formatted_body": "<mx-reply><blockquote><a href=\"https://matrix.to/#/!r:s/$abc\">In reply to</a> <a href=\"https://matrix.to/#/@alice:s\">@alice:s</a><br>hola</blockquote></mx-reply>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/<txnId>`.
### 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/<txnId>` (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/<roomId>/redact/<eventId>/<txnId>
{ "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": "<base64>",
"ed25519:QWERTYUIOP": "<base64>"
},
"signatures": { "@lucas:s": { "ed25519:QWERTYUIOP": "<sig>" } }
},
"one_time_keys": {
"signed_curve25519:AAAAAA": { "key": "<base64>", "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/<txnId>
{
"messages": {
"@bob:s": {
"DEVICE1": {
"algorithm": "m.olm.v1.curve25519-aes-sha2",
"ciphertext": { "<bob_curve25519>": { "type": 0|1, "body": "..." } },
"sender_key": "<lucas_curve25519>"
}
}
}
}
```
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/<user_id>/account_data/m.cross_signing.{master,self_signing,user_signing}
GET /_matrix/client/v3/user/<user_id>/account_data/m.secret_storage.default_key
GET /_matrix/client/v3/user/<user_id>/account_data/m.secret_storage.key.<keyId>
```
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/<serverName>/<mediaId>
Authorization: Bearer <token> # v1 require auth (v3 sin auth deprecado)
```
Respuesta: bytes binarios. `Content-Type` indica el mime.
### 8.2 Thumbnail
```
GET /_matrix/client/v1/media/thumbnail/<serverName>/<mediaId>?
width=96&height=96&method=scale&animated=false
```
### 8.3 Upload
```
POST /_matrix/media/v3/upload
Content-Type: image/png
Authorization: Bearer <token>
<binary bytes>
```
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 `<img src>` directo (necesita auth header).
---
## 9. Profile + presence
### 9.1 Displayname + avatar
```
GET /_matrix/client/v3/profile/<userId>
```
Respuesta:
```json
{ "displayname": "Lucas", "avatar_url": "mxc://..." }
```
### 9.2 Set displayname
```
PUT /_matrix/client/v3/profile/<userId>/displayname
{ "displayname": "Nuevo nombre" }
```
### 9.3 Presence
```
PUT /_matrix/client/v3/presence/<userId>/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 `<mx-reply>` 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.
+377
View File
@@ -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())})
}
+67
View File
@@ -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<any> {
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,
});
});
});
+1
View File
@@ -12,6 +12,7 @@
"test:ui": "vitest --ui", "test:ui": "vitest --ui",
"e2e": "playwright test", "e2e": "playwright test",
"e2e:ui": "playwright test --ui", "e2e:ui": "playwright test --ui",
"e2e:wails": "playwright test --config playwright.cdp.config.ts",
"e2e:report": "playwright show-report" "e2e:report": "playwright show-report"
}, },
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1 +1 @@
9a66d5a5186912b91bb20602c66c7f8e 68223a3dca6c9351bad4d13d8f189cf0
+32
View File
@@ -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,
},
});
+130
View File
@@ -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<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
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<T>;
}
// --- read endpoints ---
export async function GetLastUserID(): Promise<string> {
// /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<any> {
// 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<any> {
return callJSON("/diagnostics");
}
export async function GetLogPath(): Promise<string> {
const r: any = await callJSON("/logs?n=1");
return r.path || "";
}
export async function GetLogTail(n: number): Promise<string[]> {
const r: any = await callJSON(`/logs?n=${encodeURIComponent(n)}`);
return r.lines || [];
}
export async function ListRooms(): Promise<any[]> {
const r: any = await callJSON("/rooms");
return r.rooms || [];
}
export async function LoadTimeline(room_id: string, limit: number): Promise<any[]> {
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<void> {
await callJSON("/start?skip_crypto=true", {
method: "POST",
body: JSON.stringify({ user_id }),
});
}
export async function StartNoCrypto(user_id: string): Promise<void> {
await callJSON("/start?skip_crypto=true", {
method: "POST",
body: JSON.stringify({ user_id }),
});
}
export async function Stop(): Promise<void> {
await fetch(API + "/stop", { method: "POST" });
}
export async function SendText(room_id: string, body: string): Promise<string> {
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<string> {
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<string> {
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<void> {
await fetch(API + "/wipe_session", { method: "POST" });
}
export async function SetContext(_arg: any): Promise<void> {
// no-op in dev
}
+71
View File
@@ -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<string, Set<Handler>>();
let pollTimer: ReturnType<typeof setInterval> | 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);
}
+41 -6
View File
@@ -1,7 +1,42 @@
import {defineConfig} from 'vite' import { defineConfig, loadEnv } from "vite";
import react from '@vitejs/plugin-react' import react from "@vitejs/plugin-react";
import path from "node:path";
// https://vitejs.dev/config/ // E2E/dev mode: when VITE_E2E_API is set, swap Wails bindings for HTTP shims
export default defineConfig({ // that hit the matrix_client_pc E2E HTTP server. The production `wails build`
plugins: [react()] // 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,
},
};
});
+1 -1
View File
@@ -8,6 +8,7 @@ require (
github.com/wailsapp/wails/v2 v2.11.0 github.com/wailsapp/wails/v2 v2.11.0
github.com/yuin/goldmark v1.8.2 github.com/yuin/goldmark v1.8.2
github.com/zalando/go-keyring v0.2.8 github.com/zalando/go-keyring v0.2.8
go.mau.fi/util v0.9.9
maunium.net/go/mautrix v0.28.0 maunium.net/go/mautrix v0.28.0
) )
@@ -45,7 +46,6 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // 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/crypto v0.51.0 // indirect
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
golang.org/x/net v0.54.0 // indirect golang.org/x/net v0.54.0 // indirect
+7
View File
@@ -21,6 +21,12 @@ func main() {
defer logger.Close() defer logger.Close()
logger.Info("starting matrix_client_pc", "version", "0.1.0") 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() ms := NewMatrixService()
err = wails.Run(&options.App{ err = wails.Run(&options.App{
@@ -34,6 +40,7 @@ func main() {
OnStartup: func(ctx context.Context) { OnStartup: func(ctx context.Context) {
ms.SetContext(ctx) ms.SetContext(ctx)
logger.Info("wails ctx ready") logger.Info("wails ctx ready")
MaybeStartE2EServer(ms)
}, },
OnShutdown: func(ctx context.Context) { OnShutdown: func(ctx context.Context) {
logger.Info("shutdown") logger.Info("shutdown")
+159 -33
View File
@@ -35,13 +35,15 @@ var defaultScopes = []string{
// MatrixService is bound to the Wails frontend. // MatrixService is bound to the Wails frontend.
type MatrixService struct { type MatrixService struct {
ctx context.Context ctx context.Context
mu sync.Mutex mu sync.Mutex
store *infra.KeyringTokenStore store *infra.KeyringTokenStore
client *mautrix.Client client *mautrix.Client
sync *infra.MatrixSyncServiceHandle sync *infra.MatrixSyncServiceHandle
crypto *infra.MatrixCryptoInitResult crypto *infra.MatrixCryptoInitResult
userID string userID string
lastError string // last surfaced error message (for diagnostics panel)
errorTs time.Time
} }
func NewMatrixService() *MatrixService { func NewMatrixService() *MatrixService {
@@ -91,19 +93,27 @@ func (s *MatrixService) Login() (string, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() 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{ cfg := infra.MasOidcLoopbackConfig{
Issuer: masIssuer, Issuer: masIssuer,
ClientID: masClientID, ClientID: masClientID,
Scopes: defaultScopes, Scopes: scopes,
LoopbackPort: loopbackPort, LoopbackPort: loopbackPort,
OpenBrowser: true, OpenBrowser: true,
TimeoutSeconds: oidcTimeoutSeconds, TimeoutSeconds: oidcTimeoutSeconds,
} }
res, err := infra.MasOidcLoopback(cfg) res, err := infra.MasOidcLoopback(cfg)
if err != nil { if err != nil {
logError("oidc loopback failed", "err", err) s.recordError(fmt.Errorf("oidc loopback: %w", err))
return "", fmt.Errorf("oidc: %w", err) return "", fmt.Errorf("oidc: %w", err)
} }
logInfo("oidc loopback OK", "token_type", res.TokenType, "expires_in", res.ExpiresIn, "scope", res.Scope) 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). // Pre-fetch user_id by hitting /whoami directly (mautrix requires UserID at NewClient).
userID, deviceID, err := whoami(s.ctx, homeserverURL, res.AccessToken) userID, deviceID, err := whoami(s.ctx, homeserverURL, res.AccessToken)
if err != nil { 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) 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:<id> 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) logInfo("whoami OK", "user_id", userID, "device_id", deviceID)
clientCfg := infra.MatrixClientInitConfig{ clientCfg := infra.MatrixClientInitConfig{
@@ -216,6 +233,19 @@ type Diagnostics struct {
LastError string `json:"last_error,omitempty"` 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. // GetDiagnostics returns a live snapshot of service state + a fresh ListRooms count.
func (s *MatrixService) GetDiagnostics() Diagnostics { func (s *MatrixService) GetDiagnostics() Diagnostics {
s.mu.Lock() s.mu.Lock()
@@ -226,6 +256,7 @@ func (s *MatrixService) GetDiagnostics() Diagnostics {
ClientReady: s.client != nil, ClientReady: s.client != nil,
CryptoInitialized: s.crypto != nil, CryptoInitialized: s.crypto != nil,
SyncActive: s.sync != nil, SyncActive: s.sync != nil,
LastError: s.lastError,
} }
client := s.client client := s.client
s.mu.Unlock() 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. // 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. // Must be called after Login() or after a successful GetSession() for a returning user.
// Idempotent: safe to call multiple times for the same user. // Idempotent: safe to call multiple times for the same user.
func (s *MatrixService) Start(userID string) error { func (s *MatrixService) Start(userID string) error {
return s.startInternal(userID, false)
}
func (s *MatrixService) startInternal(userID string, skipCrypto bool) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -349,46 +392,113 @@ func (s *MatrixService) Start(userID string) error {
cryptoStorePath := filepath.Join(storeDir, "crypto.db") cryptoStorePath := filepath.Join(storeDir, "crypto.db")
// Wrap MatrixCryptoInit in 60s timeout — hang here is the canonical MAS-UIA-rejection signal. if skipCrypto {
cryptoCtx, cancel := context.WithTimeout(s.ctx, 60*time.Second) logWarn("crypto init SKIPPED — encrypted rooms wont decrypt", "user_id", userID)
defer cancel() syncRes, err := infra.MatrixSyncService(s.ctx, infra.MatrixSyncServiceConfig{
Client: clientRes.Client,
cryptoRes, err := infra.MatrixCryptoInit(cryptoCtx, infra.MatrixCryptoInitConfig{ })
Client: clientRes.Client, if err != nil {
StorePath: cryptoStorePath, s.recordError(fmt.Errorf("sync service start (no crypto): %w", err))
PickleKey: pickleKey, return fmt.Errorf("matrix sync: %w", err)
}) }
if err != nil { s.client = clientRes.Client
logError("crypto init failed", s.sync = syncRes
"err", err, s.userID = userID
"crypto_store", cryptoStorePath, go s.fanout()
"hint", "If hang: MAS rejected UIA. WIPE crypto.db + relogin.", logInfo("StartNoCrypto complete", "user_id", userID)
) return nil
return fmt.Errorf("matrix crypto init: %w", err)
} }
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{ syncRes, err := infra.MatrixSyncService(s.ctx, infra.MatrixSyncServiceConfig{
Client: clientRes.Client, Client: clientRes.Client,
}) })
if err != nil { 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) return fmt.Errorf("matrix sync: %w", err)
} }
logInfo("sync service started") logInfo("sync service started")
s.client = clientRes.Client s.client = clientRes.Client
s.crypto = cryptoRes
s.sync = syncRes s.sync = syncRes
s.userID = userID s.userID = userID
// Fan events out via Wails runtime.
go s.fanout() 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 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. // GetLogTail returns the last n lines of the app log file for the diagnostics UI.
func (s *MatrixService) GetLogTail(n int) ([]string, error) { func (s *MatrixService) GetLogTail(n int) ([]string, error) {
if n <= 0 { if n <= 0 {
@@ -524,6 +634,22 @@ func (s *MatrixService) SendMarkdown(roomID, md string) (string, error) {
return string(evID), nil 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. // loadOrCreatePickleKey returns the 32-byte pickle key for the user.
// If absent in keyring, generates fresh random bytes, hex-encodes them, persists, and returns. // If absent in keyring, generates fresh random bytes, hex-encodes them, persists, and returns.
func (s *MatrixService) loadOrCreatePickleKey(tok *infra.Token) ([]byte, error) { func (s *MatrixService) loadOrCreatePickleKey(tok *infra.Token) ([]byte, error) {
+24
View File
@@ -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
+30
View File
@@ -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
+31
View File
@@ -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."
+37
View File
@@ -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"
+5
View File
@@ -12,4 +12,9 @@ package main
import ( import (
_ "github.com/mattn/go-sqlite3" _ "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"
) )