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

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