| 1 | //go:build windows |
| 2 | |
| 3 | package desktoplauncher |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | _ "embed" |
| 9 | "encoding/binary" |
| 10 | "fmt" |
| 11 | "os" |
| 12 | "os/exec" |
| 13 | "path/filepath" |
| 14 | "runtime" |
| 15 | "strings" |
| 16 | "syscall" |
| 17 | "testing" |
| 18 | "time" |
| 19 | |
| 20 | "reasonix/internal/installlayout" |
| 21 | ) |
| 22 | |
| 23 | const consoleTestEnv = "REASONIX_LAUNCHER_CONSOLE_TEST" |
| 24 | |
| 25 | //go:embed testdata/console-probe/main.go |
| 26 | var consoleProbeSource string |
| 27 | |
| 28 | // TestMain lets a GUI copy of the test executable run the installed launch path. |
| 29 | func TestMain(m *testing.M) { |
| 30 | root := os.Getenv(consoleTestEnv) |
| 31 | if root == "" { |
| 32 | os.Exit(m.Run()) |
| 33 | } |
| 34 | exe, err := os.Executable() |
| 35 | if err != nil { |
| 36 | panic(err) |
| 37 | } |
| 38 | name := filepath.Base(exe) |
| 39 | kernel := syscall.NewLazyDLL("kernel32.dll") |
| 40 | cp, _, _ := kernel.NewProc("GetConsoleCP").Call() |
| 41 | window, _, _ := kernel.NewProc("GetConsoleWindow").Call() |
| 42 | fmt.Printf("console-state %s %d %d\n", name, cp, window) |
| 43 | switch name { |
| 44 | case "reasonix-launcher.exe", "Reasonix.exe": |
| 45 | os.Exit(Run(os.Args[1:], "test")) |
| 46 | default: |
| 47 | panic("unexpected console test executable: " + name) |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | func TestLauncherDoesNotCreateConsoleWindow(t *testing.T) { |
| 52 | exe, err := os.Executable() |
| 53 | if err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | executableBytes, err := os.ReadFile(exe) |
| 57 | if err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | // Match the shipped -H windowsgui launcher while keeping its children CUI. |
| 61 | // A CREATE_NO_WINDOW console parent can pass an invisible console onward, |
| 62 | // masking the missing creation flag in the launcher under test. |
| 63 | guiBytes := bytes.Clone(executableBytes) |
| 64 | peOffset := binary.LittleEndian.Uint32(guiBytes[0x3c:0x40]) |
| 65 | binary.LittleEndian.PutUint16(guiBytes[peOffset+24+68:], 2) |
| 66 | probeBytes := buildConsoleProbe(t) |
| 67 | for _, entry := range []string{"reasonix-launcher.exe", "Reasonix.exe"} { |
| 68 | for _, legacy := range []bool{false, true} { |
| 69 | t.Run(fmt.Sprintf("%s/legacy=%t", entry, legacy), func(t *testing.T) { |
| 70 | root := t.TempDir() |
| 71 | active := filepath.Join(root, "versions", "v1.0.0") |
| 72 | if err := os.MkdirAll(active, 0o755); err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | paths := []string{filepath.Join(root, entry), filepath.Join(active, "reasonix-desktop.exe")} |
| 76 | if legacy { |
| 77 | paths = append(paths, filepath.Join(root, "reasonix-guard.exe")) |
| 78 | } else if err := writeConsoleTestPointer(root); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | for _, path := range paths { |
| 82 | data := probeBytes |
| 83 | if path == paths[0] { |
| 84 | data = guiBytes |
| 85 | } |
| 86 | if err := os.WriteFile(path, data, 0o755); err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | } |
| 90 | ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) |
| 91 | defer cancel() |
| 92 | cmd := exec.CommandContext(ctx, paths[0]) |
| 93 | cmd.Env = append(os.Environ(), consoleTestEnv+"="+root) |
| 94 | cmd.WaitDelay = 5 * time.Second |
| 95 | output, err := cmd.CombinedOutput() |
| 96 | if err != nil { |
| 97 | t.Fatalf("launcher: %v\n%s", err, output) |
| 98 | } |
| 99 | seen := make(map[string]bool) |
| 100 | for line := range bytes.SplitSeq(output, []byte{'\n'}) { |
| 101 | if !bytes.HasPrefix(line, []byte("console-state ")) { |
| 102 | continue |
| 103 | } |
| 104 | var name string |
| 105 | var cp, window uint64 |
| 106 | if _, err := fmt.Sscanf(string(line), "console-state %s %d %d", &name, &cp, &window); err != nil { |
| 107 | t.Fatal(err) |
| 108 | } |
| 109 | seen[name] = true |
| 110 | // CREATE_NO_WINDOW still permits a console code page; the |
| 111 | // regression is creating a window, not having console I/O. |
| 112 | if window != 0 { |
| 113 | t.Errorf("%s created a console window: codepage=%d window=%d", name, cp, window) |
| 114 | } |
| 115 | } |
| 116 | for _, path := range paths { |
| 117 | if !seen[filepath.Base(path)] { |
| 118 | t.Errorf("missing process probe for %s: %s", filepath.Base(path), output) |
| 119 | } |
| 120 | } |
| 121 | if legacy { |
| 122 | if _, err := os.Stat(filepath.Join(root, "reasonix-guard.exe")); !os.IsNotExist(err) { |
| 123 | t.Errorf("completed legacy migrator was not removed: %v", err) |
| 124 | } |
| 125 | } |
| 126 | if strings.Contains(string(output), "error:") { |
| 127 | t.Fatalf("launcher reported an error: %s", output) |
| 128 | } |
| 129 | }) |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | func writeConsoleTestPointer(root string) error { |
| 135 | return installlayout.WriteCurrent(root, installlayout.CurrentPointer{ |
| 136 | SchemaVersion: 1, ActiveVersion: "v1.0.0", ActiveDir: "versions/v1.0.0", |
| 137 | }) |
| 138 | } |
| 139 | |
| 140 | func buildConsoleProbe(t *testing.T) []byte { |
| 141 | t.Helper() |
| 142 | dir := t.TempDir() |
| 143 | if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(consoleProbeSource), 0o644); err != nil { |
| 144 | t.Fatal(err) |
| 145 | } |
| 146 | ctx, cancel := context.WithTimeout(context.Background(), time.Minute) |
| 147 | defer cancel() |
| 148 | cmd := exec.CommandContext(ctx, "go", "build", "-o", "probe.exe", "main.go") |
| 149 | cmd.Dir = dir |
| 150 | cmd.Env = append(os.Environ(), "GOOS=windows", "GOARCH="+runtime.GOARCH, "CGO_ENABLED=0", "GO111MODULE=off") |
| 151 | if output, err := cmd.CombinedOutput(); err != nil { |
| 152 | t.Fatalf("build console probe: %v\n%s", err, output) |
| 153 | } |
| 154 | data, err := os.ReadFile(filepath.Join(dir, "probe.exe")) |
| 155 | if err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | return data |
| 159 | } |
| 160 |