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:
+159
-33
@@ -35,13 +35,15 @@ var defaultScopes = []string{
|
||||
|
||||
// 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
|
||||
ctx context.Context
|
||||
mu sync.Mutex
|
||||
store *infra.KeyringTokenStore
|
||||
client *mautrix.Client
|
||||
sync *infra.MatrixSyncServiceHandle
|
||||
crypto *infra.MatrixCryptoInitResult
|
||||
userID string
|
||||
lastError string // last surfaced error message (for diagnostics panel)
|
||||
errorTs time.Time
|
||||
}
|
||||
|
||||
func NewMatrixService() *MatrixService {
|
||||
@@ -91,19 +93,27 @@ func (s *MatrixService) Login() (string, error) {
|
||||
s.mu.Lock()
|
||||
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{
|
||||
Issuer: masIssuer,
|
||||
ClientID: masClientID,
|
||||
Scopes: defaultScopes,
|
||||
Scopes: scopes,
|
||||
LoopbackPort: loopbackPort,
|
||||
OpenBrowser: true,
|
||||
TimeoutSeconds: oidcTimeoutSeconds,
|
||||
}
|
||||
res, err := infra.MasOidcLoopback(cfg)
|
||||
if err != nil {
|
||||
logError("oidc loopback failed", "err", err)
|
||||
s.recordError(fmt.Errorf("oidc loopback: %w", err))
|
||||
return "", fmt.Errorf("oidc: %w", err)
|
||||
}
|
||||
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).
|
||||
userID, deviceID, err := whoami(s.ctx, homeserverURL, res.AccessToken)
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
|
||||
clientCfg := infra.MatrixClientInitConfig{
|
||||
@@ -216,6 +233,19 @@ type Diagnostics struct {
|
||||
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.
|
||||
func (s *MatrixService) GetDiagnostics() Diagnostics {
|
||||
s.mu.Lock()
|
||||
@@ -226,6 +256,7 @@ func (s *MatrixService) GetDiagnostics() Diagnostics {
|
||||
ClientReady: s.client != nil,
|
||||
CryptoInitialized: s.crypto != nil,
|
||||
SyncActive: s.sync != nil,
|
||||
LastError: s.lastError,
|
||||
}
|
||||
client := s.client
|
||||
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.
|
||||
// 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 {
|
||||
return s.startInternal(userID, false)
|
||||
}
|
||||
|
||||
func (s *MatrixService) startInternal(userID string, skipCrypto bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -349,46 +392,113 @@ func (s *MatrixService) Start(userID string) error {
|
||||
|
||||
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)
|
||||
if skipCrypto {
|
||||
logWarn("crypto init SKIPPED — encrypted rooms wont decrypt", "user_id", userID)
|
||||
syncRes, err := infra.MatrixSyncService(s.ctx, infra.MatrixSyncServiceConfig{
|
||||
Client: clientRes.Client,
|
||||
})
|
||||
if err != nil {
|
||||
s.recordError(fmt.Errorf("sync service start (no crypto): %w", err))
|
||||
return fmt.Errorf("matrix sync: %w", err)
|
||||
}
|
||||
s.client = clientRes.Client
|
||||
s.sync = syncRes
|
||||
s.userID = userID
|
||||
go s.fanout()
|
||||
logInfo("StartNoCrypto complete", "user_id", userID)
|
||||
return nil
|
||||
}
|
||||
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{
|
||||
Client: clientRes.Client,
|
||||
})
|
||||
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)
|
||||
}
|
||||
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)
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (s *MatrixService) GetLogTail(n int) ([]string, error) {
|
||||
if n <= 0 {
|
||||
@@ -524,6 +634,22 @@ func (s *MatrixService) SendMarkdown(roomID, md string) (string, error) {
|
||||
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.
|
||||
// If absent in keyring, generates fresh random bytes, hex-encodes them, persists, and returns.
|
||||
func (s *MatrixService) loadOrCreatePickleKey(tok *infra.Token) ([]byte, error) {
|
||||
|
||||
Reference in New Issue
Block a user