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

+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)
}
}