Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion desktop/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"log"
"net/http"
"net/url"
Expand Down Expand Up @@ -98,6 +99,13 @@ type App struct {
quitApproved atomic.Bool

pluginFS *PluginFS

// recent relay errors — bounded ring, newest-first.
relayErrMu sync.Mutex
relayErrors []RelayErrorEntry

// writeFile is os.WriteFile in production; tests substitute a stub.
writeFile writeFileFunc
}

// NewApp creates a new App application struct.
Expand Down Expand Up @@ -198,7 +206,7 @@ func (a *App) applyRelayConfig(cfg appConfig) {
}
uplinkCtx, cancel := context.WithCancel(a.ctx)
a.uplinkCancel = cancel
a.uplink = newUplink(cfg.RelayURL, cfg.RelayToken, cfg.RemotePermissionOrDefault(), a.host)
a.uplink = newUplink(cfg.RelayURL, cfg.RelayToken, cfg.RemotePermissionOrDefault(), a.host, a.recordRelayError)
go a.uplink.Run(uplinkCtx)
log.Printf("desktop: uplink configured for %s", cfg.RelayURL)
}
Expand Down Expand Up @@ -889,3 +897,71 @@ func (a *App) CreatePairingToken() (PairingTokenResponse, error) {
}
return out, nil
}

// recordRelayError appends an error entry to the recent-errors ring buffer.
// Nil errors are dropped. Messages are passed through redactErrorLine so
// tokens / Authorization / Cookie values are masked. Newest-first ordering;
// when the buffer is full the oldest entry falls off.
func (a *App) recordRelayError(err error) {
if err == nil {
return
}
entry := RelayErrorEntry{
Timestamp: time.Now().UTC().Format(time.RFC3339),
Message: redactErrorLine(err.Error()),
}
a.relayErrMu.Lock()
defer a.relayErrMu.Unlock()
a.relayErrors = append([]RelayErrorEntry{entry}, a.relayErrors...)
if len(a.relayErrors) > maxRelayErrors {
a.relayErrors = a.relayErrors[:maxRelayErrors]
}
}

// snapshotRelayErrors returns a copy of the recent-errors ring buffer.
// Callers receive a fresh slice safe to mutate; the underlying buffer is
// unaffected.
func (a *App) snapshotRelayErrors() []RelayErrorEntry {
a.relayErrMu.Lock()
defer a.relayErrMu.Unlock()
out := make([]RelayErrorEntry, len(a.relayErrors))
copy(out, a.relayErrors)
return out
}

// writeFileFunc is the function `ExportDiagnostics` uses to persist content.
// Held as a field on App so tests can substitute a capturing stub instead of
// touching disk. Defaults to os.WriteFile in production.
type writeFileFunc func(path string, data []byte, perm fs.FileMode) error

// GetDiagnostics is the Wails-exposed binding that returns the current
// diagnostics payload. userAgent should be the renderer's navigator.userAgent.
func (a *App) GetDiagnostics(userAgent string) DiagnosticsPayload {
return collectDiagnostics(a, userAgent)
}

// ExportDiagnostics opens a native save dialog (default filename
// "atterm-diagnostics-<ts>.txt") and writes content to the chosen path.
// Returns "" when the user cancelled. Returns ("", err) only on actual
// I/O failure after the user picked a path.
func (a *App) ExportDiagnostics(content string) (string, error) {
defaultName := "atterm-diagnostics-" + time.Now().UTC().Format("2006-01-02T15-04-05Z") + ".txt"
path, err := wailsruntime.SaveFileDialog(a.ctx, wailsruntime.SaveDialogOptions{
Title: "Export diagnostics",
DefaultFilename: defaultName,
Filters: []wailsruntime.FileFilter{
{DisplayName: "Text Files (*.txt)", Pattern: "*.txt"},
},
})
if err != nil || path == "" {
return "", err
}
wf := a.writeFile
if wf == nil {
wf = os.WriteFile
}
if err := wf(path, []byte(content), 0o600); err != nil {
return "", err
}
return path, nil
}
178 changes: 178 additions & 0 deletions desktop/diagnostics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package main

import (
"net/url"
"regexp"
"runtime"
"time"
)

// redactToken returns the first 12 characters of an API token followed by "…".
// For atk_ tokens this yields "atk_AbCdEfGh…" — enough to recognise the token
// in a log line, not enough to authenticate. Empty input returns empty.
func redactToken(s string) string {
if s == "" {
return ""
}
if len(s) <= 12 {
return s + "…"
}
return s[:12] + "…"
}

// redactURL returns scheme://host[:port] only — drops path, query, and
// fragment so URLs that carry tokens in ?t=… are stripped before display.
func redactURL(u string) string {
if u == "" {
return ""
}
parsed, err := url.Parse(u)
if err != nil || parsed.Host == "" || parsed.Scheme == "" {
return "(invalid url)"
}
return parsed.Scheme + "://" + parsed.Host
}

var (
tokenRE = regexp.MustCompile(`atk_[A-Za-z0-9_-]{8,}`)
authRE = regexp.MustCompile(`(?i)(authorization\s*:\s*bearer\s+)\S+`)
cookieRE = regexp.MustCompile(`(?i)(cookie\s*:\s*)\S+`)
)

// redactErrorLine masks API tokens, Authorization headers, and Cookie headers
// in a free-form error message.
func redactErrorLine(s string) string {
s = tokenRE.ReplaceAllStringFunc(s, redactToken)
s = authRE.ReplaceAllString(s, "${1}[redacted]")
s = cookieRE.ReplaceAllString(s, "${1}[redacted]")
return s
}

var (
safariRE = regexp.MustCompile(`Version/(\S+)\s+Safari`)
edgeRE = regexp.MustCompile(`Edg/(\S+)`)
webkitGTKRE = regexp.MustCompile(`(?:^|[^e])WebKit/(\S+)`)
)

// RelayErrorEntry is a single relay-error history record. Timestamps are
// RFC3339 UTC; messages have already been passed through redactErrorLine.
type RelayErrorEntry struct {
Timestamp string `json:"timestamp"`
Message string `json:"message"`
}

const maxRelayErrors = 5

// parseWebViewSummary extracts a WebView identifier + version from a user
// agent string. Returns the raw UA when no known pattern matches; returns
// empty string for empty input.
func parseWebViewSummary(ua string) string {
if ua == "" {
return ""
}
if m := safariRE.FindStringSubmatch(ua); len(m) > 1 {
return "WKWebView (Safari/" + m[1] + ")"
}
if m := edgeRE.FindStringSubmatch(ua); len(m) > 1 {
return "WebView2 (Edg/" + m[1] + ")"
}
if m := webkitGTKRE.FindStringSubmatch(ua); len(m) > 1 {
return "WebKitGTK (WebKit/" + m[1] + ")"
}
return ua
}

// ConfigSummary holds the redaction-safe slice of appConfig that's safe to
// share. No paths to secrets, no token values.
type ConfigSummary struct {
DefaultShell string `json:"default_shell"`
Locale string `json:"locale"`
TerminalTheme string `json:"terminal_theme"`
NotificationsEnabled bool `json:"notifications_enabled"`
ShellIntegrationEnabled bool `json:"shell_integration_enabled"`
WebGLRendererEnabled bool `json:"webgl_renderer_enabled"`
LoggingEnabled bool `json:"logging_enabled"`
LogFilePath string `json:"log_file_path"`
AutoCheckUpdates bool `json:"auto_check_updates"`
CommandNotifyThresholdSeconds int `json:"command_notify_threshold_seconds"`
}

// DiagnosticsPayload is the JSON shape exposed by App.GetDiagnostics and
// consumed by formatDiagnostics in TypeScript.
type DiagnosticsPayload struct {
GeneratedAt string `json:"generated_at"`
AppVersion string `json:"app_version"`
OS string `json:"os"`
Arch string `json:"arch"`
OSVersion string `json:"os_version"`
WebViewSummary string `json:"webview_summary"`
UserAgent string `json:"user_agent"`
RelayURL string `json:"relay_url"`
RelayStatus string `json:"relay_status"`
RelayTokenRedacted string `json:"relay_token_redacted"`
AllowInsecureRelay bool `json:"allow_insecure_relay"`
RemotePermission string `json:"remote_permission"`
UplinkPaused bool `json:"uplink_paused"`
RecentRelayErrors []RelayErrorEntry `json:"recent_relay_errors"`
Config ConfigSummary `json:"config"`
}

// collectDiagnostics gathers the runtime state of the desktop App into a
// DiagnosticsPayload. userAgent is the renderer's navigator.userAgent; it
// is recorded raw in UserAgent and also parsed into WebViewSummary.
func collectDiagnostics(a *App, userAgent string) DiagnosticsPayload {
cfg := a.cfgStore.Get()

a.mu.Lock()
connected := a.uplink != nil
a.mu.Unlock()
paused := cfg.RelayPaused

status := "not_configured"
switch {
case cfg.RelayURL == "":
status = "not_configured"
case paused:
status = "paused"
case connected:
status = "connected"
default:
status = "disconnected"
}

errs := a.snapshotRelayErrors()
// Nil-safe: tests that load a zero-init App might never have touched the
// ring buffer. Marshal-friendly: always emit an array, not null.
if errs == nil {
errs = []RelayErrorEntry{}
}

return DiagnosticsPayload{
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
AppVersion: Version,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
OSVersion: systemVersion(),
WebViewSummary: parseWebViewSummary(userAgent),
UserAgent: userAgent,
RelayURL: redactURL(cfg.RelayURL),
RelayStatus: status,
RelayTokenRedacted: redactToken(cfg.RelayToken),
AllowInsecureRelay: cfg.AllowInsecureRelay,
RemotePermission: cfg.RemotePermissionOrDefault(),
UplinkPaused: paused,
RecentRelayErrors: errs,
Config: ConfigSummary{
DefaultShell: cfg.DefaultShellOrDefault(),
Locale: cfg.LocalePreferenceOrDefault(),
TerminalTheme: cfg.TerminalThemeOrDefault(),
NotificationsEnabled: cfg.NotificationsEnabledOrDefault(),
ShellIntegrationEnabled: cfg.ShellIntegrationEnabledOrDefault(),
WebGLRendererEnabled: cfg.WebglRendererEnabledOrDefault(),
LoggingEnabled: cfg.LogToFileEnabledOrDefault(),
LogFilePath: cfg.LogFilePathOrDefault(),
AutoCheckUpdates: cfg.AutoCheckUpdatesOrDefault(),
CommandNotifyThresholdSeconds: cfg.CommandNotifyThresholdSecondsOrDefault(),
},
}
}
78 changes: 78 additions & 0 deletions desktop/diagnostics_errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package main

import (
"context"
"errors"
"fmt"
"strings"
"testing"

"nhooyr.io/websocket"
)

func TestRecordRelayError_RingBufferKeeps5Newest(t *testing.T) {
a := newRelayTestApp(t)
for i := 0; i < 8; i++ {
a.recordRelayError(fmt.Errorf("err-%d", i))
}
got := a.snapshotRelayErrors()
if len(got) != 5 {
t.Fatalf("want 5, got %d", len(got))
}
if got[0].Message != "err-7" {
t.Fatalf("newest first failed: %q", got[0].Message)
}
if got[4].Message != "err-3" {
t.Fatalf("oldest in buffer wrong: %q", got[4].Message)
}
}

func TestRecordRelayError_NilIsNoop(t *testing.T) {
a := newRelayTestApp(t)
a.recordRelayError(nil)
if got := a.snapshotRelayErrors(); len(got) != 0 {
t.Fatalf("nil should not record, got %d entries", len(got))
}
}

func TestRecordRelayError_RedactsTokensInMessage(t *testing.T) {
a := newRelayTestApp(t)
a.recordRelayError(errors.New("401 dial failed: atk_abcdefghij blocked"))
got := a.snapshotRelayErrors()
if len(got) != 1 {
t.Fatalf("want 1, got %d", len(got))
}
if !strings.Contains(got[0].Message, "atk_abcdefgh…") {
t.Fatalf("expected redacted token, got %q", got[0].Message)
}
}

func TestSnapshotRelayErrors_ReturnsCopy(t *testing.T) {
a := newRelayTestApp(t)
a.recordRelayError(fmt.Errorf("e"))
snap := a.snapshotRelayErrors()
snap[0].Message = "mutated"
again := a.snapshotRelayErrors()
if again[0].Message != "e" {
t.Fatalf("internal state was mutated by caller: %q", again[0].Message)
}
}

func TestUplink_HandleCloseError_RecordsAuthFailure(t *testing.T) {
a := newRelayTestApp(t)
u := newUplink("ws://test", "atk_test", "full", a.host, a.recordRelayError)
// Stub eventsEmit so we don't hit wailsruntime in tests.
u.eventsEmit = func(ctx context.Context, name string, data ...interface{}) {}
// Pretend a 4001 close from the relay.
u.handleCloseError(context.Background(), websocket.CloseError{
Code: 4001,
Reason: "",
})
got := a.snapshotRelayErrors()
if len(got) != 1 {
t.Fatalf("want 1 error recorded, got %d", len(got))
}
if got[0].Message != "auth_invalid_token" {
t.Fatalf("expected reason mapping, got %q", got[0].Message)
}
}
Loading
Loading