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>
258 lines
9.2 KiB
Go
258 lines
9.2 KiB
Go
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)
|
||
}
|
||
}
|