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.
This commit is contained in:
BIN
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
@@ -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
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 9.5 KiB |
+12
-21
@@ -2,36 +2,27 @@ import { useEffect, useState } from "react";
|
|||||||
import { Box, LoadingOverlay } from "@mantine/core";
|
import { Box, LoadingOverlay } from "@mantine/core";
|
||||||
import LoginScreen from "./LoginScreen";
|
import LoginScreen from "./LoginScreen";
|
||||||
import HomeScreen from "./HomeScreen";
|
import HomeScreen from "./HomeScreen";
|
||||||
import { GetSession } from "../wailsjs/go/main/MatrixService";
|
import { GetLastUserID, GetSession } from "../wailsjs/go/main/MatrixService";
|
||||||
|
|
||||||
const LAST_USER_KEY = "matrix_client_pc.last_user_id";
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [userID, setUserID] = useState<string | null>(null);
|
const [userID, setUserID] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const last = localStorage.getItem(LAST_USER_KEY);
|
(async () => {
|
||||||
if (!last) {
|
try {
|
||||||
setLoading(false);
|
const last = await GetLastUserID();
|
||||||
return;
|
if (!last) return;
|
||||||
}
|
const s = await GetSession(last);
|
||||||
GetSession(last)
|
|
||||||
.then((s) => {
|
|
||||||
if (s && s.has_token) setUserID(s.user_id);
|
if (s && s.has_token) setUserID(s.user_id);
|
||||||
})
|
} finally {
|
||||||
.finally(() => setLoading(false));
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogin = (uid: string) => {
|
const handleLogin = (uid: string) => setUserID(uid);
|
||||||
localStorage.setItem(LAST_USER_KEY, uid);
|
const handleLogout = () => setUserID(null);
|
||||||
setUserID(uid);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
localStorage.removeItem(LAST_USER_KEY);
|
|
||||||
setUserID(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box pos="relative" mih="100vh">
|
<Box pos="relative" mih="100vh">
|
||||||
|
|||||||
+143
-1
@@ -6,8 +6,11 @@ import {
|
|||||||
Burger,
|
Burger,
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
|
Code,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
Modal,
|
||||||
|
ScrollArea,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
@@ -15,11 +18,16 @@ import {
|
|||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import {
|
import {
|
||||||
|
IconBug,
|
||||||
IconLock,
|
IconLock,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
|
IconStethoscope,
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
|
GetDiagnostics,
|
||||||
|
GetLogPath,
|
||||||
|
GetLogTail,
|
||||||
Logout,
|
Logout,
|
||||||
SendMarkdown,
|
SendMarkdown,
|
||||||
SendText,
|
SendText,
|
||||||
@@ -35,6 +43,25 @@ import { useMatrixTimeline } from "./hooks/useMatrixTimeline";
|
|||||||
|
|
||||||
const NAVBAR_WIDTH = 300;
|
const NAVBAR_WIDTH = 300;
|
||||||
|
|
||||||
|
function HealthRow({ label, value, ok }: { label: string; value: string; ok: boolean }) {
|
||||||
|
return (
|
||||||
|
<Group gap="sm" justify="space-between">
|
||||||
|
<Text size="sm" c="dimmed">{label}</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Box
|
||||||
|
w={8}
|
||||||
|
h={8}
|
||||||
|
style={{
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: ok ? "var(--mantine-color-green-6)" : "var(--mantine-color-red-6)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Code>{value}</Code>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomeScreen({
|
export default function HomeScreen({
|
||||||
userID,
|
userID,
|
||||||
onLogout,
|
onLogout,
|
||||||
@@ -46,6 +73,35 @@ export default function HomeScreen({
|
|||||||
const [activeRoomID, setActiveRoomID] = useState<string | null>(null);
|
const [activeRoomID, setActiveRoomID] = useState<string | null>(null);
|
||||||
const [started, setStarted] = useState(false);
|
const [started, setStarted] = useState(false);
|
||||||
const [startError, setStartError] = useState<string | null>(null);
|
const [startError, setStartError] = useState<string | null>(null);
|
||||||
|
const [diagOpen, { open: openDiagnostics, close: closeDiagnostics }] = useDisclosure(false);
|
||||||
|
const [healthOpen, { open: openHealth, close: closeHealth }] = useDisclosure(false);
|
||||||
|
const [logLines, setLogLines] = useState<string[]>([]);
|
||||||
|
const [logPath, setLogPath] = useState<string>("");
|
||||||
|
const [health, setHealth] = useState<any | null>(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 { rooms } = useMatrixRooms(started);
|
||||||
const { events, loading: timelineLoading, error: timelineError } = useMatrixTimeline(
|
const { events, loading: timelineLoading, error: timelineError } = useMatrixTimeline(
|
||||||
@@ -64,12 +120,18 @@ export default function HomeScreen({
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
const msg = String(e?.message ?? e);
|
const msg = String(e?.message ?? e);
|
||||||
setStartError(msg);
|
setStartError(msg);
|
||||||
|
const stale = msg.includes("token rejected") || msg.includes("M_UNKNOWN_TOKEN");
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: "Sync error",
|
title: stale ? "Token expired — re-login required" : "Sync error",
|
||||||
message: msg,
|
message: msg,
|
||||||
color: "red",
|
color: "red",
|
||||||
autoClose: false,
|
autoClose: false,
|
||||||
});
|
});
|
||||||
|
// Auto-logout on stale token so user lands back on LoginScreen.
|
||||||
|
if (stale) {
|
||||||
|
try { await Logout(userID); } catch {}
|
||||||
|
onLogout();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -145,6 +207,25 @@ export default function HomeScreen({
|
|||||||
{userID}
|
{userID}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="green"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconStethoscope size={14} />}
|
||||||
|
onClick={openHealth}
|
||||||
|
>
|
||||||
|
Health
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconBug size={14} />}
|
||||||
|
onClick={openDiagnostics}
|
||||||
|
>
|
||||||
|
Logs
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -154,6 +235,7 @@ export default function HomeScreen({
|
|||||||
Logout
|
Logout
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
</Group>
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
|
|
||||||
<AppShell.Navbar>
|
<AppShell.Navbar>
|
||||||
@@ -241,6 +323,66 @@ export default function HomeScreen({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={healthOpen}
|
||||||
|
onClose={closeHealth}
|
||||||
|
title="Health — comprobar chats"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Snapshot del estado del MatrixService backend.
|
||||||
|
</Text>
|
||||||
|
<Button size="xs" variant="light" onClick={refreshHealth} loading={healthLoading}>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
{!health && <Text c="dimmed">No data yet.</Text>}
|
||||||
|
{health?.error && (
|
||||||
|
<Text c="red" size="sm">
|
||||||
|
{health.error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{health && !health.error && (
|
||||||
|
<Stack gap={4}>
|
||||||
|
<HealthRow label="Started" value={String(health.started)} ok={health.started} />
|
||||||
|
<HealthRow label="Client ready" value={String(health.client_ready)} ok={health.client_ready} />
|
||||||
|
<HealthRow label="Crypto initialized" value={String(health.crypto_initialized)} ok={health.crypto_initialized} />
|
||||||
|
<HealthRow label="Sync active" value={String(health.sync_active)} ok={health.sync_active} />
|
||||||
|
<HealthRow label="User ID" value={health.user_id || "—"} ok={!!health.user_id} />
|
||||||
|
<HealthRow label="Homeserver" value={health.homeserver_url || "—"} ok />
|
||||||
|
<HealthRow label="Rooms total" value={String(health.rooms_count)} ok={health.rooms_count > 0} />
|
||||||
|
<HealthRow label="Encrypted rooms" value={String(health.encrypted_rooms)} ok />
|
||||||
|
<HealthRow label="Direct messages" value={String(health.dms_count)} ok />
|
||||||
|
{health.last_error && (
|
||||||
|
<Text c="red" size="xs" mt="sm">
|
||||||
|
Last error: {health.last_error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={diagOpen}
|
||||||
|
onClose={closeDiagnostics}
|
||||||
|
title="Diagnostics — last 200 log lines"
|
||||||
|
size="xl"
|
||||||
|
>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Log file: <Code>{logPath || "(unknown)"}</Code>
|
||||||
|
</Text>
|
||||||
|
<ScrollArea h={500} type="auto">
|
||||||
|
<pre style={{ fontSize: 11, lineHeight: 1.4, margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-all" }}>
|
||||||
|
{logLines.length === 0 ? "(empty)" : logLines.join("\n")}
|
||||||
|
</pre>
|
||||||
|
</ScrollArea>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <UserConfigDir>/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)
|
||||||
|
}
|
||||||
@@ -14,9 +14,16 @@ import (
|
|||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
func main() {
|
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()
|
ms := NewMatrixService()
|
||||||
|
|
||||||
err := wails.Run(&options.App{
|
err = wails.Run(&options.App{
|
||||||
Title: "matrix_client_pc",
|
Title: "matrix_client_pc",
|
||||||
Width: 1280,
|
Width: 1280,
|
||||||
Height: 800,
|
Height: 800,
|
||||||
@@ -26,12 +33,17 @@ func main() {
|
|||||||
BackgroundColour: &options.RGBA{R: 26, G: 27, B: 30, A: 1},
|
BackgroundColour: &options.RGBA{R: 26, G: 27, B: 30, A: 1},
|
||||||
OnStartup: func(ctx context.Context) {
|
OnStartup: func(ctx context.Context) {
|
||||||
ms.SetContext(ctx)
|
ms.SetContext(ctx)
|
||||||
|
logger.Info("wails ctx ready")
|
||||||
|
},
|
||||||
|
OnShutdown: func(ctx context.Context) {
|
||||||
|
logger.Info("shutdown")
|
||||||
},
|
},
|
||||||
Bind: []interface{}{
|
Bind: []interface{}{
|
||||||
ms,
|
ms,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.Error("wails error", "err", err)
|
||||||
log.Fatalln("Wails error:", err)
|
log.Fatalln("Wails error:", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+138
-3
@@ -22,7 +22,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
homeserverURL = "https://matrix-af2f3d.organic-machine.com"
|
homeserverURL = "https://matrix-af2f3d.organic-machine.com"
|
||||||
masIssuer = "https://auth-af2f3d.organic-machine.com/"
|
masIssuer = "https://auth-af2f3d.organic-machine.com/"
|
||||||
masClientID = "VDC4XQ2ZKN2TJ0BYVJ54FK7M6Y"
|
masClientID = "3DC4XQ2ZKN2TJ0BYVJ54FK7M6Y"
|
||||||
loopbackPort = 8765
|
loopbackPort = 8765
|
||||||
keyringServiceName = "fn_registry.matrix_client_pc"
|
keyringServiceName = "fn_registry.matrix_client_pc"
|
||||||
oidcTimeoutSeconds = 300
|
oidcTimeoutSeconds = 300
|
||||||
@@ -91,6 +91,8 @@ 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)
|
||||||
|
|
||||||
cfg := infra.MasOidcLoopbackConfig{
|
cfg := infra.MasOidcLoopbackConfig{
|
||||||
Issuer: masIssuer,
|
Issuer: masIssuer,
|
||||||
ClientID: masClientID,
|
ClientID: masClientID,
|
||||||
@@ -101,14 +103,18 @@ func (s *MatrixService) Login() (string, error) {
|
|||||||
}
|
}
|
||||||
res, err := infra.MasOidcLoopback(cfg)
|
res, err := infra.MasOidcLoopback(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logError("oidc loopback failed", "err", 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)
|
||||||
|
|
||||||
// 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)
|
||||||
return "", fmt.Errorf("whoami: %w", err)
|
return "", fmt.Errorf("whoami: %w", err)
|
||||||
}
|
}
|
||||||
|
logInfo("whoami OK", "user_id", userID, "device_id", deviceID)
|
||||||
|
|
||||||
clientCfg := infra.MatrixClientInitConfig{
|
clientCfg := infra.MatrixClientInitConfig{
|
||||||
HomeserverURL: homeserverURL,
|
HomeserverURL: homeserverURL,
|
||||||
@@ -135,11 +141,22 @@ func (s *MatrixService) Login() (string, error) {
|
|||||||
tok.ExpiresAt = time.Now().Add(time.Duration(res.ExpiresIn) * time.Second)
|
tok.ExpiresAt = time.Now().Add(time.Duration(res.ExpiresIn) * time.Second)
|
||||||
}
|
}
|
||||||
if err := s.store.Save(userID, tok); err != nil {
|
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)
|
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
|
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.
|
// GetSession returns the persisted session for the given user_id.
|
||||||
func (s *MatrixService) GetSession(userID string) (*SessionView, error) {
|
func (s *MatrixService) GetSession(userID string) (*SessionView, error) {
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
@@ -178,9 +195,62 @@ func (s *MatrixService) Logout(userID string) error {
|
|||||||
s.client = nil
|
s.client = nil
|
||||||
s.crypto = nil
|
s.crypto = nil
|
||||||
s.userID = ""
|
s.userID = ""
|
||||||
|
if err := clearLastUser(); err != nil {
|
||||||
|
logWarn("clear last_user.txt failed (non-fatal)", "err", err)
|
||||||
|
}
|
||||||
return s.store.Delete(userID)
|
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.
|
// Stop shuts down the sync loop without deleting credentials. Safe to call multiple times.
|
||||||
func (s *MatrixService) Stop() {
|
func (s *MatrixService) Stop() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -212,10 +282,22 @@ func (s *MatrixService) Start(userID string) error {
|
|||||||
s.sync = nil
|
s.sync = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logInfo("Start invoked", "user_id", userID)
|
||||||
|
|
||||||
tok, err := s.store.Load(userID)
|
tok, err := s.store.Load(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logError("keyring load failed", "err", err, "user_id", userID)
|
||||||
return fmt.Errorf("keyring load: %w", err)
|
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)
|
storeDir := userStoreDir(userID)
|
||||||
clientCfg := infra.MatrixClientInitConfig{
|
clientCfg := infra.MatrixClientInitConfig{
|
||||||
@@ -228,7 +310,35 @@ func (s *MatrixService) Start(userID string) error {
|
|||||||
}
|
}
|
||||||
clientRes, err := infra.MatrixClientInit(clientCfg)
|
clientRes, err := infra.MatrixClientInit(clientCfg)
|
||||||
if err != nil {
|
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.
|
// Pickle key: load from keyring (hex), or generate fresh and persist.
|
||||||
@@ -249,15 +359,23 @@ func (s *MatrixService) Start(userID string) error {
|
|||||||
PickleKey: pickleKey,
|
PickleKey: pickleKey,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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{
|
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)
|
||||||
return fmt.Errorf("matrix sync: %w", err)
|
return fmt.Errorf("matrix sync: %w", err)
|
||||||
}
|
}
|
||||||
|
logInfo("sync service started")
|
||||||
|
|
||||||
s.client = clientRes.Client
|
s.client = clientRes.Client
|
||||||
s.crypto = cryptoRes
|
s.crypto = cryptoRes
|
||||||
@@ -267,9 +385,26 @@ func (s *MatrixService) Start(userID string) error {
|
|||||||
// Fan events out via Wails runtime.
|
// Fan events out via Wails runtime.
|
||||||
go s.fanout()
|
go s.fanout()
|
||||||
|
|
||||||
|
logInfo("Start complete", "user_id", userID)
|
||||||
return nil
|
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() {
|
func (s *MatrixService) fanout() {
|
||||||
if s.ctx == nil || s.sync == nil {
|
if s.ctx == nil || s.sync == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user