Files
OpsLog/bootlog.go
T
rouggy 9887b2aa78 feat(startup): accept a fixed-version WebView2 carried beside OpsLog
'The Evergreen installer will not install either' moves the problem out
of OpsLog's reach: the runtime is a Windows component, and on a managed,
offline or otherwise locked-down machine it may simply refuse.

Microsoft publishes the same runtime in a FIXED VERSION form — a folder
an application carries and points at, needing no installation and no
administrator. That is exactly this case, so OpsLog now looks for it
beside its own executable and uses it when it is there. The versioned
subfolder the download unpacks into is handled, because asking someone to
flatten it by hand is one more step to get wrong on a machine where
nothing starts.

No setting for it: that would be a question asked of the one operator
least able to answer it. The folder is either there or it is not.

The startup log also records the Windows build — the first thing anyone
diagnosing a runtime that will not install is going to ask for.
2026-08-25 21:17:09 +02:00

216 lines
7.7 KiB
Go

package main
// The first breadcrumbs, written before anything else can fail.
//
// OpsLog keeps its log in the data folder, which lives beside the executable —
// so every fault that happens BEFORE that folder exists is invisible. Reported
// from a Windows 10 machine: "the process appears, no data folder is created,
// nothing starts", with no file anywhere to say why. There was nothing to read
// because the only place we write to had not been created yet.
//
// This writes to %LOCALAPPDATA%\OpsLog\startup.log instead: a folder Windows
// guarantees is writable for the user, whatever OpsLog itself was installed
// into. It records the handful of milestones between the process starting and
// the window appearing, and nothing else — it is not a second log, it is the
// answer to "it does not start".
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
)
// bootLogPath is the file, or "" when even LOCALAPPDATA is unavailable.
func bootLogPath() string {
dir := os.Getenv("LOCALAPPDATA")
if strings.TrimSpace(dir) == "" {
dir = os.TempDir()
}
if dir == "" {
return ""
}
dir = filepath.Join(dir, "OpsLog")
if err := os.MkdirAll(dir, 0o755); err != nil {
return ""
}
return filepath.Join(dir, "startup.log")
}
// bootLog appends one line. Never fails loudly: it exists to explain a failure,
// so it must not become one.
func bootLog(format string, args ...any) {
p := bootLogPath()
if p == "" {
return
}
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return
}
defer f.Close()
// Trimmed when it gets long. A startup log that grows for two years is a
// file nobody opens, and the interesting launch is always the last one.
if fi, err := f.Stat(); err == nil && fi.Size() > 256*1024 {
f.Close()
_ = os.Remove(p)
f, err = os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return
}
defer f.Close()
}
fmt.Fprintf(f, "%s %s\n", time.Now().Format("2006-01-02 15:04:05.000"), fmt.Sprintf(format, args...))
}
// bootLogLaunch records what was launched and from where.
func bootLogLaunch() {
exe, _ := os.Executable()
bootLog("launch: %s %v", exe, os.Args[1:])
}
// checkDataDirWritable makes sure the folder OpsLog keeps everything in can
// actually be created and written to, and says exactly what failed if not.
//
// The data folder sits beside the executable, which is fine on a stick or in a
// home directory and refused outright under Program Files — where Windows
// silently denies the write to anything not elevated. That refusal used to end
// the launch with no window and no message.
func checkDataDirWritable() error {
dir, err := userDataDir()
if err != nil {
return fmt.Errorf("cannot work out where to keep the data: %w", err)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("cannot create the data folder %s: %w", dir, err)
}
probe := filepath.Join(dir, ".writetest")
if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil {
return fmt.Errorf("the data folder %s cannot be written to: %w", dir, err)
}
_ = os.Remove(probe)
bootLog("data dir ok: %s", dir)
return nil
}
// startupReached flips as soon as OnStartup runs — the first moment OpsLog's
// own code is executing inside the window's lifecycle.
var startupReached atomic.Bool
// startupStuckMessage is what an operator sees when the window never starts.
//
// It names the two causes and what to do about each, because "OpsLog is not
// responding" sends people to reinstall OpsLog, which is the one thing that
// cannot help: the part that has not started is not ours.
const startupStuckMessage = "OpsLog started but its window never opened.\n\n" +
"This is the WebView2 runtime failing to start, and it is almost always one of two things:\n\n" +
"• The Microsoft Edge WebView2 Runtime is missing — install it from Microsoft, then start OpsLog again.\n" +
"• An antivirus is blocking msedgewebview2.exe — add OpsLog's folder to its exceptions.\n\n" +
"The details are in the OpsLog folder under LOCALAPPDATA (startup.log)."
// webviewDataPath is where WebView2 keeps its profile.
//
// Named rather than left to the default, which lands in the ROAMING profile: on
// a managed account that folder can be redirected to a network share, and a
// WebView2 profile on a share that is slow or unreachable does not fail — it
// hangs, which is indistinguishable from a runtime that will not start.
//
// It also gives the folder a name someone can be told to delete: a corrupt
// profile is a real cause of this, and "delete this folder and try again" is
// only advice if the folder can be pointed at.
func webviewDataPath() string {
dir := os.Getenv("LOCALAPPDATA")
if strings.TrimSpace(dir) == "" {
return "" // let Wails decide; there is nothing better to offer
}
p := filepath.Join(dir, "OpsLog", "WebView2")
if err := os.MkdirAll(p, 0o755); err != nil {
bootLog("WebView2 profile folder %s could not be created: %v — leaving it to Wails", p, err)
return ""
}
return p
}
// stuckMarkerPath is written before the window is attempted and removed once it
// opens, so the NEXT launch can tell that the last one never got there.
func stuckMarkerPath() string {
dir := os.Getenv("LOCALAPPDATA")
if strings.TrimSpace(dir) == "" {
dir = os.TempDir()
}
return filepath.Join(dir, "OpsLog", ".launching")
}
// lastLaunchHung is set at startup from the marker left by the previous run.
var lastLaunchHung bool
// noteLaunchAttempt records that a window is about to be attempted, and reports
// whether the previous attempt ever finished.
func noteLaunchAttempt() {
p := stuckMarkerPath()
if _, err := os.Stat(p); err == nil {
lastLaunchHung = true
bootLog("the previous launch never opened its window — trying again with GPU acceleration off")
}
_ = os.MkdirAll(filepath.Dir(p), 0o755)
_ = os.WriteFile(p, []byte(time.Now().Format(time.RFC3339)), 0o644)
}
// clearLaunchMarker is called once the window is up: the attempt finished.
func clearLaunchMarker() { _ = os.Remove(stuckMarkerPath()) }
// fixedWebView2Path finds a FIXED-VERSION WebView2 runtime shipped beside
// OpsLog, or "" when there is none.
//
// Microsoft publishes the runtime in two forms. Evergreen installs itself into
// Windows and updates itself — the normal case, and the one this looks for
// first. FIXED VERSION is a folder of files an application carries with it and
// points at, needing no installation and no administrator: it exists precisely
// for machines where Evergreen cannot be installed, which is not a rare
// situation on a managed, offline or otherwise locked-down Windows.
//
// So: drop the extracted folder next to OpsLog.exe and it is used. Nothing to
// configure — a setting for this would be a question asked of the one operator
// least able to answer it, on a machine where nothing starts.
func fixedWebView2Path() string {
exe, err := os.Executable()
if err != nil {
return ""
}
base := filepath.Dir(exe)
for _, name := range []string{"WebView2", "WebView2Runtime", "webview2"} {
dir := filepath.Join(base, name)
if p := findWebView2Binary(dir); p != "" {
bootLog("using the fixed-version WebView2 runtime in %s", p)
return p
}
}
return ""
}
// findWebView2Binary returns dir (or its single versioned subfolder) when it
// holds msedgewebview2.exe — the fixed-version download unpacks with the
// version as a folder level, and asking someone to flatten it by hand is one
// more step to get wrong.
func findWebView2Binary(dir string) string {
if fileExists(filepath.Join(dir, "msedgewebview2.exe")) {
return dir
}
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
for _, e := range entries {
if !e.IsDir() {
continue
}
sub := filepath.Join(dir, e.Name())
if fileExists(filepath.Join(sub, "msedgewebview2.exe")) {
return sub
}
}
return ""
}