| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "log/slog" |
| 6 | "time" |
| 7 | |
| 8 | "reasonix/desktop/internal/hostrpc" |
| 9 | ) |
| 10 | |
| 11 | // rpcNativeHost implements nativeHost over host/* requests to the Electron |
| 12 | // shell. Fire-and-forget window operations use a short timeout so a wedged |
| 13 | // shell can never block a business goroutine on the Go side. |
| 14 | type rpcNativeHost struct { |
| 15 | server *hostrpc.Server |
| 16 | } |
| 17 | |
| 18 | var _ nativeHost = rpcNativeHost{} |
| 19 | |
| 20 | const ( |
| 21 | rpcHostWindowTimeout = 5 * time.Second |
| 22 | rpcHostDialogTimeout = 24 * time.Hour |
| 23 | ) |
| 24 | |
| 25 | type hostValueResult struct { |
| 26 | Value bool `json:"value"` |
| 27 | } |
| 28 | |
| 29 | type hostPathResult struct { |
| 30 | Path string `json:"path"` |
| 31 | } |
| 32 | |
| 33 | type hostPathsResult struct { |
| 34 | Paths []string `json:"paths"` |
| 35 | } |
| 36 | |
| 37 | type hostButtonResult struct { |
| 38 | Button string `json:"button"` |
| 39 | } |
| 40 | |
| 41 | type hostScreenList struct { |
| 42 | Screens []struct { |
| 43 | X int `json:"x"` |
| 44 | Y int `json:"y"` |
| 45 | Width int `json:"width"` |
| 46 | Height int `json:"height"` |
| 47 | Scale float64 `json:"scale"` |
| 48 | Primary bool `json:"primary"` |
| 49 | } `json:"screens"` |
| 50 | } |
| 51 | |
| 52 | type hostFileFilter struct { |
| 53 | DisplayName string `json:"displayName"` |
| 54 | Pattern string `json:"pattern"` |
| 55 | } |
| 56 | |
| 57 | type hostDialogParams struct { |
| 58 | Title string `json:"title,omitempty"` |
| 59 | DefaultDirectory string `json:"defaultDirectory,omitempty"` |
| 60 | DefaultFilename string `json:"defaultFilename,omitempty"` |
| 61 | Filters []hostFileFilter `json:"filters"` |
| 62 | Multiple bool `json:"multiple,omitempty"` |
| 63 | } |
| 64 | |
| 65 | func hostDialogParamsFrom(opts nativeDialogOptions) hostDialogParams { |
| 66 | p := hostDialogParams{ |
| 67 | Title: opts.Title, |
| 68 | DefaultDirectory: opts.DefaultDirectory, |
| 69 | DefaultFilename: opts.DefaultFilename, |
| 70 | Filters: []hostFileFilter{}, |
| 71 | } |
| 72 | for _, f := range opts.Filters { |
| 73 | p.Filters = append(p.Filters, hostFileFilter(f)) |
| 74 | } |
| 75 | return p |
| 76 | } |
| 77 | |
| 78 | func (h rpcNativeHost) call(ctx context.Context, method string, params any, result any, timeout time.Duration) error { |
| 79 | if ctx == nil { |
| 80 | ctx = context.Background() |
| 81 | } |
| 82 | ctx, cancel := context.WithTimeout(ctx, timeout) |
| 83 | defer cancel() |
| 84 | err := h.server.Request(ctx, method, params, result) |
| 85 | if err != nil { |
| 86 | slog.Warn("desktop host: native call failed", "method", method, "err", err) |
| 87 | } |
| 88 | return err |
| 89 | } |
| 90 | |
| 91 | func (h rpcNativeHost) fire(ctx context.Context, method string, params any) { |
| 92 | _ = h.call(ctx, method, params, nil, rpcHostWindowTimeout) |
| 93 | } |
| 94 | |
| 95 | func (h rpcNativeHost) ShowWindow(ctx context.Context) { |
| 96 | h.fire(ctx, "host/window.show", map[string]string{"reason": "service"}) |
| 97 | } |
| 98 | func (h rpcNativeHost) ShowApplication(ctx context.Context) { |
| 99 | h.fire(ctx, "host/window.show", map[string]string{"reason": "application"}) |
| 100 | } |
| 101 | func (h rpcNativeHost) HideWindow(ctx context.Context) { h.fire(ctx, "host/window.hide", struct{}{}) } |
| 102 | func (h rpcNativeHost) HideApplication(ctx context.Context) { h.fire(ctx, "host/app.hide", struct{}{}) } |
| 103 | func (h rpcNativeHost) MaximiseWindow(ctx context.Context) { |
| 104 | h.fire(ctx, "host/window.maximise", struct{}{}) |
| 105 | } |
| 106 | func (h rpcNativeHost) UnmaximiseWindow(ctx context.Context) { |
| 107 | h.fire(ctx, "host/window.unmaximise", struct{}{}) |
| 108 | } |
| 109 | func (h rpcNativeHost) MinimiseWindow(ctx context.Context) { |
| 110 | h.fire(ctx, "host/window.minimise", struct{}{}) |
| 111 | } |
| 112 | func (h rpcNativeHost) UnminimiseWindow(ctx context.Context) { |
| 113 | h.fire(ctx, "host/window.unminimise", struct{}{}) |
| 114 | } |
| 115 | func (h rpcNativeHost) ToggleMaximiseWindow(ctx context.Context) { |
| 116 | h.fire(ctx, "host/window.toggleMaximise", struct{}{}) |
| 117 | } |
| 118 | func (h rpcNativeHost) CenterWindow(ctx context.Context) { |
| 119 | h.fire(ctx, "host/window.center", struct{}{}) |
| 120 | } |
| 121 | |
| 122 | func (h rpcNativeHost) WindowIsMaximised(ctx context.Context) bool { |
| 123 | var out hostValueResult |
| 124 | _ = h.call(ctx, "host/window.isMaximised", struct{}{}, &out, rpcHostWindowTimeout) |
| 125 | return out.Value |
| 126 | } |
| 127 | |
| 128 | func (h rpcNativeHost) WindowIsMinimised(ctx context.Context) bool { |
| 129 | var out hostValueResult |
| 130 | _ = h.call(ctx, "host/window.isMinimised", struct{}{}, &out, rpcHostWindowTimeout) |
| 131 | return out.Value |
| 132 | } |
| 133 | |
| 134 | func (h rpcNativeHost) SetWindowPosition(ctx context.Context, x, y int) { |
| 135 | h.fire(ctx, "host/window.setPosition", map[string]int{"x": x, "y": y}) |
| 136 | } |
| 137 | |
| 138 | func (h rpcNativeHost) SetWindowTitle(ctx context.Context, title string) { |
| 139 | h.fire(ctx, "host/window.setTitle", map[string]string{"title": title}) |
| 140 | } |
| 141 | |
| 142 | func (h rpcNativeHost) Screens(ctx context.Context) ([]nativeScreen, error) { |
| 143 | var out hostScreenList |
| 144 | if err := h.call(ctx, "host/screen.list", struct{}{}, &out, rpcHostWindowTimeout); err != nil { |
| 145 | return nil, err |
| 146 | } |
| 147 | screens := make([]nativeScreen, 0, len(out.Screens)) |
| 148 | for _, s := range out.Screens { |
| 149 | screens = append(screens, nativeScreen{Width: s.Width, Height: s.Height, Primary: s.Primary, Scale: s.Scale}) |
| 150 | } |
| 151 | return screens, nil |
| 152 | } |
| 153 | |
| 154 | func (h rpcNativeHost) OpenDirectoryDialog(ctx context.Context, opts nativeDialogOptions) (string, error) { |
| 155 | var out hostPathResult |
| 156 | err := h.call(ctx, "host/dialog.openDirectory", hostDialogParamsFrom(opts), &out, rpcHostDialogTimeout) |
| 157 | return out.Path, err |
| 158 | } |
| 159 | |
| 160 | func (h rpcNativeHost) OpenFileDialog(ctx context.Context, opts nativeDialogOptions) (string, error) { |
| 161 | var out hostPathsResult |
| 162 | err := h.call(ctx, "host/dialog.openFile", hostDialogParamsFrom(opts), &out, rpcHostDialogTimeout) |
| 163 | if err != nil || len(out.Paths) == 0 { |
| 164 | return "", err |
| 165 | } |
| 166 | return out.Paths[0], nil |
| 167 | } |
| 168 | |
| 169 | func (h rpcNativeHost) SaveFileDialog(ctx context.Context, opts nativeDialogOptions) (string, error) { |
| 170 | var out hostPathResult |
| 171 | err := h.call(ctx, "host/dialog.saveFile", hostDialogParamsFrom(opts), &out, rpcHostDialogTimeout) |
| 172 | return out.Path, err |
| 173 | } |
| 174 | |
| 175 | func (h rpcNativeHost) MessageDialog(ctx context.Context, opts nativeMessageOptions) (string, error) { |
| 176 | buttons := opts.Buttons |
| 177 | if buttons == nil { |
| 178 | buttons = []string{} |
| 179 | } |
| 180 | var out hostButtonResult |
| 181 | err := h.call(ctx, "host/dialog.message", map[string]any{ |
| 182 | "type": string(opts.Type), |
| 183 | "title": opts.Title, |
| 184 | "message": opts.Message, |
| 185 | "buttons": buttons, |
| 186 | "defaultButton": opts.DefaultButton, |
| 187 | "cancelButton": opts.CancelButton, |
| 188 | }, &out, rpcHostDialogTimeout) |
| 189 | return out.Button, err |
| 190 | } |
| 191 | |
| 192 | func (h rpcNativeHost) OpenExternal(ctx context.Context, url string) { |
| 193 | h.fire(ctx, "host/shell.openExternal", map[string]string{"url": url}) |
| 194 | } |
| 195 | |
| 196 | func (h rpcNativeHost) Quit(ctx context.Context) { h.fire(ctx, "host/app.quit", struct{}{}) } |
| 197 | |
| 198 | func (h rpcNativeHost) OpenDevTools(ctx context.Context) { |
| 199 | h.fire(ctx, "host/devtools.toggle", struct{}{}) |
| 200 | } |
| 201 |