// Package shell is what the desktop app's webview loads: the web client, // served through the same host package as warp-web, plus the few pages and // endpoints only the desktop app has. // // Unlike warp-web, the desktop app isn't installed next to one homeserver, // so nothing is proxied: the client calls the homeserver directly, as any // Matrix client does (homeservers answer CORS for every origin). Which // homeserver new sign-ins go to is asked on first run and kept in the // settings file. A session already signed in keeps the base URL it signed // in with, so changing the homeserver never sends one server's access // token to another. package shell import ( "context" "encoding/json" "errors" "html/template" "io/fs" "log/slog" "net/http" "net/url" "strings" "sync/atomic" "time" "github.com/dariuszjaniak/warp/apps/web/host" ) const ( // SetupPath is the first-run page, also where "Change homeserver…" // goes. SetupPath = "/_desktop/setup" // openPath takes a URL to open in the system browser: the page's links // to other sites go there instead of navigating the app's window. openPath = "/_desktop/open" ) // Options configures the shell. type Options struct { // Store keeps the chosen homeserver. Store *Store // Homeserver, when set, is used for this run instead of the saved // one, and isn't saved. Homeserver string // Client is the built web client; nil serves host's "not built" page. Client fs.FS // Version is reported in /config.json. Version string // Logger defaults to slog.Default(). Logger *slog.Logger // OpenURL opens a URL in the system browser. OpenURL func(string) error // HTTPClient is used to find and check a homeserver on the setup // page. Defaults to a client with a 15 second timeout. HTTPClient *http.Client // Notify shows a system notification; nil drops them. Notify func(Notification) error // SetTitle gets the page's title whenever it changes. SetTitle func(string) } // Shell is the webview's http.Handler. type Shell struct { opts Options app atomic.Pointer[app] } // app is the web client configured for one homeserver. type app struct { homeserver string handler http.Handler } // New returns the shell, serving the client for the saved homeserver, or // the setup page when there is none yet. func New(opts Options) (*Shell, error) { if opts.Store == nil { return nil, errors.New("shell: Store is required") } if opts.Logger == nil { opts.Logger = slog.Default() } if opts.HTTPClient == nil { opts.HTTPClient = &http.Client{Timeout: 15 * time.Second} } if opts.Client != nil { client, ok, err := withDesktopScripts(opts.Client) if err != nil { return nil, err } if !ok { opts.Logger.Warn("the client's index.html has no ; links will open in the app's window, and there are no notifications") } opts.Client = client } s := &Shell{opts: opts} if opts.Homeserver != "" { if err := s.use(opts.Homeserver); err != nil { return nil, err } return s, nil } // A damaged settings file only costs the choice of server: the setup // page asks again and overwrites it. st, err := opts.Store.Load() if err != nil { opts.Logger.Warn("can't read the settings; asking for the homeserver again", "err", err) } if st.Homeserver != "" { if err := s.use(st.Homeserver); err != nil { opts.Logger.Warn("the saved homeserver isn't valid; asking again", "err", err) } } return s, nil } // Homeserver returns the base URL new sign-ins go to, or "" before setup. func (s *Shell) Homeserver() string { if a := s.app.Load(); a != nil { return a.homeserver } return "" } func (s *Shell) use(homeserver string) error { u, err := parseBase(homeserver) if err != nil { return errors.New("shell: the homeserver must be an absolute http(s) URL, got " + homeserver) } h, err := host.New(host.Options{ PublicHomeserver: u, Client: s.opts.Client, Version: s.opts.Version, Logger: s.opts.Logger, AnyCallOrigin: true, }) if err != nil { return err } s.app.Store(&app{homeserver: u.String(), handler: h}) return nil } func (s *Shell) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case SetupPath: s.serveSetup(w, r) return case openPath: s.serveOpen(w, r) return case notifyPath: s.serveNotify(w, r) return case titlePath: s.serveTitle(w, r) return case linkScriptPath: writeScript(w, linkScript) return case bridgeScriptPath: writeScript(w, bridgeScript) return } a := s.app.Load() if a == nil { // Before setup every page is the setup page; there is no // homeserver to configure the client with yet. if r.Method == http.MethodGet || r.Method == http.MethodHead { s.renderSetup(w, setupPage{}) return } http.Error(w, "the homeserver isn't set up yet", http.StatusServiceUnavailable) return } a.handler.ServeHTTP(w, r) } func writeScript(w http.ResponseWriter, script string) { h := w.Header() h.Set("Content-Type", "text/javascript; charset=utf-8") h.Set("Cache-Control", "no-cache") h.Set("X-Content-Type-Options", "nosniff") _, _ = w.Write([]byte(script)) } func (s *Shell) serveOpen(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var body struct { URL string `json:"url"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&body); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } u, err := url.Parse(body.URL) if err != nil || !externalScheme(u) { http.Error(w, "only http, https and mailto links are opened", http.StatusBadRequest) return } if s.opts.OpenURL == nil { http.Error(w, "can't open links", http.StatusNotImplemented) return } if err := s.opts.OpenURL(u.String()); err != nil { s.opts.Logger.Warn("can't open a link in the browser", "err", err) http.Error(w, "can't open the link", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } func externalScheme(u *url.URL) bool { switch u.Scheme { case "http", "https": return u.Host != "" case "mailto": return u.Opaque != "" } return false } func (s *Shell) serveSetup(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet, http.MethodHead: cur := s.Homeserver() s.renderSetup(w, setupPage{Server: cur, Current: cur}) case http.MethodPost: r.Body = http.MaxBytesReader(w, r.Body, 8<<10) if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } input := r.PostForm.Get("server") ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() base, err := Discover(ctx, s.opts.HTTPClient, input) if err == nil { err = s.use(base) } if err != nil { s.renderSetup(w, setupPage{Server: input, Current: s.Homeserver(), Error: err.Error()}) return } // The rest of the settings stay; a damaged file is replaced. st, _ := s.opts.Store.Load() st.Homeserver = base if err := s.opts.Store.Save(st); err != nil { // The choice still holds for this run. s.opts.Logger.Warn("can't save the settings", "err", err) } s.opts.Logger.Info("homeserver set", "homeserver", base) // A meta refresh rather than a redirect: not every webview // follows a redirect from the app's own scheme handler. writePage(w, doneTemplate, nil) default: w.Header().Set("Allow", "GET, HEAD, POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } type setupPage struct { Server string // the input's value Current string // the homeserver in use, if any Error string } func (s *Shell) renderSetup(w http.ResponseWriter, p setupPage) { writePage(w, setupTemplate, p) } func writePage(w http.ResponseWriter, t *template.Template, data any) { h := w.Header() h.Set("Content-Type", "text/html; charset=utf-8") h.Set("Cache-Control", "no-store") h.Set("X-Content-Type-Options", "nosniff") h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'") var b strings.Builder if err := t.Execute(&b, data); err != nil { http.Error(w, "template: "+err.Error(), http.StatusInternalServerError) return } _, _ = w.Write([]byte(b.String())) } // The pages use the design tokens from @warp/ui (apps/ui/src/styles/tokens.css). const pageStyle = ` :root{--surface:#fff;--sunken:#f3f5f4;--line:#e1e6e4;--line-strong:#7b8682;--ink:#111114;--muted:#5b6461; --teal:#00806a;--on-teal:#fff;--danger:#b42318;--danger-soft:#fde8e6;--ring:0 0 0 2px #fff,0 0 0 4px #00806a;color-scheme:light} @media (prefers-color-scheme:dark){:root{--surface:#0f1213;--sunken:#161a1c;--line:#2a3134;--line-strong:#6e7976; --ink:#eceeee;--muted:#9aa5a1;--teal:#22c3a6;--on-teal:#062a23;--danger:#f97066;--danger-soft:#3a1512; --ring:0 0 0 2px #0f1213,0 0 0 4px #3ed6b8;color-scheme:dark}} *{box-sizing:border-box} body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--sunken);color:var(--ink); font:14px/20px Figtree,system-ui,-apple-system,"Segoe UI",sans-serif;padding:16px} main{width:100%;max-width:420px;background:var(--surface);border:1px solid var(--line);border-radius:18px;padding:32px} h1{font:600 22px/28px Outfit,Figtree,system-ui,sans-serif;margin:0 0 8px} p{margin:0 0 16px;color:var(--muted)} label{display:block;font-weight:600;margin:0 0 6px} input{width:100%;height:40px;padding:0 12px;border:1px solid var(--line-strong);border-radius:8px; background:var(--surface);color:var(--ink);font:inherit} input:focus{outline:none;box-shadow:var(--ring)} .hint{font-size:13px;margin:6px 0 0} .error{background:var(--danger-soft);color:var(--danger);border-radius:8px;padding:8px 12px;margin:0 0 16px} .actions{display:flex;gap:8px;justify-content:flex-end;margin-top:24px} button,a.button{height:40px;padding:0 16px;border-radius:9999px;border:1px solid transparent;font-family:inherit;font-size:14px;font-weight:600;line-height:38px; cursor:pointer;text-decoration:none;display:inline-block} button{background:var(--teal);color:var(--on-teal)} a.button{color:var(--ink);border-color:var(--line)} button:focus-visible,a.button:focus-visible{outline:none;box-shadow:var(--ring)} code{font:13px/20px "JetBrains Mono",ui-monospace,monospace} ` var setupTemplate = template.Must(template.New("setup").Parse(` Choose a homeserver · Warp

{{if .Current}}Change homeserver{{else}}Welcome to Warp{{end}}

{{if .Current}}New sign-ins will go to this server. If you’re signed in, you stay signed in to {{.Current}} until you sign out.{{else}}Which Matrix homeserver is your account on?{{end}}

{{if .Error}}{{end}}

A server name like example.org, or an address like https://matrix.example.org.

{{if .Current}}Cancel{{end}}
`)) var doneTemplate = template.Must(template.New("done").Parse(` Warp `))