Files
yellowjacket/main.go
T
logan 168e588387 feat(android): route slog to logcat
Every slog line the app wrote on Android went to /dev/null, including
the one naming the error it was about to os.Exit on. #52 is what that
cost: a process that vanished with no tombstone, no AndroidRuntime
stack and nothing in `logcat -b crash`, at Priority/Critical for
months, whose entire diagnosis was one sLogger.Error main.go was
already writing.

backend/androidlog is a slog.Handler over __android_log_write, chosen
in main() by build tag rather than by a runtime check so that a desktop
binary links no cgo for a platform it cannot run on.

**Everything except the write itself is untagged.** That is
androidpayload.go's discipline pushed as far as it goes: the only
toolchain that compiles the android tag is a cross-compiler and the
only thing that runs it is a phone, so the priority mapping, the
formatting, the chunking and the handler's own attr and group
bookkeeping are ordinary Go that `go test` exercises everywhere, and
android.go is fifteen lines that hand a string to liblog.

Four things in it are load-bearing.

**The tag is a fixed string, not the application id.** The debug build
carries `applicationIdSuffix ".dev"` so it can be installed beside the
release app, and it is the only build whose WebView can be inspected --
so a tag derived from the id is a different tag on the one build
anybody debugging this app is running, and the filter meant to show
these lines would hide them exactly where they were being looked for.

**The priorities are android/log.h's own values, asserted twice.**
android.go carries constant expressions that do not compile as uint if
the header renumbers; the untagged test writes the six numbers out
longhand, because comparing a constant to itself passes on any
renumbering. A wrong priority is the failure that hides rather than
breaks -- logcat prints whatever number it is handed, so an Error filed
as Info is present, correct, and invisible to every filter.

**Formatting is delegated to slog's TextHandler.** WithAttrs and
WithGroup are the half of slog.Handler that is easy to get subtly
wrong, and a logger whose groups are wrong is a logger nobody reads.
The derived handlers share the parent's buffer *and its mutex*: a
second mutex would guard nothing, and two loggers derived from one
would splice their bytes into a single line under load.

**A line is chunked, because liblog drops what does not fit.** The
kernel logger's entry is 4068 bytes for tag and message together and
the remainder goes without comment, so a long record would be truncated
in the middle of the thing worth reading.

Time and level are dropped from the formatted line, since logcat stamps
every entry with both -- and dropping them by *key* also ate a caller's
own "level" attribute, which the on-device probe caught and
TestACallersOwnLevelAttrSurvives now holds. ReplaceAttr sees an empty
group path for the built-ins and for every top-level attribute alike,
so the kinds are what separate them.

Verified on the reference device (TLP301, Android 14): a debug build
logs I/W/E under the `yellowjacket` tag at the right priorities, and
the first thing it surfaced was a real warning nobody could previously
see -- `champion index rebuild failed ... disk I/O error (6410)`.

Closes #160
2026-08-21 16:30:32 -04:00

228 lines
7.6 KiB
Go

// Package main is the entry point for the Yellowjacket application.
package main
import (
"embed"
"log/slog"
"os"
"strings"
"sync/atomic"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
"yellowjacket/backend"
"yellowjacket/backend/assets"
"yellowjacket/backend/config"
"yellowjacket/backend/profiling"
"yellowjacket/backend/system"
"yellowjacket/internal/dev"
)
// version and commit are set at build time via ldflags.
var (
version = "dev"
commit = "unknown"
)
//go:embed all:frontend/dist
var frontendDistAssets embed.FS
// mainStarted latches the first entry into main().
//
// **On Android main() is called once per *activity*, and the process
// outlives the activity.** Wails' JNI entry point is
// `nativeInit`, which does two things: it re-points the native
// library's global reference at the calling `WailsBridge`, and it runs
// `go mainFunc()`. `MainActivity.onCreate` calls it, and Android
// recreates the activity — for a configuration change it does not
// declare, under memory pressure, or on every single background when
// the user has "Don't keep activities" switched on — **without
// restarting the process**.
//
// So main() ran again, on a live app, and every path out of that is
// fatal:
//
// - `application.New` returns the *existing* `globalApplication` when
// there is one, silently discarding the second set of Services.
// - `app.Run()` then refuses, by design: `a.starting` is still true,
// because Android's `platformRun` is `select{}` and never returns.
// It answers "application is running or a previous run has failed".
// - which lands on `os.Exit(1)` at the foot of this function, and
// that takes down the **first**, perfectly healthy app with it —
// its database, its queue, and the audio that a foreground service
// is holding the process alive to play.
//
// ActivityManager then restarts the app, which is the report: "crashes
// or restarts when reopened after running in the background". It never
// left a tombstone because `os.Exit` is not a crash, and it never left
// a log line because an Android app's fd 1 goes to /dev/null.
//
// The latch is the whole fix, and it has to be **first**: everything
// below it — `NewYellowJacketApp` above all, which opens the SQLite
// database — is work that must not happen twice in one process.
// Returning early is not a degraded mode: `nativeInit` has already
// re-attached the bridge, so the recreated activity's WebView talks to
// the app that is still running, with its queue and its playback
// position intact. See CLAUDE.md, "An activity is a view onto the
// process".
//
// It is inert off Android, where a process has exactly one main().
var mainStarted atomic.Bool
// claimMainOnce reports whether this is the first call to main() in
// this process. See mainStarted.
func claimMainOnce() bool {
return mainStarted.CompareAndSwap(false, true)
}
func main() {
// Android calls main() once per activity, and the process outlives
// the activity. Nothing below this line may run twice.
if !claimMainOnce() {
return
}
// **Mobile has no home directory, and this must run before anything
// asks for a path.** backend/system resolves config and data from
// $HOME or the OS equivalent, and on Android there is neither: its
// switch on runtime.GOOS took the default branch and returned
// errUnsupportedOS, so NewYellowJacketApp failed and main() exited
// six milliseconds after the JNI bridge came up -- with no panic and
// no tombstone, because os.Exit is not a crash.
//
// StoragePath() is the platform's own answer (getFilesDir() on
// Android, Application Support on iOS) and returns "" on desktop,
// where UseHomeOverride is then a no-op -- so this needs no build
// tag and changes nothing off mobile.
system.UseHomeOverride(application.Mobile.StoragePath())
// WebKitGTK's DMABuf renderer crashes on NVIDIA GPUs under Wayland.
// Only disable it for that specific combo so AMD/Intel and X11 users
// keep full hardware-accelerated buffer sharing. Users can also
// force the workaround with WEBKIT_DISABLE_DMABUF_RENDERER=1.
if os.Getenv("WEBKIT_DISABLE_DMABUF_RENDERER") == "" && isNVIDIAWayland() {
_ = os.Setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1")
}
isDev := dev.IsDev
// create sLogger
loglevel := resolveLogLevel(isDev)
sLogger := slog.New(newLogHandler(&slog.HandlerOptions{Level: loglevel}))
slog.SetDefault(sLogger)
sLogger.Info("starting yellowjacket", "version", version, "commit", commit)
// Start profiling server (pprof + trace). In production builds this
// is a no-op — the compiler eliminates all profiling code.
stopProfiler := profiling.Start(sLogger)
// create asset handler
assetHandler, err := assets.NewAssetHandler(sLogger, frontendDistAssets)
if err != nil {
sLogger.Error("could not create asset handler", "err", err.Error())
stopProfiler()
os.Exit(1)
}
yjApp, err := backend.NewYellowJacketApp(sLogger, assetHandler)
if err != nil {
sLogger.Error("problem initializing yellowjacket", "err", err.Error())
stopProfiler()
os.Exit(1)
}
winCfg := yjApp.WindowConfig()
app := application.New(application.Options{
Name: "yellowjacket",
Description: "A cross-platform desktop music player",
Services: yjApp.Services,
Assets: assetHandler.Options,
// v3 logs through slog directly, so v2's logger.Logger adapter
// (backend/logging) is gone rather than ported.
Logger: sLogger,
ShouldQuit: yjApp.ShouldQuit,
OnShutdown: yjApp.OnShutdown,
})
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "yellowjacket",
Width: winCfg.Width,
Height: winCfg.Height,
MinWidth: config.MinWidth,
MinHeight: config.MinHeight,
BackgroundColour: application.NewRGB(27, 38, 54),
URL: "/",
Linux: application.LinuxWindow{
WebviewGpuPolicy: application.WebviewGpuPolicyAlways,
},
})
// The window's size has to be read while the window still exists,
// which OnShutdown is too late for.
window.OnWindowEvent(
events.Common.WindowClosing,
func(*application.WindowEvent) { yjApp.SaveWindowState(window) },
)
err = app.Run()
stopProfiler()
if err != nil {
sLogger.Error("application error", "err", err.Error())
os.Exit(1)
}
}
// resolveLogLevel determines the slog level. In dev mode the default
// is Info (not Debug) to avoid flooding stdout during library scans.
// Set YJ_LOG_LEVEL=debug to restore verbose logging.
//
// Accepted values: debug, info, warn, error (case-insensitive).
// Production builds always default to Info.
func resolveLogLevel(_ bool) slog.Level {
if env := os.Getenv("YJ_LOG_LEVEL"); env != "" {
switch strings.ToLower(env) {
case "debug":
return slog.LevelDebug
case "info":
return slog.LevelInfo
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
}
}
// Default: Info for both dev and prod.
return slog.LevelInfo
}
// isNVIDIAWayland returns true when running under a Wayland session with
// an NVIDIA GPU. This combination triggers DMABuf rendering crashes in
// WebKitGTK, so we need to disable the DMABuf renderer for it.
func isNVIDIAWayland() bool {
// Not Wayland → safe.
if os.Getenv("WAYLAND_DISPLAY") == "" && os.Getenv("XDG_SESSION_TYPE") != "wayland" {
return false
}
// Check for NVIDIA kernel modules (works even without nvidia-smi).
if data, err := os.ReadFile("/proc/driver/nvidia/version"); err == nil {
_ = data
return true
}
// Fallback: check if the nvidia module is loaded.
if data, err := os.ReadFile("/proc/modules"); err == nil {
if strings.Contains(string(data), "nvidia") {
return true
}
}
return false
}