Skip to content

Commit 33fc3da

Browse files
midagedevclaude
andcommitted
desktop: a boot that failed before the window says so on screen (GDK-1243)
A pre-window boot failure — the mirror-schema refusal from a newer gadak, an unreadable config — used to log.Fatal: the store's sentence reached stderr and the log file, and a double-clicked or gadak:// launch showed nothing at all (measured: exit with no window, v0.18.1 app on a schema-40 mirror). bootFatal keeps that trail byte-for-byte and adds a native dialog with the same sentence, recovery command included. Not a wails dialog, at either failure site: before application.New the global the dialog helpers dereference is nil, and between New and Run the dialog would queue on a main queue nothing drains yet — the same judgment fatal_windows.go recorded for its MessageBoxW isolation. So windows reuses windowsMessageBox and darwin runs osascript (the internal/sync notifier's escaping idiom); Linux ships no desktop artifact and keeps stderr. The dialog call is a seam a test swaps, and the boot path is pinned FAIL-first with a scratch home whose user_version outruns the build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5bbba38 commit 33fc3da

4 files changed

Lines changed: 270 additions & 2 deletions

File tree

desktop/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,14 @@ would try to self-swap.
235235
- `main.go` opens the profile's mirror (`GADAK_PROFILE` respected), builds the
236236
API handler, starts the sync loop and update check — the same wiring as
237237
`cmd/gadak serve`, minus the listener and workspace mounts.
238+
- A boot failure before any window exists (a mirror schema refusal from a
239+
newer gadak, an unreadable config, a locked DB) goes through `bootFatal` in
240+
`main.go`: the same stderr + log line the old `log.Fatal`
241+
wrote, then a native dialog carrying the error verbatim — `MessageBoxW` on
242+
Windows (the `fatal_windows.go` primitive), `osascript display dialog` on
243+
macOS. Not a wails dialog: before `application.New`, `application.Get()`
244+
is nil, and between New and `app.Run` nothing drains the main-queue
245+
dispatch a wails dialog needs. Linux keeps stderr (no shipped artifact).
238246
- v3's asset server takes one handler, not a file system plus a fallback, so
239247
`assetHandler` in `main.go` does that split: a GET that names a file in the
240248
embedded bundle is served from it, everything else goes to `fallbackHandler`.

desktop/gdk1243_test.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"database/sql"
6+
"errors"
7+
"go/ast"
8+
"go/parser"
9+
"go/token"
10+
"log"
11+
"os"
12+
"os/exec"
13+
"path/filepath"
14+
"runtime"
15+
"strings"
16+
"testing"
17+
18+
"github.com/midagedev/gadak/internal/config"
19+
"github.com/midagedev/gadak/internal/store"
20+
21+
_ "modernc.org/sqlite" // pure-Go driver: same one internal/store registers
22+
)
23+
24+
// TestGDK1243MainRoutesBootErrorToBootFatal is the GDK-1243 wiring half: a
25+
// boot failure returned from run() (mirror schema refusal, locked DB, any
26+
// pre-window error) must reach bootFatal — the exit that shows a native
27+
// dialog — not a bare log.Fatal, which a double-clicked app user never sees.
28+
// AST pin for the same reason gdk658_test.go parses main.go: main() itself is
29+
// not callable from a test.
30+
//
31+
// FAIL-first: against the pre-fix main (log.Fatal on run's error) the
32+
// bootFatal lookup fails and log.Fatal is still present.
33+
func TestGDK1243MainRoutesBootErrorToBootFatal(t *testing.T) {
34+
fset := token.NewFileSet()
35+
f, err := parser.ParseFile(fset, "main.go", nil, 0)
36+
if err != nil {
37+
t.Fatal(err)
38+
}
39+
var mainFn *ast.FuncDecl
40+
for _, d := range f.Decls {
41+
fn, ok := d.(*ast.FuncDecl)
42+
if !ok || fn.Name == nil || fn.Name.Name != "main" || fn.Recv != nil {
43+
continue
44+
}
45+
mainFn = fn
46+
break
47+
}
48+
if mainFn == nil || mainFn.Body == nil {
49+
t.Fatal("func main() not found in main.go")
50+
}
51+
calls := topLevelCallNames(mainFn.Body)
52+
hasBootFatal, hasLogFatal := false, false
53+
for _, name := range calls {
54+
switch name {
55+
case "bootFatal":
56+
hasBootFatal = true
57+
case "log.Fatal":
58+
hasLogFatal = true
59+
}
60+
}
61+
if !hasBootFatal {
62+
t.Fatalf("GDK-1243: main() never calls bootFatal — a run() error still dies without a dialog. calls=%v", calls)
63+
}
64+
if hasLogFatal {
65+
t.Fatalf("GDK-1243: main() still calls log.Fatal directly — that path has no dialog. calls=%v", calls)
66+
}
67+
}
68+
69+
// writeFutureMirror builds the GDK-1243 repro home: a scratch GADAK_HOME
70+
// whose gadak.db carries a user_version no gadak build can read (999 > any
71+
// len(migrations)). Same technique as internal/store's too-new test.
72+
func writeFutureMirror(t *testing.T, home string) {
73+
t.Helper()
74+
raw, err := sql.Open("sqlite", "file:"+filepath.Join(home, "gadak.db"))
75+
if err != nil {
76+
t.Fatal(err)
77+
}
78+
if _, err := raw.Exec("PRAGMA user_version = 999"); err != nil {
79+
raw.Close()
80+
t.Fatal(err)
81+
}
82+
if err := raw.Close(); err != nil {
83+
t.Fatal(err)
84+
}
85+
}
86+
87+
// TestGDK1243FutureMirrorReachesDialogSeam is the runtime half: the exact
88+
// boot the v0.18.1 measurement saw die silently (scratch home, mirror from a
89+
// newer gadak) must return run()'s error, and reporting it must hand the
90+
// store's sentence to the dialog seam verbatim. Headless by construction —
91+
// the seam is swapped, so no modal runs (the CI macos-14 runner has no user
92+
// session to dismiss one). The real ~/.gadak is never touched: GADAK_HOME
93+
// points at t.TempDir().
94+
func TestGDK1243FutureMirrorReachesDialogSeam(t *testing.T) {
95+
home := t.TempDir()
96+
writeFutureMirror(t, home)
97+
t.Setenv("GADAK_HOME", home)
98+
config.SetProfile("")
99+
t.Cleanup(func() { config.SetProfile("") })
100+
101+
err := run()
102+
if err == nil {
103+
t.Fatal("run() with a future mirror returned nil — it would have opened a window; the refusal is gone")
104+
}
105+
var tooNew *store.SchemaTooNewError
106+
if !errors.As(err, &tooNew) {
107+
t.Fatalf("boot failure must be the typed store refusal, got %T: %v", err, err)
108+
}
109+
110+
var gotTitle, gotText string
111+
shown := 0
112+
orig := showBootErrorDialog
113+
showBootErrorDialog = func(title, text string) {
114+
shown++
115+
gotTitle, gotText = title, text
116+
}
117+
defer func() { showBootErrorDialog = orig }()
118+
119+
reportBootFailure(err)
120+
121+
if shown != 1 {
122+
t.Fatalf("dialog seam called %d times, want 1", shown)
123+
}
124+
if gotTitle != "Gadak" {
125+
t.Fatalf("dialog title = %q, want Gadak", gotTitle)
126+
}
127+
if gotText != tooNew.Error() {
128+
t.Fatalf("dialog text must be the store's sentence verbatim — no translation, no summary:\n got: %s\nwant: %s", gotText, tooNew.Error())
129+
}
130+
}
131+
132+
// TestGDK1243ReportKeepsStderrTrail pins the CLI-parity half: the dialog is
133+
// additive, and the stderr/log line the old log.Fatal wrote still happens.
134+
func TestGDK1243ReportKeepsStderrTrail(t *testing.T) {
135+
var buf bytes.Buffer
136+
log.SetOutput(&buf)
137+
defer log.SetOutput(os.Stderr)
138+
139+
orig := showBootErrorDialog
140+
showBootErrorDialog = func(string, string) {}
141+
defer func() { showBootErrorDialog = orig }()
142+
143+
reportBootFailure(errors.New("boot boom"))
144+
145+
if !strings.Contains(buf.String(), "boot boom") {
146+
t.Fatalf("stderr/log trail lost; log wrote: %q", buf.String())
147+
}
148+
}
149+
150+
// TestGDK1243BootDialogScriptEscaping: the dialog body is arbitrary error
151+
// prose — quotes, backslashes (Windows paths in the store's recovery
152+
// command), line breaks. The AppleScript literal must carry all of them.
153+
func TestGDK1243BootDialogScriptEscaping(t *testing.T) {
154+
script := bootDialogScript("Gad\"ak", "mirror \"x\" at C:\\Users\\a\\b.db\nsecond line")
155+
want := "display dialog \"mirror \\\"x\\\" at C:\\\\Users\\\\a\\\\b.db\nsecond line\" with title \"Gad\\\"ak\" with icon stop"
156+
if script != want {
157+
t.Fatalf("script:\n got: %q\nwant: %q", script, want)
158+
}
159+
}
160+
161+
// TestGDK1243BootDialogScriptCompiles is the artifact/code detector for the
162+
// AppleScript string: bootDialogScript's output is consumed by an external
163+
// interpreter, so a Go-level string assertion alone cannot prove it is valid
164+
// AppleScript. osacompile compiles the production script — with a nasty body
165+
// and with the real store refusal sentence — without running or showing
166+
// anything. darwin-only; elsewhere the dialog is not the surface anyway.
167+
func TestGDK1243BootDialogScriptCompiles(t *testing.T) {
168+
if runtime.GOOS != "darwin" {
169+
t.Skip("osacompile is darwin-only; other GOOS keep stderr as the boot-failure surface")
170+
}
171+
tooNew := &store.SchemaTooNewError{Path: "/tmp/h/gadak.db", Have: 999, Supported: 40}
172+
for _, text := range []string{
173+
tooNew.Error(),
174+
"quote \" backslash \\ and\nnewline",
175+
} {
176+
out := filepath.Join(t.TempDir(), "probe.scpt")
177+
cmd := exec.Command("osacompile", "-o", out, "-e", bootDialogScript("Gadak", text))
178+
if err := cmd.Run(); err != nil {
179+
t.Fatalf("boot dialog script does not compile as AppleScript (text=%q): %v", text, err)
180+
}
181+
}
182+
}

desktop/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ require (
2828
github.com/midagedev/gadak v0.0.0
2929
github.com/wailsapp/wails/v3 v3.0.0-beta.12
3030
golang.org/x/sys v0.47.0
31+
modernc.org/sqlite v1.56.0
3132
)
3233

3334
require (
@@ -52,5 +53,4 @@ require (
5253
modernc.org/libc v1.74.4 // indirect
5354
modernc.org/mathutil v1.7.1 // indirect
5455
modernc.org/memory v1.11.0 // indirect
55-
modernc.org/sqlite v1.56.0 // indirect
5656
)

desktop/main.go

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"net/http"
1919
"net/url"
2020
"os"
21+
"os/exec"
2122
"path"
2223
"runtime"
2324
"runtime/debug"
@@ -71,7 +72,7 @@ func main() {
7172
server.Version = strings.TrimPrefix(appVersion, "v")
7273
}
7374
if err := run(); err != nil {
74-
log.Fatal(err)
75+
bootFatal(err)
7576
}
7677
}
7778

@@ -612,6 +613,83 @@ func showNativeError(title, text string) {
612613
windowsMessageBox(title, text)
613614
}
614615

616+
// bootFatal is the pre-window boot exit: run() failed before the user had
617+
// anything to look at — a mirror schema refusal from a newer gadak, an
618+
// unreadable config, a locked DB. The old `log.Fatal(err)` put the store's
619+
// sentence on stderr and in the log file, neither of which a double-clicked
620+
// or gadak:// launch ever shows, so the app died with no window and no
621+
// message. This is the same output plus a native dialog, then the same exit
622+
// code log.Fatal used. (Issue key lives in gdk1243_test.go — doc-checks
623+
// resolves public-surface citations against the backlog snapshot.)
624+
func bootFatal(err error) {
625+
reportBootFailure(err)
626+
os.Exit(1)
627+
}
628+
629+
// reportBootFailure is the stderr/log/dialog half of bootFatal, split off so
630+
// tests can drive it with the dialog seam swapped (CI has no display to
631+
// dismiss a modal on).
632+
func reportBootFailure(err error) {
633+
// Same destination log.Fatal wrote to: the applog file plus stderr
634+
// (applog.Install mirrors both). The trail is the CLI-parity surface;
635+
// the dialog is additive, not a replacement.
636+
log.Print(err)
637+
showBootErrorDialog("Gadak", err.Error())
638+
}
639+
640+
// showBootErrorDialog is the production dialog for a pre-window boot
641+
// failure. It is a package var so tests can replace it and assert the boot
642+
// path reached the dialog with the store's message verbatim — without
643+
// showing a real modal.
644+
var showBootErrorDialog = func(title, text string) {
645+
nativeBootErrorDialog(runtime.GOOS, title, text)
646+
}
647+
648+
// nativeBootErrorDialog surfaces a boot failure where the OS has a cheap
649+
// pre-window surface. Not a wails dialog, at either failure site:
650+
// - an apprun.Open failure (the schema-refusal site) happens before
651+
// application.New, so application.Get() is nil — globalApplication is
652+
// only assigned inside New (pkg/application/application.go) and the
653+
// dialog helpers dispatch through InvokeAsync, which dereferences it.
654+
// - a StartOriginPassthrough failure runs after New but before app.Run,
655+
// and MessageDialog.Show dispatches through dispatch_async(main_queue),
656+
// which nothing drains until app.Run pumps the native event loop — the
657+
// dialog would queue and the process would exit without showing it.
658+
//
659+
// That is the same judgment fatal_windows.go made for its MessageBoxW
660+
// isolation. Linux ships no desktop artifact (README), so stderr stays the
661+
// truth there.
662+
func nativeBootErrorDialog(goos, title, text string) {
663+
switch goos {
664+
case "windows":
665+
windowsMessageBox(title, text)
666+
case "darwin":
667+
if err := macOSShowBootError(title, text); err != nil {
668+
log.Printf("gadak-desktop: boot dialog: %v", err)
669+
}
670+
}
671+
}
672+
673+
// bootDialogScript is the osascript line for a boot failure. Escaping is the
674+
// same idiom as internal/sync's osascript notifier (notify.go): \ and "
675+
// only — raw newlines inside an -e argument compile, so multi-line error
676+
// text stays multi-line (measured).
677+
func bootDialogScript(title, text string) string {
678+
esc := func(s string) string {
679+
s = strings.ReplaceAll(s, `\`, `\\`)
680+
s = strings.ReplaceAll(s, `"`, `\"`)
681+
return s
682+
}
683+
return fmt.Sprintf(`display dialog "%s" with title "%s" with icon stop`, esc(text), esc(title))
684+
}
685+
686+
// macOSShowBootError runs the dialog and blocks until it is dismissed —
687+
// MessageBoxW parity. A context with no user session (SSH, CI) errors and
688+
// the caller logs it; stderr already carries the message either way.
689+
func macOSShowBootError(title, text string) error {
690+
return exec.Command("osascript", "-e", bootDialogScript(title, text)).Run()
691+
}
692+
615693
func applyWindowChrome(opts *application.WebviewWindowOptions, chrome string) {
616694
if chrome != windowChromeTrafficLightsInset {
617695
return

0 commit comments

Comments
 (0)