| 1 | package hostrpc |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "maps" |
| 7 | |
| 8 | "reasonix/internal/extension/rpcwire" |
| 9 | "reasonix/internal/pathidentity" |
| 10 | ) |
| 11 | |
| 12 | // Error codes from docs/DESKTOP_HOST_PROTOCOL.md. Business errors raised by |
| 13 | // an invoked method use CodeBusiness with data.method naming the command. |
| 14 | const ( |
| 15 | CodeBusiness = -32000 |
| 16 | CodeProtocolMismatch = -32001 |
| 17 | CodeNotReady = -32002 |
| 18 | CodeContractMismatch = -32003 |
| 19 | CodeBuildMismatch = -32004 |
| 20 | CodeInstanceMismatch = -32005 |
| 21 | ) |
| 22 | |
| 23 | // Identity is what the service reports about itself in hello and checks |
| 24 | // the shell against. Home is the canonical Reasonix data home. |
| 25 | type Identity struct { |
| 26 | Version string |
| 27 | Channel string |
| 28 | Commit string |
| 29 | Home string |
| 30 | } |
| 31 | |
| 32 | // HelloParams is the shell's desktop/hello request. |
| 33 | type HelloParams struct { |
| 34 | ProtocolVersion int `json:"protocolVersion"` |
| 35 | ContractDigest string `json:"contractDigest"` |
| 36 | Build BuildInfo `json:"build"` |
| 37 | Host HostInfo `json:"host"` |
| 38 | Instance HelloInstance `json:"instance"` |
| 39 | } |
| 40 | |
| 41 | // BuildInfo identifies one side's release build. |
| 42 | type BuildInfo struct { |
| 43 | Version string `json:"version"` |
| 44 | Channel string `json:"channel"` |
| 45 | Commit string `json:"commit"` |
| 46 | } |
| 47 | |
| 48 | // HostInfo describes the shell runtime. |
| 49 | type HostInfo struct { |
| 50 | Name string `json:"name"` |
| 51 | Version string `json:"version"` |
| 52 | Chrome string `json:"chrome"` |
| 53 | Platform string `json:"platform"` |
| 54 | Arch string `json:"arch"` |
| 55 | } |
| 56 | |
| 57 | // HelloInstance is the data home the shell was launched for; Dev relaxes |
| 58 | // the build check for unpackaged shells. |
| 59 | type HelloInstance struct { |
| 60 | Home string `json:"home"` |
| 61 | Dev bool `json:"dev"` |
| 62 | } |
| 63 | |
| 64 | // HelloResult is the service's desktop/hello response. |
| 65 | type HelloResult struct { |
| 66 | ProtocolVersion int `json:"protocolVersion"` |
| 67 | ContractDigest string `json:"contractDigest"` |
| 68 | Service ServiceInfo `json:"service"` |
| 69 | RuntimeGeneration string `json:"runtimeGeneration"` |
| 70 | Resources Resources `json:"resources"` |
| 71 | Window *WindowGeometry `json:"window,omitempty"` |
| 72 | Instance *InstanceInfo `json:"instance,omitempty"` |
| 73 | // The shell validates both identifiers as present strings and accepts |
| 74 | // empty values when diagnostics are disabled; omitting the keys is a |
| 75 | // handshake failure, so they must never carry omitempty. |
| 76 | RunID string `json:"runId"` |
| 77 | IncidentID string `json:"incidentId"` |
| 78 | DiagnosticsEnabled bool `json:"diagnosticsEnabled"` |
| 79 | } |
| 80 | |
| 81 | // InstanceInfo extends the handshake without invalidating older shells. |
| 82 | type InstanceInfo struct { |
| 83 | IdentityVersion int `json:"identityVersion"` |
| 84 | IdentityDigest string `json:"identityDigest"` |
| 85 | LegacyID string `json:"legacyId"` |
| 86 | } |
| 87 | |
| 88 | type ShutdownParams struct { |
| 89 | RequestID string `json:"requestId"` |
| 90 | Reason string `json:"reason"` |
| 91 | } |
| 92 | |
| 93 | type ShutdownStatusParams struct { |
| 94 | RequestID string `json:"requestId"` |
| 95 | } |
| 96 | |
| 97 | type ShutdownResult struct { |
| 98 | RequestID string `json:"requestId"` |
| 99 | Reason string `json:"reason"` |
| 100 | Phase string `json:"phase"` |
| 101 | Outcome string `json:"outcome"` |
| 102 | Completed bool `json:"completed"` |
| 103 | Retryable bool `json:"retryable"` |
| 104 | ErrorCode string `json:"errorCode,omitempty"` |
| 105 | Error string `json:"error,omitempty"` |
| 106 | UpdatedAt string `json:"updatedAt"` |
| 107 | } |
| 108 | |
| 109 | // ServiceInfo is the service build plus its process id. |
| 110 | type ServiceInfo struct { |
| 111 | BuildInfo |
| 112 | PID int `json:"pid"` |
| 113 | } |
| 114 | |
| 115 | // Resources locates the loopback origin serving authorised assets and the |
| 116 | // bearer token the shell's main process attaches to every request. |
| 117 | type Resources struct { |
| 118 | Origin string `json:"origin"` |
| 119 | Token string `json:"token"` |
| 120 | } |
| 121 | |
| 122 | // WindowGeometry is the initial main-window geometry the shell creates |
| 123 | // the hidden window with. |
| 124 | type WindowGeometry struct { |
| 125 | Position *WindowPosition `json:"position,omitempty"` |
| 126 | Width int `json:"width"` |
| 127 | Height int `json:"height"` |
| 128 | MinWidth int `json:"minWidth"` |
| 129 | MinHeight int `json:"minHeight"` |
| 130 | Frameless bool `json:"frameless"` |
| 131 | ZoomFactor float64 `json:"zoomFactor"` |
| 132 | } |
| 133 | |
| 134 | // WindowPosition is optional: an absent saved position asks the shell to center. |
| 135 | type WindowPosition struct { |
| 136 | X int `json:"x"` |
| 137 | Y int `json:"y"` |
| 138 | } |
| 139 | |
| 140 | func mismatch(code int, name, message string, detail map[string]any) error { |
| 141 | data := map[string]any{"name": name} |
| 142 | maps.Copy(data, detail) |
| 143 | return &rpcwire.RPCError{Code: code, Message: message, Data: data} |
| 144 | } |
| 145 | |
| 146 | func notReady() error { |
| 147 | return mismatch(CodeNotReady, "not_ready", "desktop/hello has not completed", nil) |
| 148 | } |
| 149 | |
| 150 | // validateHello runs the handshake checks in the order the protocol lists |
| 151 | // them; the first failure is the terminal error the shell displays. |
| 152 | func validateHello(p HelloParams, digest string, id Identity) error { |
| 153 | if p.ProtocolVersion != ProtocolVersion { |
| 154 | return mismatch(CodeProtocolMismatch, "protocol_mismatch", |
| 155 | fmt.Sprintf("shell speaks protocol %d, service speaks %d", p.ProtocolVersion, ProtocolVersion), |
| 156 | map[string]any{"expected": ProtocolVersion, "got": p.ProtocolVersion}) |
| 157 | } |
| 158 | if p.ContractDigest != digest { |
| 159 | return mismatch(CodeContractMismatch, "contract_mismatch", |
| 160 | "shell and service were built from different desktop contracts", |
| 161 | map[string]any{"expected": digest, "got": p.ContractDigest}) |
| 162 | } |
| 163 | dev := p.Instance.Dev || p.Build.Version == "dev" || id.Version == "dev" |
| 164 | if !dev && p.Build.Version != id.Version { |
| 165 | return mismatch(CodeBuildMismatch, "build_mismatch", |
| 166 | fmt.Sprintf("shell build %s does not match service build %s", p.Build.Version, id.Version), |
| 167 | map[string]any{"expected": id.Version, "got": p.Build.Version}) |
| 168 | } |
| 169 | same, identityErr := pathidentity.Same(p.Instance.Home, id.Home, pathidentity.Options{FollowLeaf: true}) |
| 170 | if identityErr != nil || !same { |
| 171 | return mismatch(CodeInstanceMismatch, "instance_mismatch", |
| 172 | "shell data home does not match the service data home", |
| 173 | map[string]any{"reason": identityReason(identityErr)}) |
| 174 | } |
| 175 | return nil |
| 176 | } |
| 177 | |
| 178 | func identityReason(err error) string { |
| 179 | if err == nil { |
| 180 | return "different_directory" |
| 181 | } |
| 182 | var identityErr *pathidentity.Error |
| 183 | if errors.As(err, &identityErr) { |
| 184 | return string(identityErr.Kind) |
| 185 | } |
| 186 | return "unknown" |
| 187 | } |
| 188 |