diff --git a/appicon.ico b/appicon.ico new file mode 100644 index 0000000..9c721eb Binary files /dev/null and b/appicon.ico differ diff --git a/applog.go b/applog.go new file mode 100644 index 0000000..7eba494 --- /dev/null +++ b/applog.go @@ -0,0 +1,123 @@ +package main + +import ( + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "sync" +) + +// Logger writes structured logs to both stderr (dev) and a rotating file under the user +// config dir. Acceso seguro entre goroutines via sync.Mutex. +type Logger struct { + mu sync.Mutex + file *os.File + slogger *slog.Logger + logPath string +} + +var globalLogger *Logger + +func InitLogger() (*Logger, error) { + cfgDir, err := os.UserConfigDir() + if err != nil { + cfgDir = filepath.Join(os.Getenv("HOME"), ".config") + } + dir := filepath.Join(cfgDir, "matrix_client_pc") + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("mkdir log dir: %w", err) + } + logPath := filepath.Join(dir, "app.log") + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return nil, fmt.Errorf("open log file: %w", err) + } + + multi := io.MultiWriter(os.Stderr, f) + handler := slog.NewTextHandler(multi, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }) + l := &Logger{ + file: f, + slogger: slog.New(handler), + logPath: logPath, + } + globalLogger = l + l.Info("logger initialized", "path", logPath) + return l, nil +} + +func (l *Logger) Close() error { + l.mu.Lock() + defer l.mu.Unlock() + if l.file != nil { + _ = l.file.Sync() + return l.file.Close() + } + return nil +} + +func (l *Logger) Path() string { return l.logPath } + +func (l *Logger) Debug(msg string, args ...any) { l.slogger.Debug(msg, args...) } +func (l *Logger) Info(msg string, args ...any) { l.slogger.Info(msg, args...) } +func (l *Logger) Warn(msg string, args ...any) { l.slogger.Warn(msg, args...) } +func (l *Logger) Error(msg string, args ...any) { l.slogger.Error(msg, args...) } + +// Package-level helpers — no-op if InitLogger not called yet (defensive for tests). +func logDebug(msg string, args ...any) { + if globalLogger != nil { + globalLogger.Debug(msg, args...) + } +} +func logInfo(msg string, args ...any) { + if globalLogger != nil { + globalLogger.Info(msg, args...) + } +} +func logWarn(msg string, args ...any) { + if globalLogger != nil { + globalLogger.Warn(msg, args...) + } +} +func logError(msg string, args ...any) { + if globalLogger != nil { + globalLogger.Error(msg, args...) + } +} + +// TailLog returns the last N lines of the log file. +func TailLog(n int) ([]string, error) { + if globalLogger == nil { + return nil, fmt.Errorf("logger not initialized") + } + b, err := os.ReadFile(globalLogger.logPath) + if err != nil { + return nil, fmt.Errorf("read log: %w", err) + } + // Split + return last N. + lines := splitLines(string(b)) + if len(lines) <= n { + return lines, nil + } + return lines[len(lines)-n:], nil +} + +func splitLines(s string) []string { + out := []string{} + cur := "" + for _, r := range s { + if r == '\n' { + out = append(out, cur) + cur = "" + continue + } + cur += string(r) + } + if cur != "" { + out = append(out, cur) + } + return out +} diff --git a/build/appicon.png b/build/appicon.png index 63617fe..b2ceb53 100644 Binary files a/build/appicon.png and b/build/appicon.png differ diff --git a/build/windows/icon.ico b/build/windows/icon.ico index f334798..9c721eb 100644 Binary files a/build/windows/icon.ico and b/build/windows/icon.ico differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6372093..46363e1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,36 +2,27 @@ import { useEffect, useState } from "react"; import { Box, LoadingOverlay } from "@mantine/core"; import LoginScreen from "./LoginScreen"; import HomeScreen from "./HomeScreen"; -import { GetSession } from "../wailsjs/go/main/MatrixService"; - -const LAST_USER_KEY = "matrix_client_pc.last_user_id"; +import { GetLastUserID, GetSession } from "../wailsjs/go/main/MatrixService"; export default function App() { const [userID, setUserID] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { - const last = localStorage.getItem(LAST_USER_KEY); - if (!last) { - setLoading(false); - return; - } - GetSession(last) - .then((s) => { + (async () => { + try { + const last = await GetLastUserID(); + if (!last) return; + const s = await GetSession(last); if (s && s.has_token) setUserID(s.user_id); - }) - .finally(() => setLoading(false)); + } finally { + setLoading(false); + } + })(); }, []); - const handleLogin = (uid: string) => { - localStorage.setItem(LAST_USER_KEY, uid); - setUserID(uid); - }; - - const handleLogout = () => { - localStorage.removeItem(LAST_USER_KEY); - setUserID(null); - }; + const handleLogin = (uid: string) => setUserID(uid); + const handleLogout = () => setUserID(null); return ( diff --git a/frontend/src/HomeScreen.tsx b/frontend/src/HomeScreen.tsx index e5d60ae..e62535a 100644 --- a/frontend/src/HomeScreen.tsx +++ b/frontend/src/HomeScreen.tsx @@ -6,8 +6,11 @@ import { Burger, Button, Center, + Code, Group, Loader, + Modal, + ScrollArea, Stack, Text, Title, @@ -15,11 +18,16 @@ import { import { useDisclosure } from "@mantine/hooks"; import { notifications } from "@mantine/notifications"; import { + IconBug, IconLock, IconLogout, + IconStethoscope, IconUserCircle, } from "@tabler/icons-react"; import { + GetDiagnostics, + GetLogPath, + GetLogTail, Logout, SendMarkdown, SendText, @@ -35,6 +43,25 @@ import { useMatrixTimeline } from "./hooks/useMatrixTimeline"; const NAVBAR_WIDTH = 300; +function HealthRow({ label, value, ok }: { label: string; value: string; ok: boolean }) { + return ( + + {label} + + + {value} + + + ); +} + export default function HomeScreen({ userID, onLogout, @@ -46,6 +73,35 @@ export default function HomeScreen({ const [activeRoomID, setActiveRoomID] = useState(null); const [started, setStarted] = useState(false); const [startError, setStartError] = useState(null); + const [diagOpen, { open: openDiagnostics, close: closeDiagnostics }] = useDisclosure(false); + const [healthOpen, { open: openHealth, close: closeHealth }] = useDisclosure(false); + const [logLines, setLogLines] = useState([]); + const [logPath, setLogPath] = useState(""); + const [health, setHealth] = useState(null); + const [healthLoading, setHealthLoading] = useState(false); + + useEffect(() => { + if (!diagOpen) return; + GetLogTail(200).then(setLogLines).catch(() => {}); + GetLogPath().then(setLogPath).catch(() => {}); + }, [diagOpen]); + + async function refreshHealth() { + setHealthLoading(true); + try { + const h = await GetDiagnostics(); + setHealth(h); + } catch (e) { + setHealth({ error: String(e) }); + } finally { + setHealthLoading(false); + } + } + + useEffect(() => { + if (!healthOpen) return; + refreshHealth(); + }, [healthOpen]); const { rooms } = useMatrixRooms(started); const { events, loading: timelineLoading, error: timelineError } = useMatrixTimeline( @@ -64,12 +120,18 @@ export default function HomeScreen({ if (!cancelled) { const msg = String(e?.message ?? e); setStartError(msg); + const stale = msg.includes("token rejected") || msg.includes("M_UNKNOWN_TOKEN"); notifications.show({ - title: "Sync error", + title: stale ? "Token expired — re-login required" : "Sync error", message: msg, color: "red", autoClose: false, }); + // Auto-logout on stale token so user lands back on LoginScreen. + if (stale) { + try { await Logout(userID); } catch {} + onLogout(); + } } } })(); @@ -145,14 +207,34 @@ export default function HomeScreen({ {userID} - + + + + + @@ -241,6 +323,66 @@ export default function HomeScreen({ )} + + + + + + Snapshot del estado del MatrixService backend. + + + + {!health && No data yet.} + {health?.error && ( + + {health.error} + + )} + {health && !health.error && ( + + + + + + + + 0} /> + + + {health.last_error && ( + + Last error: {health.last_error} + + )} + + )} + + + + + + + Log file: {logPath || "(unknown)"} + + +
+              {logLines.length === 0 ? "(empty)" : logLines.join("\n")}
+            
+
+
+
); } diff --git a/last_user.go b/last_user.go new file mode 100644 index 0000000..57d4a71 --- /dev/null +++ b/last_user.go @@ -0,0 +1,41 @@ +package main + +import ( + "os" + "path/filepath" + "strings" +) + +// lastUserFilePath returns the path to the file storing the last-logged-in user ID. +// Lives in /matrix_client_pc/last_user.txt. +func lastUserFilePath() string { + cfg, err := os.UserConfigDir() + if err != nil { + cfg = filepath.Join(os.Getenv("HOME"), ".config") + } + return filepath.Join(cfg, "matrix_client_pc", "last_user.txt") +} + +func readLastUser() string { + b, err := os.ReadFile(lastUserFilePath()) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +func writeLastUser(userID string) error { + path := lastUserFilePath() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + return os.WriteFile(path, []byte(userID), 0o600) +} + +func clearLastUser() error { + path := lastUserFilePath() + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil + } + return os.Remove(path) +} diff --git a/main.go b/main.go index ab9cf4d..9dce8a1 100644 --- a/main.go +++ b/main.go @@ -14,9 +14,16 @@ import ( var assets embed.FS func main() { + logger, err := InitLogger() + if err != nil { + log.Fatalln("logger init:", err) + } + defer logger.Close() + logger.Info("starting matrix_client_pc", "version", "0.1.0") + ms := NewMatrixService() - err := wails.Run(&options.App{ + err = wails.Run(&options.App{ Title: "matrix_client_pc", Width: 1280, Height: 800, @@ -26,12 +33,17 @@ func main() { BackgroundColour: &options.RGBA{R: 26, G: 27, B: 30, A: 1}, OnStartup: func(ctx context.Context) { ms.SetContext(ctx) + logger.Info("wails ctx ready") + }, + OnShutdown: func(ctx context.Context) { + logger.Info("shutdown") }, Bind: []interface{}{ ms, }, }) if err != nil { + logger.Error("wails error", "err", err) log.Fatalln("Wails error:", err) } } diff --git a/matrix_service.go b/matrix_service.go index b72adaa..bc07959 100644 --- a/matrix_service.go +++ b/matrix_service.go @@ -22,7 +22,7 @@ import ( const ( homeserverURL = "https://matrix-af2f3d.organic-machine.com" masIssuer = "https://auth-af2f3d.organic-machine.com/" - masClientID = "VDC4XQ2ZKN2TJ0BYVJ54FK7M6Y" + masClientID = "3DC4XQ2ZKN2TJ0BYVJ54FK7M6Y" loopbackPort = 8765 keyringServiceName = "fn_registry.matrix_client_pc" oidcTimeoutSeconds = 300 @@ -91,6 +91,8 @@ 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, @@ -101,14 +103,18 @@ func (s *MatrixService) Login() (string, error) { } 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, @@ -135,11 +141,22 @@ func (s *MatrixService) Login() (string, error) { 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 /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 == "" { @@ -178,9 +195,62 @@ func (s *MatrixService) Logout(userID string) error { 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() @@ -212,10 +282,22 @@ func (s *MatrixService) Start(userID string) error { 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{ @@ -228,7 +310,35 @@ func (s *MatrixService) Start(userID string) error { } clientRes, err := infra.MatrixClientInit(clientCfg) if err != nil { - return fmt.Errorf("matrix init: %w", err) + 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. @@ -249,15 +359,23 @@ func (s *MatrixService) Start(userID string) error { PickleKey: pickleKey, }) if err != nil { - return fmt.Errorf("matrix crypto init (hang here = MAS rejected UIA, see memory feedback_agents_e2ee_unblock_pattern): %w", err) + 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 @@ -267,9 +385,26 @@ func (s *MatrixService) Start(userID string) error { // 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