diff --git a/client/ui/frontend/src/app.tsx b/client/ui/frontend/src/app.tsx index 1f143fbd6..e3aa72735 100644 --- a/client/ui/frontend/src/app.tsx +++ b/client/ui/frontend/src/app.tsx @@ -15,6 +15,11 @@ import { welcome } from "@/lib/welcome"; import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx"; import { initI18n } from "@/lib/i18n"; import { initPlatform } from "@/lib/platform"; +import { initLogForwarding } from "@/lib/logs"; + +// Install console.* + uncaught-error forwarding before anything else runs +// so even init-time logs reach the Go log pipeline. +initLogForwarding(); welcome(); diff --git a/client/ui/frontend/src/lib/logs.ts b/client/ui/frontend/src/lib/logs.ts new file mode 100644 index 000000000..5a0a0a322 --- /dev/null +++ b/client/ui/frontend/src/lib/logs.ts @@ -0,0 +1,80 @@ +import { UILog } from "@bindings/services"; + +// Forwards browser console output and uncaught errors into the Go logrus +// pipeline. Originals still fire, so DevTools is unchanged; the Go +// --log-level does the gating. + +type Level = "trace" | "debug" | "info" | "warn" | "error"; + +const METHOD_LEVELS: Record = { + trace: "trace", + debug: "debug", + log: "info", + info: "info", + warn: "warn", + error: "error", +}; + +// Sources whose output is noise and shouldn't be forwarded. +const IGNORED_SOURCES = new Set(["welcome.ts"]); + +let installed = false; + +function format(args: unknown[]): string { + return args + .map((a) => { + if (typeof a === "string") return a; + if (a instanceof Error) return a.stack || a.message; + try { + return JSON.stringify(a); + } catch { + return String(a); + } + }) + .join(" "); +} + +// First stack frame outside this module as ":" (best-effort; +// minified prod stacks degrade to chunk names). +function callerSource(): string { + const stack = new Error().stack; + if (!stack) return ""; + for (const line of stack.split("\n").slice(1)) { + if (line.includes("/logs.ts")) continue; + const m = line.match(/([^/\\() ]+\.[a-z]+):(\d+):\d+/i); + if (m) return `${m[1]}:${m[2]}`; + } + return ""; +} + +function forward(level: Level, args: unknown[]) { + try { + const source = callerSource(); + if (IGNORED_SOURCES.has(source.split(":")[0])) return; + // Fire-and-forget; don't touch console here (would recurse). + void UILog.Log(level, source, format(args)); + } catch { + // swallow + } +} + +export function initLogForwarding() { + if (installed) return; + installed = true; + + const c = console as unknown as Record void>; + for (const [method, level] of Object.entries(METHOD_LEVELS)) { + const original = c[method]?.bind(console); + c[method] = (...args: unknown[]) => { + original?.(...args); + forward(level, args); + }; + } + + window.addEventListener("error", (e) => { + forward("error", [`uncaught error: ${e.message}`, e.error ?? ""]); + }); + window.addEventListener("unhandledrejection", (e) => { + forward("error", ["unhandled promise rejection:", e.reason]); + }); +} diff --git a/client/ui/main.go b/client/ui/main.go index e4eabc17d..c91f363ac 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -316,6 +316,7 @@ func registerServices(app *application.App, conn *Conn, s registeredServices) { app.RegisterService(application.NewService(services.NewPreferences(s.prefStore))) app.RegisterService(application.NewService(services.NewAutostart(app.Autostart))) app.RegisterService(application.NewService(services.NewVersion())) + app.RegisterService(application.NewService(services.NewUILog())) } // newMainWindow creates the hidden main window, sized to the user's last view diff --git a/client/ui/services/uilog.go b/client/ui/services/uilog.go new file mode 100644 index 000000000..0ce3c35c1 --- /dev/null +++ b/client/ui/services/uilog.go @@ -0,0 +1,38 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "context" + + "github.com/sirupsen/logrus" +) + +// UILog lets the frontend forward console output into the Go logrus +// pipeline. The JS origin rides in the message ("[ui ] ...") +// because logrus's ReportCaller always pins the source to this file. +type UILog struct{} + +func NewUILog() *UILog { return &UILog{} } + +// Log forwards one frontend console entry. level is trace/debug/info/warn/ +// error (anything else → info); source is the JS origin (may be empty). +func (s *UILog) Log(_ context.Context, level, source, msg string) { + if source != "" { + msg = "[ui " + source + "] " + msg + } else { + msg = "[ui] " + msg + } + switch level { + case "trace": + logrus.Trace(msg) + case "debug": + logrus.Debug(msg) + case "warn", "warning": + logrus.Warn(msg) + case "error": + logrus.Error(msg) + default: + logrus.Info(msg) + } +}