Files
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

310 lines
11 KiB
Go
Raw Permalink 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 host is the warp-web HTTP server: it serves the built web client,
// its runtime configuration and, by default, proxies the client's Matrix
// requests to the homeserver.
//
// Proxying keeps the client same-origin: no CORS, no homeserver URL baked
// into the build, and a homeserver that only has to be reachable from
// warp-web. A deployment that already serves the homeserver publicly can
// turn the proxy off and send the browser there directly instead.
package host
import (
"bytes"
"encoding/json"
"errors"
"io/fs"
"log/slog"
"net"
"net/http"
"net/http/httputil"
"net/url"
"path"
"strings"
"time"
)
// Options configures the handler. Exactly one of Homeserver and
// PublicHomeserver is set.
type Options struct {
// Homeserver is where proxied requests go, e.g. http://warp:8008. The
// browser then talks only to warp-web.
Homeserver *url.URL
// PublicHomeserver is the homeserver's public URL, for a deployment
// where the browser calls it directly; warp-web then proxies nothing.
PublicHomeserver *url.URL
// Client is the built web client rooted at its index.html; nil serves
// a page saying it has not been built.
Client fs.FS
// Version is reported in /config.json.
Version string
// Logger receives proxy errors. Defaults to slog.Default().
Logger *slog.Logger
// Transport overrides the proxy's round tripper, for tests.
Transport http.RoundTripper
// CallOrigins are origins outside the homeserver the browser may
// connect to for group calls: the LiveKit JWT service and the LiveKit
// SFU's WebSocket, e.g. https://livekit-jwt.example and
// wss://livekit.example. They are added to the CSP's connect-src.
CallOrigins []string
// AnyCallOrigin lets the page connect to any https: or wss: origin.
// The desktop app sets it: it can be pointed at any homeserver, so it
// learns the call backend at run time and can't list it in advance.
AnyCallOrigin bool
}
// proxiedPrefixes are the parts of the homeserver a Matrix client uses: the
// Client-Server API and media. Federation, server keys and the admin API
// are not reachable through warp-web.
var proxiedPrefixes = []string{"/_matrix/client/", "/_matrix/media/"}
// wellKnownClientPath is proxied too: it is where the client finds the
// group call backend (org.matrix.msc4143.rtc_foci).
const wellKnownClientPath = "/.well-known/matrix/client"
// Config is the body of /config.json, which the client reads on start.
type Config struct {
// HomeserverURL is where the client sends Matrix requests; empty
// means its own origin, through warp-web's proxy.
HomeserverURL string `json:"homeserver_url"`
Version string `json:"version"`
}
// New returns the warp-web handler.
func New(opts Options) (http.Handler, error) {
if (opts.Homeserver == nil) == (opts.PublicHomeserver == nil) {
return nil, errors.New("host: set exactly one of Homeserver and PublicHomeserver")
}
for _, u := range []*url.URL{opts.Homeserver, opts.PublicHomeserver} {
if u != nil && ((u.Scheme != "http" && u.Scheme != "https") || u.Host == "") {
return nil, errors.New("host: the homeserver must be an absolute http(s) URL")
}
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
h := &handler{opts: opts}
cfg := Config{Version: opts.Version}
connect := "'self'"
if u := opts.PublicHomeserver; u != nil {
cfg.HomeserverURL = strings.TrimRight(u.String(), "/")
connect += " " + u.Scheme + "://" + u.Host
}
for _, raw := range opts.CallOrigins {
u, err := url.Parse(strings.TrimRight(raw, "/"))
if err != nil || u.Host == "" || u.Path != "" || u.RawQuery != "" ||
(u.Scheme != "http" && u.Scheme != "https" && u.Scheme != "ws" && u.Scheme != "wss") {
return nil, errors.New("host: a call origin must be an http(s) or ws(s) origin, got " + raw)
}
connect += " " + u.Scheme + "://" + u.Host
// LiveKit falls back from its WebSocket to an HTTP request on
// the same host to find out why a connection failed.
switch u.Scheme {
case "ws":
connect += " http://" + u.Host
case "wss":
connect += " https://" + u.Host
}
}
if opts.AnyCallOrigin {
connect += " https: wss:"
}
body, err := json.Marshal(cfg)
if err != nil {
return nil, err
}
h.config = body
// Media is fetched with the access token and shown from blob: URLs,
// because authenticated media can't be loaded by an <img src>. The
// end-to-end encryption code is WebAssembly (matrix-sdk-crypto-wasm),
// which needs 'wasm-unsafe-eval' to compile; it allows no JavaScript eval.
h.csp = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; connect-src " + connect + "; " +
"worker-src 'self' blob:; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
if opts.Homeserver != nil {
h.proxy = newProxy(opts)
}
if opts.Client != nil {
index, err := fs.ReadFile(opts.Client, "index.html")
if err != nil {
return nil, err
}
h.index = index
}
return h, nil
}
func newProxy(opts Options) *httputil.ReverseProxy {
transport := opts.Transport
if transport == nil {
transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 256,
MaxIdleConnsPerHost: 256,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
// /sync long-polls for up to its timeout, 30s from this client.
ResponseHeaderTimeout: 2 * time.Minute,
}
}
upstream := opts.Homeserver
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(upstream)
pr.SetXForwarded()
// The client authenticates with its access token, never a
// cookie; don't pass on any a shared domain might have set.
pr.Out.Header.Del("Cookie")
},
Transport: transport,
// Pass data on as it arrives, so a large download streams.
FlushInterval: -1,
ModifyResponse: func(res *http.Response) error {
res.Header.Del("Set-Cookie")
if res.Header.Get("Cache-Control") == "" {
res.Header.Set("Cache-Control", "no-store")
}
return nil
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
opts.Logger.Warn("homeserver unreachable", "path", r.URL.Path, "err", err)
writeError(w, http.StatusBadGateway, "M_UNKNOWN", "The web client service can’t reach the homeserver.")
},
}
}
type handler struct {
opts Options
proxy *httputil.ReverseProxy
index []byte
config []byte
csp string
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
hdr := w.Header()
hdr.Set("X-Content-Type-Options", "nosniff")
hdr.Set("Referrer-Policy", "no-referrer")
p := r.URL.Path
switch {
case p == "/healthz":
hdr.Set("Content-Type", "text/plain; charset=utf-8")
hdr.Set("Cache-Control", "no-store")
_, _ = w.Write([]byte("ok\n"))
return
case p == wellKnownClientPath && h.proxy != nil:
h.proxy.ServeHTTP(w, r)
return
case strings.HasPrefix(p, "/_matrix/") || strings.HasPrefix(p, "/_warp/") || strings.HasPrefix(p, "/_synapse/"):
// A path that cleans to something else (dot segments, doubled
// slashes) is refused rather than normalised: what is allowed
// here must be exactly what the homeserver routes.
if h.proxy == nil || !clean(p) || !proxied(p) {
writeError(w, http.StatusNotFound, "M_UNRECOGNIZED", "Not served by the web client service.")
return
}
h.proxy.ServeHTTP(w, r)
return
}
hdr.Set("X-Frame-Options", "DENY")
if r.Method != http.MethodGet && r.Method != http.MethodHead {
hdr.Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if p == "/config.json" {
hdr.Set("Content-Type", "application/json")
hdr.Set("Cache-Control", "no-cache")
http.ServeContent(w, r, "config.json", time.Time{}, bytes.NewReader(h.config))
return
}
hdr.Set("Content-Security-Policy", h.csp)
h.serveStatic(w, r)
}
// clean reports whether p is already in canonical form. A trailing slash
// counts as canonical: the C-S API has routes that end in one, like
// GET /pushrules/.
func clean(p string) bool {
c := path.Clean(p)
if strings.HasSuffix(p, "/") && c != "/" {
c += "/"
}
return c == p
}
func proxied(p string) bool {
for _, prefix := range proxiedPrefixes {
if strings.HasPrefix(p, prefix) {
return true
}
}
return false
}
func (h *handler) serveStatic(w http.ResponseWriter, r *http.Request) {
if h.opts.Client == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(notBuiltPage))
return
}
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
if name != "" && name != "index.html" {
if st, err := fs.Stat(h.opts.Client, name); err == nil && st.Mode().IsRegular() {
if strings.HasPrefix(name, "assets/") {
// Vite puts a content hash in every file name under assets/.
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "public, max-age=3600")
}
http.ServeFileFS(w, r, h.opts.Client, name)
return
}
if strings.HasPrefix(name, "assets/") || (!strings.Contains(name, "/") && path.Ext(name) != "") {
// A missing asset is a 404, not the app: answering a stale
// script URL with HTML only produces a confusing MIME error.
// Deeper paths are routes even with a dot in them, like
// /room/!abc:warp.example.
http.NotFound(w, r)
return
}
}
// Everything else is a route of the single-page app.
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(h.index))
}
func writeError(w http.ResponseWriter, status int, errcode, msg string) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"errcode": errcode, "error": msg})
}
const notBuiltPage = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Warp</title>
<style>body{font:14px/20px system-ui,sans-serif;max-width:560px;margin:15vh auto;padding:0 16px;color:#111114}
code{font:13px/20px ui-monospace,monospace;background:#f3f5f4;padding:1px 4px;border-radius:4px}
@media (prefers-color-scheme:dark){body{background:#0f1213;color:#eceeee}code{background:#161a1c}}</style></head>
<body><h1 style="font-size:20px">The web client isn’t built into this binary</h1>
<p>This warp-web was compiled before the frontend. Build it, then the binary:</p>
<p><code>pnpm --filter @warp/web build</code><br><code>make web</code></p></body></html>
`