diff --git a/.dockerignore b/.dockerignore index 6c7c49c..7a47277 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,7 @@ **/node_modules **/dist !apps/admin/internal/panel/build -!apps/web/internal/client/build +!apps/web/client/build bin coverage.* .complement diff --git a/Makefile b/Makefile index 52eb390..733fa9f 100644 --- a/Makefile +++ b/Makefile @@ -136,7 +136,7 @@ WEB_DIR := apps/web WEB_IMAGE ?= warp-web .PHONY: web-web -web-web: ## Build the web client frontend into apps/web/internal/client/build/dist +web-web: ## Build the web client frontend into apps/web/client/build/dist $(PNPM) --filter @warp/web build .PHONY: web @@ -160,6 +160,82 @@ web-test: ## Typecheck the frontend and test warp-web web-docker: ## Build the warp-web container image docker build -t $(WEB_IMAGE):$(VERSION) -f $(WEB_DIR)/Dockerfile . +# --- Desktop client (apps/desktop) ---------------------------------------- +# The web client in a native window (Wails v3). It embeds the same build as +# warp-web, so `make desktop` builds the web frontend first. + +DESKTOP_DIR := apps/desktop + +# Windows builds are GUI programs: no console window behind the app. +ifeq ($(OS),Windows_NT) +DESKTOP_LDFLAGS := -H windowsgui +else +DESKTOP_LDFLAGS := +endif + +.PHONY: desktop +desktop: web-web ## Build bin/warp-desktop with the web client embedded + cd $(DESKTOP_DIR) && $(GO) build -tags production -ldflags "-X main.version=$(VERSION) $(DESKTOP_LDFLAGS)" -o ../../$(BIN_DIR)/warp-desktop$(EXE) ./cmd/warp-desktop + +.PHONY: desktop-run +desktop-run: web-web ## Run the desktop client with the developer tools on + cd $(DESKTOP_DIR) && $(GO) run ./cmd/warp-desktop $(ARGS) + +.PHONY: desktop-test +desktop-test: ## Vet and test the desktop client's Go code + cd $(DESKTOP_DIR) && $(GO) vet ./... && $(GO) test ./... + +# Packaging. The inputs are in apps/desktop/build. The wails3 CLI, at the +# version go.mod pins, makes the icons and the Windows .syso and builds the +# Linux packages; `go run` fetches it, so there is nothing to install. +# DESKTOP_VERSION is the numeric version the OS sees (Windows version info, +# Info.plist, package version); `-version` still prints $(VERSION). +WAILS3 ?= $(GO) run github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.27 +DESKTOP_VERSION ?= 0.1.0 +DESKTOP_MAINTAINER ?= $(shell git config user.name) <$(shell git config user.email)> +DESKTOP_FORMATS ?= deb rpm archlinux +DESKTOP_GEN := $(BIN_DIR)/desktop-build +DESKTOP_APP := $(BIN_DIR)/Warp.app + +# The .syso files are committed, so `make desktop` needs none of this; run +# it after changing the icon, the manifest or DESKTOP_VERSION. +.PHONY: desktop-resources +desktop-resources: ## Regenerate the desktop icons and the Windows .syso files + mkdir -p $(DESKTOP_GEN) + cd $(DESKTOP_DIR) && $(WAILS3) generate icons -input build/appicon.png -sizes 256,128,64 \ + -windowsfilename ../../$(DESKTOP_GEN)/tile.ico -macfilename build/darwin/icons.icns + cd $(DESKTOP_DIR) && $(GO) run build/windows/mkico.go -small build/windows/favicon.ico \ + -large ../../$(DESKTOP_GEN)/tile.ico -out build/windows/icon.ico + sed 's/{{VERSION}}/$(DESKTOP_VERSION)/g' $(DESKTOP_DIR)/build/windows/info.json > $(DESKTOP_GEN)/info.json + sed 's/{{VERSION}}/$(DESKTOP_VERSION)/g' $(DESKTOP_DIR)/build/windows/warp-desktop.exe.manifest > $(DESKTOP_GEN)/warp-desktop.exe.manifest + cd $(DESKTOP_DIR) && for arch in amd64 arm64; do \ + $(WAILS3) generate syso -arch $$arch -icon build/windows/icon.ico \ + -manifest ../../$(DESKTOP_GEN)/warp-desktop.exe.manifest -info ../../$(DESKTOP_GEN)/info.json \ + -out cmd/warp-desktop/rsrc_windows_$$arch.syso || exit 1; \ + done + +# Run on a Mac: the binary needs cgo. Ad-hoc signed, so it runs on the +# machine that built it; distributing it needs a Developer ID and notarizing. +.PHONY: desktop-macos +desktop-macos: desktop ## Build bin/Warp.app (on macOS) + @test "$$(uname -s)" = Darwin || { echo "desktop-macos runs on macOS"; exit 1; } + rm -rf $(DESKTOP_APP) + mkdir -p $(DESKTOP_APP)/Contents/MacOS $(DESKTOP_APP)/Contents/Resources + cp $(BIN_DIR)/warp-desktop $(DESKTOP_APP)/Contents/MacOS/ + cp $(DESKTOP_DIR)/build/darwin/icons.icns $(DESKTOP_APP)/Contents/Resources/ + sed 's/{{VERSION}}/$(DESKTOP_VERSION)/g' $(DESKTOP_DIR)/build/darwin/Info.plist > $(DESKTOP_APP)/Contents/Info.plist + codesign --force --deep --sign - $(DESKTOP_APP) + +# Run on Linux: the binary needs cgo. Packages land in bin/. +.PHONY: desktop-linux +desktop-linux: desktop ## Build Linux packages of the desktop client (on Linux) + @test "$$(uname -s)" = Linux || { echo "desktop-linux runs on Linux"; exit 1; } + cd $(DESKTOP_DIR) && for format in $(DESKTOP_FORMATS); do \ + VERSION=$(DESKTOP_VERSION) GOARCH=$$($(GO) env GOARCH) MAINTAINER="$(DESKTOP_MAINTAINER)" \ + $(WAILS3) tool package -name warp-desktop -format $$format \ + -config build/linux/nfpm.yaml -out ../../$(BIN_DIR) || exit 1; \ + done + .PHONY: complement-image complement-image: ## Build the Complement homeserver image docker build -t $(COMPLEMENT_IMAGE) -f $(SERVER_DIR)/test/complement/Dockerfile . diff --git a/README.md b/README.md index 96bd2f2..a31805a 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,9 @@ own process, `warp-admin`, next to the homeserver: The web client is a Matrix client for the browser. It is optional in the same way, a process of its own, `warp-web`: `docker compose --profile web up -d`, or see [apps/web](apps/web/README.md). +The desktop client, `warp-desktop`, is the same client in a native window +(Wails v3) that signs in to any homeserver: `make desktop`, or see +[apps/desktop](apps/desktop/README.md). Also in place: argon2id passwords, hashed access and refresh tokens, per-endpoint rate limiting, YAML + environment configuration, structured diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 0000000..1bcd152 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,145 @@ +# Warp desktop client + +The [web client](../web/README.md) in a native window, built with +[Wails v3](https://v3alpha.wails.io). Every screen is the web client's: +`warp-desktop` embeds the same build `warp-web` does and serves it through +the same Go package (`apps/web/host`), so web and desktop don't drift +apart. It runs on Windows (WebView2), macOS (WKWebView) and Linux +(WebKitGTK). + +``` +window ──► wails.localhost ──► shell (this app) ── the web client, /config.json + │ first-run page, links + └──────────────────────────────────────────────► homeserver /_matrix/* +``` + +Unlike `warp-web`, the desktop app isn't installed next to one homeserver, +so it proxies nothing. The client calls the homeserver directly, the way +any Matrix client does; homeservers answer CORS for every origin. The +Content-Security-Policy allows the homeserver plus any `https:` and `wss:` +origin, because the call backend (the LiveKit JWT service and SFU) is +learned from the homeserver at run time and can't be listed in advance. + +## What it adds to the web client + +- **First run asks for the homeserver.** A server name (`example.org`) or + an address. Discovery follows `/.well-known/matrix/client` and checks + `/_matrix/client/versions`. If the well-known document names an address + that doesn't answer, the typed address is used when that one works. + **File → Change homeserver…** asks again. A session already signed in + keeps the server it signed in to until you sign out, so changing the + homeserver never sends one server's access token to another. +- **Links to other sites open in the system browser.** That covers a URL in + a message, `target="_blank"` and `window.open`. Without this they would + replace the app in its window or open a bare webview window. + `/_desktop/links.js` does it; the shell adds a ``) + patched := bytes.Replace(index, []byte(""), 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 } diff --git a/apps/desktop/internal/shell/settings.go b/apps/desktop/internal/shell/settings.go new file mode 100644 index 0000000..46753af --- /dev/null +++ b/apps/desktop/internal/shell/settings.go @@ -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) +} diff --git a/apps/desktop/internal/shell/shell.go b/apps/desktop/internal/shell/shell.go new file mode 100644 index 0000000..1342f7e --- /dev/null +++ b/apps/desktop/internal/shell/shell.go @@ -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 ; 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(` + +Choose a homeserver · Warp +
+

{{if .Current}}Change homeserver{{else}}Welcome to Warp{{end}}

+

{{if .Current}}New sign-ins will go to this server. If you’re signed in, you stay signed in to {{.Current}} until you sign out.{{else}}Which Matrix homeserver is your account on?{{end}}

+{{if .Error}}{{end}} +
+ + +

A server name like example.org, or an address like https://matrix.example.org.

+
{{if .Current}}Cancel{{end}}
+
+
+`)) + +var doneTemplate = template.Must(template.New("done").Parse(` + +Warp +`)) diff --git a/apps/desktop/internal/shell/shell_test.go b/apps/desktop/internal/shell/shell_test.go new file mode 100644 index 0000000..f16a7e9 --- /dev/null +++ b/apps/desktop/internal/shell/shell_test.go @@ -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("Warp")}, + "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(), "Warp") { + 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("Warp")}, + }}) + for _, p := range []string{"/", "/room/!abc:warp.example"} { + body := do(s, http.MethodGet, p, "").Body.String() + if !strings.Contains(body, ``+"\n "+``) { + 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) + } +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 5406213..26317b1 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -26,8 +26,9 @@ FROM golang:1.26-alpine AS build WORKDIR /src/apps/web COPY apps/web/go.mod ./ COPY apps/web/cmd ./cmd -COPY apps/web/internal ./internal -COPY --from=web /src/apps/web/internal/client/build/dist ./internal/client/build/dist +COPY apps/web/host ./host +COPY apps/web/client/client.go ./client/ +COPY --from=web /src/apps/web/client/build/dist ./client/build/dist ARG VERSION=0.0.0-dev ENV CGO_ENABLED=0 GOWORK=off diff --git a/apps/web/README.md b/apps/web/README.md index 1de27b6..b81b1d1 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -171,7 +171,7 @@ proxies the same paths warp-web does, so the app code is the same in both. | Command | What | |---|---| -| `make web-web` | build the frontend into `internal/client/build/dist` | +| `make web-web` | build the frontend into `client/build/dist` | | `make web` | build the frontend, then `bin/warp-web` with it embedded | | `make web-test` | typecheck the frontend, vet and test the Go host | | `make web-docker` | build the container image | @@ -188,8 +188,8 @@ page saying the client is missing. | `src/views/` | sign-in, sign-up, home, room | | `src/components/` | timeline, message body, room info, dialogs | | `cmd/warp-web/` | the binary | -| `internal/host/` | static serving, `/config.json` and the allow-listed proxy | -| `internal/client/` | the embedded build | +| `host/` | static serving, `/config.json` and the allow-listed proxy (shared with [the desktop app](../desktop/README.md)) | +| `client/` | the embedded build (shared with the desktop app) | The SDK's objects change in place and emit events. The stores turn those events into a version counter (bumped at most once a frame), and the views diff --git a/apps/web/client/build/README.md b/apps/web/client/build/README.md new file mode 100644 index 0000000..d3d1632 --- /dev/null +++ b/apps/web/client/build/README.md @@ -0,0 +1,4 @@ +`pnpm build` in `apps/web` writes the client to `dist/` here, and package +`client` embeds it into the `warp-web` and `warp-desktop` binaries. This file +keeps the directory in git so the Go code compiles before the frontend is +built; such a binary serves a page saying the client has not been built. diff --git a/apps/web/internal/client/client.go b/apps/web/client/client.go similarity index 100% rename from apps/web/internal/client/client.go rename to apps/web/client/client.go diff --git a/apps/web/cmd/warp-web/main.go b/apps/web/cmd/warp-web/main.go index e0e56f8..8d998af 100644 --- a/apps/web/cmd/warp-web/main.go +++ b/apps/web/cmd/warp-web/main.go @@ -19,8 +19,8 @@ import ( "syscall" "time" - "github.com/dariuszjaniak/warp/apps/web/internal/client" - "github.com/dariuszjaniak/warp/apps/web/internal/host" + "github.com/dariuszjaniak/warp/apps/web/client" + "github.com/dariuszjaniak/warp/apps/web/host" ) // Set with -ldflags "-X main.version=...". diff --git a/apps/web/internal/host/host.go b/apps/web/host/host.go similarity index 97% rename from apps/web/internal/host/host.go rename to apps/web/host/host.go index ec1aba4..512b096 100644 --- a/apps/web/internal/host/host.go +++ b/apps/web/host/host.go @@ -46,6 +46,10 @@ type Options struct { // 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 @@ -103,6 +107,9 @@ func New(opts Options) (http.Handler, error) { connect += " https://" + u.Host } } + if opts.AnyCallOrigin { + connect += " https: wss:" + } body, err := json.Marshal(cfg) if err != nil { return nil, err diff --git a/apps/web/internal/host/host_test.go b/apps/web/host/host_test.go similarity index 96% rename from apps/web/internal/host/host_test.go rename to apps/web/host/host_test.go index 211764e..e43160a 100644 --- a/apps/web/internal/host/host_test.go +++ b/apps/web/host/host_test.go @@ -336,3 +336,16 @@ func TestCallOriginsJoinConnectSrc(t *testing.T) { t.Error("a call origin with a path was accepted") } } + +func TestAnyCallOrigin(t *testing.T) { + public, _ := url.Parse("http://localhost:8008") + h, err := New(Options{PublicHomeserver: public, Client: clientFS, AnyCallOrigin: true}) + if err != nil { + t.Fatal(err) + } + rec := do(h, http.MethodGet, "/", nil) + want := "connect-src 'self' http://localhost:8008 https: wss:;" + if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, want) { + t.Errorf("CSP %q", csp) + } +} diff --git a/apps/web/internal/client/build/README.md b/apps/web/internal/client/build/README.md deleted file mode 100644 index ea0cdf4..0000000 --- a/apps/web/internal/client/build/README.md +++ /dev/null @@ -1,4 +0,0 @@ -`pnpm build` in `apps/web` writes the client to `dist/` here, and -`internal/client` embeds it into the `warp-web` binary. This file keeps the -directory in git so the Go code compiles before the frontend is built; such a -binary serves a page saying the client has not been built. diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 4ed93fa..1804c63 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -14,8 +14,8 @@ export default defineConfig({ alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, }, build: { - // Embedded into the warp-web binary by internal/client. - outDir: 'internal/client/build/dist', + // Embedded into the warp-web binary by client/ (and the desktop app). + outDir: 'client/build/dist', emptyOutDir: true, assetsInlineLimit: 0, // matrix-js-sdk is most of the bundle; it is one chunk on purpose. diff --git a/go.work b/go.work index a0020ce..6623e26 100644 --- a/go.work +++ b/go.work @@ -2,9 +2,8 @@ go 1.26.4 use ( ./apps/admin + ./apps/desktop ./apps/web ./server ) -// The other client sub-projects join the workspace as they are created: -// use ./apps/desktop (Wails v3 shell, Phase 8.3)