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
228 lines
7.6 KiB
Go
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
|
|
}
|