| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/rand" |
| 7 | "crypto/subtle" |
| 8 | "encoding/hex" |
| 9 | "encoding/json" |
| 10 | "errors" |
| 11 | "fmt" |
| 12 | "io" |
| 13 | "log/slog" |
| 14 | "net" |
| 15 | "net/http" |
| 16 | "os" |
| 17 | "os/signal" |
| 18 | "path/filepath" |
| 19 | "runtime/debug" |
| 20 | "slices" |
| 21 | "strings" |
| 22 | "syscall" |
| 23 | "time" |
| 24 | |
| 25 | "reasonix/desktop/internal/hostrpc" |
| 26 | "reasonix/desktop/internal/instanceidentity" |
| 27 | "reasonix/internal/config" |
| 28 | "reasonix/internal/extension/rpcwire" |
| 29 | ) |
| 30 | |
| 31 | const ( |
| 32 | hostRPCFlag = "--host-rpc" |
| 33 | emitContractFlag = "-emit-contract" |
| 34 | contractTSFile = "desktopContract.generated.ts" |
| 35 | contractJSONFile = "desktopContract.generated.json" |
| 36 | |
| 37 | desktopWindowMinWidth = 760 |
| 38 | desktopWindowMinHeight = 480 |
| 39 | hostDetachedShutdownTimeout = 15 * time.Second |
| 40 | ) |
| 41 | |
| 42 | type detachedShutdownResult struct { |
| 43 | status shutdownStatus |
| 44 | err error |
| 45 | } |
| 46 | |
| 47 | func hostRPCRequested(args []string) bool { return slices.Contains(args, hostRPCFlag) } |
| 48 | |
| 49 | // exitIfHostLaunchMode runs -emit-contract or --host-rpc and exits with its |
| 50 | // code; any other launch returns to the caller. |
| 51 | func exitIfHostLaunchMode(args []string) { |
| 52 | if dir, ok := emitContractDir(args); ok { |
| 53 | os.Exit(runEmitContract(dir)) |
| 54 | } |
| 55 | if hostRPCRequested(args) { |
| 56 | os.Exit(runHostRPC(NewApp(), os.Stdin, os.Stdout)) |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | // emitContractDir returns the directory named by -emit-contract <dir> or |
| 61 | // -emit-contract=<dir>; a leading double dash is accepted too. |
| 62 | func emitContractDir(args []string) (string, bool) { |
| 63 | for i, arg := range args { |
| 64 | name := "-" + strings.TrimLeft(arg, "-") |
| 65 | if name == emitContractFlag && i+1 < len(args) { |
| 66 | return args[i+1], true |
| 67 | } |
| 68 | if dir, ok := strings.CutPrefix(name, emitContractFlag+"="); ok && dir != "" { |
| 69 | return dir, true |
| 70 | } |
| 71 | } |
| 72 | return "", false |
| 73 | } |
| 74 | |
| 75 | // emitContract writes the TypeScript and JSON contract files into dir from |
| 76 | // the App type alone; no App instance or runtime is constructed. |
| 77 | func emitContract(dir string) error { |
| 78 | return emitHostContract(dir, dir) |
| 79 | } |
| 80 | |
| 81 | func emitHostContract(dir, ownersDir string) error { |
| 82 | owners, err := hostrpc.SourceOwnership(".", "App") |
| 83 | if err != nil { |
| 84 | return err |
| 85 | } |
| 86 | registry, err := hostrpc.NewRegistryWithOwners((*App)(nil), nil, owners) |
| 87 | if err != nil { |
| 88 | return err |
| 89 | } |
| 90 | contract := hostrpc.Build(registry, hostEventNames) |
| 91 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 92 | return err |
| 93 | } |
| 94 | var ts, js bytes.Buffer |
| 95 | if err := hostrpc.WriteTypeScript(&ts, contract); err != nil { |
| 96 | return err |
| 97 | } |
| 98 | if err := hostrpc.WriteJSON(&js, contract); err != nil { |
| 99 | return err |
| 100 | } |
| 101 | ownerJSON, err := json.MarshalIndent(owners, "", " ") |
| 102 | if err != nil { |
| 103 | return err |
| 104 | } |
| 105 | if err := os.WriteFile(filepath.Join(ownersDir, hostCommandOwnersFile), append(ownerJSON, '\n'), 0o644); err != nil { |
| 106 | return err |
| 107 | } |
| 108 | if err := os.WriteFile(filepath.Join(dir, contractTSFile), ts.Bytes(), 0o644); err != nil { |
| 109 | return err |
| 110 | } |
| 111 | return os.WriteFile(filepath.Join(dir, contractJSONFile), js.Bytes(), 0o644) |
| 112 | } |
| 113 | |
| 114 | func runEmitContract(dir string) int { |
| 115 | if err := emitHostContract(dir, "."); err != nil { |
| 116 | fmt.Fprintln(os.Stderr, "emit-contract:", err) |
| 117 | return 1 |
| 118 | } |
| 119 | return 0 |
| 120 | } |
| 121 | |
| 122 | // runHostRPC serves the desktop host protocol over stdin/stdout until the |
| 123 | // shell closes stdin or acknowledges desktop/shutdown. It returns the |
| 124 | // process exit code. |
| 125 | func runHostRPC(app *App, stdin io.Reader, stdout io.Writer) int { |
| 126 | stopEndpoint, err := startUpdateEndpoint() |
| 127 | if err != nil { |
| 128 | slog.Error("desktop host: update endpoint", "err", err) |
| 129 | return 2 |
| 130 | } |
| 131 | defer stopEndpoint() |
| 132 | registry, err := newDesktopRegistry(app) |
| 133 | if err != nil { |
| 134 | slog.Error("desktop host: contract registry", "err", err) |
| 135 | return 2 |
| 136 | } |
| 137 | // Lifecycle evidence and the probationary-update identity are claimed |
| 138 | // before any request, before the shell connects. |
| 139 | prepareDesktopDiagnostics(app) |
| 140 | capturePendingUpdateHealthIdentity(app) |
| 141 | defer app.releaseDesktopDiagnosticsOwnership() |
| 142 | shutdownAttempted := false |
| 143 | defer func() { |
| 144 | if shutdownAttempted || app.shutdownStatus("").Completed { |
| 145 | return |
| 146 | } |
| 147 | status, shutdownErr := requestDetachedShutdown(app, shutdownRequest{ |
| 148 | RequestID: newDesktopLifecycleRunID(), Reason: shutdownReasonStartupFailure, |
| 149 | }, hostDetachedShutdownTimeout) |
| 150 | if shutdownErr != nil || !status.Completed { |
| 151 | slog.Error("desktop host: startup-failure cleanup failed", "err", shutdownErr, "phase", status.Phase) |
| 152 | } |
| 153 | }() |
| 154 | generation, err := randomHex(8) |
| 155 | if err != nil { |
| 156 | slog.Error("desktop host: runtime generation", "err", err) |
| 157 | return 2 |
| 158 | } |
| 159 | token, err := randomHex(32) |
| 160 | if err != nil { |
| 161 | slog.Error("desktop host: resource token", "err", err) |
| 162 | return 2 |
| 163 | } |
| 164 | origin, stopOrigin, err := startResourceOrigin(app, token) |
| 165 | if err != nil { |
| 166 | slog.Error("desktop host: resource origin", "err", err) |
| 167 | return 2 |
| 168 | } |
| 169 | defer stopOrigin() |
| 170 | |
| 171 | appCtx, cancel := context.WithCancel(context.Background()) |
| 172 | defer cancel() |
| 173 | hostCtx, stopSignals := signal.NotifyContext(appCtx, os.Interrupt, syscall.SIGTERM) |
| 174 | defer stopSignals() |
| 175 | conn := rpcwire.NewConn(stdin, stdout, rpcwire.Options{ |
| 176 | StrictJSONRPC: true, |
| 177 | MaxInboundBytes: 64 << 20, |
| 178 | MaxOutboundBytes: 64 << 20, |
| 179 | MaxConcurrentHandlers: 512, |
| 180 | MaxWriteStall: 30 * time.Second, |
| 181 | Name: "desktop-host", |
| 182 | }) |
| 183 | bridge := &hostShellBridge{app: app} |
| 184 | server := hostrpc.NewServer(conn, hostrpc.ServerConfig{ |
| 185 | Registry: registry, |
| 186 | Contract: hostrpc.Build(registry, hostEventNames), |
| 187 | Hooks: hostRPCHooks(appCtx, app, bridge, hostrpc.Resources{Origin: origin, Token: token}), |
| 188 | Identity: hostIdentity(), |
| 189 | Generation: "g-" + generation, |
| 190 | }) |
| 191 | bridge.server = server |
| 192 | app.hostShell = bridge |
| 193 | app.setNativeHost(rpcNativeHost{server: server}) |
| 194 | runtimeEventsEmitFallback = func(_ context.Context, name string, payload ...any) { |
| 195 | server.Emit(name, payload...) |
| 196 | } |
| 197 | if err := server.Serve(hostCtx); err != nil { |
| 198 | reason := shutdownReasonConnectionLost |
| 199 | if hostCtx.Err() != nil && appCtx.Err() == nil { |
| 200 | reason = shutdownReasonSystemSignal |
| 201 | } |
| 202 | slog.Error("desktop host: connection ended", "err", err) |
| 203 | shutdownAttempted = true |
| 204 | status, shutdownErr := requestDetachedShutdown(app, shutdownRequest{ |
| 205 | RequestID: newDesktopLifecycleRunID(), Reason: reason, |
| 206 | }, hostDetachedShutdownTimeout) |
| 207 | if shutdownErr != nil || !status.Completed { |
| 208 | slog.Error("desktop host: connection-loss cleanup failed", "err", shutdownErr, "phase", status.Phase) |
| 209 | } |
| 210 | return 1 |
| 211 | } |
| 212 | if !app.shutdownStatus("").Completed { |
| 213 | shutdownAttempted = true |
| 214 | status, shutdownErr := requestDetachedShutdown(app, shutdownRequest{ |
| 215 | RequestID: newDesktopLifecycleRunID(), Reason: shutdownReasonConnectionLost, |
| 216 | }, hostDetachedShutdownTimeout) |
| 217 | if shutdownErr != nil || !status.Completed { |
| 218 | slog.Error("desktop host: EOF cleanup failed", "err", shutdownErr, "phase", status.Phase) |
| 219 | return 1 |
| 220 | } |
| 221 | } |
| 222 | return 0 |
| 223 | } |
| 224 | |
| 225 | // requestDetachedShutdown bounds cleanup only after the shell transport or OS |
| 226 | // signal is already gone. Explicit user shutdown remains unbounded and |
| 227 | // retryable because its window is still available to surface a save failure. |
| 228 | func requestDetachedShutdown(app *App, request shutdownRequest, timeout time.Duration) (shutdownStatus, error) { |
| 229 | result := make(chan detachedShutdownResult, 1) |
| 230 | go func() { |
| 231 | status, err := app.requestShutdown(context.Background(), request) |
| 232 | result <- detachedShutdownResult{status: status, err: err} |
| 233 | }() |
| 234 | timer := time.NewTimer(timeout) |
| 235 | defer timer.Stop() |
| 236 | select { |
| 237 | case completed := <-result: |
| 238 | return completed.status, completed.err |
| 239 | case <-timer.C: |
| 240 | status := app.shutdownStatus("") |
| 241 | reason := status.Reason |
| 242 | if reason == "" { |
| 243 | reason = normalizeShutdownReason(request.Reason) |
| 244 | } |
| 245 | app.lifecycle.tracker.markShutdown(reason, status.Phase, "interrupted") |
| 246 | return status, fmt.Errorf("detached shutdown timed out after %s: %w", timeout, context.DeadlineExceeded) |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | // hostRPCHooks binds the shell's lifecycle requests to the App hooks, always |
| 251 | // with the service-lifetime context the App stores. |
| 252 | func hostRPCHooks(ctx context.Context, app *App, bridge *hostShellBridge, resources hostrpc.Resources) hostrpc.Hooks { |
| 253 | return hostrpc.Hooks{ |
| 254 | Hello: func(hostrpc.HelloParams) (hostrpc.HelloResult, error) { |
| 255 | runID := "" |
| 256 | if app.lifecycle.tracker != nil { |
| 257 | runID = app.lifecycle.tracker.state.RunID |
| 258 | } |
| 259 | return hostrpc.HelloResult{ |
| 260 | Resources: resources, Window: initialDesktopWindowGeometry(), |
| 261 | RunID: runID, IncidentID: app.lifecycle.tracker.incidentID(), |
| 262 | DiagnosticsEnabled: app.diagnosticsTelemetry, |
| 263 | }, nil |
| 264 | }, |
| 265 | Start: func(context.Context) error { app.startup(ctx); return nil }, |
| 266 | DOMReady: func(context.Context) error { app.domReady(ctx); return nil }, |
| 267 | RendererAttached: func(context.Context, int) error { |
| 268 | app.ReportDesktopWebViewReady() |
| 269 | return nil |
| 270 | }, |
| 271 | BeforeClose: func(_ context.Context, reason string) bool { return bridge.beforeClose(ctx, reason) }, |
| 272 | Shutdown: func(requestCtx context.Context, params hostrpc.ShutdownParams) (hostrpc.ShutdownResult, error) { |
| 273 | status, err := app.requestShutdown(requestCtx, shutdownRequest{RequestID: params.RequestID, Reason: params.Reason}) |
| 274 | return hostRPCShutdownResult(status), err |
| 275 | }, |
| 276 | ShutdownStatus: func(_ context.Context, params hostrpc.ShutdownStatusParams) (hostrpc.ShutdownResult, error) { |
| 277 | return hostRPCShutdownResult(app.shutdownStatus(params.RequestID)), nil |
| 278 | }, |
| 279 | HostEvent: bridge.handleHostEvent, |
| 280 | BrowserControl: func(_ context.Context, enabled bool) error { |
| 281 | app.setBrowserControlEnabled(enabled) |
| 282 | return nil |
| 283 | }, |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | func hostRPCShutdownResult(status shutdownStatus) hostrpc.ShutdownResult { |
| 288 | return hostrpc.ShutdownResult{ |
| 289 | RequestID: status.RequestID, Reason: status.Reason, Phase: status.Phase, Outcome: status.Outcome, |
| 290 | Completed: status.Completed, Retryable: status.Retryable, ErrorCode: status.ErrorCode, |
| 291 | Error: status.Error, UpdatedAt: status.UpdatedAt, |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | func hostIdentity() hostrpc.Identity { |
| 296 | return hostrpc.Identity{ |
| 297 | Version: version, |
| 298 | Channel: channel, |
| 299 | Commit: buildCommit(), |
| 300 | Home: instanceidentity.AccessHome(config.ReasonixHomeDir()), |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | func buildCommit() string { |
| 305 | info, ok := debug.ReadBuildInfo() |
| 306 | if !ok { |
| 307 | return "" |
| 308 | } |
| 309 | for _, setting := range info.Settings { |
| 310 | if setting.Key == "vcs.revision" { |
| 311 | return setting.Value |
| 312 | } |
| 313 | } |
| 314 | return "" |
| 315 | } |
| 316 | |
| 317 | // startResourceOrigin serves the authorised asset middlewares on a loopback |
| 318 | // port behind a bearer token; anything they do not claim is a 404. |
| 319 | func startResourceOrigin(app *App, token string) (origin string, stop func(), err error) { |
| 320 | listener, err := net.Listen("tcp", "127.0.0.1:0") |
| 321 | if err != nil { |
| 322 | return "", nil, err |
| 323 | } |
| 324 | handler := http.NotFoundHandler() |
| 325 | chain := []func(http.Handler) http.Handler{ |
| 326 | app.jsProfilingMiddleware(), |
| 327 | app.remoteMarkdownImageMiddleware(), |
| 328 | app.workspaceMediaMiddleware(), |
| 329 | app.themeAssetMiddleware(), |
| 330 | } |
| 331 | for _, middleware := range slices.Backward(chain) { |
| 332 | handler = middleware(handler) |
| 333 | } |
| 334 | server := &http.Server{Handler: bearerAuth(token, handler), ReadHeaderTimeout: 10 * time.Second} |
| 335 | go func() { |
| 336 | if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { |
| 337 | slog.Warn("desktop host: resource origin stopped", "err", err) |
| 338 | } |
| 339 | }() |
| 340 | stop = func() { |
| 341 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| 342 | defer cancel() |
| 343 | _ = server.Shutdown(ctx) |
| 344 | } |
| 345 | return "http://" + listener.Addr().String(), stop, nil |
| 346 | } |
| 347 | |
| 348 | func bearerAuth(token string, next http.Handler) http.Handler { |
| 349 | want := []byte("Bearer " + token) |
| 350 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 351 | if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), want) != 1 { |
| 352 | w.Header().Set("WWW-Authenticate", "Bearer") |
| 353 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 354 | return |
| 355 | } |
| 356 | next.ServeHTTP(w, r) |
| 357 | }) |
| 358 | } |
| 359 | |
| 360 | func randomHex(n int) (string, error) { |
| 361 | buf := make([]byte, n) |
| 362 | if _, err := rand.Read(buf); err != nil { |
| 363 | return "", err |
| 364 | } |
| 365 | return hex.EncodeToString(buf), nil |
| 366 | } |
| 367 |