package shell import ( "encoding/json" "mime" "net/http" "strings" "unicode/utf8" ) // bridgeScriptPath serves bridgeScript, which withDesktopScripts adds to // the client's index.html next to the link script. const bridgeScriptPath = "/_desktop/bridge.js" const ( // notifyPath shows a system notification: the client's notifications // store calls window.warpDesktop.notify for new messages and calls. notifyPath = "/_desktop/notify" // titlePath takes the page's title, which counts unread rooms, for the // window title and the tray icon's tooltip. titlePath = "/_desktop/title" ) // bridgeScript is what the client finds of the desktop app: window.warpDesktop // (see apps/web/src/stores/notifications.ts), and the page title sent along // whenever it changes. A click on a notification comes back as a // "warp-desktop:open-room" event, dispatched by the app. const bridgeScript = `(() => { if (window.warpDesktop) return const post = (path, body) => fetch(path, { method: 'POST', credentials: 'omit', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }).catch(() => {}) window.warpDesktop = { notify: (n) => post('/_desktop/notify', { id: n.id, title: n.title, body: n.body, roomId: n.roomId }), } let last = null const sendTitle = () => { if (document.title === last) return last = document.title post('/_desktop/title', { title: last }) } new MutationObserver(sendTitle).observe(document.head, { subtree: true, childList: true, characterData: true }) document.addEventListener('DOMContentLoaded', sendTitle) })() ` // Notification is a system notification the client asks for. type Notification struct { // ID replaces an earlier notification with the same ID: one per room. ID string `json:"id"` Title string `json:"title"` Body string `json:"body"` RoomID string `json:"roomId"` } // Longest title and body shown; the system cuts them shorter anyway. const ( maxTitle = 200 maxBody = 1000 ) // readJSON decodes a small JSON body. It insists on the JSON content type, // which a page of another origin can't send without a CORS preflight that // the shell never answers. func readJSON(w http.ResponseWriter, r *http.Request, v any) bool { if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return false } if ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); ct != "application/json" { http.Error(w, "expected application/json", http.StatusUnsupportedMediaType) return false } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(v); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return false } return true } func (s *Shell) serveNotify(w http.ResponseWriter, r *http.Request) { var n Notification if !readJSON(w, r, &n) { return } n.Title = clip(n.Title, maxTitle) n.Body = clip(n.Body, maxBody) if n.ID == "" || n.Title == "" { http.Error(w, "id and title are required", http.StatusBadRequest) return } if s.opts.Notify == nil { w.WriteHeader(http.StatusNoContent) return } if err := s.opts.Notify(n); err != nil { s.opts.Logger.Warn("can't show a notification", "err", err) http.Error(w, "can't show the notification", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } func (s *Shell) serveTitle(w http.ResponseWriter, r *http.Request) { var body struct { Title string `json:"title"` } if !readJSON(w, r, &body) { return } if s.opts.SetTitle != nil { s.opts.SetTitle(clip(body.Title, maxTitle)) } w.WriteHeader(http.StatusNoContent) } // clip trims s and cuts it to at most n runes, without control characters. func clip(s string, n int) string { s = strings.Map(func(r rune) rune { if r == '\n' || r == '\t' { return ' ' } if r < 0x20 || r == 0x7f { return -1 } return r }, strings.TrimSpace(s)) if utf8.RuneCountInString(s) <= n { return s } return string([]rune(s)[:n-1]) + "…" }