| 1 | package hostrpc |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "log/slog" |
| 8 | "maps" |
| 9 | "os" |
| 10 | "sync" |
| 11 | "sync/atomic" |
| 12 | |
| 13 | "reasonix/desktop/internal/instanceidentity" |
| 14 | "reasonix/internal/extension/rpcwire" |
| 15 | "reasonix/internal/pathidentity" |
| 16 | ) |
| 17 | |
| 18 | // Hooks are the lifecycle owners behind the desktop/* requests. A nil hook |
| 19 | // answers its request with success and no effect. |
| 20 | type Hooks struct { |
| 21 | Hello func(HelloParams) (HelloResult, error) |
| 22 | Start func(ctx context.Context) error |
| 23 | DOMReady func(ctx context.Context) error |
| 24 | RendererAttached func(ctx context.Context, generation int) error |
| 25 | BeforeClose func(ctx context.Context, reason string) (prevent bool) |
| 26 | Shutdown func(ctx context.Context, params ShutdownParams) (ShutdownResult, error) |
| 27 | ShutdownStatus func(ctx context.Context, params ShutdownStatusParams) (ShutdownResult, error) |
| 28 | HostEvent func(ctx context.Context, name string, payload json.RawMessage) error |
| 29 | BrowserControl func(ctx context.Context, enabled bool) error |
| 30 | } |
| 31 | |
| 32 | // ServerConfig assembles one service process's identity around its registry. |
| 33 | type ServerConfig struct { |
| 34 | Registry *Registry |
| 35 | Contract Contract |
| 36 | Hooks Hooks |
| 37 | Identity Identity |
| 38 | Generation string |
| 39 | } |
| 40 | |
| 41 | // Event is the params object of a desktop/event notification. |
| 42 | type Event struct { |
| 43 | Seq int64 `json:"seq"` |
| 44 | Generation string `json:"generation"` |
| 45 | Name string `json:"name"` |
| 46 | Args []any `json:"args"` |
| 47 | } |
| 48 | |
| 49 | // Server answers the shell over one rpcwire connection. |
| 50 | type Server struct { |
| 51 | conn *rpcwire.Conn |
| 52 | cfg ServerConfig |
| 53 | digest string |
| 54 | |
| 55 | ready atomic.Bool |
| 56 | helloMu sync.Mutex |
| 57 | |
| 58 | emitMu sync.Mutex |
| 59 | seq int64 |
| 60 | |
| 61 | done chan struct{} |
| 62 | doneOnce sync.Once |
| 63 | } |
| 64 | |
| 65 | // NewServer registers the desktop/* handlers on conn. Call Serve afterwards; |
| 66 | // conn must not be served by anyone else. |
| 67 | func NewServer(conn *rpcwire.Conn, cfg ServerConfig) *Server { |
| 68 | s := &Server{conn: conn, cfg: cfg, digest: cfg.Contract.Digest(), done: make(chan struct{})} |
| 69 | conn.Handle("desktop/hello", s.hello) |
| 70 | conn.Handle("desktop/start", s.gated(s.start)) |
| 71 | conn.Handle("desktop/domReady", s.gated(s.domReady)) |
| 72 | conn.Handle("desktop/rendererAttached", s.gated(s.rendererAttached)) |
| 73 | conn.Handle("desktop/beforeClose", s.gated(s.beforeClose)) |
| 74 | conn.Handle("desktop/shutdown", s.gated(s.shutdown)) |
| 75 | conn.Handle("desktop/shutdownStatus", s.gated(s.shutdownStatus)) |
| 76 | conn.Handle("desktop/hostEvent", s.gated(s.hostEvent)) |
| 77 | conn.Handle("desktop/browserControl", s.gated(s.browserControl)) |
| 78 | conn.Handle("desktop/invoke", s.gated(s.invoke)) |
| 79 | return s |
| 80 | } |
| 81 | |
| 82 | // Serve pumps the connection until the shell closes its end, the context |
| 83 | // ends, or desktop/shutdown has been acknowledged. A clean end returns nil. |
| 84 | func (s *Server) Serve(ctx context.Context) error { |
| 85 | errc := make(chan error, 1) |
| 86 | go func() { errc <- s.conn.Serve(ctx) }() |
| 87 | select { |
| 88 | case err := <-errc: |
| 89 | return err |
| 90 | case <-s.done: |
| 91 | return nil |
| 92 | case <-ctx.Done(): |
| 93 | return ctx.Err() |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | // Emit writes one desktop/event notification. Sequence numbers are assigned |
| 98 | // and written under one lock, so the wire order equals the call order. |
| 99 | func (s *Server) Emit(name string, args ...any) { |
| 100 | if args == nil { |
| 101 | args = []any{} |
| 102 | } |
| 103 | s.emitMu.Lock() |
| 104 | defer s.emitMu.Unlock() |
| 105 | s.seq++ |
| 106 | err := s.conn.Notify("desktop/event", Event{Seq: s.seq, Generation: s.cfg.Generation, Name: name, Args: args}) |
| 107 | if err != nil { |
| 108 | slog.Warn("desktop host: event not delivered", "name", name, "seq", s.seq, "err", err) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | // Request issues a host/* reverse request and decodes the result into result |
| 113 | // when it is non-nil. |
| 114 | func (s *Server) Request(ctx context.Context, method string, params any, result any) error { |
| 115 | raw, err := s.conn.Request(ctx, method, params) |
| 116 | if err != nil { |
| 117 | return err |
| 118 | } |
| 119 | if result == nil || len(raw) == 0 { |
| 120 | return nil |
| 121 | } |
| 122 | return json.Unmarshal(raw, result) |
| 123 | } |
| 124 | |
| 125 | func (s *Server) gated(h rpcwire.RequestHandler) rpcwire.RequestHandler { |
| 126 | return func(ctx context.Context, params json.RawMessage) (any, error) { |
| 127 | if !s.ready.Load() { |
| 128 | return nil, notReady() |
| 129 | } |
| 130 | return h(ctx, params) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | func (s *Server) hello(_ context.Context, raw json.RawMessage) (any, error) { |
| 135 | var p HelloParams |
| 136 | if err := decodeParams(raw, &p); err != nil { |
| 137 | return nil, err |
| 138 | } |
| 139 | s.helloMu.Lock() |
| 140 | defer s.helloMu.Unlock() |
| 141 | if s.ready.Load() { |
| 142 | return nil, &rpcwire.RPCError{Code: rpcwire.ErrInvalidRequest, Message: "desktop/hello already completed"} |
| 143 | } |
| 144 | if err := validateHello(p, s.digest, s.cfg.Identity); err != nil { |
| 145 | return nil, err |
| 146 | } |
| 147 | var result HelloResult |
| 148 | if s.cfg.Hooks.Hello != nil { |
| 149 | var err error |
| 150 | if result, err = s.cfg.Hooks.Hello(p); err != nil { |
| 151 | return nil, &rpcwire.RPCError{Code: rpcwire.ErrInternal, Message: "hello: " + err.Error()} |
| 152 | } |
| 153 | } |
| 154 | result.ProtocolVersion = ProtocolVersion |
| 155 | result.ContractDigest = s.digest |
| 156 | result.Service = ServiceInfo{ |
| 157 | BuildInfo: BuildInfo{Version: s.cfg.Identity.Version, Channel: s.cfg.Identity.Channel, Commit: s.cfg.Identity.Commit}, |
| 158 | PID: os.Getpid(), |
| 159 | } |
| 160 | result.RuntimeGeneration = s.cfg.Generation |
| 161 | result.Instance = &InstanceInfo{ |
| 162 | IdentityVersion: pathidentity.Version, |
| 163 | IdentityDigest: instanceidentity.Digest(s.cfg.Identity.Home), |
| 164 | LegacyID: instanceidentity.ForHome(s.cfg.Identity.Home), |
| 165 | } |
| 166 | s.ready.Store(true) |
| 167 | return result, nil |
| 168 | } |
| 169 | |
| 170 | func (s *Server) start(ctx context.Context, _ json.RawMessage) (any, error) { |
| 171 | return empty(runHook(ctx, s.cfg.Hooks.Start)) |
| 172 | } |
| 173 | |
| 174 | func (s *Server) domReady(ctx context.Context, _ json.RawMessage) (any, error) { |
| 175 | return empty(runHook(ctx, s.cfg.Hooks.DOMReady)) |
| 176 | } |
| 177 | |
| 178 | func (s *Server) rendererAttached(ctx context.Context, raw json.RawMessage) (any, error) { |
| 179 | var p struct { |
| 180 | RendererGeneration int `json:"rendererGeneration"` |
| 181 | } |
| 182 | if err := decodeParams(raw, &p); err != nil { |
| 183 | return nil, err |
| 184 | } |
| 185 | if s.cfg.Hooks.RendererAttached == nil { |
| 186 | return empty(nil) |
| 187 | } |
| 188 | return empty(s.cfg.Hooks.RendererAttached(ctx, p.RendererGeneration)) |
| 189 | } |
| 190 | |
| 191 | func (s *Server) beforeClose(ctx context.Context, raw json.RawMessage) (any, error) { |
| 192 | var p struct { |
| 193 | Reason string `json:"reason"` |
| 194 | } |
| 195 | if err := decodeParams(raw, &p); err != nil { |
| 196 | return nil, err |
| 197 | } |
| 198 | prevent := false |
| 199 | if s.cfg.Hooks.BeforeClose != nil { |
| 200 | prevent = s.cfg.Hooks.BeforeClose(ctx, p.Reason) |
| 201 | } |
| 202 | return map[string]bool{"prevent": prevent}, nil |
| 203 | } |
| 204 | |
| 205 | func (s *Server) shutdown(ctx context.Context, raw json.RawMessage) (any, error) { |
| 206 | var params ShutdownParams |
| 207 | if err := decodeParams(raw, ¶ms); err != nil { |
| 208 | return nil, err |
| 209 | } |
| 210 | if s.cfg.Hooks.Shutdown == nil { |
| 211 | return ShutdownResult{RequestID: params.RequestID, Reason: params.Reason, Phase: "completed", Outcome: "success", Completed: true}, nil |
| 212 | } |
| 213 | result, err := s.cfg.Hooks.Shutdown(ctx, params) |
| 214 | if err != nil { |
| 215 | // Shutdown failures are returned as typed results so the shell can retain |
| 216 | // the window and offer a retry without parsing an RPC error string. |
| 217 | return result, nil |
| 218 | } |
| 219 | if !result.Completed { |
| 220 | return result, nil |
| 221 | } |
| 222 | return rpcwire.RespondThen(result, func(error) { |
| 223 | s.doneOnce.Do(func() { close(s.done) }) |
| 224 | }), nil |
| 225 | } |
| 226 | |
| 227 | func (s *Server) shutdownStatus(ctx context.Context, raw json.RawMessage) (any, error) { |
| 228 | var params ShutdownStatusParams |
| 229 | if err := decodeParams(raw, ¶ms); err != nil { |
| 230 | return nil, err |
| 231 | } |
| 232 | if s.cfg.Hooks.ShutdownStatus == nil { |
| 233 | return ShutdownResult{RequestID: params.RequestID, Phase: "idle", Outcome: "not_started", Retryable: true}, nil |
| 234 | } |
| 235 | result, err := s.cfg.Hooks.ShutdownStatus(ctx, params) |
| 236 | if err != nil { |
| 237 | return nil, internalError(err) |
| 238 | } |
| 239 | return result, nil |
| 240 | } |
| 241 | |
| 242 | func (s *Server) hostEvent(ctx context.Context, raw json.RawMessage) (any, error) { |
| 243 | var p struct { |
| 244 | Name string `json:"name"` |
| 245 | Payload json.RawMessage `json:"payload"` |
| 246 | } |
| 247 | if err := decodeParams(raw, &p); err != nil { |
| 248 | return nil, err |
| 249 | } |
| 250 | if s.cfg.Hooks.HostEvent == nil { |
| 251 | return empty(nil) |
| 252 | } |
| 253 | return empty(s.cfg.Hooks.HostEvent(ctx, p.Name, p.Payload)) |
| 254 | } |
| 255 | |
| 256 | // browserControl carries the shell's capability switch for the built-in |
| 257 | // browser; the shell owns the persisted value and pushes it on every change. |
| 258 | func (s *Server) browserControl(ctx context.Context, raw json.RawMessage) (any, error) { |
| 259 | var p struct { |
| 260 | Enabled *bool `json:"enabled"` |
| 261 | } |
| 262 | if err := decodeParams(raw, &p); err != nil { |
| 263 | return nil, err |
| 264 | } |
| 265 | if p.Enabled == nil { |
| 266 | return nil, &rpcwire.RPCError{Code: rpcwire.ErrInvalidParams, Message: "enabled is required"} |
| 267 | } |
| 268 | if s.cfg.Hooks.BrowserControl == nil { |
| 269 | return empty(nil) |
| 270 | } |
| 271 | return empty(s.cfg.Hooks.BrowserControl(ctx, *p.Enabled)) |
| 272 | } |
| 273 | |
| 274 | func (s *Server) invoke(ctx context.Context, raw json.RawMessage) (any, error) { |
| 275 | var p struct { |
| 276 | Method string `json:"method"` |
| 277 | Args []json.RawMessage `json:"args"` |
| 278 | } |
| 279 | if err := decodeParams(raw, &p); err != nil { |
| 280 | return nil, err |
| 281 | } |
| 282 | result, err := s.cfg.Registry.Invoke(ctx, p.Method, p.Args) |
| 283 | if err == nil { |
| 284 | return result, nil |
| 285 | } |
| 286 | data := map[string]any{"method": p.Method} |
| 287 | var detailed interface{ RPCErrorData() map[string]any } |
| 288 | if errors.As(err, &detailed) { |
| 289 | maps.Copy(data, detailed.RPCErrorData()) |
| 290 | } |
| 291 | var unknown *UnknownMethodError |
| 292 | var invalid *InvalidArgsError |
| 293 | var panicked *PanicError |
| 294 | switch { |
| 295 | case errors.As(err, &unknown): |
| 296 | return nil, &rpcwire.RPCError{Code: rpcwire.ErrMethodNotFound, Message: err.Error(), Data: data} |
| 297 | case errors.As(err, &invalid): |
| 298 | return nil, &rpcwire.RPCError{Code: rpcwire.ErrInvalidParams, Message: err.Error(), Data: data} |
| 299 | case errors.As(err, &panicked): |
| 300 | slog.Error("desktop host: bound method panicked", "method", p.Method, "panic", panicked.Value, "stack", string(panicked.Stack)) |
| 301 | return nil, &rpcwire.RPCError{Code: rpcwire.ErrInternal, Message: err.Error(), Data: data} |
| 302 | } |
| 303 | return nil, &rpcwire.RPCError{Code: CodeBusiness, Message: err.Error(), Data: data} |
| 304 | } |
| 305 | |
| 306 | func decodeParams(raw json.RawMessage, into any) error { |
| 307 | if len(raw) == 0 { |
| 308 | return nil |
| 309 | } |
| 310 | if err := json.Unmarshal(raw, into); err != nil { |
| 311 | return &rpcwire.RPCError{Code: rpcwire.ErrInvalidParams, Message: "invalid params: " + err.Error()} |
| 312 | } |
| 313 | return nil |
| 314 | } |
| 315 | |
| 316 | func runHook(ctx context.Context, hook func(context.Context) error) error { |
| 317 | if hook == nil { |
| 318 | return nil |
| 319 | } |
| 320 | return hook(ctx) |
| 321 | } |
| 322 | |
| 323 | func empty(err error) (any, error) { |
| 324 | if err != nil { |
| 325 | return nil, internalError(err) |
| 326 | } |
| 327 | return struct{}{}, nil |
| 328 | } |
| 329 | |
| 330 | func internalError(err error) error { |
| 331 | var rpcErr *rpcwire.RPCError |
| 332 | if errors.As(err, &rpcErr) { |
| 333 | return rpcErr |
| 334 | } |
| 335 | return &rpcwire.RPCError{Code: rpcwire.ErrInternal, Message: err.Error()} |
| 336 | } |
| 337 |