返回 DeepSeek-Reasonix
launcher.go
根目录 / internal / desktoplauncher / launcher.go
1 // Package desktoplauncher implements the permanent Reasonix desktop entry
2 // point. It deliberately owns no crash-loop, rollback, or safe-mode policy.
3 package desktoplauncher
4
5 import (
6 "errors"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "runtime"
12 "strings"
13
14 "reasonix/internal/appidentity"
15 "reasonix/internal/installlayout"
16 "reasonix/internal/proc"
17 )
18
19 // Run resolves the active desktop, performs the one-time legacy handoff when
20 // needed, and starts the desktop process.
21 func Run(args []string, buildVersion string) int {
22 if len(args) > 0 && args[0] == "--repair-shortcuts" {
23 if err := repairInstallerShortcuts(args[1:], ResolveInstallRoot, appidentity.RepairShortcuts); err != nil {
24 fmt.Fprintln(os.Stderr, "error: repair Windows shortcut integration:", err)
25 return 1
26 }
27 return 0
28 }
29 if len(args) == 1 {
30 switch args[0] {
31 case "version", "--version", "-v":
32 fmt.Println("reasonix-launcher", buildVersion)
33 return 0
34 case "help", "--help", "-h":
35 usage()
36 return 0
37 }
38 }
39 if err := appidentity.ApplyToCurrentProcess(); err != nil {
40 fmt.Fprintln(os.Stderr, "warning: apply Windows app identity:", err)
41 }
42
43 installRoot, err := ResolveInstallRoot()
44 if err != nil {
45 fmt.Fprintln(os.Stderr, "error:", err)
46 return 1
47 }
48 if err := appidentity.RepairOwnedShortcuts(installRoot); err != nil {
49 fmt.Fprintln(os.Stderr, "warning: repair Windows shortcut identity:", err)
50 }
51 if err := runLegacyMigratorIfNeeded(installRoot); err != nil {
52 fmt.Fprintln(os.Stderr, "error:", err)
53 return 1
54 }
55
56 desktopPath, err := ResolveDesktopPath(installRoot)
57 if err != nil {
58 fmt.Fprintln(os.Stderr, "error:", err)
59 return 1
60 }
61
62 if handled, code := coordinatedLaunch(installRoot, args); handled {
63 return code
64 }
65
66 cmd := exec.Command(desktopPath, StripLegacyLaunchArgs(args)...)
67 proc.HideConsole(cmd)
68 cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
69 cmd.Dir = installRoot
70 if DetachByDefault() {
71 if err := cmd.Start(); err != nil {
72 fmt.Fprintln(os.Stderr, "error:", err)
73 return 1
74 }
75 return 0
76 }
77 if err := cmd.Run(); err != nil {
78 var exit *exec.ExitError
79 if errors.As(err, &exit) {
80 return exit.ExitCode()
81 }
82 fmt.Fprintln(os.Stderr, "error:", err)
83 return 1
84 }
85 return 0
86 }
87
88 // ResolveInstallRoot returns the directory containing the launcher.
89 func ResolveInstallRoot() (string, error) {
90 exe, err := os.Executable()
91 if err != nil {
92 return "", fmt.Errorf("resolve launcher path: %w", err)
93 }
94 return resolveInstallRoot(exe)
95 }
96
97 func resolveInstallRoot(exe string) (string, error) {
98 resolved, err := resolveExecutablePath(exe)
99 if err != nil {
100 return "", fmt.Errorf("resolve launcher path: %w", err)
101 }
102 return filepath.Clean(filepath.Dir(resolved)), nil
103 }
104
105 // ResolveDesktopPath resolves current.json when present. A present but invalid
106 // pointer is always fatal; only a genuinely absent pointer may use the flat
107 // sibling fallback needed by package-managed installs.
108 func ResolveDesktopPath(installRoot string) (string, error) {
109 currentPath := filepath.Join(installRoot, installlayout.CurrentFileName)
110 if _, err := os.Lstat(currentPath); err == nil {
111 return installlayout.ActiveDesktopPath(installRoot)
112 } else if !os.IsNotExist(err) {
113 return "", fmt.Errorf("inspect current.json: %w", err)
114 }
115 if path := siblingDesktop(installRoot); path != "" {
116 return path, nil
117 }
118 return "", fmt.Errorf("cannot locate reasonix-desktop under %s (missing current.json)", installRoot)
119 }
120
121 func runLegacyMigratorIfNeeded(installRoot string) error {
122 currentPath := filepath.Join(installRoot, installlayout.CurrentFileName)
123 if _, err := os.Lstat(currentPath); err == nil {
124 return nil
125 } else if !os.IsNotExist(err) {
126 return fmt.Errorf("inspect current.json before migration: %w", err)
127 }
128
129 migratorName := "reasonix-guard"
130 if runtime.GOOS == "windows" {
131 migratorName += ".exe"
132 }
133 migratorPath := filepath.Join(installRoot, migratorName)
134 info, err := os.Lstat(migratorPath)
135 if os.IsNotExist(err) {
136 return nil
137 }
138 if err != nil {
139 return fmt.Errorf("inspect legacy migrator: %w", err)
140 }
141 if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
142 return fmt.Errorf("legacy migrator is not a regular file")
143 }
144
145 cmd := exec.Command(migratorPath, "--install-root", installRoot, "--no-relaunch")
146 proc.HideConsole(cmd)
147 cmd.Dir = installRoot
148 cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
149 if err := cmd.Run(); err != nil {
150 return fmt.Errorf("legacy layout migration failed: %w", err)
151 }
152 if _, err := installlayout.ReadCurrent(installRoot); err != nil {
153 return fmt.Errorf("legacy layout migration did not commit current.json: %w", err)
154 }
155 // The parent launcher owns Windows cleanup after the migrator process exits.
156 // Unix migrators normally unlink themselves, so NotExist is also success.
157 if err := os.Remove(migratorPath); err != nil && !os.IsNotExist(err) {
158 return fmt.Errorf("remove completed legacy migrator: %w", err)
159 }
160 return nil
161 }
162
163 func siblingDesktop(installRoot string) string {
164 path := filepath.Join(installRoot, installlayout.DesktopBinaryName())
165 info, err := os.Lstat(path)
166 if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
167 return ""
168 }
169 return path
170 }
171
172 // StripLegacyLaunchArgs removes tokens emitted by old Guard shortcuts.
173 func StripLegacyLaunchArgs(args []string) []string {
174 out := make([]string, 0, len(args))
175 skipNext := false
176 for i := range args {
177 if skipNext {
178 skipNext = false
179 continue
180 }
181 arg := args[i]
182 switch arg {
183 case "launch", "--detach", "--safe-mode", "-safe-mode":
184 continue
185 case "--app":
186 skipNext = true
187 continue
188 case "--":
189 return append(out, args[i:]...)
190 }
191 if strings.HasPrefix(arg, "--app=") {
192 continue
193 }
194 out = append(out, arg)
195 }
196 return out
197 }
198
199 // DetachByDefault reports whether the Windows packaged entry should start the
200 // desktop and exit immediately.
201 func DetachByDefault() bool {
202 if runtime.GOOS != "windows" {
203 return false
204 }
205 exe, err := os.Executable()
206 if err != nil {
207 return false
208 }
209 name := strings.ToLower(filepath.Base(exe))
210 return name == "reasonix-launcher.exe" || name == "reasonix.exe"
211 }
212
213 func usage() {
214 fmt.Println("usage: reasonix-launcher [args...]")
215 fmt.Println(" Starts the active Reasonix desktop from current.json.")
216 fmt.Println(" Legacy --safe-mode / launch --detach tokens are ignored.")
217 fmt.Println(" --repair-shortcuts <absolute.lnk...> repairs owned installer shortcuts without launching.")
218 }
219
219 lines GO