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
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:
1 parent
76dfc98a86
commit
b6bf3ce741
38 files changed
+1616
-17
No files matched your search
+1
-1
@@ -2,7 +2,7 @@
|
||||
**/node_modules
|
||||
**/dist
|
||||
!apps/admin/internal/panel/build
|
||||
!apps/web/internal/client/build
|
||||
!apps/web/client/build
|
||||
bin
|
||||
coverage.*
|
||||
.complement
|
||||
|
||||
@@ -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 .
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<script>` tag for it to
|
||||
`index.html`. Only `http`, `https` and `mailto` URLs are opened.
|
||||
- **One instance at a time.** Sessions and encryption keys live in the
|
||||
webview's storage, and two instances on one store would both sync and
|
||||
use the same one-time keys. Starting the app again brings the open window
|
||||
forward.
|
||||
|
||||
## Where things are kept
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| The chosen homeserver | `desktop.json` in the user config directory, under `Warp` (`%AppData%\Warp` on Windows, `~/Library/Application Support/Warp` on macOS, `~/.config/Warp` on Linux) |
|
||||
| Sessions, encryption keys, cache | the webview's storage: `%LocalAppData%\Warp\WebView2` on Windows; the platform webview's default location elsewhere |
|
||||
|
||||
Signing out deletes a session's keys, the same as in the browser (see
|
||||
[Encryption keys in the browser](../web/README.md#encryption-keys-in-the-browser)).
|
||||
|
||||
## Build and run
|
||||
|
||||
Requires Go 1.26, Node 22 and pnpm. On Windows the WebView2 runtime, which
|
||||
Windows 11 ships with, and no C compiler. On macOS, Xcode's command line
|
||||
tools. On Linux, a C compiler with `libgtk-4-dev` and `libwebkitgtk-6.0-dev`
|
||||
(Wails needs cgo there, and builds against GTK 4 by default).
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
make desktop # builds the web client, then bin/warp-desktop
|
||||
./bin/warp-desktop
|
||||
```
|
||||
|
||||
| Flag | Environment | |
|
||||
|---|---|---|
|
||||
| `-homeserver` | `WARP_DESKTOP_HOMESERVER` | use this homeserver for this run without saving it |
|
||||
| `-version` | | print the version |
|
||||
|
||||
| Command | What |
|
||||
|---|---|
|
||||
| `make desktop` | a release build: `-tags production` (no developer tools), and on Windows a GUI program with no console |
|
||||
| `make desktop-run` | build the web client and run the app with the developer tools (View → Open Developer Tools) |
|
||||
| `make desktop-test` | vet and test the Go code |
|
||||
| `make desktop-macos` | on macOS: `bin/Warp.app` |
|
||||
| `make desktop-linux` | on Linux: `bin/warp-desktop.deb`, `.rpm` and `.pkg.tar.zst` |
|
||||
| `make desktop-resources` | regenerate the icons and the Windows `.syso` files |
|
||||
|
||||
A development build (one without `-tags production`) also opens the Chrome
|
||||
DevTools Protocol when `WARP_DESKTOP_REMOTE_DEBUGGING_PORT` is set, on
|
||||
Windows. A script or Playwright can then drive the real window:
|
||||
|
||||
```bash
|
||||
WARP_DESKTOP_REMOTE_DEBUGGING_PORT=9229 go run ./cmd/warp-desktop
|
||||
```
|
||||
|
||||
## Packaging
|
||||
|
||||
The inputs are in `build/`. The `wails3` CLI does the generating and the
|
||||
Linux packaging; the Makefile runs it with `go run` at the version
|
||||
`go.mod` pins (`v3.0.0-beta.27`), so it needs no install. `DESKTOP_VERSION`
|
||||
in the Makefile (default `0.1.0`) is the version the OS sees in each
|
||||
package; `-version` prints the build's git version.
|
||||
|
||||
- **Windows.** `cmd/warp-desktop/rsrc_windows_{amd64,arm64}.syso` carry the
|
||||
icon, the version info and the application manifest (per-monitor DPI,
|
||||
common controls 6, `asInvoker`). They are committed, so `make desktop`
|
||||
(and a plain `go build`) links them in with no extra step. The icon,
|
||||
`build/windows/icon.ico`, takes 16–48 px from the brand favicon (the
|
||||
symbol's small cut) and 64–256 px from the app icon tile;
|
||||
`build/windows/mkico.go` merges the two. After changing the icon,
|
||||
`build/windows/info.json`, the manifest or `DESKTOP_VERSION`, run
|
||||
`make desktop-resources` and commit the `.syso` files.
|
||||
- **macOS.** `make desktop-macos`, on a Mac, builds the binary and puts it
|
||||
in `bin/Warp.app` with `build/darwin/Info.plist` and `icons.icns`, then
|
||||
signs the bundle ad hoc. The `Info.plist` has `NSCameraUsageDescription`
|
||||
and `NSMicrophoneUsageDescription`: without them macOS ends the app the
|
||||
moment a call or the QR scanner asks for the camera or microphone. The
|
||||
bundle is for the CPU it was built on, and ad-hoc signed it opens only on
|
||||
the machine that built it; to hand it out, sign it with a Developer ID
|
||||
and notarize it.
|
||||
- **Linux.** `make desktop-linux`, on Linux, builds the binary and turns it
|
||||
into `.deb`, `.rpm` and Arch packages with nfpm (through `wails3 tool
|
||||
package`, configured by `build/linux/nfpm.yaml`). They install
|
||||
`/usr/bin/warp-desktop`, a menu entry and the icon, and depend on GTK 4
|
||||
and WebKitGTK 6.0 (Ubuntu 24.04+, Debian 13+, Fedora 40+).
|
||||
`DESKTOP_FORMATS=deb` builds one format; `DESKTOP_MAINTAINER` defaults
|
||||
to your git name and email.
|
||||
|
||||
`build/appicon.png` and `build/windows/favicon.ico` are copies from the
|
||||
brand kit (`DESIGN/warp-brand/logo-kit`, not in git):
|
||||
`symbol/warp-symbol-app-icon-1024.png` and `web/favicon.ico`.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `cmd/warp-desktop/` | the binary: the Wails app, window, menu and single-instance lock |
|
||||
| `internal/shell/` | the webview's `http.Handler`: the client through `apps/web/host`, the first-run page and discovery, the settings file, links |
|
||||
| `build/` | packaging inputs: icons, the Windows manifest and version info, `Info.plist`, the nfpm config |
|
||||
|
||||
## Not yet
|
||||
|
||||
These follow from [PLAN.md §8.3](../../PLAN.md):
|
||||
|
||||
- Installers and signing: a Windows installer (NSIS or MSIX) and a signed
|
||||
executable, a universal (arm64 + amd64), Developer ID signed and
|
||||
notarized macOS `.app` in a `.dmg`, an AppImage or Flatpak. `wails3`
|
||||
has tasks for most of these. None of the packages above is built in CI
|
||||
yet.
|
||||
- Native notifications, a tray icon, auto-update.
|
||||
- Sync and encryption in Go (`mautrix-go`) behind the same Pinia stores,
|
||||
with the access token in the OS keychain. Today the desktop app runs
|
||||
matrix-js-sdk in the webview exactly as the browser does. Whether to move
|
||||
this to Go is open question 5 in the plan.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Warp</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Warp</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>warp-desktop</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>io.github.dariuszjaniak.warp</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{{VERSION}}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{{VERSION}}</string>
|
||||
<key>CFBundleGetInfoString</key>
|
||||
<string>A Matrix client</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icons</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.social-networking</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Warp uses the camera for video calls and to scan verification QR codes.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Warp uses the microphone for voice and video calls.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
@@ -0,0 +1,45 @@
|
||||
# Linux packages (deb, rpm, Arch) for warp-desktop, built by
|
||||
# `make desktop-linux` with `wails3 tool package`, which runs nfpm
|
||||
# (https://nfpm.goreleaser.com). Paths are relative to apps/desktop.
|
||||
# VERSION, GOARCH and MAINTAINER come from the Makefile.
|
||||
|
||||
name: warp-desktop
|
||||
arch: ${GOARCH}
|
||||
platform: linux
|
||||
version: ${VERSION}
|
||||
release: "1"
|
||||
section: net
|
||||
priority: optional
|
||||
maintainer: ${MAINTAINER}
|
||||
description: |
|
||||
Warp, a Matrix client: the Warp web client in a native window.
|
||||
|
||||
contents:
|
||||
- src: ../../bin/warp-desktop
|
||||
dst: /usr/bin/warp-desktop
|
||||
file_info:
|
||||
mode: 0755
|
||||
- src: ./build/linux/warp-desktop.desktop
|
||||
dst: /usr/share/applications/warp-desktop.desktop
|
||||
- src: ./cmd/warp-desktop/appicon.png
|
||||
dst: /usr/share/icons/hicolor/256x256/apps/warp-desktop.png
|
||||
- src: ./build/appicon.png
|
||||
dst: /usr/share/icons/hicolor/1024x1024/apps/warp-desktop.png
|
||||
|
||||
scripts:
|
||||
postinstall: ./build/linux/postinstall.sh
|
||||
|
||||
# Wails v3 builds against GTK 4 and WebKitGTK 6.0 by default
|
||||
# (Ubuntu 24.04+, Debian 13+, Fedora 40+).
|
||||
depends:
|
||||
- libgtk-4-1
|
||||
- libwebkitgtk-6.0-4
|
||||
overrides:
|
||||
rpm:
|
||||
depends:
|
||||
- gtk4
|
||||
- webkitgtk6.0
|
||||
archlinux:
|
||||
depends:
|
||||
- gtk4
|
||||
- webkitgtk-6.0
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
# Make the menu entry and icon show up without logging out.
|
||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||
update-desktop-database -q /usr/share/applications || true
|
||||
fi
|
||||
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||
gtk-update-icon-cache -q -t /usr/share/icons/hicolor || true
|
||||
fi
|
||||
exit 0
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=Warp
|
||||
Comment=A Matrix client
|
||||
Exec=/usr/bin/warp-desktop
|
||||
Icon=warp-desktop
|
||||
Terminal=false
|
||||
Categories=Network;InstantMessaging;Chat;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"fixed": {
|
||||
"file_version": "{{VERSION}}",
|
||||
"product_version": "{{VERSION}}"
|
||||
},
|
||||
"info": {
|
||||
"0409": {
|
||||
"ProductName": "Warp",
|
||||
"ProductVersion": "{{VERSION}}",
|
||||
"FileVersion": "{{VERSION}}",
|
||||
"FileDescription": "Warp",
|
||||
"Comments": "A Matrix client",
|
||||
"InternalName": "warp-desktop",
|
||||
"OriginalFilename": "warp-desktop.exe"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//go:build ignore
|
||||
|
||||
// Command mkico writes the Windows icon: the small sizes from the brand
|
||||
// favicon (the symbol's small cut, drawn for 16–48 px) and the large sizes
|
||||
// from the app icon tile. Both inputs are .ico files whose images are PNGs.
|
||||
//
|
||||
// go run build/windows/mkico.go -small favicon.ico -large tile.ico -out icon.ico
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type image struct {
|
||||
size int // pixels; 256 is stored as 0 in the directory
|
||||
data []byte
|
||||
}
|
||||
|
||||
func main() {
|
||||
small := flag.String("small", "", ".ico with the images below -split px")
|
||||
large := flag.String("large", "", ".ico with the images from -split px up")
|
||||
split := flag.Int("split", 64, "the first size taken from -large")
|
||||
out := flag.String("out", "", "output .ico")
|
||||
flag.Parse()
|
||||
|
||||
var images []image
|
||||
for _, in := range []struct {
|
||||
path string
|
||||
keep func(int) bool
|
||||
}{
|
||||
{*small, func(s int) bool { return s < *split }},
|
||||
{*large, func(s int) bool { return s >= *split }},
|
||||
} {
|
||||
all, err := read(in.path)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
for _, im := range all {
|
||||
if in.keep(im.size) {
|
||||
images = append(images, im)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(images, func(i, j int) bool { return images[i].size > images[j].size })
|
||||
if err := os.WriteFile(*out, write(images), 0o644); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
|
||||
func read(path string) ([]image, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) < 6 || binary.LittleEndian.Uint16(b[2:]) != 1 {
|
||||
return nil, fmt.Errorf("%s: not an .ico file", path)
|
||||
}
|
||||
n := int(binary.LittleEndian.Uint16(b[4:]))
|
||||
var images []image
|
||||
for i := 0; i < n; i++ {
|
||||
e := b[6+16*i:]
|
||||
size := int(e[0])
|
||||
if size == 0 {
|
||||
size = 256
|
||||
}
|
||||
length := binary.LittleEndian.Uint32(e[8:])
|
||||
offset := binary.LittleEndian.Uint32(e[12:])
|
||||
data := b[offset : offset+length]
|
||||
if !bytes.HasPrefix(data, []byte("\x89PNG")) {
|
||||
return nil, fmt.Errorf("%s: the %d px image is not a PNG", path, size)
|
||||
}
|
||||
images = append(images, image{size, data})
|
||||
}
|
||||
return images, nil
|
||||
}
|
||||
|
||||
func write(images []image) []byte {
|
||||
var b bytes.Buffer
|
||||
le := binary.LittleEndian
|
||||
binary.Write(&b, le, [3]uint16{0, 1, uint16(len(images))})
|
||||
offset := 6 + 16*len(images)
|
||||
for _, im := range images {
|
||||
dim := byte(im.size)
|
||||
if im.size >= 256 {
|
||||
dim = 0
|
||||
}
|
||||
binary.Write(&b, le, struct {
|
||||
W, H, Colors, Reserved byte
|
||||
Planes, BitCount uint16
|
||||
Length, Offset uint32
|
||||
}{dim, dim, 0, 0, 1, 32, uint32(len(im.data)), uint32(offset)})
|
||||
offset += len(im.data)
|
||||
}
|
||||
for _, im := range images {
|
||||
b.Write(im.data)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func fail(err error) {
|
||||
fmt.Fprintln(os.Stderr, "mkico:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<assemblyIdentity type="win32" name="io.github.dariuszjaniak.warp" version="{{VERSION}}.0" processorArchitecture="*"/>
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
<asmv3:application>
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness>
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
</assembly>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
@@ -0,0 +1,18 @@
|
||||
//go:build !production
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// devBrowserArgs opens the Chrome DevTools Protocol on Windows when
|
||||
// WARP_DESKTOP_REMOTE_DEBUGGING_PORT is set, so a script or Playwright can
|
||||
// drive the app's webview. Production builds leave it out.
|
||||
func devBrowserArgs() []string {
|
||||
if p, err := strconv.Atoi(os.Getenv("WARP_DESKTOP_REMOTE_DEBUGGING_PORT")); err == nil && p > 0 {
|
||||
return []string{"--remote-debugging-port=" + strconv.Itoa(p)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build production
|
||||
|
||||
package main
|
||||
|
||||
func devBrowserArgs() []string { return nil }
|
||||
@@ -0,0 +1,163 @@
|
||||
// Command warp-desktop is the Warp desktop client: the web client in a
|
||||
// native window (Wails v3), talking to the homeserver directly.
|
||||
//
|
||||
// warp-desktop [-homeserver https://matrix.example.org]
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
"github.com/dariuszjaniak/warp/apps/desktop/internal/shell"
|
||||
"github.com/dariuszjaniak/warp/apps/web/client"
|
||||
)
|
||||
|
||||
// Set with -ldflags "-X main.version=...".
|
||||
var version = "0.0.0-dev"
|
||||
|
||||
//go:embed appicon.png
|
||||
var icon []byte
|
||||
|
||||
// appID names the app to the OS: the single-instance lock, and the
|
||||
// directory the settings live in on Linux and macOS.
|
||||
const appID = "io.github.dariuszjaniak.warp"
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "warp-desktop:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
homeserver := flag.String("homeserver", os.Getenv("WARP_DESKTOP_HOMESERVER"),
|
||||
"homeserver base URL for this run, instead of the one chosen in the app (WARP_DESKTOP_HOMESERVER)")
|
||||
showVersion := flag.Bool("version", false, "print the version and exit")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println("warp-desktop", version)
|
||||
return nil
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
dirs, err := dataDirs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
files, built := client.FS()
|
||||
if !built {
|
||||
logger.Warn("the web client was not built into this binary; serving a placeholder page")
|
||||
files = nil
|
||||
}
|
||||
|
||||
var app *application.App
|
||||
sh, err := shell.New(shell.Options{
|
||||
Store: shell.NewStore(filepath.Join(dirs.config, "desktop.json")),
|
||||
Homeserver: *homeserver,
|
||||
Client: files,
|
||||
Version: version,
|
||||
Logger: logger,
|
||||
OpenURL: func(u string) error { return app.Browser.OpenURL(u) },
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var window *application.WebviewWindow
|
||||
app = application.New(application.Options{
|
||||
Name: "Warp",
|
||||
Description: "A Matrix client",
|
||||
Icon: icon,
|
||||
Logger: logger,
|
||||
Assets: application.AssetOptions{Handler: sh},
|
||||
// The client's sessions and encryption keys are in the webview's
|
||||
// storage; two instances on one store would both sync and both
|
||||
// use the same one-time keys.
|
||||
SingleInstance: &application.SingleInstanceOptions{
|
||||
UniqueID: appID,
|
||||
OnSecondInstanceLaunch: func(application.SecondInstanceData) {
|
||||
if window != nil {
|
||||
window.Restore()
|
||||
window.Show()
|
||||
window.Focus()
|
||||
}
|
||||
},
|
||||
},
|
||||
Mac: application.MacOptions{
|
||||
ApplicationShouldTerminateAfterLastWindowClosed: true,
|
||||
},
|
||||
Windows: application.WindowsOptions{
|
||||
WebviewUserDataPath: dirs.webview,
|
||||
AdditionalBrowserArgs: devBrowserArgs(),
|
||||
},
|
||||
})
|
||||
|
||||
window = app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Name: "main",
|
||||
Title: "Warp",
|
||||
Width: 1200,
|
||||
Height: 800,
|
||||
MinWidth: 360,
|
||||
MinHeight: 480,
|
||||
URL: "/",
|
||||
})
|
||||
|
||||
app.Menu.SetApplicationMenu(appMenu(app, window))
|
||||
|
||||
return app.Run()
|
||||
}
|
||||
|
||||
func appMenu(app *application.App, window *application.WebviewWindow) *application.Menu {
|
||||
menu := app.NewMenu()
|
||||
if runtime.GOOS == "darwin" {
|
||||
menu.AddRole(application.AppMenu)
|
||||
}
|
||||
file := menu.AddSubmenu("File")
|
||||
file.Add("Change homeserver…").OnClick(func(*application.Context) {
|
||||
window.SetURL(shell.SetupPath)
|
||||
})
|
||||
file.AddSeparator()
|
||||
if runtime.GOOS == "darwin" {
|
||||
file.AddRole(application.CloseWindow)
|
||||
} else {
|
||||
file.AddRole(application.Quit)
|
||||
}
|
||||
menu.AddRole(application.EditMenu)
|
||||
menu.AddRole(application.ViewMenu)
|
||||
menu.AddRole(application.WindowMenu)
|
||||
return menu
|
||||
}
|
||||
|
||||
type dirs struct {
|
||||
config string // settings
|
||||
webview string // the webview's storage, on Windows
|
||||
}
|
||||
|
||||
func dataDirs() (dirs, error) {
|
||||
cfg, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return dirs{}, err
|
||||
}
|
||||
d := dirs{config: filepath.Join(cfg, "Warp")}
|
||||
if runtime.GOOS == "windows" {
|
||||
// %LocalAppData%: the webview's storage is large and machine-local,
|
||||
// so it doesn't belong in the roaming profile with the settings.
|
||||
local, err := os.UserCacheDir()
|
||||
if err != nil {
|
||||
return dirs{}, err
|
||||
}
|
||||
d.webview = filepath.Join(local, "Warp", "WebView2")
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,21 @@
|
||||
module github.com/dariuszjaniak/warp/apps/desktop
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/dariuszjaniak/warp/apps/web v0.0.0
|
||||
github.com/wailsapp/wails/v3 v3.0.0-beta.27
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/adrg/xdg v0.5.3 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
)
|
||||
|
||||
// The web client and its host package come from this repository.
|
||||
replace github.com/dariuszjaniak/warp/apps/web => ../web
|
||||
@@ -0,0 +1,32 @@
|
||||
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
||||
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
|
||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-beta.27 h1:GpjEHi/lutVxBk7QAIqwLnygKyAhg5qBhr6IfOe+Obc=
|
||||
github.com/wailsapp/wails/v3 v3.0.0-beta.27/go.mod h1:5DR/EG5FHU8+BNV7tQYQ/rJMjoFsVlYpM+0gp1+gcTI=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,128 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Discover turns what someone typed on the first-run page into a homeserver
|
||||
// base URL, the way Matrix clients do (spec: client-server API, server
|
||||
// discovery). The input is a server name ("example.org") or a URL; a
|
||||
// /.well-known/matrix/client on that host may delegate to another base URL;
|
||||
// and whatever comes out has to answer /_matrix/client/versions.
|
||||
//
|
||||
// The errors are written for the person who typed the input.
|
||||
func Discover(ctx context.Context, c *http.Client, input string) (string, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", errors.New("Enter a server name, like example.org, or its address.")
|
||||
}
|
||||
raw := input
|
||||
if !strings.Contains(raw, "://") {
|
||||
raw = "https://" + raw
|
||||
}
|
||||
u, err := parseBase(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("“%s” isn’t a server name or address.", input)
|
||||
}
|
||||
|
||||
delegated, err := wellKnown(ctx, c, u)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if delegated == nil || *delegated == *u {
|
||||
if err := checkVersions(ctx, c, u); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
err = checkVersions(ctx, c, delegated)
|
||||
if err == nil {
|
||||
return delegated.String(), nil
|
||||
}
|
||||
// A server whose well-known names an address that doesn't work (a
|
||||
// development server advertising its public name, say) is still
|
||||
// usable at the address that was typed.
|
||||
if checkVersions(ctx, c, u) == nil {
|
||||
return u.String(), nil
|
||||
}
|
||||
return "", fmt.Errorf("%s points to %s. %w", u.Host, delegated, err)
|
||||
}
|
||||
|
||||
// parseBase accepts an absolute http(s) URL with no query or fragment and
|
||||
// returns it without a trailing slash.
|
||||
func parseBase(raw string) (*url.URL, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil ||
|
||||
u.RawQuery != "" || u.Fragment != "" {
|
||||
return nil, errors.New("not an http(s) base URL")
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/")
|
||||
u.RawPath = ""
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// wellKnown returns the base URL the host delegates to, or nil when it
|
||||
// doesn't say (no document, or one without m.homeserver). A host that can't
|
||||
// be reached at all is not an error here: the versions check reports it.
|
||||
func wellKnown(ctx context.Context, c *http.Client, u *url.URL) (*url.URL, error) {
|
||||
wk := url.URL{Scheme: u.Scheme, Host: u.Host, Path: "/.well-known/matrix/client"}
|
||||
body, status, err := get(ctx, c, wk.String())
|
||||
if err != nil || status != http.StatusOK {
|
||||
return nil, nil
|
||||
}
|
||||
var doc struct {
|
||||
Homeserver *struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
} `json:"m.homeserver"`
|
||||
}
|
||||
if json.Unmarshal(body, &doc) != nil || doc.Homeserver == nil {
|
||||
return nil, nil
|
||||
}
|
||||
base, err := parseBase(doc.Homeserver.BaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s points to “%s”, which isn’t a valid homeserver address.", u.Host, doc.Homeserver.BaseURL)
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func checkVersions(ctx context.Context, c *http.Client, base *url.URL) error {
|
||||
body, status, err := get(ctx, c, base.String()+"/_matrix/client/versions")
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return fmt.Errorf("%s took too long to answer.", base.Host)
|
||||
}
|
||||
return fmt.Errorf("Can’t reach %s.", base.Host)
|
||||
}
|
||||
var v struct {
|
||||
Versions []string `json:"versions"`
|
||||
}
|
||||
if status != http.StatusOK || json.Unmarshal(body, &v) != nil || len(v.Versions) == 0 {
|
||||
return fmt.Errorf("%s doesn’t look like a Matrix homeserver.", base.Host)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func get(ctx context.Context, c *http.Client, u string) ([]byte, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
res, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(res.Body, 64<<10))
|
||||
return body, res.StatusCode, err
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"time"
|
||||
)
|
||||
|
||||
// linkScriptPath serves linkScript, which withLinkScript adds to the
|
||||
// client's index.html. A script of the page's own, rather than one the
|
||||
// webview injects, runs before the app on every platform and is allowed by
|
||||
// the client's Content-Security-Policy (script-src 'self').
|
||||
const linkScriptPath = "/_desktop/links.js"
|
||||
|
||||
// linkScript makes links to other sites open in the system browser. A URL
|
||||
// in a message would otherwise replace the app in its own window, or open a
|
||||
// bare webview window for target="_blank"; this sends those to openPath
|
||||
// instead. Links within the app, and blob: URLs for downloads, are left
|
||||
// alone.
|
||||
const linkScript = `(() => {
|
||||
if (window.__warpDesktopLinks) return
|
||||
window.__warpDesktopLinks = true
|
||||
const external = (href) => {
|
||||
try {
|
||||
const u = new URL(href, location.href)
|
||||
return u.origin !== location.origin && ['http:', 'https:', 'mailto:'].includes(u.protocol) ? u.href : null
|
||||
} catch { return null }
|
||||
}
|
||||
const open = (href) => fetch('/_desktop/open', {
|
||||
method: 'POST', credentials: 'omit',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: href }),
|
||||
}).catch(() => {})
|
||||
const onClick = (e) => {
|
||||
if (e.defaultPrevented || e.button > 1) return
|
||||
const a = e.target instanceof Element ? e.target.closest('a[href]') : null
|
||||
const href = a && external(a.href)
|
||||
if (!href) return
|
||||
e.preventDefault()
|
||||
open(href)
|
||||
}
|
||||
// auxclick is the middle button, which would open a new window.
|
||||
document.addEventListener('click', onClick, true)
|
||||
document.addEventListener('auxclick', onClick, true)
|
||||
const windowOpen = window.open
|
||||
window.open = function (url, ...rest) {
|
||||
const href = url == null ? null : external(String(url))
|
||||
if (href) { open(href); return null }
|
||||
return windowOpen.call(window, url, ...rest)
|
||||
}
|
||||
})()
|
||||
`
|
||||
|
||||
// withLinkScript returns the client with a script tag for linkScript at the
|
||||
// top of index.html's head. ok is false when index.html has no <head> to
|
||||
// add it to; the client is then returned unchanged.
|
||||
func withLinkScript(client fs.FS) (_ fs.FS, ok bool, _ error) {
|
||||
index, err := fs.ReadFile(client, "index.html")
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
tag := []byte(`<head>` + "\n " + `<script src="` + linkScriptPath + `"></script>`)
|
||||
patched := bytes.Replace(index, []byte("<head>"), 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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
`))
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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.
|
||||
File renamed without changes.
@@ -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=...".
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user