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>
322 lines
11 KiB
Go
322 lines
11 KiB
Go
// 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>
|
||
`))
|