| 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 |