返回 DeepSeek-Reasonix
main.go
根目录 / desktop / main.go
1 // Command reasonix-desktop is the Wails shell around the Reasonix kernel: a native
2 // window hosting a webview frontend, with the Go-side control.Controller bound
3 // directly to the UI (no HTTP hop — bindings in, runtime events out). It lives in
4 // a nested module (reasonix/desktop) so the CGO/WebKit desktop build never touches
5 // the CLI's CGO_ENABLED=0 single-static-binary guarantee, while still importing
6 // the same internal/* kernel.
7 package main
8
9 import (
10 "embed"
11 "os"
12 "path/filepath"
13 goruntime "runtime"
14 "strconv"
15 "strings"
16
17 "github.com/wailsapp/wails/v2"
18 "github.com/wailsapp/wails/v2/pkg/options"
19 "github.com/wailsapp/wails/v2/pkg/options/assetserver"
20 "github.com/wailsapp/wails/v2/pkg/options/linux"
21 "github.com/wailsapp/wails/v2/pkg/options/mac"
22 "github.com/wailsapp/wails/v2/pkg/options/windows"
23
24 // Blank imports wire compile-time built-ins into their registries, exactly as
25 // cmd/reasonix does — boot.Build resolves providers/tools from these registries.
26 _ "reasonix/internal/provider/anthropic"
27 _ "reasonix/internal/provider/openai"
28 _ "reasonix/internal/provider/responses"
29 "reasonix/internal/repair"
30 _ "reasonix/internal/tool/builtin"
31 )
32
33 // assets embeds the built frontend. `all:` so dotfiles (e.g. the dist .gitkeep
34 // that keeps this directive compilable before the first `pnpm build`) are
35 // included. A real run requires `pnpm build` (or `wails build`) to populate dist.
36 //
37 //go:embed all:frontend/dist
38 var assets embed.FS
39
40 // version is injected at build time via `wails build -ldflags "-X main.version=..."`,
41 // mirroring cmd/reasonix/main.go. The auto-updater reads it (App.Version) to compare
42 // against the published manifest; an un-injected dev build stays "dev" and never
43 // prompts to update.
44 var version = "dev"
45
46 // channel records the build's release line, injected via
47 // `-X main.channel=preview`. Default "stable" tracks the public release;
48 // "preview" tracks the opt-in test line. Legacy "canary" builds are treated as
49 // preview for compatibility.
50 var channel = "stable"
51
52 // macSelfUpdate is injected as "true" only for Developer ID signed + notarized
53 // macOS release builds. Local/ad-hoc macOS builds keep the manual download path.
54 var macSelfUpdate = "false"
55
56 const (
57 disableWebview2GPUEnv = "REASONIX_DESKTOP_DISABLE_WEBVIEW2_GPU"
58 linuxDRIRenderNodeGlob = "/dev/dri/renderD*"
59 )
60
61 func macSelfUpdateAllowed() bool {
62 switch strings.ToLower(strings.TrimSpace(macSelfUpdate)) {
63 case "1", "true", "yes", "on":
64 return true
65 default:
66 return false
67 }
68 }
69
70 func windowsWebview2GPUDisabled() bool {
71 if raw, ok := os.LookupEnv(disableWebview2GPUEnv); ok {
72 switch strings.ToLower(strings.TrimSpace(raw)) {
73 case "1", "true", "yes", "on":
74 return true
75 case "0", "false", "no", "off", "":
76 return false
77 }
78 }
79 return channel == "preview" || channel == "canary"
80 }
81
82 func linuxWebviewGpuPolicy(pattern string) linux.WebviewGpuPolicy {
83 matches, err := filepath.Glob(pattern)
84 if err == nil {
85 for _, path := range matches {
86 f, err := os.OpenFile(path, os.O_RDWR, 0)
87 if err == nil {
88 _ = f.Close()
89 return linux.WebviewGpuPolicyOnDemand
90 }
91 }
92 }
93 return linux.WebviewGpuPolicyNever
94 }
95
96 func main() {
97 // Detached macOS self-update child: wait for the old PID, hold the shared
98 // repair mutation lock, then swap the .app bundle. Must run before Wails.
99 if handled, exitCode := maybeRunMacUpdateHandoff(os.Args[1:]); handled {
100 os.Exit(exitCode)
101 }
102 capturePreviousFatalCrash()
103 installFatalCrashOutput()
104
105 launch := parseDesktopLaunchArgs(os.Args[1:])
106
107 app := NewApp()
108 title := "Reasonix"
109 singleInstance := singleInstanceLock(app)
110 appMenu := app.createAppMenu()
111 dragAndDrop := &options.DragAndDrop{EnableFileDrop: true}
112 bindings := []any{app}
113
114 if launch.RemoteWindowTicket != "" {
115 // A remote web child window: a second Reasonix process that hosts the
116 // SSH Serve page for one remote host. It deliberately skips local
117 // runtimes (tabs, tray, heartbeat, providers) and exposes no Wails
118 // bindings, local menus, or file drops, so it can never act as a second
119 // local app. Its single-instance identity is per owner and host, so one
120 // Desktop reuses its window while a restarted Desktop cannot adopt an
121 // unregistered survivor from the prior process.
122 if launch.RemoteWindowHostKey == "" || !isRemoteWindowOwnerID(launch.RemoteWindowOwnerID) || launch.RemoteWindowParentPID <= 0 {
123 println("Error: remote window ticket requires valid host and owner identities")
124 return
125 }
126 app.remoteWindowTicket = launch.RemoteWindowTicket
127 app.remoteWindowHostKey = launch.RemoteWindowHostKey
128 app.remoteWindowOwnerID = launch.RemoteWindowOwnerID
129 app.remoteWindowParentPID = launch.RemoteWindowParentPID
130 singleInstance = remoteWindowSingleInstanceLock(app)
131 appMenu = nil
132 dragAndDrop = &options.DragAndDrop{DisableWebViewDrop: true}
133 bindings = nil
134 } else {
135 // Observe previous run for crash diagnostics only. Startup tracking must
136 // never force Safe Mode, disable plugins, or select a previous binary.
137 app.previousRun = repair.NewStartupTracker("").ObservePreviousRun()
138 capturePendingUpdateHealthIdentity(app)
139 }
140
141 // Restore saved window size, or fall back to the default.
142 width, height := 1240, 720
143 if saved, ok := loadWindowState(); ok {
144 if saved.Width > 0 {
145 width = saved.Width
146 }
147 if saved.Height > 0 {
148 height = saved.Height
149 }
150 }
151
152 // Restore saved desktop zoom factor (WebView2 ZoomFactor), or default to 1.0.
153 zoomFactor := 1.0
154 if zf, ok := loadZoomFactor(); ok && zf > 0 {
155 zoomFactor = zf
156 }
157
158 // On Linux, cover JavaScriptCore's lazy signal-handler installation window.
159 // Other platforms provide a no-op implementation.
160 scheduleWebKitSignalHandlerRepair()
161
162 err := wails.Run(&options.App{
163 Title: title,
164 Width: width,
165 Height: height,
166 Frameless: goruntime.GOOS == "windows",
167 Logger: newCrashCaptureLogger(app),
168 MinWidth: 760,
169 MinHeight: 480,
170 // Match the dark UI shell so the initial webview background doesn't flash
171 // white before CSS loads — particularly visible on WebKitGTK.
172 BackgroundColour: &options.RGBA{R: 26, G: 26, B: 46, A: 255},
173 AssetServer: &assetserver.Options{
174 Assets: assets,
175 Middleware: assetserver.ChainMiddleware(
176 app.remoteWindowAssetMiddleware(),
177 app.jsProfilingMiddleware(),
178 app.remoteMarkdownImageMiddleware(),
179 app.workspaceMediaMiddleware(),
180 app.themeAssetMiddleware(),
181 ),
182 },
183 OnStartup: app.startup,
184 OnDomReady: app.domReady,
185 OnBeforeClose: app.beforeClose,
186 OnShutdown: app.shutdown,
187 Bind: bindings,
188 SingleInstanceLock: singleInstance,
189
190 // Start hidden — domReady positions and shows the window after restoring
191 // geometry, so the user never sees the default size/position flash.
192 StartHidden: true,
193
194 // Native application menu (File > Settings, Edit, Window).
195 Menu: appMenu,
196
197 // Native OS file drops: the webview withholds dropped files' paths from the
198 // HTML drop event, so the frontend (composer) reads them via runtime.OnFileDrop
199 // against the --wails-drop-target element instead.
200 DragAndDrop: dragAndDrop,
201
202 // --- per-platform adaptation (see desktop/README.md for the rationale) ---
203 Mac: &mac.Options{
204 // Inset traffic-lights over a frameless-feeling header; the frontend
205 // leaves a drag region at the top (CSS --wails-draggable).
206 TitleBar: mac.TitleBarHiddenInset(),
207 // Follow the OS appearance so the title bar matches light/dark system
208 // preference instead of being locked to dark.
209 Appearance: mac.DefaultAppearance,
210 },
211 Windows: &windows.Options{
212 // Follow the OS theme so the title bar matches light/dark system
213 // preference instead of being locked to dark.
214 Theme: windows.SystemDefault,
215 ZoomFactor: zoomFactor,
216 WebviewGpuIsDisabled: windowsWebview2GPUDisabled(),
217 },
218 Linux: &linux.Options{
219 ProgramName: "Reasonix",
220 // WebKitGTK GPU compositing is inconsistent across distros/drivers and
221 // is the one real cross-platform rough edge for a Go+webview stack:
222 // "always" can yield blank or flickering webviews on some setups, so
223 // we let the webview decide on demand when a render node is usable, and
224 // disable acceleration when remote/software-rendered sessions cannot
225 // access /dev/dri.
226 WebviewGpuPolicy: linuxWebviewGpuPolicy(linuxDRIRenderNodeGlob),
227 },
228 })
229 if err != nil {
230 println("Error:", err.Error())
231 }
232 }
233
234 // desktopLaunchOptions captures legacy argv that old installers/shortcuts may
235 // still pass. Fields are accepted and ignored so migration never crashes on
236 // unknown product switches.
237 type desktopLaunchOptions struct {
238 // LegacySafeModeArg is true when --safe-mode was present. v1.20+ ignores it.
239 LegacySafeModeArg bool
240 // RemoteWindowTicket is the one-shot ticket name for an SSH remote web
241 // window child process. The URL and Serve token never appear in argv.
242 RemoteWindowTicket string
243 // RemoteWindowHostKey is the non-secret per-host digest that derives the
244 // child window's single-instance identity and validates the ticket.
245 RemoteWindowHostKey string
246 // RemoteWindowOwnerID scopes same-host reuse to the primary Desktop process
247 // that spawned the child. RemoteWindowParentPID lets the child close when
248 // that owner and its loopback SSH tunnel disappear.
249 RemoteWindowOwnerID string
250 RemoteWindowParentPID int
251 }
252
253 func parseDesktopLaunchArgs(args []string) desktopLaunchOptions {
254 var out desktopLaunchOptions
255 for _, arg := range args {
256 switch {
257 case arg == "--safe-mode" || arg == "-safe-mode":
258 out.LegacySafeModeArg = true
259 case arg == "launch" || arg == "--detach":
260 // Legacy launch tokens from old shortcuts. They produce no behavior.
261 case strings.HasPrefix(arg, remoteWindowTicketArgPrefix):
262 out.RemoteWindowTicket = strings.TrimPrefix(arg, remoteWindowTicketArgPrefix)
263 case strings.HasPrefix(arg, remoteWindowHostArgPrefix):
264 out.RemoteWindowHostKey = strings.TrimPrefix(arg, remoteWindowHostArgPrefix)
265 case strings.HasPrefix(arg, remoteWindowOwnerArgPrefix):
266 out.RemoteWindowOwnerID = strings.TrimPrefix(arg, remoteWindowOwnerArgPrefix)
267 case strings.HasPrefix(arg, remoteWindowParentArgPrefix):
268 out.RemoteWindowParentPID, _ = strconv.Atoi(strings.TrimPrefix(arg, remoteWindowParentArgPrefix))
269 }
270 }
271 return out
272 }
273
273 lines GO