Desktop client (warp-desktop) with Wails v3
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

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>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-18 21:43:54 +02:00
1 parent 76dfc98a86
commit b6bf3ce741
38 files changed
+1616 -17

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
package shell
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// Discover turns what someone typed on the first-run page into a homeserver
// base URL, the way Matrix clients do (spec: client-server API, server
// discovery). The input is a server name ("example.org") or a URL; a
// /.well-known/matrix/client on that host may delegate to another base URL;
// and whatever comes out has to answer /_matrix/client/versions.
//
// The errors are written for the person who typed the input.
func Discover(ctx context.Context, c *http.Client, input string) (string, error) {
input = strings.TrimSpace(input)
if input == "" {
return "", errors.New("Enter a server name, like example.org, or its address.")
}
raw := input
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
u, err := parseBase(raw)
if err != nil {
return "", fmt.Errorf("“%s” isn’t a server name or address.", input)
}
delegated, err := wellKnown(ctx, c, u)
if err != nil {
return "", err
}
if delegated == nil || *delegated == *u {
if err := checkVersions(ctx, c, u); err != nil {
return "", err
}
return u.String(), nil
}
err = checkVersions(ctx, c, delegated)
if err == nil {
return delegated.String(), nil
}
// A server whose well-known names an address that doesn't work (a
// development server advertising its public name, say) is still
// usable at the address that was typed.
if checkVersions(ctx, c, u) == nil {
return u.String(), nil
}
return "", fmt.Errorf("%s points to %s. %w", u.Host, delegated, err)
}
// parseBase accepts an absolute http(s) URL with no query or fragment and
// returns it without a trailing slash.
func parseBase(raw string) (*url.URL, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, err
}
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil ||
u.RawQuery != "" || u.Fragment != "" {
return nil, errors.New("not an http(s) base URL")
}
u.Path = strings.TrimRight(u.Path, "/")
u.RawPath = ""
return u, nil
}
// wellKnown returns the base URL the host delegates to, or nil when it
// doesn't say (no document, or one without m.homeserver). A host that can't
// be reached at all is not an error here: the versions check reports it.
func wellKnown(ctx context.Context, c *http.Client, u *url.URL) (*url.URL, error) {
wk := url.URL{Scheme: u.Scheme, Host: u.Host, Path: "/.well-known/matrix/client"}
body, status, err := get(ctx, c, wk.String())
if err != nil || status != http.StatusOK {
return nil, nil
}
var doc struct {
Homeserver *struct {
BaseURL string `json:"base_url"`
} `json:"m.homeserver"`
}
if json.Unmarshal(body, &doc) != nil || doc.Homeserver == nil {
return nil, nil
}
base, err := parseBase(doc.Homeserver.BaseURL)
if err != nil {
return nil, fmt.Errorf("%s points to “%s”, which isn’t a valid homeserver address.", u.Host, doc.Homeserver.BaseURL)
}
return base, nil
}
func checkVersions(ctx context.Context, c *http.Client, base *url.URL) error {
body, status, err := get(ctx, c, base.String()+"/_matrix/client/versions")
if err != nil {
if ctx.Err() != nil {
return fmt.Errorf("%s took too long to answer.", base.Host)
}
return fmt.Errorf("Can’t reach %s.", base.Host)
}
var v struct {
Versions []string `json:"versions"`
}
if status != http.StatusOK || json.Unmarshal(body, &v) != nil || len(v.Versions) == 0 {
return fmt.Errorf("%s doesn’t look like a Matrix homeserver.", base.Host)
}
return nil
}
func get(ctx context.Context, c *http.Client, u string) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/json")
res, err := c.Do(req)
if err != nil {
return nil, 0, err
}
defer res.Body.Close()
body, err := io.ReadAll(io.LimitReader(res.Body, 64<<10))
return body, res.StatusCode, err
}
+105
View File
@@ -0,0 +1,105 @@
package shell
import (
"bytes"
"io/fs"
"time"
)
// linkScriptPath serves linkScript, which withLinkScript adds to the
// client's index.html. A script of the page's own, rather than one the
// webview injects, runs before the app on every platform and is allowed by
// the client's Content-Security-Policy (script-src 'self').
const linkScriptPath = "/_desktop/links.js"
// linkScript makes links to other sites open in the system browser. A URL
// in a message would otherwise replace the app in its own window, or open a
// bare webview window for target="_blank"; this sends those to openPath
// instead. Links within the app, and blob: URLs for downloads, are left
// alone.
const linkScript = `(() => {
if (window.__warpDesktopLinks) return
window.__warpDesktopLinks = true
const external = (href) => {
try {
const u = new URL(href, location.href)
return u.origin !== location.origin && ['http:', 'https:', 'mailto:'].includes(u.protocol) ? u.href : null
} catch { return null }
}
const open = (href) => fetch('/_desktop/open', {
method: 'POST', credentials: 'omit',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: href }),
}).catch(() => {})
const onClick = (e) => {
if (e.defaultPrevented || e.button > 1) return
const a = e.target instanceof Element ? e.target.closest('a[href]') : null
const href = a && external(a.href)
if (!href) return
e.preventDefault()
open(href)
}
// auxclick is the middle button, which would open a new window.
document.addEventListener('click', onClick, true)
document.addEventListener('auxclick', onClick, true)
const windowOpen = window.open
window.open = function (url, ...rest) {
const href = url == null ? null : external(String(url))
if (href) { open(href); return null }
return windowOpen.call(window, url, ...rest)
}
})()
`
// withLinkScript returns the client with a script tag for linkScript at the
// top of index.html's head. ok is false when index.html has no <head> to
// add it to; the client is then returned unchanged.
func withLinkScript(client fs.FS) (_ fs.FS, ok bool, _ error) {
index, err := fs.ReadFile(client, "index.html")
if err != nil {
return nil, false, err
}
tag := []byte(`<head>` + "\n " + `<script src="` + linkScriptPath + `"></script>`)
patched := bytes.Replace(index, []byte("<head>"), tag, 1)
if bytes.Equal(patched, index) {
return client, false, nil
}
return indexFS{FS: client, index: patched}, true, nil
}
// indexFS is a client whose index.html is replaced.
type indexFS struct {
fs.FS
index []byte
}
func (f indexFS) Open(name string) (fs.File, error) {
if name != "index.html" {
return f.FS.Open(name)
}
return &memFile{Reader: bytes.NewReader(f.index), size: int64(len(f.index))}, nil
}
func (f indexFS) ReadFile(name string) ([]byte, error) {
if name != "index.html" {
return fs.ReadFile(f.FS, name)
}
return bytes.Clone(f.index), nil
}
type memFile struct {
*bytes.Reader
size int64
}
func (m *memFile) Stat() (fs.FileInfo, error) { return memInfo{m.size}, nil }
func (m *memFile) Close() error { return nil }
type memInfo struct{ size int64 }
func (i memInfo) Name() string { return "index.html" }
func (i memInfo) Size() int64 { return i.size }
func (i memInfo) Mode() fs.FileMode { return 0o444 }
func (i memInfo) ModTime() time.Time { return time.Time{} }
func (i memInfo) IsDir() bool { return false }
func (i memInfo) Sys() any { return nil }
+61
View File
@@ -0,0 +1,61 @@
package shell
import (
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
"sync"
)
// Settings is what the desktop app keeps between runs. The client's own
// state (sessions, keys) lives in the webview's storage, not here.
type Settings struct {
// Homeserver is the base URL new sign-ins go to, e.g.
// https://matrix.example.org. Empty until the first-run page has asked.
Homeserver string `json:"homeserver,omitempty"`
}
// Store reads and writes Settings as a JSON file.
type Store struct {
path string
mu sync.Mutex
}
// NewStore returns a store backed by the file at path. The file and its
// directory are created on the first Save.
func NewStore(path string) *Store { return &Store{path: path} }
// Load returns the saved settings, or the zero value when there are none.
func (s *Store) Load() (Settings, error) {
s.mu.Lock()
defer s.mu.Unlock()
var st Settings
b, err := os.ReadFile(s.path)
if errors.Is(err, fs.ErrNotExist) {
return st, nil
}
if err != nil {
return st, err
}
return st, json.Unmarshal(b, &st)
}
// Save writes the settings, replacing the file atomically.
func (s *Store) Save(st Settings) error {
s.mu.Lock()
defer s.mu.Unlock()
b, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
+321
View File
@@ -0,0 +1,321 @@
// 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>
`))
+257
View File
@@ -0,0 +1,257 @@
package shell
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"testing/fstest"
)
var clientFS = fstest.MapFS{
"index.html": {Data: []byte("<!doctype html><title>Warp</title>")},
"assets/app-1.js": {Data: []byte("console.log(1)")},
}
// homeserver is a fake homeserver: /versions, and a well-known document
// when delegate is set.
func homeserver(t *testing.T, delegate string) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("GET /_matrix/client/versions", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"versions":["v1.11"]}`))
})
if delegate != "" {
mux.HandleFunc("GET /.well-known/matrix/client", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"m.homeserver":{"base_url":"` + delegate + `/"}}`))
})
}
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func newShell(t *testing.T, opts Options) (*Shell, *Store) {
t.Helper()
if opts.Store == nil {
opts.Store = NewStore(filepath.Join(t.TempDir(), "desktop.json"))
}
if opts.Client == nil {
opts.Client = clientFS
}
s, err := New(opts)
if err != nil {
t.Fatal(err)
}
return s, opts.Store
}
func do(h http.Handler, method, target, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, strings.NewReader(body))
if method == http.MethodPost && strings.HasPrefix(target, SetupPath) {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestFirstRunAsksForTheHomeserver(t *testing.T) {
s, _ := newShell(t, Options{})
for _, p := range []string{"/", "/room/!abc:warp.example", SetupPath} {
rec := do(s, http.MethodGet, p, "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Welcome to Warp") {
t.Errorf("GET %s: %d %q", p, rec.Code, rec.Body.String())
}
}
if rec := do(s, http.MethodPost, "/_matrix/client/v3/login", "{}"); rec.Code != http.StatusServiceUnavailable {
t.Errorf("POST before setup: %d", rec.Code)
}
}
func TestSetupSavesTheHomeserverAndServesTheClient(t *testing.T) {
hs := homeserver(t, "")
s, store := newShell(t, Options{})
rec := do(s, http.MethodPost, SetupPath, url.Values{"server": {hs.URL + "/"}}.Encode())
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `http-equiv="refresh"`) {
t.Fatalf("setup: %d %q", rec.Code, rec.Body.String())
}
if got := s.Homeserver(); got != hs.URL {
t.Errorf("Homeserver() = %q, want %q", got, hs.URL)
}
if st, _ := store.Load(); st.Homeserver != hs.URL {
t.Errorf("saved %q", st.Homeserver)
}
// The client is served now, told to call the homeserver directly.
if rec := do(s, http.MethodGet, "/", ""); !strings.Contains(rec.Body.String(), "<title>Warp</title>") {
t.Errorf("GET / after setup: %q", rec.Body.String())
}
rec = do(s, http.MethodGet, "/config.json", "")
var cfg struct {
HomeserverURL string `json:"homeserver_url"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &cfg); err != nil || cfg.HomeserverURL != hs.URL {
t.Errorf("config.json %q", rec.Body.String())
}
// Nothing is proxied.
if rec := do(s, http.MethodGet, "/_matrix/client/versions", ""); rec.Code != http.StatusNotFound {
t.Errorf("/_matrix proxied: %d", rec.Code)
}
// The next run starts with it.
s2, _ := newShell(t, Options{Store: store})
if s2.Homeserver() != hs.URL {
t.Errorf("next run: %q", s2.Homeserver())
}
if rec := do(s2, http.MethodGet, SetupPath, ""); !strings.Contains(rec.Body.String(), "Change homeserver") {
t.Errorf("setup page once configured: %q", rec.Body.String())
}
}
func TestSetupRejectsWhatIsntAHomeserver(t *testing.T) {
notMatrix := httptest.NewServer(http.NotFoundHandler())
t.Cleanup(notMatrix.Close)
s, store := newShell(t, Options{})
rec := do(s, http.MethodPost, SetupPath, url.Values{"server": {notMatrix.URL}}.Encode())
if !strings.Contains(rec.Body.String(), "doesn’t look like a Matrix homeserver") {
t.Errorf("body %q", rec.Body.String())
}
// What was typed stays in the field.
if !strings.Contains(rec.Body.String(), `value="`+notMatrix.URL+`"`) {
t.Errorf("input not kept: %q", rec.Body.String())
}
if s.Homeserver() != "" {
t.Errorf("homeserver set to %q", s.Homeserver())
}
if st, _ := store.Load(); st.Homeserver != "" {
t.Errorf("saved %q", st.Homeserver)
}
}
func TestDamagedSettingsAskAgain(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "desktop.json"))
if err := store.Save(Settings{Homeserver: "matrix.example"}); err != nil {
t.Fatal(err)
}
s, _ := newShell(t, Options{Store: store})
if s.Homeserver() != "" {
t.Errorf("Homeserver() = %q", s.Homeserver())
}
if rec := do(s, http.MethodGet, "/", ""); !strings.Contains(rec.Body.String(), "Welcome to Warp") {
t.Errorf("GET /: %q", rec.Body.String())
}
}
func TestOverrideIsNotSaved(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "desktop.json"))
if err := store.Save(Settings{Homeserver: "https://saved.example"}); err != nil {
t.Fatal(err)
}
s, _ := newShell(t, Options{Store: store, Homeserver: "http://localhost:8008"})
if s.Homeserver() != "http://localhost:8008" {
t.Errorf("Homeserver() = %q", s.Homeserver())
}
if st, _ := store.Load(); st.Homeserver != "https://saved.example" {
t.Errorf("saved %q", st.Homeserver)
}
if _, err := New(Options{Store: store, Homeserver: "matrix.example"}); err == nil {
t.Error("a homeserver without a scheme was accepted")
}
}
func TestCSPAllowsCallBackends(t *testing.T) {
s, _ := newShell(t, Options{Homeserver: "http://localhost:8008"})
csp := do(s, http.MethodGet, "/", "").Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "connect-src 'self' http://localhost:8008 https: wss:;") {
t.Errorf("CSP %q", csp)
}
}
func TestLinkScriptIsAddedToTheClient(t *testing.T) {
s, _ := newShell(t, Options{Homeserver: "http://localhost:8008", Client: fstest.MapFS{
"index.html": {Data: []byte("<!doctype html><html><head><title>Warp</title></head></html>")},
}})
for _, p := range []string{"/", "/room/!abc:warp.example"} {
body := do(s, http.MethodGet, p, "").Body.String()
if !strings.Contains(body, `<head>`+"\n "+`<script src="/_desktop/links.js"></script><title>`) {
t.Errorf("GET %s: %q", p, body)
}
}
rec := do(s, http.MethodGet, linkScriptPath, "")
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/javascript") ||
!strings.Contains(rec.Body.String(), "/_desktop/open") {
t.Errorf("links.js: %d %q", rec.Code, rec.Header().Get("Content-Type"))
}
}
func TestOpenURL(t *testing.T) {
var opened []string
s, _ := newShell(t, Options{OpenURL: func(u string) error { opened = append(opened, u); return nil }})
for _, u := range []string{"https://example.org/a?b=c", "mailto:alice@example.org"} {
if rec := do(s, http.MethodPost, openPath, `{"url":"`+u+`"}`); rec.Code != http.StatusNoContent {
t.Errorf("open %s: %d", u, rec.Code)
}
}
for _, u := range []string{"file:///etc/passwd", "javascript:alert(1)", "https://", "ms-settings:"} {
if rec := do(s, http.MethodPost, openPath, `{"url":"`+u+`"}`); rec.Code != http.StatusBadRequest {
t.Errorf("open %s: %d", u, rec.Code)
}
}
if rec := do(s, http.MethodGet, openPath, ""); rec.Code != http.StatusMethodNotAllowed {
t.Errorf("GET open: %d", rec.Code)
}
if len(opened) != 2 {
t.Errorf("opened %q", opened)
}
}
func TestDiscover(t *testing.T) {
target := homeserver(t, "")
delegating := homeserver(t, target.URL)
badDelegate := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"m.homeserver":{"base_url":"ftp://x"}}`))
}))
t.Cleanup(badDelegate.Close)
c := http.DefaultClient
ctx := context.Background()
if got, err := Discover(ctx, c, " "+target.URL+"/ "); err != nil || got != target.URL {
t.Errorf("plain: %q %v", got, err)
}
if got, err := Discover(ctx, c, delegating.URL); err != nil || got != target.URL {
t.Errorf("delegated: %q %v", got, err)
}
if _, err := Discover(ctx, c, badDelegate.URL); err == nil || !strings.Contains(err.Error(), "isn’t a valid homeserver address") {
t.Errorf("bad delegation: %v", err)
}
// Delegation to an address that doesn't answer falls back to the one
// typed, when that one is a homeserver itself.
deadDelegation := homeserver(t, "http://127.0.0.1:1")
if got, err := Discover(ctx, c, deadDelegation.URL); err != nil || got != deadDelegation.URL {
t.Errorf("dead delegation: %q %v", got, err)
}
nowhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"m.homeserver":{"base_url":"http://127.0.0.1:1"}}`))
}))
t.Cleanup(nowhere.Close)
if _, err := Discover(ctx, c, nowhere.URL); err == nil || !strings.Contains(err.Error(), "points to http://127.0.0.1:1. Can’t reach 127.0.0.1:1.") {
t.Errorf("delegation to nowhere: %v", err)
}
for _, in := range []string{"", "https://x.example/?a=b", "ftp://x.example"} {
if _, err := Discover(ctx, c, in); err == nil {
t.Errorf("%q accepted", in)
}
}
// A bare name gets https://; nothing listens there, so it can't be reached.
if _, err := Discover(ctx, c, "127.0.0.1:1"); err == nil || !strings.Contains(err.Error(), "Can’t reach 127.0.0.1:1") {
t.Errorf("unreachable: %v", err)
}
}