Files
Warp/apps/desktop/internal/shell/shell.go
T
tajniak81andClaude Opus 5.5 b6bf3ce741
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
Desktop client (warp-desktop) with Wails v3
apps/desktop is the web client in a native window. It embeds the same
build warp-web does and serves it through the same Go package, so every
screen is shared. Unlike warp-web it proxies nothing: the client calls the
homeserver directly, and the CSP allows the homeserver plus any https:
and wss: origin for the call backend.

- First run asks for the homeserver: a server name or address, with
  .well-known discovery and a /versions check; File > Change homeserver
  asks again. Signed-in sessions keep their own base URL, so a change
  never sends one server's token to another.
- Links to other sites open in the system browser (/_desktop/links.js,
  added to index.html); only http, https and mailto are opened.
- One instance at a time, since sessions and keys live in the webview's
  storage.
- Dev builds open the DevTools Protocol on Windows when
  WARP_DESKTOP_REMOTE_DEBUGGING_PORT is set, for scripted testing.
- Packaging: icon, version info and manifest in the Windows exe, a macOS
  .app with camera and microphone descriptions, Linux packages via nfpm.
  Make targets: desktop, desktop-run, desktop-test, desktop-resources,
  desktop-macos, desktop-linux.

Web client: host and client move out of internal/ so the desktop app can
import them, and host gains AnyCallOrigin. The Dockerfile, .dockerignore
and the vite output path follow the move.

Pinned to Wails v3.0.0-beta.27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:43:54 +02:00

322 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}
// 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 := withLinkScript(opts.Client)
if err != nil {
return nil, err
}
if !ok {
opts.Logger.Warn("the client's index.html has no <head>; links to other sites will open in the app's window")
}
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 linkScriptPath:
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(linkScript))
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 (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
}
if err := s.opts.Store.Save(Settings{Homeserver: base}); 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(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Choose a homeserver · Warp</title><style>` + pageStyle + `</style></head>
<body><main>
<h1>{{if .Current}}Change homeserver{{else}}Welcome to Warp{{end}}</h1>
<p>{{if .Current}}New sign-ins will go to this server. If you’re signed in, you stay signed in to <code>{{.Current}}</code> until you sign out.{{else}}Which Matrix homeserver is your account on?{{end}}</p>
{{if .Error}}<div class="error" role="alert">{{.Error}}</div>{{end}}
<form method="post" action="/_desktop/setup">
<label for="server">Homeserver</label>
<input id="server" name="server" value="{{.Server}}" placeholder="example.org" autocomplete="url" autocapitalize="off" spellcheck="false" required autofocus>
<p class="hint">A server name like <code>example.org</code>, or an address like <code>https://matrix.example.org</code>.</p>
<div class="actions">{{if .Current}}<a class="button" href="/">Cancel</a>{{end}}<button type="submit">Continue</button></div>
</form>
</main></body></html>
`))
var doneTemplate = template.Must(template.New("done").Parse(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=/">
<title>Warp</title><style>` + pageStyle + `</style></head><body></body></html>
`))