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:
@@ -0,0 +1,44 @@
|
|||||||
|
[0;92mWails CLI[0m [0;31mv2.11.0[0m
|
||||||
|
|
||||||
|
[0;92mExecuting: go mod tidy[0m
|
||||||
|
[90m[90m•[0m[0m [39m[39mGenerating bindings: [0m[0mDone.
|
||||||
|
[90m[90m•[0m[0m [39m[39mInstalling frontend dependencies: [0m[0mDone.
|
||||||
|
[90m[90m•[0m[0m [39m[39mCompiling frontend: [0m[0mDone.
|
||||||
|
|
||||||
|
> 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
|
||||||
|
|
||||||
|
[0;92mRunning frontend DevWatcher command: 'pnpm dev'[0m
|
||||||
|
Building application for development...
|
||||||
|
[0;92mVite Server URL: http://localhost:5174/[0m
|
||||||
|
➜ Local: http://localhost:5174/
|
||||||
|
➜ Network: use --host to expose
|
||||||
|
[90m[90m•[0m[0m [39m[39mGenerating bindings: [0m[0mDone.
|
||||||
|
[90m[90m•[0m[0m [39m[39mCompiling application: [0m[0mDone.
|
||||||
|
[90m[90m•[0m[0m [39m[39mPackaging application: [0m[0mDone.
|
||||||
|
|
||||||
|
[0;92mUsing DevServer URL: http://localhost:34115[0m
|
||||||
|
[0;92mUsing Frontend DevServer URL: http://localhost:5173[0m
|
||||||
|
[0;92mUsing reload debounce setting of 100 milliseconds[0m
|
||||||
|
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
|
||||||
|
|
||||||
|
[0;92mWatching (sub)/directory: /home/lucas/fn_registry/projects/element_agents/apps/matrix_client_pc[0m
|
||||||
|
[0;92m
|
||||||
|
|
||||||
|
To develop in the browser and call your bound Go methods from Javascript, navigate to: http://localhost:34115[0m
|
||||||
|
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
|
||||||
|
[0;92m
|
||||||
|
Caught quit[0m
|
||||||
|
[0;92mDevelopment mode exited[0m
|
||||||
|
[31;107m[31;107m ♥ [0m[0m [92m[92mIf Wails is useful to you or your company, please consider sponsoring the project:[0m[0m
|
||||||
|
https://github.com/sponsors/leaanthony
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1325685
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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
@@ -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())})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 @@
|
|||||||
9a66d5a5186912b91bb20602c66c7f8e
|
68223a3dca6c9351bad4d13d8f189cf0
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
Executable
+24
@@ -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
|
||||||
Executable
+30
@@ -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
|
||||||
Executable
+31
@@ -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."
|
||||||
Executable
+37
@@ -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"
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user