Files
matrix_client_pc/matrix_service.go
T
egutierrez 23c933bfa2 feat: persist last user + diagnostics + logging + icon + defensive whoami
Backend:
- last_user.go: writes/reads <UserConfigDir>/matrix_client_pc/last_user.txt.
  Login persists; Logout clears.
- GetLastUserID bind replaces fragile localStorage in App.tsx.
- GetDiagnostics bind: live snapshot (started, client_ready, crypto_init,
  sync_active, rooms_count, encrypted_rooms, dms_count, last_error).
- applog.go: slog to stderr + <UserConfigDir>/matrix_client_pc/app.log.
  GetLogTail + GetLogPath binds.
- matrix_service.go logs throughout Login/Start. After MatrixClientInit,
  if client.DeviceID empty -> retry whoami + persist back (defensive).
- main.go inits logger before wails.Run, OnShutdown logs close.

Frontend:
- App.tsx awaits GetLastUserID() instead of localStorage.
- HomeScreen.tsx Health modal (green stethoscope button) with HealthRow
  status dots — comprobar chats.
- Auto-relogin on token-rejected error in Start().

Icon:
- appicon.ico (Phosphor chat-circle + #7c3aed) generated via generate_app_icon.
- build/windows/icon.ico replaced (Wails embeds via windres).
- build/appicon.png regenerated from ico (256x256).

Refs: issues 0147 + 0148 + 0150 (partial). Fixes M_UNKNOWN_TOKEN auto-recovery.
2026-05-25 17:20:52 +02:00

547 lines
16 KiB
Go

package main
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"path/filepath"
"sync"
"time"
"fn-registry/projects/element_agents/apps/matrix_client_pc/internal/infra"
"github.com/wailsapp/wails/v2/pkg/runtime"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// Constants are operator-configurable later via settings UI. Hardcoded for issue 0147 MVP.
const (
homeserverURL = "https://matrix-af2f3d.organic-machine.com"
masIssuer = "https://auth-af2f3d.organic-machine.com/"
masClientID = "3DC4XQ2ZKN2TJ0BYVJ54FK7M6Y"
loopbackPort = 8765
keyringServiceName = "fn_registry.matrix_client_pc"
oidcTimeoutSeconds = 300
)
var defaultScopes = []string{
"openid",
"urn:matrix:org.matrix.msc2967.client:api:*",
}
// MatrixService is bound to the Wails frontend.
type MatrixService struct {
ctx context.Context
mu sync.Mutex
store *infra.KeyringTokenStore
client *mautrix.Client
sync *infra.MatrixSyncServiceHandle
crypto *infra.MatrixCryptoInitResult
userID string
}
func NewMatrixService() *MatrixService {
return &MatrixService{
store: infra.NewKeyringTokenStore(keyringServiceName),
}
}
func (s *MatrixService) SetContext(ctx context.Context) {
s.ctx = ctx
}
// SessionView is the safe-to-send JSON for the frontend (no tokens).
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"`
}
// MatrixEvent is the exportable, JSON-friendly shape of a Matrix event for the frontend.
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"`
}
// SyncEventView is what we emit through Wails events. Mirrors MatrixSyncEvent but without
// the raw event pointer that wouldn't survive JSON serialization.
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"`
}
// Login launches the OAuth2 PKCE flow against MAS. Blocks until completion or timeout.
// Returns the user_id of the authenticated session.
func (s *MatrixService) Login() (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
logInfo("Login start", "client_id", masClientID, "loopback", loopbackPort, "issuer", masIssuer)
cfg := infra.MasOidcLoopbackConfig{
Issuer: masIssuer,
ClientID: masClientID,
Scopes: defaultScopes,
LoopbackPort: loopbackPort,
OpenBrowser: true,
TimeoutSeconds: oidcTimeoutSeconds,
}
res, err := infra.MasOidcLoopback(cfg)
if err != nil {
logError("oidc loopback failed", "err", err)
return "", fmt.Errorf("oidc: %w", err)
}
logInfo("oidc loopback OK", "token_type", res.TokenType, "expires_in", res.ExpiresIn, "scope", res.Scope)
// Pre-fetch user_id by hitting /whoami directly (mautrix requires UserID at NewClient).
userID, deviceID, err := whoami(s.ctx, homeserverURL, res.AccessToken)
if err != nil {
logError("whoami failed", "err", err, "homeserver", homeserverURL)
return "", fmt.Errorf("whoami: %w", err)
}
logInfo("whoami OK", "user_id", userID, "device_id", deviceID)
clientCfg := infra.MatrixClientInitConfig{
HomeserverURL: homeserverURL,
UserID: userID,
DeviceID: deviceID,
AccessToken: res.AccessToken,
StoreDir: userStoreDir(userID),
EnableCrypto: false, // crypto goes through MatrixCryptoInit in Start()
}
if _, err := infra.MatrixClientInit(clientCfg); err != nil {
return "", fmt.Errorf("matrix init: %w", err)
}
tok := infra.Token{
AccessToken: res.AccessToken,
RefreshToken: res.RefreshToken,
UserID: userID,
DeviceID: deviceID,
HomeserverURL: homeserverURL,
Issuer: masIssuer,
ClientID: masClientID,
}
if res.ExpiresIn > 0 {
tok.ExpiresAt = time.Now().Add(time.Duration(res.ExpiresIn) * time.Second)
}
if err := s.store.Save(userID, tok); err != nil {
logError("keyring save failed", "err", err, "user_id", userID)
return "", fmt.Errorf("keyring save: %w", err)
}
if err := writeLastUser(userID); err != nil {
logWarn("write last_user.txt failed (non-fatal)", "err", err)
}
logInfo("login complete + token persisted", "user_id", userID)
return userID, nil
}
// GetLastUserID returns the last-logged-in user ID persisted in <UserConfigDir>/matrix_client_pc/last_user.txt.
// Empty string if never logged in or if file unreadable.
func (s *MatrixService) GetLastUserID() string {
return readLastUser()
}
// GetSession returns the persisted session for the given user_id.
func (s *MatrixService) GetSession(userID string) (*SessionView, error) {
if userID == "" {
return nil, errors.New("user_id required (v0.1.0 multi-account index TODO)")
}
tok, err := s.store.Load(userID)
if err != nil {
if errors.Is(err, infra.ErrNotFound) {
return nil, nil
}
return nil, fmt.Errorf("keyring load: %w", err)
}
view := &SessionView{
UserID: tok.UserID,
DeviceID: tok.DeviceID,
HomeserverURL: tok.HomeserverURL,
HasToken: tok.AccessToken != "",
}
if !tok.ExpiresAt.IsZero() {
view.ExpiresAt = tok.ExpiresAt.Format(time.RFC3339)
}
return view, nil
}
// Logout deletes the persisted token + stops sync.
func (s *MatrixService) Logout(userID string) error {
s.mu.Lock()
defer s.mu.Unlock()
if userID == "" {
return errors.New("user_id required")
}
if s.sync != nil {
s.sync.Stop()
s.sync = nil
}
s.client = nil
s.crypto = nil
s.userID = ""
if err := clearLastUser(); err != nil {
logWarn("clear last_user.txt failed (non-fatal)", "err", err)
}
return s.store.Delete(userID)
}
// Diagnostics is a snapshot of the live Matrix service state, used by the frontend
// "comprobar chats" panel. Safe to call any time (returns zero values if not started).
type Diagnostics struct {
Started bool `json:"started"`
UserID string `json:"user_id"`
HomeserverURL string `json:"homeserver_url"`
ClientReady bool `json:"client_ready"`
CryptoInitialized bool `json:"crypto_initialized"`
SyncActive bool `json:"sync_active"`
RoomsCount int `json:"rooms_count"`
EncryptedRooms int `json:"encrypted_rooms"`
DMsCount int `json:"dms_count"`
LastError string `json:"last_error,omitempty"`
}
// GetDiagnostics returns a live snapshot of service state + a fresh ListRooms count.
func (s *MatrixService) GetDiagnostics() Diagnostics {
s.mu.Lock()
d := Diagnostics{
Started: s.sync != nil,
UserID: s.userID,
HomeserverURL: homeserverURL,
ClientReady: s.client != nil,
CryptoInitialized: s.crypto != nil,
SyncActive: s.sync != nil,
}
client := s.client
s.mu.Unlock()
if client != nil {
rooms, err := infra.MatrixRoomList(s.ctx, infra.MatrixRoomListConfig{Client: client})
if err != nil {
d.LastError = err.Error()
logWarn("diagnostics: room list error", "err", err)
} else {
d.RoomsCount = len(rooms)
for _, r := range rooms {
if r.IsEncrypted {
d.EncryptedRooms++
}
if r.IsDirect {
d.DMsCount++
}
}
}
}
logInfo("GetDiagnostics", "rooms", d.RoomsCount, "encrypted", d.EncryptedRooms, "dms", d.DMsCount)
return d
}
// Stop shuts down the sync loop without deleting credentials. Safe to call multiple times.
func (s *MatrixService) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if s.sync != nil {
s.sync.Stop()
s.sync = nil
}
}
// Start initializes the Matrix client + crypto + sync loop for the given user.
// Must be called after Login() or after a successful GetSession() for a returning user.
// Idempotent: safe to call multiple times for the same user.
func (s *MatrixService) Start(userID string) error {
s.mu.Lock()
defer s.mu.Unlock()
if userID == "" {
return errors.New("user_id required")
}
// Idempotent for same user
if s.sync != nil && s.userID == userID {
return nil
}
// Different user or restart: stop previous
if s.sync != nil {
s.sync.Stop()
s.sync = nil
}
logInfo("Start invoked", "user_id", userID)
tok, err := s.store.Load(userID)
if err != nil {
logError("keyring load failed", "err", err, "user_id", userID)
return fmt.Errorf("keyring load: %w", err)
}
logInfo("token loaded from keyring",
"user_id", tok.UserID,
"device_id", tok.DeviceID,
"homeserver", tok.HomeserverURL,
"client_id", tok.ClientID,
"has_refresh", tok.RefreshToken != "",
"expires_at", tok.ExpiresAt,
"now", time.Now(),
)
storeDir := userStoreDir(userID)
clientCfg := infra.MatrixClientInitConfig{
HomeserverURL: tok.HomeserverURL,
UserID: tok.UserID,
DeviceID: tok.DeviceID,
AccessToken: tok.AccessToken,
StoreDir: storeDir,
EnableCrypto: false,
}
clientRes, err := infra.MatrixClientInit(clientCfg)
if err != nil {
logError("matrix client init failed (token rejected by Synapse)",
"err", err,
"user_id", userID,
"hint", "Token may be stale — call Logout(user_id) then Login() again",
)
return fmt.Errorf("matrix init: %w (token rejected — re-login required)", err)
}
logInfo("matrix client init OK", "store_dir", storeDir, "device_id", string(clientRes.Client.DeviceID))
// Defensive: if DeviceID still empty after init, retry whoami + persist back.
// Happens when keyring has stale token (saved before whoami fixed) or when
// MAS-issued token's whoami response omits device_id (some servers do this).
if clientRes.Client.DeviceID == "" {
logWarn("client.DeviceID empty after init — retrying whoami")
uid, did, werr := whoami(s.ctx, tok.HomeserverURL, tok.AccessToken)
if werr != nil {
logError("whoami retry failed", "err", werr)
return fmt.Errorf("whoami retry: %w", werr)
}
if did == "" {
logError("Synapse whoami returned empty device_id — MAS session likely lacks device binding",
"user_id", uid,
)
return fmt.Errorf("synapse whoami did not return device_id — re-login required to bind a device")
}
clientRes.Client.DeviceID = id.DeviceID(did)
tok.DeviceID = did
_ = s.store.Save(userID, *tok)
logInfo("whoami retry OK + persisted", "device_id", did)
}
// Pickle key: load from keyring (hex), or generate fresh and persist.
pickleKey, err := s.loadOrCreatePickleKey(tok)
if err != nil {
return fmt.Errorf("pickle key: %w", err)
}
cryptoStorePath := filepath.Join(storeDir, "crypto.db")
// Wrap MatrixCryptoInit in 60s timeout — hang here is the canonical MAS-UIA-rejection signal.
cryptoCtx, cancel := context.WithTimeout(s.ctx, 60*time.Second)
defer cancel()
cryptoRes, err := infra.MatrixCryptoInit(cryptoCtx, infra.MatrixCryptoInitConfig{
Client: clientRes.Client,
StorePath: cryptoStorePath,
PickleKey: pickleKey,
})
if err != nil {
logError("crypto init failed",
"err", err,
"crypto_store", cryptoStorePath,
"hint", "If hang: MAS rejected UIA. WIPE crypto.db + relogin.",
)
return fmt.Errorf("matrix crypto init: %w", err)
}
logInfo("crypto init OK", "store", cryptoStorePath)
syncRes, err := infra.MatrixSyncService(s.ctx, infra.MatrixSyncServiceConfig{
Client: clientRes.Client,
})
if err != nil {
logError("sync service start failed", "err", err)
return fmt.Errorf("matrix sync: %w", err)
}
logInfo("sync service started")
s.client = clientRes.Client
s.crypto = cryptoRes
s.sync = syncRes
s.userID = userID
// Fan events out via Wails runtime.
go s.fanout()
logInfo("Start complete", "user_id", userID)
return nil
}
// GetLogTail returns the last n lines of the app log file for the diagnostics UI.
func (s *MatrixService) GetLogTail(n int) ([]string, error) {
if n <= 0 {
n = 200
}
return TailLog(n)
}
// GetLogPath returns the absolute path to the log file (for the diagnostics UI).
func (s *MatrixService) GetLogPath() string {
if globalLogger == nil {
return ""
}
return globalLogger.Path()
}
func (s *MatrixService) fanout() {
if s.ctx == nil || s.sync == nil {
return
}
events := s.sync.Events
errs := s.sync.Errors
for {
select {
case <-s.ctx.Done():
return
case ev, ok := <-events:
if !ok {
return
}
view := SyncEventView{
Type: ev.Type,
RoomID: ev.RoomID,
EventID: ev.EventID,
Sender: ev.Sender,
Ts: ev.Ts,
Body: ev.Body,
}
runtime.EventsEmit(s.ctx, "matrix:event", view)
case e, ok := <-errs:
if !ok {
return
}
if e != nil {
runtime.EventsEmit(s.ctx, "matrix:error", e.Error())
}
}
}
}
// ListRooms returns the joined rooms with summary metadata.
func (s *MatrixService) ListRooms() ([]infra.RoomSummary, error) {
s.mu.Lock()
client := s.client
s.mu.Unlock()
if client == nil {
return nil, errors.New("matrix service not started — call Start() first")
}
return infra.MatrixRoomList(s.ctx, infra.MatrixRoomListConfig{Client: client})
}
// LoadTimeline fetches the last N messages of a room (most recent first).
func (s *MatrixService) LoadTimeline(roomID string, limit int) ([]MatrixEvent, error) {
s.mu.Lock()
client := s.client
s.mu.Unlock()
if client == nil {
return nil, errors.New("matrix service not started — call Start() first")
}
if limit <= 0 {
limit = 50
}
msgs, err := client.Messages(s.ctx, id.RoomID(roomID), "", "", mautrix.DirectionBackward, nil, limit)
if err != nil {
return nil, fmt.Errorf("messages: %w", err)
}
out := make([]MatrixEvent, 0, len(msgs.Chunk))
for _, evt := range msgs.Chunk {
out = append(out, eventToView(evt))
}
return out, nil
}
func eventToView(evt *event.Event) MatrixEvent {
view := MatrixEvent{
EventID: evt.ID.String(),
RoomID: evt.RoomID.String(),
Sender: evt.Sender.String(),
Type: evt.Type.String(),
Ts: evt.Timestamp,
}
if evt.Type == event.EventEncrypted {
view.EncryptedRaw = true
return view
}
// Try to parse and extract body for messages.
if evt.Type == event.EventMessage {
_ = evt.Content.ParseRaw(evt.Type)
if mc, ok := evt.Content.Parsed.(*event.MessageEventContent); ok && mc != nil {
view.Body = mc.Body
}
}
return view
}
// SendText sends a plain text message to the given room.
func (s *MatrixService) SendText(roomID, body string) (string, error) {
s.mu.Lock()
client := s.client
s.mu.Unlock()
if client == nil {
return "", errors.New("matrix service not started — call Start() first")
}
evID, err := infra.MatrixSendText(s.ctx, client, id.RoomID(roomID), body)
if err != nil {
return "", err
}
return string(evID), nil
}
// SendMarkdown sends a markdown-formatted message (rendered + sanitized HTML).
func (s *MatrixService) SendMarkdown(roomID, md string) (string, error) {
s.mu.Lock()
client := s.client
s.mu.Unlock()
if client == nil {
return "", errors.New("matrix service not started — call Start() first")
}
evID, err := infra.MatrixSendMarkdown(s.ctx, client, id.RoomID(roomID), md)
if err != nil {
return "", err
}
return string(evID), nil
}
// loadOrCreatePickleKey returns the 32-byte pickle key for the user.
// If absent in keyring, generates fresh random bytes, hex-encodes them, persists, and returns.
func (s *MatrixService) loadOrCreatePickleKey(tok *infra.Token) ([]byte, error) {
if tok.PickleKeyHex != "" {
key, err := hex.DecodeString(tok.PickleKeyHex)
if err == nil && len(key) == 32 {
return key, nil
}
// Malformed key in keyring — fall through and regenerate.
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return nil, fmt.Errorf("rand: %w", err)
}
tok.PickleKeyHex = hex.EncodeToString(buf)
if err := s.store.Save(tok.UserID, *tok); err != nil {
return nil, fmt.Errorf("save pickle key: %w", err)
}
return buf, nil
}