diff --git a/CHANGELOG.md b/CHANGELOG.md index d56c064..22819c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -146,6 +146,17 @@ because it turns other people's test suites red. ### Fixed +- **A window the graphics toolkit could not create is a refusal that says + why, not a process with no window in it.** On a Windows machine whose + graphics driver offers no OpenGL 2.1 - a virtual machine without 3D + acceleration, measured on Windows Server 2025 under VirtualBox - the + window binary opened nothing, said nothing, and stayed in the task list + until the session ended. It now says what did not happen, quotes the + driver's reason as the toolkit reported it, points at the command line + (which needs no graphics driver), and exits 1 - on standard error and in + a system dialog, because the window that would have carried the message + is what failed. + - **The switch between the three ways of stating a size freezes with the rest of the form while a run is going.** It stayed live: during a run, choosing another way rebuilt the size boxes under a form drawn as frozen. diff --git a/README.md b/README.md index ab4882e..c62a602 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,11 @@ through OpenGL and reaches it through C, so that one needs a C compiler and is built natively on each system. Built without one it still compiles, and says on start that it has no window in it and that everything is on the command line. +The window needs a graphics driver that provides OpenGL 2.1. A Windows +virtual machine without 3D acceleration has none - there the window binary +says so in a dialog and on standard error, and exits 1. The command line +needs no graphics driver and does everything the window does. + ## 🚀 Quick start **1. Make a file.** One PNG, exactly two megabytes: diff --git a/internal/guard/guitext_test.go b/internal/guard/guitext_test.go index 319f5d6..d008b29 100644 --- a/internal/guard/guitext_test.go +++ b/internal/guard/guitext_test.go @@ -82,22 +82,26 @@ var notWords = map[string]string{ `"png"`: "a format id in a comment example rather than a label", `"uxtheme.dll"`: "a Windows library this program loads, a file name and not a word", `"kernel32.dll"`: "a Windows library this program loads, a file name and not a word", + `"user32.dll"`: "a Windows library this program loads for the dialog it shows when it has no window, a file name and not a word", `"GetProcAddress"`: "the Win32 entry point looked up by name, an identifier and not a word", - `"windows"`: "a system name asked of the compiler", - `"linux"`: "a system name asked of the compiler", - `"darwin"`: "a system name asked of the compiler", - `"GOOS="`: "an environment variable handed to the compiler", - `"CGO_ENABLED=1"`: "an environment variable handed to the compiler", - `"true"`: "compared against a declared default, never shown", - `"\n"`: "what joins the lines of the status area, not a word", - `"\n\n"`: "the blank line between two refusals the form could not place, not a word", - `"Ag"`: "a sample measured to find how tall a row must be - an ascender and a descender, never drawn", - `"%s: %w"`: "how one error is wrapped around another, both already worded", - `"•"`: "the marker in front of a list item, a shape rather than a word", - `"panel"`: "our name for a colour, in the palette the toolkit asks by name", - `"lift"`: "our name for what the pointer does to the filled button, a colour the palette holds", - `"shade"`: "our name for what a press does to the filled button, a colour the palette holds", - `"fyneDo"`: "a migration flag the toolkit reads, never shown", + `"MessageBoxW"`: "the Win32 entry point that shows a system dialog, looked up by name, an identifier and not a word", + `"Cause:"`: "the word the toolkit's logger puts in front of the error it was handed, read as an anchor " + + "to find the toolkit's own sentence and never shown - see CauseFrom in opening.go", + `"windows"`: "a system name asked of the compiler", + `"linux"`: "a system name asked of the compiler", + `"darwin"`: "a system name asked of the compiler", + `"GOOS="`: "an environment variable handed to the compiler", + `"CGO_ENABLED=1"`: "an environment variable handed to the compiler", + `"true"`: "compared against a declared default, never shown", + `"\n"`: "what joins the lines of the status area, not a word", + `"\n\n"`: "the blank line between two refusals the form could not place, not a word", + `"Ag"`: "a sample measured to find how tall a row must be - an ascender and a descender, never drawn", + `"%s: %w"`: "how one error is wrapped around another, both already worded", + `"•"`: "the marker in front of a list item, a shape rather than a word", + `"panel"`: "our name for a colour, in the palette the toolkit asks by name", + `"lift"`: "our name for what the pointer does to the filled button, a colour the palette holds", + `"shade"`: "our name for what a press does to the filled button, a colour the palette holds", + `"fyneDo"`: "a migration flag the toolkit reads, never shown", // The application's own name and id are its identity rather than prose. // An application is not renamed in another language, and the desktop uses // both to decide which running program this is. What a person READS in the diff --git a/internal/guard/hardening_test.go b/internal/guard/hardening_test.go index 58c2d89..80cd837 100644 --- a/internal/guard/hardening_test.go +++ b/internal/guard/hardening_test.go @@ -41,6 +41,8 @@ func TestALibraryIsOnlyLoadedLazilyWhenTheSystemAlreadyHasIt(t *testing.T) { // rather than remembered: the KnownDLLs registry key on 2026-09-06. knownDLLs := map[string]string{ "kernel32.dll": "a KnownDLL, always already mapped - free space asks it for GetDiskFreeSpaceExW", + "user32.dll": "a KnownDLL, measured in the registry on 2026-09-16 among the same thirty seven - " + + "the window binary asks it for MessageBoxW when the toolkit could not open a window (O218)", } // Where a load may name something this cannot read, and why. A path worked diff --git a/internal/guard/notelemetry_test.go b/internal/guard/notelemetry_test.go index c8ee43a..2568ea7 100644 --- a/internal/guard/notelemetry_test.go +++ b/internal/guard/notelemetry_test.go @@ -165,6 +165,7 @@ var allowedURLs = map[string]string{ var allowedLibraries = map[string]string{ "kernel32.dll": "free disk space, and GetProcAddress for the call below", "uxtheme.dll": "the dark window menu, called by ordinal - see darkmenus_windows.go", + "user32.dll": "the system dialog shown when the toolkit could not open a window - see dialog_windows.go", } // socketCalls are the low level calls that open or use a socket. syscall itself diff --git a/internal/guard/windowrefused_test.go b/internal/guard/windowrefused_test.go new file mode 100644 index 0000000..d97df91 --- /dev/null +++ b/internal/guard/windowrefused_test.go @@ -0,0 +1,278 @@ +package guard + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strings" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/driver" + "fyne.io/fyne/v2/test" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" +) + +// A window the toolkit could not create is a refusal, not a process with no +// window in it. +// +// Measured on 2026-09-16 on a Windows Server 2025 guest without 3D +// acceleration (O218): the toolkit's window creation fails with "WGL: The +// driver does not appear to support OpenGL", it logs that to a standard +// error the windows-subsystem binary does not have, and Run then waits +// forever on a channel nobody closes. No window, no message, no exit code - +// untouchable rule 6 broken three times at once. +// +// The decision is taken from the window's STATE: the driver's NativeWindow +// answers with a zero handle on every platform when the window was never +// created. That is what this pins, with a stand-in window whose handle is +// whatever the case says - the test driver has no OpenGL and cannot be made +// to fail the real way, so the seam is the only place a guard can reach. +// +// The real thing is checked the way the rest of run_cgo.go is, by a run of +// the binary: docs/GUI-NO-OPENGL-2026-09-16.md. + +// plainWindow is a window that records being shown and cannot say whether +// it has a native one - the shape of the test driver. +type plainWindow struct { + fyne.Window + calls []string +} + +func (w *plainWindow) Show() { w.calls = append(w.calls, "show") } + +// nativeWindow is a window whose driver answers NativeWindow with whatever +// context the case gave it. +type nativeWindow struct { + plainWindow + context any +} + +func (w *nativeWindow) RunNative(f func(any)) { f(w.context) } + +func TestAWindowTheToolkitGaveNoWindowIsRefusedAndNeverRun(t *testing.T) { + test.NewApp() + t.Cleanup(func() { test.NewApp() }) + + // Every platform context the driver hands out, empty and filled. The + // filled ones use 1 rather than a real handle: the question is zero or + // not, and a real handle is what the toolkit has when it has a window. + refused := map[string]any{ + "windows": driver.WindowsWindowContext{}, + "mac": driver.MacWindowContext{}, + "x11": driver.X11WindowContext{}, + "wayland": driver.WaylandWindowContext{}, + } + opened := map[string]any{ + "windows": driver.WindowsWindowContext{HWND: 1}, + "mac": driver.MacWindowContext{NSWindow: 1}, + "x11": driver.X11WindowContext{WindowHandle: 1}, + "wayland": driver.WaylandWindowContext{WaylandSurface: 1}, + } + + for name, context := range refused { + w := &nativeWindow{plainWindow: plainWindow{Window: test.NewWindow(nil)}, context: context} + code := gui.OpenOrRefuse(w, func() { w.calls = append(w.calls, "run") }, func() { w.calls = append(w.calls, "refuse") }) + if got := strings.Join(w.calls, ","); got != "show,refuse" { + t.Errorf("%s, no native window: the calls were %q and they have to be show,refuse.\n"+ + "Show first, because only after it can the toolkit have failed - and then the "+ + "refusal, and NOT the loop, which on such a machine never ends.", name, got) + } + if code != 1 { + t.Errorf("%s, no native window: exit code %d, and a window that could not open exits 1 "+ + "like a build with no window in it.", name, code) + } + } + + for name, context := range opened { + w := &nativeWindow{plainWindow: plainWindow{Window: test.NewWindow(nil)}, context: context} + code := gui.OpenOrRefuse(w, func() { w.calls = append(w.calls, "run") }, func() { w.calls = append(w.calls, "refuse") }) + if got := strings.Join(w.calls, ","); got != "show,run" { + t.Errorf("%s, native window present: the calls were %q and they have to be show,run - "+ + "the same two calls ShowAndRun makes, in the same order.", name, got) + } + if code != 0 { + t.Errorf("%s, native window present: exit code %d after a run that returned normally.", name, code) + } + } + + // A driver that cannot answer at all, and one that answers with a + // context this code does not know: both are taken as a window that + // opened, because nothing about them is known to have failed. The test + // driver is the first kind, and every guard drawing a screen rests on it. + plain := &plainWindow{Window: test.NewWindow(nil)} + if code := gui.OpenOrRefuse(plain, func() { plain.calls = append(plain.calls, "run") }, func() { plain.calls = append(plain.calls, "refuse") }); code != 0 || strings.Join(plain.calls, ",") != "show,run" { + t.Errorf("a window whose driver has no NativeWindow: code %d, calls %q - it has to run, "+ + "because the test driver is such a window and it draws every screen this package checks.", + code, strings.Join(plain.calls, ",")) + } + unknown := &nativeWindow{plainWindow: plainWindow{Window: test.NewWindow(nil)}, context: struct{}{}} + if code := gui.OpenOrRefuse(unknown, func() { unknown.calls = append(unknown.calls, "run") }, func() { unknown.calls = append(unknown.calls, "refuse") }); code != 0 || strings.Join(unknown.calls, ",") != "show,run" { + t.Errorf("a window whose driver answers with an unknown context: code %d, calls %q - "+ + "cannot tell is not the same as failed.", code, strings.Join(unknown.calls, ",")) + } +} + +// The three lines the toolkit wrote on the guest, verbatim from the +// measurement, with the module path the way a -trimpath build spells it. +const toolkitSaid = "2026/09/16 12:06:56 Fyne error: window creation error\n" + + "2026/09/16 12:06:56 Cause: APIUnavailable: WGL: The driver does not appear to support OpenGL\n" + + "2026/09/16 12:06:56 At: fyne.io/fyne/v2@v2.8.1/internal/driver/glfw/driver.go:180\n" + +func TestTheToolkitsOwnReasonReachesTheSentenceAndItsAbsenceDoesNotBreakIt(t *testing.T) { + wantCause := "APIUnavailable: WGL: The driver does not appear to support OpenGL" + if got := gui.CauseFrom(toolkitSaid); got != wantCause { + t.Errorf("the cause read from what the toolkit logged is %q, wanted %q.\n"+ + "That line is the one thing on the guest that names the driver, and it is the "+ + "toolkit's sentence rather than ours, so it is quoted.", got, wantCause) + } + if got := gui.CauseFrom("2026/09/16 12:06:56 Fyne error: window creation error\n"); got != "" { + t.Errorf("a log with no cause line gave %q, and it has to give nothing - the refusal "+ + "does not depend on the detail.", got) + } + + // No catalogue loaded: every sentence answers with its English then, which + // is what a build whose catalogue failed to read answers too. + with := text.WindowRefused(wantCause) + without := text.WindowRefused("") + for name, sentence := range map[string]string{"with a cause": with, "without one": without} { + for _, must := range []string{"tfg --help", "OpenGL 2.1"} { + if !strings.Contains(sentence, must) { + t.Errorf("the refusal %s does not say %q: %q\n"+ + "Four parts, D6 - what did not happen, why, what works instead, what to do about it.", name, must, sentence) + } + } + if strings.Contains(sentence, "{{") { + t.Errorf("the refusal %s carries an unfilled template: %q", name, sentence) + } + } + if !strings.Contains(with, wantCause) { + t.Errorf("the refusal with a cause does not quote it: %q", with) + } + if strings.Contains(without, "said:") { + t.Errorf("the refusal without a cause quotes an empty one: %q", without) + } +} + +// addressedFirst is the name of the variable whose address is the first +// argument of call, as in io.MultiWriter(&said, ...), or nothing. +func addressedFirst(call *ast.CallExpr) string { + if len(call.Args) == 0 { + return "" + } + first, ok := call.Args[0].(*ast.UnaryExpr) + if !ok || first.Op != token.AND { + return "" + } + if buffer, ok := first.X.(*ast.Ident); ok { + return buffer.Name + } + return "" +} + +// stringOf is the name of x in a call shaped f(x.String()), or nothing. +func stringOf(call *ast.CallExpr) string { + if len(call.Args) != 1 { + return "" + } + inner, ok := call.Args[0].(*ast.CallExpr) + if !ok { + return "" + } + sel, ok := inner.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "String" { + return "" + } + if buffer, ok := sel.X.(*ast.Ident); ok { + return buffer.Name + } + return "" +} + +// The window binary goes through the seam above and never through +// ShowAndRun, and the copy of the toolkit's log is written before the +// toolkit's own stream is - a multi-writer stops at the first writer that +// fails, and the stream a windows-subsystem binary does not have is exactly +// such a writer. Read from the source, because run_cgo.go is behind cgo and +// the test driver cannot fail the way the guest did. +func TestTheWindowBinaryOpensThroughTheRefusalSeam(t *testing.T) { + root := repoRoot(t) + fset := token.NewFileSet() + seamCalls, showAndRun := 0, []string{} + // The buffer the cause is read from, and the buffer the copy of the log + // goes to. They have to be the same variable - named by the code, not + // by this guard, because "the first writer is an address" passed for + // the address of any buffer at all (an outside review of the pull + // request named it), and a copy into some other buffer leaves the one + // CauseFrom reads empty. + readFrom, copiedTo := "", "" + + err := filepath.WalkDir(filepath.Join(root, "internal", "gui"), func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return walkErr + } + file, perr := parser.ParseFile(fset, path, nil, 0) + if perr != nil { + return perr + } + rel := filepath.ToSlash(strings.TrimPrefix(path, root+string(filepath.Separator))) + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + switch fun := call.Fun.(type) { + case *ast.SelectorExpr: + switch fun.Sel.Name { + case "ShowAndRun": + showAndRun = append(showAndRun, rel) + case "MultiWriter": + if pkg, ok := fun.X.(*ast.Ident); ok && pkg.Name == "io" && strings.HasSuffix(rel, "run_cgo.go") { + copiedTo = addressedFirst(call) + } + } + case *ast.Ident: + if !strings.HasSuffix(rel, "run_cgo.go") { + return true + } + switch fun.Name { + case "OpenOrRefuse": + seamCalls++ + case "CauseFrom": + readFrom = stringOf(call) + } + } + return true + }) + return nil + }) + if err != nil { + t.Fatalf("walking internal/gui: %v", err) + } + + if len(showAndRun) != 0 { + t.Errorf("ShowAndRun is called in %v.\n"+ + "It is Show then Run with no question between them, and on a machine where the "+ + "toolkit cannot create a window it runs a loop nothing ends (O218). Open through "+ + "gui.OpenOrRefuse instead.", showAndRun) + } + if seamCalls != 1 { + t.Errorf("run_cgo.go calls OpenOrRefuse %d time(s), and the window binary opens through "+ + "it exactly once - that is the seam the guard above reaches.", seamCalls) + } + if readFrom == "" { + t.Fatalf("run_cgo.go does not read the cause with CauseFrom(.String()), so this " + + "guard cannot tell which buffer the copy of the toolkit's log has to reach.") + } + if copiedTo != readFrom { + t.Errorf("run_cgo.go hands io.MultiWriter %q first, and CauseFrom reads %q.\n"+ + "io.MultiWriter stops at the first writer that fails. The toolkit's own stream is "+ + "standard error, which a windows-subsystem binary does not have, so the copy has to "+ + "come first - and it has to be the buffer the cause is read from, or that buffer stays "+ + "empty and the refusal says nothing about the driver.", copiedTo, readFrom) + } +} diff --git a/internal/gui/dialog_other.go b/internal/gui/dialog_other.go new file mode 100644 index 0000000..3edb79d --- /dev/null +++ b/internal/gui/dialog_other.go @@ -0,0 +1,10 @@ +//go:build !windows + +package gui + +// sayInADialog has nothing to add outside Windows: a binary there keeps its +// standard error, so the sentence that the window could not open reaches +// the terminal it was started from. A person who started it from a file +// manager sees nothing, and that is written down as not done rather than +// papered over - the system dialog that would answer it differs by desktop. +func sayInADialog(string, string) {} diff --git a/internal/gui/dialog_windows.go b/internal/gui/dialog_windows.go new file mode 100644 index 0000000..3870426 --- /dev/null +++ b/internal/gui/dialog_windows.go @@ -0,0 +1,44 @@ +//go:build windows + +package gui + +import ( + "syscall" + "unsafe" +) + +// The flavour of system dialog: an OK button under an error icon, brought to +// the front, because it is the only thing this process has on screen. +const ( + messageBoxIconError = 0x00000010 + messageBoxSetForeground = 0x00010000 +) + +// sayInADialog puts title and body in a system dialog owned by no window, +// for the one moment this program has something to say and no window to say +// it in. A binary built for the windows subsystem has no console either, so +// without this the sentence goes nowhere a person looks. +// +// user32.dll by name, and that is allowed where uxtheme was not: it is a +// KnownDLL - measured in the registry on 2026-09-16, thirty seven entries and +// it is among them - so it is already mapped and the loader hands back the +// module that is there without consulting the search order. The guard over +// this form lists it with the same measurement. +// +// The dialog needs a desktop and answers nought without one. That answer is +// not read: the sentence has already gone to standard error and the exit code +// says the rest, so a session without a desktop loses the picture and nothing +// else. +func sayInADialog(title, body string) { + messageBox := syscall.NewLazyDLL("user32.dll").NewProc("MessageBoxW") + caption, err := syscall.UTF16PtrFromString(title) + if err != nil { + return + } + text, err := syscall.UTF16PtrFromString(body) + if err != nil { + return + } + _, _, _ = messageBox.Call(0, uintptr(unsafe.Pointer(text)), uintptr(unsafe.Pointer(caption)), + messageBoxIconError|messageBoxSetForeground) +} diff --git a/internal/gui/opening.go b/internal/gui/opening.go new file mode 100644 index 0000000..1171147 --- /dev/null +++ b/internal/gui/opening.go @@ -0,0 +1,91 @@ +package gui + +import ( + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/driver" +) + +// OpenOrRefuse shows w and runs the toolkit's loop only if the toolkit gave +// it a window. When it did not, refuse is called instead and the answer is 1, +// the exit code a build with no window in it already uses. +// +// Measured on 2026-09-16, on a Windows Server 2025 guest without 3D +// acceleration (O218): the graphics driver there offers no OpenGL, so the +// toolkit cannot create its window. It logs why to a standard error that a +// binary built for the windows subsystem does not have, and then Run waits +// forever - the failure path calls Quit before the loop is marked running, +// and Quit closes nothing then. A double click, nothing on screen, and +// tfg-gui.exe in the task list until the session ends. +// +// ShowAndRun is Show followed by Run and nothing else, so on a machine where +// the window opens this is the same two calls in the same order. The +// question between them is asked of the window's state, not of the +// toolkit's log - see Shown. +func OpenOrRefuse(w fyne.Window, run func(), refuse func()) int { + w.Show() + if !Shown(w) { + refuse() + return 1 + } + run() + return 0 +} + +// Shown reports whether the toolkit gave w a native window when it was shown. +// +// Asked of the driver's NativeWindow, which answers with a zero handle on +// every platform when the window it wraps was never created - each platform +// file checks for a missing view before filling the handle in. That is a +// public answer about state, and it does not depend on the wording of +// whatever the toolkit logged on the way. +// +// A window whose driver cannot answer is taken as shown: the test driver +// draws without a native window at all, so for it there is nothing to have +// failed. The same goes for a context this does not know how to read, which +// no desktop platform hands out. +func Shown(w fyne.Window) bool { + native, ok := w.(driver.NativeWindow) + if !ok { + return true + } + handle, answered := uintptr(0), false + native.RunNative(func(context any) { + switch c := context.(type) { + case driver.WindowsWindowContext: + handle, answered = c.HWND, true + case driver.MacWindowContext: + handle, answered = c.NSWindow, true + case driver.X11WindowContext: + handle, answered = c.WindowHandle, true + case driver.WaylandWindowContext: + handle, answered = c.WaylandSurface, true + } + }) + if !answered { + return true + } + return handle != 0 +} + +// causeMarker is the word the toolkit's logger puts in front of the error it +// was handed, on its own line after the line that says what failed. Read as +// an anchor and never shown: what comes after it is the toolkit's sentence +// about the driver, which is the one worth repeating to the person. +const causeMarker = "Cause:" + +// CauseFrom picks the toolkit's own reason out of what it logged while the +// window was being opened, for the sentence that says the window could not +// be. The decision that it could not is taken elsewhere, from the window's +// state, so this only adds detail: the text after the cause marker, or +// nothing when no line carries one. A toolkit that changes how it logs costs +// the detail and not the refusal. +func CauseFrom(logged string) string { + for _, line := range strings.Split(logged, "\n") { + if _, after, found := strings.Cut(line, causeMarker); found { + return strings.TrimSpace(after) + } + } + return "" +} diff --git a/internal/gui/run_cgo.go b/internal/gui/run_cgo.go index 5dab59d..d3984e5 100644 --- a/internal/gui/run_cgo.go +++ b/internal/gui/run_cgo.go @@ -3,8 +3,10 @@ package gui import ( + "bytes" "fmt" "io" + "log" "net/url" "path/filepath" @@ -235,6 +237,16 @@ func run(showCatalogue bool, errOut io.Writer) int { fmt.Fprintln(errOut, text.CatalogueNotLoaded(err)) } + // Where the toolkit says what went wrong when it cannot open its window. + // It writes through the standard logger, whose stream a binary built for + // the windows subsystem does not have, so the stream is kept as it was + // and a copy is held for the sentence at the end of this function. The + // copy comes first: a writer that stops at the first failure would stop + // at a standard error that is not there. Our own code never logs, so on a + // machine where the window opens the copy stays empty (O218). + var said bytes.Buffer + log.SetOutput(io.MultiWriter(&said, log.Writer())) + a := app.NewWithID(appID) // The picture the desktop shows for this program, in the taskbar, in the // switcher and on the window itself - the toolkit says an application icon @@ -288,6 +300,15 @@ func run(showCatalogue bool, errOut io.Writer) int { if !showCatalogue { w.SetOnClosed(func() { host.rememberThisSize() }) } - w.ShowAndRun() - return 0 + // Shown, then run - and refused out loud when the toolkit gave the + // window no window. Not ShowAndRun, which is the same two calls without + // the question between them, and which on a machine without OpenGL runs + // a loop nothing will ever end (O218). The sentence goes to standard error + // for whatever started this, and to a system dialog for the person, + // because the window that would have carried it is what failed. + return OpenOrRefuse(w, a.Run, func() { + sentence := text.WindowRefused(CauseFrom(said.String())) + fmt.Fprintln(errOut, sentence) + sayInADialog(text.WindowRefusedTitle(), sentence) + }) } diff --git a/internal/gui/text/locale/en.json b/internal/gui/text/locale/en.json index 7476a09..9f9c71c 100644 --- a/internal/gui/text/locale/en.json +++ b/internal/gui/text/locale/en.json @@ -412,6 +412,18 @@ "description": "Shown in the window. Carries one value, {{.Directory}}, which has to stay spelled exactly that way.", "other": "will go to {{.Directory}}" }, + "WindowRefused": { + "description": "Shown in a system dialog and on standard error when the toolkit could not open the window at all, so there is no window to show it in. Carries one value, {{.Cause}}, which has to stay spelled exactly that way.", + "other": "The window could not be opened. It draws through OpenGL 2.1, and the graphics toolkit could not get that from the driver on this computer. The toolkit said: {{.Cause}}. Everything the window does is also on the command line - run \"tfg --help\" - and that needs no graphics driver. To get the window, use a graphics driver that provides OpenGL 2.1." + }, + "WindowRefusedNoCause": { + "description": "Shown in a system dialog and on standard error when the toolkit could not open the window at all, so there is no window to show it in.", + "other": "The window could not be opened. It draws through OpenGL 2.1, and the graphics toolkit could not get that from the driver on this computer. Everything the window does is also on the command line - run \"tfg --help\" - and that needs no graphics driver. To get the window, use a graphics driver that provides OpenGL 2.1." + }, + "WindowRefusedTitle": { + "description": "Shown in a system dialog and on standard error when the toolkit could not open the window at all, so there is no window to show it in.", + "other": "Testing Files Generator could not open its window" + }, "WorkingOutTheCost": { "description": "Shown in the window.", "other": "Working out what this would cost..." diff --git a/internal/gui/text/text.go b/internal/gui/text/text.go index e17edd8..d66a169 100644 --- a/internal/gui/text/text.go +++ b/internal/gui/text/text.go @@ -248,6 +248,34 @@ func CatalogueNotLoaded(err error) string { "so the window is in English: " + err.Error() } +// WindowRefusedTitle is over the one dialog this program can show without a +// window of its own: the toolkit could not open one. +func WindowRefusedTitle() string { + return say("WindowRefusedTitle", "Testing Files Generator could not open its window") +} + +// WindowRefused is what the window binary says when the toolkit could not +// create a window at all - measured on a virtual machine without 3D +// acceleration, where the graphics driver offers no OpenGL (O218). +// +// Four parts, D6: what did not happen, why, what works instead, and what to +// do about it. The cause is the toolkit's own sentence about the driver, +// quoted rather than said, so it stays in English whatever the language - +// and it is left out rather than quoted empty when the toolkit gave none. +// +// Through the catalogue although it may reach a terminal: it is the same +// sentence in the dialog and on standard error, and the dialog is read by +// the person the window was for. +func WindowRefused(cause string) string { + // One literal each, however long: the catalogue is written from the + // calls a script can see, and a sentence built from pieces is invisible + // to it. + if cause == "" { + return say("WindowRefusedNoCause", "The window could not be opened. It draws through OpenGL 2.1, and the graphics toolkit could not get that from the driver on this computer. Everything the window does is also on the command line - run \"tfg --help\" - and that needs no graphics driver. To get the window, use a graphics driver that provides OpenGL 2.1.") + } + return sayf("WindowRefused", "The window could not be opened. It draws through OpenGL 2.1, and the graphics toolkit could not get that from the driver on this computer. The toolkit said: {{.Cause}}. Everything the window does is also on the command line - run \"tfg --help\" - and that needs no graphics driver. To get the window, use a graphics driver that provides OpenGL 2.1.", map[string]any{"Cause": cause}) +} + // NotAWholeNumber refuses a box that should hold digits and does not. // // The field is named by its label rather than by its key, because this is read