diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 191c183..5ab3291 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -375,3 +375,58 @@ jobs: status="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/_warp/admin/v1/stats \ -H "Authorization: Bearer $token")" test "$status" = "404" + + desktop: + name: Desktop client (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-latest] + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: apps/desktop/go.sum + + # Wails builds against GTK 4 and WebKitGTK 6.0 on Linux, with cgo. + - name: Install the Linux webview + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libgtk-4-dev libwebkitgtk-6.0-dev + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Build the web client + run: pnpm --filter @warp/web build + + - name: Verify formatting + if: runner.os == 'Linux' + working-directory: apps/desktop + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "These files need gofmt:" + echo "$unformatted" + exit 1 + fi + + - name: Vet and test + working-directory: apps/desktop + run: | + go vet ./... + go test -count=1 ./... + + - name: Build + working-directory: apps/desktop + shell: bash + run: go build -tags production -o warp-desktop ./cmd/warp-desktop diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 0000000..2c3cee0 --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,140 @@ +name: Desktop release + +# Builds the desktop client for Windows, Linux and macOS, the Windows +# installer, the Linux packages and the update manifest, and keeps them as +# one workflow artifact. Publishing is up to you: put the files and +# warp-desktop.json in one directory on a web server, at the URL in the +# DESKTOP_UPDATE_URL repository variable, which the builds check for +# updates. +# +# Start it by pushing a tag desktop-v1.2.3, or by hand with a version. +# Nothing is signed yet: the Windows build shows SmartScreen's warning and +# the macOS one opens only after "Open anyway" (see apps/desktop/README.md). + +on: + push: + tags: ["desktop-v*"] + workflow_dispatch: + inputs: + version: + description: Version, e.g. 1.2.3 + required: true + +permissions: + contents: read + +env: + GO_VERSION: "1.26" + DESKTOP_VERSION: ${{ inputs.version || github.ref_name }} + DESKTOP_UPDATE_URL: ${{ vars.DESKTOP_UPDATE_URL }} + +jobs: + build: + name: Build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: windows-latest + goos: windows + - os: ubuntu-24.04 + goos: linux + - os: macos-latest + goos: darwin + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: apps/desktop/go.sum + + - name: Version + run: | + v="${DESKTOP_VERSION#desktop-v}" + echo "DESKTOP_VERSION=${v#v}" >> "$GITHUB_ENV" + + - name: Install the Linux webview and packaging tools + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libgtk-4-dev libwebkitgtk-6.0-dev + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Windows binary and installer + if: runner.os == 'Windows' + # The same steps as `make desktop-windows-installer`; the runner has + # NSIS but no make. + run: | + pnpm --filter @warp/web build + (cd apps/desktop && go build -tags production -o ../../bin/warp-desktop.exe \ + -ldflags "-X main.version=$DESKTOP_VERSION -X main.appVersion=$DESKTOP_VERSION -X main.updateURL=$DESKTOP_UPDATE_URL -H windowsgui" \ + ./cmd/warp-desktop) + (cd apps/desktop/build/windows && makensis -V2 -DVERSION="$DESKTOP_VERSION" \ + -DEXE=../../../../bin/warp-desktop.exe -DOUT=../../../../bin/warp-desktop-setup.exe installer.nsi) + mkdir -p dist + cp bin/warp-desktop.exe dist/warp-desktop-windows-amd64.exe + cp bin/warp-desktop-setup.exe "dist/warp-desktop-$DESKTOP_VERSION-setup.exe" + + - name: Linux binary and packages + if: runner.os == 'Linux' + run: | + make desktop-linux DESKTOP_VERSION="$DESKTOP_VERSION" DESKTOP_UPDATE_URL="$DESKTOP_UPDATE_URL" + mkdir -p dist + cp bin/warp-desktop dist/warp-desktop-linux-amd64 + cp bin/*.deb bin/*.rpm bin/*.pkg.tar.zst dist/ + + - name: macOS app + if: runner.os == 'macOS' + run: | + make desktop-macos DESKTOP_VERSION="$DESKTOP_VERSION" DESKTOP_UPDATE_URL="$DESKTOP_UPDATE_URL" + mkdir -p dist + cp bin/Warp.app.zip dist/Warp-darwin-arm64.app.zip + + - uses: actions/upload-artifact@v4 + with: + name: desktop-${{ matrix.goos }} + path: dist/ + if-no-files-found: error + + manifest: + name: Update manifest + needs: build + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: apps/desktop/go.sum + + - uses: actions/download-artifact@v4 + with: + pattern: desktop-* + path: dist + merge-multiple: true + + - name: Write warp-desktop.json + run: | + v="${DESKTOP_VERSION#desktop-v}" + go -C apps/desktop run ./build/manifest -version "${v#v}" -out "$PWD/dist/warp-desktop.json" \ + windows/amd64="$PWD/dist/warp-desktop-windows-amd64.exe" \ + linux/amd64="$PWD/dist/warp-desktop-linux-amd64" \ + darwin/arm64="$PWD/dist/Warp-darwin-arm64.app.zip" + cat dist/warp-desktop.json + + - uses: actions/upload-artifact@v4 + with: + name: warp-desktop-release + path: dist/ diff --git a/Makefile b/Makefile index 4b6362d..aa65bf8 100644 --- a/Makefile +++ b/Makefile @@ -173,9 +173,18 @@ else DESKTOP_LDFLAGS := endif +# A build checks for updates when DESKTOP_UPDATE_URL names its update +# manifest (see desktop-manifest); DESKTOP_VERSION is the version it +# compares. DESKTOP_UPDATE_KEY, a PEM public key, makes it insist on signed +# releases. +DESKTOP_UPDATE_URL ?= +DESKTOP_UPDATE_KEY ?= +DESKTOP_X := -X main.version=$(VERSION) -X main.appVersion=$(DESKTOP_VERSION) \ + -X 'main.updateURL=$(DESKTOP_UPDATE_URL)' -X 'main.updateKey=$(DESKTOP_UPDATE_KEY)' + .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 + cd $(DESKTOP_DIR) && $(GO) build -tags production -ldflags "$(DESKTOP_X) $(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 @@ -225,6 +234,8 @@ desktop-macos: desktop ## Build bin/Warp.app (on 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) + # The updater's macOS artifact: the bundle, zipped. + cd $(BIN_DIR) && rm -f Warp.app.zip && ditto -c -k --keepParent Warp.app Warp.app.zip # Run on Linux: the binary needs cgo. Packages land in bin/. .PHONY: desktop-linux @@ -236,6 +247,30 @@ desktop-linux: desktop ## Build Linux packages of the desktop client (on Linux) -config build/linux/nfpm.yaml -out ../../$(BIN_DIR) || exit 1; \ done +# Run on Windows with NSIS (makensis) on the PATH: a per-user installer, +# bin/warp-desktop-setup.exe. DESKTOP_SIGN, when set, signs the installer +# and uninstaller: a command with %1 for the file. +MAKENSIS ?= makensis +DESKTOP_SIGN ?= + +.PHONY: desktop-windows-installer +desktop-windows-installer: desktop ## Build the Windows installer, bin/warp-desktop-setup.exe + cd $(DESKTOP_DIR)/build/windows && $(MAKENSIS) -V2 -DVERSION=$(DESKTOP_VERSION) \ + -DEXE=../../../../$(BIN_DIR)/warp-desktop.exe -DOUT=../../../../$(BIN_DIR)/warp-desktop-setup.exe \ + $(if $(DESKTOP_SIGN),"-DSIGN=$(DESKTOP_SIGN)") installer.nsi + +# The update manifest for the binaries in DESKTOP_ARTIFACTS (os/arch=file, +# space separated), written to bin/warp-desktop.json. Host it with the +# files, at DESKTOP_UPDATE_URL. +DESKTOP_ARTIFACTS ?= $(GOOS_HOST)/$(GOARCH_HOST)=$(BIN_DIR)/warp-desktop$(EXE) +GOOS_HOST = $(shell $(GO) env GOOS) +GOARCH_HOST = $(shell $(GO) env GOARCH) + +.PHONY: desktop-manifest +desktop-manifest: ## Write the desktop update manifest, bin/warp-desktop.json + $(GO) -C $(DESKTOP_DIR) run ./build/manifest -version $(DESKTOP_VERSION) -out $(CURDIR)/$(BIN_DIR)/warp-desktop.json \ + $(foreach a,$(DESKTOP_ARTIFACTS),$(firstword $(subst =, ,$(a)))=$(CURDIR)/$(lastword $(subst =, ,$(a)))) + # The Android app. Needs Flutter, the Android SDK, and Rust with the Android # targets (flutter_vodozemac compiles the crypto from source); see # apps/mobile/README.md. The APKs land in bin/. diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 1bcd152..cfdf9c3 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -38,12 +38,34 @@ learned from the homeserver at run time and can't be listed in advance. 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. +- **System notifications.** New messages and incoming calls show as the + system's own notifications (Windows toasts, Notification Center, the + freedesktop daemon), one per room; clicking one opens the room. The + client decides what notifies, by the account's push rules + (`apps/web/src/stores/notifications.ts`), and hands it to the shell + through `window.warpDesktop`, which `/_desktop/bridge.js` adds; the shell + shows it with Wails' notifications service. On macOS they need the + bundle (`make desktop-macos`), not the bare binary. +- **The tray and the unread count.** A tray icon (menu-bar icon on macOS) + with Open and Quit. Closing the window hides it and the app keeps + running there, so notifications still arrive; the first time, a + notification says so. **File → Quit** (Ctrl+Q) ends it. The window + title and the tray's tooltip follow the page title, and the number of + unread rooms goes on the taskbar button (Windows) or the Dock icon. On a + Linux desktop with no tray (GNOME without an AppIndicator extension), + starting the app again brings the hidden window back. +- **Updates.** A build that knows its update manifest (see + [Updates](#updates)) checks a minute after it starts and every six hours, + quietly; a newer version brings a notification, and clicking it, or + **Help → Check for updates…**, opens Wails' update window, which + downloads the new binary, checks its SHA-256 (and signature, when the + build pins a key), swaps it in and restarts. ## 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) | +| The chosen homeserver, whether the tray hint was shown | `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 @@ -75,6 +97,8 @@ make desktop # builds the web client, then bin/warp-desktop | `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 | +| `make desktop-windows-installer` | on Windows, with NSIS: `bin/warp-desktop-setup.exe` | +| `make desktop-manifest` | the update manifest, `bin/warp-desktop.json` | A development build (one without `-tags production`) also opens the Chrome DevTools Protocol when `WARP_DESKTOP_REMOTE_DEBUGGING_PORT` is set, on @@ -92,6 +116,15 @@ Linux packaging; the Makefile runs it with `go run` at the 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 installer.** `make desktop-windows-installer` turns the + binary into `bin/warp-desktop-setup.exe` with NSIS + (`build/windows/installer.nsi`). It installs for the current user, in + `%LocalAppData%\Programs\Warp` with a Start menu entry and an entry in + Installed apps, so it needs no administrator rights and the updater can + replace the executable. It points at the WebView2 download when the + runtime is missing. Uninstalling keeps your settings and the webview's + storage, so a reinstall stays signed in. `DESKTOP_SIGN` signs the + installer and uninstaller, given a signing command with `%1` for the file. - **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` @@ -103,7 +136,8 @@ package; `-version` prints the build's git version. `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` + signs the bundle ad hoc, and zips it as `bin/Warp.app.zip` for the + updater. 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 @@ -121,24 +155,52 @@ package; `-version` prints the build's git version. brand kit (`DESIGN/warp-brand/logo-kit`, not in git): `symbol/warp-symbol-app-icon-1024.png` and `web/favicon.ico`. +## Updates + +The app updates itself from a Wails update manifest: one JSON document +with the latest version and, per platform, the file to download and its +SHA-256. Any static web server can host it, next to the files it lists. + +- Build with `DESKTOP_UPDATE_URL` (the manifest's URL) and + `DESKTOP_VERSION` (the version this build is, `1.2.3`). A build without + an update URL never checks, and has no **Help** menu. +- `make desktop-manifest DESKTOP_VERSION=1.2.3 + DESKTOP_ARTIFACTS="windows/amd64=bin/warp-desktop.exe ..."` writes + `bin/warp-desktop.json`; the artifact URLs are the files' names, + relative to the manifest. +- Artifacts are the bare binaries for Windows and Linux, and + `Warp.app.zip` for macOS. The Windows one is `warp-desktop.exe`, not the + installer: the updater swaps the executable in place, which the per-user + install allows. A Linux install from a package (in `/usr`) doesn't + update itself; the package manager does that. +- `DESKTOP_UPDATE_KEY`, a PEM public key (Ed25519 or ECDSA P-256), makes + the build insist on releases signed with its private key. Without one, a + release is checked by its digest only, so the manifest's host is trusted. + +The **Desktop release** workflow (`.github/workflows/desktop-release.yml`, +on a `desktop-v1.2.3` tag or by hand) builds Windows, Linux and macOS, +the installer and the Linux packages, and writes the manifest; set the +`DESKTOP_UPDATE_URL` repository variable to where you will publish them. +It keeps everything as one workflow artifact; publishing is yours to do. + ## 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 | +| `cmd/warp-desktop/` | the binary: the Wails app, window, menu and single-instance lock; notifications, the tray and the badge (`desktop.go`); updates (`update.go`) | +| `internal/shell/` | the webview's `http.Handler`: the client through `apps/web/host`, the first-run page and discovery, the settings file, links, the bridge the client's notifications and title go through | +| `build/` | packaging inputs: icons, the Windows manifest and version info, the NSIS installer script, `Info.plist`, the nfpm config; `build/manifest`, the update manifest writer | ## 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. +- Signing, which needs certificates: an Authenticode-signed executable + and installer (without it SmartScreen warns on first run), a Developer + ID signed and notarized macOS `.app` in a `.dmg` (ad-hoc signed, it + opens elsewhere only after "Open anyway"), and a signing key for update + releases. The hooks are there: `DESKTOP_SIGN`, `DESKTOP_UPDATE_KEY`. +- A universal (arm64 + amd64) macOS build, an AppImage or Flatpak. - 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 diff --git a/apps/desktop/build/manifest/main.go b/apps/desktop/build/manifest/main.go new file mode 100644 index 0000000..b9172a9 --- /dev/null +++ b/apps/desktop/build/manifest/main.go @@ -0,0 +1,116 @@ +// Command manifest writes the update manifest the desktop app checks +// (the Wails endpoint provider's format, schemaVersion 1): the version and +// one artifact per platform, with its SHA-256. +// +// go run ./build/manifest -version 1.2.3 -out bin/warp-desktop.json \ +// windows/amd64=bin/warp-desktop.exe linux/amd64=bin/warp-desktop-linux-amd64 +// +// Artifact URLs are the files' names, which the app resolves against the +// manifest's own URL: host the manifest and the files in one directory. +// -base-url makes them absolute instead. +package main + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +type artifact struct { + URL string `json:"url"` + Filename string `json:"filename"` + Size int64 `json:"size"` + Platform string `json:"platform"` + Arch string `json:"arch"` + DigestAlgo string `json:"digestAlgo"` + Digest string `json:"digest"` +} + +type manifest struct { + SchemaVersion int `json:"schemaVersion"` + Version string `json:"version"` + Name string `json:"name,omitempty"` + Notes string `json:"notes,omitempty"` + PublishedAt string `json:"publishedAt"` + Artifacts []artifact `json:"artifacts"` +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "manifest:", err) + os.Exit(1) + } +} + +func run() error { + version := flag.String("version", "", "the release's version, e.g. 1.2.3 (required)") + notes := flag.String("notes", "", "release notes shown in the update window") + baseURL := flag.String("base-url", "", "make artifact URLs absolute under this URL") + out := flag.String("out", "", "where to write the manifest (default: standard output)") + flag.Parse() + if *version == "" || flag.NArg() == 0 { + return errors.New("usage: manifest -version X [-notes T] [-base-url U] [-out F] os/arch=file ...") + } + m := manifest{ + SchemaVersion: 1, + Version: strings.TrimPrefix(*version, "v"), + Name: "Warp " + strings.TrimPrefix(*version, "v"), + Notes: *notes, + PublishedAt: time.Now().UTC().Format(time.RFC3339), + } + for _, arg := range flag.Args() { + target, path, ok := strings.Cut(arg, "=") + goos, goarch, ok2 := strings.Cut(target, "/") + if !ok || !ok2 || path == "" { + return fmt.Errorf("%q: want os/arch=file", arg) + } + a, err := describe(path) + if err != nil { + return err + } + a.Platform, a.Arch = goos, goarch + if *baseURL != "" { + a.URL = strings.TrimRight(*baseURL, "/") + "/" + a.Filename + } + m.Artifacts = append(m.Artifacts, a) + } + b, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + b = append(b, '\n') + if *out == "" { + _, err = os.Stdout.Write(b) + return err + } + return os.WriteFile(*out, b, 0o644) +} + +func describe(path string) (artifact, error) { + f, err := os.Open(path) + if err != nil { + return artifact{}, err + } + defer f.Close() + h := sha256.New() + n, err := io.Copy(h, f) + if err != nil { + return artifact{}, err + } + name := filepath.Base(path) + return artifact{ + URL: name, + Filename: name, + Size: n, + DigestAlgo: "sha256", + Digest: base64.StdEncoding.EncodeToString(h.Sum(nil)), + }, nil +} diff --git a/apps/desktop/build/windows/installer.nsi b/apps/desktop/build/windows/installer.nsi new file mode 100644 index 0000000..240b1d3 --- /dev/null +++ b/apps/desktop/build/windows/installer.nsi @@ -0,0 +1,103 @@ +; The Windows installer for warp-desktop: `make desktop-windows-installer` +; (needs NSIS's makensis). Installs for the current user, under +; %LocalAppData%\Programs\Warp, so it needs no administrator rights and the +; app's updater can replace its own executable. +; +; makensis -DVERSION=1.2.3 -DEXE=..\..\..\..\bin\warp-desktop.exe -DOUT=..\..\..\..\bin\warp-desktop-setup.exe installer.nsi +; +; Signing: set SIGN to a command that signs the file named by %1, e.g. +; -DSIGN="signtool sign /fd sha256 /tr http://timestamp.digicert.com /td sha256 /a %1" + +Unicode true +ManifestDPIAware true +RequestExecutionLevel user +SetCompressor /SOLID lzma + +!ifndef VERSION + !define VERSION "0.0.0" +!endif +!ifndef EXE + !error "pass -DEXE=path\to\warp-desktop.exe" +!endif +!ifndef OUT + !define OUT "warp-desktop-setup.exe" +!endif + +!define NAME "Warp" +!define APPEXE "warp-desktop.exe" +!define UNINSTKEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Warp" + +!ifdef SIGN + !finalize '${SIGN}' + !uninstfinalize '${SIGN}' +!endif + +Name "${NAME}" +OutFile "${OUT}" +InstallDir "$LOCALAPPDATA\Programs\${NAME}" +InstallDirRegKey HKCU "${UNINSTKEY}" "InstallLocation" + +VIProductVersion "${VERSION}.0" +VIFileVersion "${VERSION}.0" +VIAddVersionKey "ProductName" "${NAME}" +VIAddVersionKey "FileDescription" "${NAME} installer" +VIAddVersionKey "ProductVersion" "${VERSION}" +VIAddVersionKey "FileVersion" "${VERSION}" + +!include "MUI2.nsh" +!define MUI_ICON "icon.ico" +!define MUI_UNICON "icon.ico" +!define MUI_ABORTWARNING +!define MUI_FINISHPAGE_RUN "$INSTDIR\${APPEXE}" +!define MUI_FINISHPAGE_RUN_TEXT "Open Warp" + +!insertmacro MUI_PAGE_WELCOME +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH +!insertmacro MUI_UNPAGE_CONFIRM +!insertmacro MUI_UNPAGE_INSTFILES +!insertmacro MUI_LANGUAGE "English" + +; The app runs in WebView2, which Windows 11 has. On an older Windows +; without it, the installer says where to get it rather than bundling it. +Function .onInit + ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + StrCmp $0 "" 0 found + ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" + StrCmp $0 "" 0 found + MessageBox MB_YESNO|MB_ICONEXCLAMATION "Warp needs the Microsoft Edge WebView2 Runtime, which isn't installed.$\n$\nOpen the download page?" IDNO found + ExecShell "open" "https://developer.microsoft.com/microsoft-edge/webview2/" + found: +FunctionEnd + +Section "Warp" + ; A running Warp holds its executable open. + nsExec::Exec 'taskkill /IM ${APPEXE} /F' + SetOutPath "$INSTDIR" + File "/oname=${APPEXE}" "${EXE}" + WriteUninstaller "$INSTDIR\uninstall.exe" + + CreateShortcut "$SMPROGRAMS\${NAME}.lnk" "$INSTDIR\${APPEXE}" + + WriteRegStr HKCU "${UNINSTKEY}" "DisplayName" "${NAME}" + WriteRegStr HKCU "${UNINSTKEY}" "DisplayVersion" "${VERSION}" + WriteRegStr HKCU "${UNINSTKEY}" "DisplayIcon" "$INSTDIR\${APPEXE}" + WriteRegStr HKCU "${UNINSTKEY}" "InstallLocation" "$INSTDIR" + WriteRegStr HKCU "${UNINSTKEY}" "UninstallString" '"$INSTDIR\uninstall.exe"' + WriteRegStr HKCU "${UNINSTKEY}" "Publisher" "${NAME}" + WriteRegDWORD HKCU "${UNINSTKEY}" "NoModify" 1 + WriteRegDWORD HKCU "${UNINSTKEY}" "NoRepair" 1 +SectionEnd + +; Uninstalling leaves %AppData%\Warp (the chosen homeserver) and the +; webview's storage in %LocalAppData%\Warp\WebView2 (sessions and +; encryption keys), so reinstalling keeps you signed in. +Section "Uninstall" + nsExec::Exec 'taskkill /IM ${APPEXE} /F' + Delete "$SMPROGRAMS\${NAME}.lnk" + Delete "$INSTDIR\${APPEXE}" + Delete "$INSTDIR\uninstall.exe" + RMDir "$INSTDIR" + DeleteRegKey HKCU "${UNINSTKEY}" +SectionEnd diff --git a/apps/desktop/cmd/warp-desktop/desktop.go b/apps/desktop/cmd/warp-desktop/desktop.go new file mode 100644 index 0000000..275022f --- /dev/null +++ b/apps/desktop/cmd/warp-desktop/desktop.go @@ -0,0 +1,211 @@ +package main + +import ( + "encoding/json" + "fmt" + "log/slog" + "regexp" + "strconv" + "sync" + "sync/atomic" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" + "github.com/wailsapp/wails/v3/pkg/services/dock" + "github.com/wailsapp/wails/v3/pkg/services/notifications" + + "github.com/dariuszjaniak/warp/apps/desktop/internal/shell" +) + +// desktop is what the app adds around the window: system notifications, +// the tray icon, the unread count on the taskbar or Dock, and closing to +// the tray. A chat app that quits when its window closes misses the +// messages it was meant to tell you about. +type desktop struct { + app *application.App + window *application.WebviewWindow + tray *application.SystemTray + notes *notifications.NotificationService + dock *dock.DockService + store *shell.Store + logger *slog.Logger + + quitting atomic.Bool + // onUpdate installs an update: an update notification was clicked. + onUpdate func() + + mu sync.Mutex + title string + unread int +} + +func newDesktop(store *shell.Store, logger *slog.Logger) *desktop { + return &desktop{ + notes: notifications.New(), + dock: dock.New(), + store: store, + logger: logger, + } +} + +// services are registered with the app; they start and stop with it. +func (d *desktop) services() []application.Service { + return []application.Service{application.NewService(d.notes), application.NewService(d.dock)} +} + +// shouldQuit is the app's ShouldQuit: every way of quitting (the menus, +// Cmd+Q, signing out of the OS) passes here, and from then on closing the +// window closes it. +func (d *desktop) shouldQuit() bool { + d.quitting.Store(true) + return true +} + +func (d *desktop) quit() { + d.quitting.Store(true) + d.app.Quit() +} + +// attach sets up the tray and window behaviour, once both exist. +func (d *desktop) attach(app *application.App, window *application.WebviewWindow, icon []byte) { + d.app = app + d.window = window + + menu := app.NewMenu() + menu.Add("Open Warp").OnClick(func(*application.Context) { d.show() }) + menu.AddSeparator() + menu.Add("Quit Warp").OnClick(func(*application.Context) { d.quit() }) + d.tray = app.SystemTray.New().SetIcon(icon).SetMenu(menu).OnClick(d.show) + d.tray.SetTooltip("Warp") + + window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + if d.quitting.Load() { + return + } + e.Cancel() + window.Hide() + d.hintOnce() + }) + // The Dock icon of a running app whose window is hidden. + app.Event.OnApplicationEvent(events.Mac.ApplicationShouldHandleReopen, func(*application.ApplicationEvent) { d.show() }) + + d.notes.OnNotificationResponse(d.onNotificationResponse) +} + +func (d *desktop) show() { + d.window.Show() + d.window.Restore() + d.window.Focus() +} + +// hintOnce says, the first time the window is closed, where the app went. +func (d *desktop) hintOnce() { + st, err := d.store.Load() + if err != nil || st.TrayHintShown { + return + } + st.TrayHintShown = true + if err := d.store.Save(st); err != nil { + d.logger.Warn("can't save the settings", "err", err) + } + err = d.notes.SendNotification(notifications.NotificationOptions{ + ID: "warp-tray-hint", + Title: "Warp is still running", + Body: "It keeps checking for messages. Open it or quit from the tray icon.", + Sound: ¬ifications.NotificationSound{Silent: true}, + }) + if err != nil { + d.logger.Warn("can't show a notification", "err", err) + } +} + +// notify is the shell's Notify: one notification per room, replacing the +// room's last one where the system can. No ThreadID: Windows shows it as a +// header, and a room ID is no title. +func (d *desktop) notify(n shell.Notification) error { + return d.notes.SendNotification(notifications.NotificationOptions{ + ID: n.ID, + Title: n.Title, + Body: n.Body, + Data: map[string]any{"roomId": n.RoomID}, + }) +} + +// onNotificationResponse brings the window forward and opens the room of +// the notification that was clicked. +func (d *desktop) onNotificationResponse(r notifications.NotificationResult) { + if r.Error != nil { + d.logger.Warn("notification response", "err", r.Error) + return + } + if action, _ := r.Response.UserInfo["action"].(string); action == "update" && d.onUpdate != nil { + d.onUpdate() + return + } + d.show() + roomID, _ := r.Response.UserInfo["roomId"].(string) + if roomID == "" { + return + } + detail, _ := json.Marshal(roomID) + d.window.ExecJS(fmt.Sprintf(`window.dispatchEvent(new CustomEvent('warp-desktop:open-room', { detail: %s }))`, detail)) +} + +// notifyUpdate says that a newer version is ready to install. +func (d *desktop) notifyUpdate(version string) { + err := d.notes.SendNotification(notifications.NotificationOptions{ + ID: "warp-update", + Title: "Warp " + version + " is available", + Body: "Click to install it, or use Help → Check for updates.", + Data: map[string]any{"action": "update"}, + }) + if err != nil { + d.logger.Warn("can't show a notification", "err", err) + } +} + +// unreadPrefix is how the client's page title counts unread rooms: "(3) Room · Warp". +var unreadPrefix = regexp.MustCompile(`^\((\d+)\) `) + +// unreadIn returns the unread count in a page title, 0 when it has none. +func unreadIn(title string) int { + m := unreadPrefix.FindStringSubmatch(title) + if m == nil { + return 0 + } + n, _ := strconv.Atoi(m[1]) + return n +} + +// setTitle is the shell's SetTitle: the page's title goes to the window +// and the tray, its unread count to the taskbar or Dock. +func (d *desktop) setTitle(title string) { + if title == "" { + title = "Warp" + } + n := unreadIn(title) + d.mu.Lock() + changed, countChanged := title != d.title, n != d.unread + d.title, d.unread = title, n + d.mu.Unlock() + if changed { + d.window.SetTitle(title) + d.tray.SetTooltip(title) + } + if !countChanged { + return + } + var err error + if n == 0 { + err = d.dock.RemoveBadge() + } else { + label := strconv.Itoa(n) + if n > 99 { + label = "99+" + } + err = d.dock.SetBadge(label) + } + if err != nil { + d.logger.Debug("can't set the badge", "err", err) + } +} diff --git a/apps/desktop/cmd/warp-desktop/main.go b/apps/desktop/cmd/warp-desktop/main.go index 123f0d1..2249e4f 100644 --- a/apps/desktop/cmd/warp-desktop/main.go +++ b/apps/desktop/cmd/warp-desktop/main.go @@ -61,14 +61,18 @@ func run() error { files = nil } + store := shell.NewStore(filepath.Join(dirs.config, "desktop.json")) + d := newDesktop(store, logger) var app *application.App sh, err := shell.New(shell.Options{ - Store: shell.NewStore(filepath.Join(dirs.config, "desktop.json")), + Store: store, Homeserver: *homeserver, Client: files, Version: version, Logger: logger, OpenURL: func(u string) error { return app.Browser.OpenURL(u) }, + Notify: d.notify, + SetTitle: d.setTitle, }) if err != nil { return err @@ -81,6 +85,8 @@ func run() error { Icon: icon, Logger: logger, Assets: application.AssetOptions{Handler: sh}, + Services: d.services(), + ShouldQuit: d.shouldQuit, // 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. @@ -113,12 +119,18 @@ func run() error { URL: "/", }) - app.Menu.SetApplicationMenu(appMenu(app, window)) + d.attach(app, window, icon) + updates := setupUpdates(app, logger) + if updates { + d.onUpdate = func() { checkForUpdates(app, logger) } + watchUpdates(app, d, logger) + } + app.Menu.SetApplicationMenu(appMenu(app, window, d, updates, logger)) return app.Run() } -func appMenu(app *application.App, window *application.WebviewWindow) *application.Menu { +func appMenu(app *application.App, window *application.WebviewWindow, d *desktop, updates bool, logger *slog.Logger) *application.Menu { menu := app.NewMenu() if runtime.GOOS == "darwin" { menu.AddRole(application.AppMenu) @@ -131,11 +143,16 @@ func appMenu(app *application.App, window *application.WebviewWindow) *applicati if runtime.GOOS == "darwin" { file.AddRole(application.CloseWindow) } else { - file.AddRole(application.Quit) + // Closing the window keeps the app in the tray; this ends it. + file.Add("Quit").SetAccelerator("CmdOrCtrl+Q").OnClick(func(*application.Context) { d.quit() }) } menu.AddRole(application.EditMenu) menu.AddRole(application.ViewMenu) menu.AddRole(application.WindowMenu) + if updates { + help := menu.AddSubmenu("Help") + help.Add("Check for updates…").OnClick(func(*application.Context) { checkForUpdates(app, logger) }) + } return menu } diff --git a/apps/desktop/cmd/warp-desktop/update.go b/apps/desktop/cmd/warp-desktop/update.go new file mode 100644 index 0000000..eae6f9e --- /dev/null +++ b/apps/desktop/cmd/warp-desktop/update.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "log/slog" + "os" + "runtime" + "strings" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/updater" + "github.com/wailsapp/wails/v3/pkg/updater/providers/endpoint" +) + +// Updates come from a Wails update manifest (the endpoint provider): one +// JSON document listing the latest version and a binary per platform, +// each with its SHA-256. `make desktop-manifest` writes it next to the +// binaries; any static web server can host the lot. +// +// A build checks for updates only when it is told where to look, with +// -ldflags "-X main.updateURL=https://…/warp-desktop.json -X main.appVersion=1.2.3" +// (the Makefile's DESKTOP_UPDATE_URL and DESKTOP_VERSION). updateKey, when +// set, is the PEM public key that release signatures must verify against; +// without one a release is checked by its digest only. +var ( + updateURL string + appVersion string + updateKey string +) + +// A running app checks a minute after it starts, then this often. +const ( + updateDelay = time.Minute + updateInterval = 6 * time.Hour +) + +// setupUpdates configures app.Updater; false when this build has no update +// source, and there is nothing to check. +func setupUpdates(app *application.App, logger *slog.Logger) bool { + if updateURL == "" || appVersion == "" { + return false + } + // A Linux package's binary is the package manager's to replace. + if exe, err := os.Executable(); err == nil && runtime.GOOS == "linux" && strings.HasPrefix(exe, "/usr/") { + logger.Info("updates come from the package manager", "path", exe) + return false + } + p, err := endpoint.New(endpoint.Config{URL: updateURL}) + if err != nil { + logger.Warn("updates are off", "err", err) + return false + } + // No CheckInterval: the updater's own polling opens its window on every + // check, up to date or not. watchUpdates checks quietly instead. + cfg := updater.Config{ + CurrentVersion: appVersion, + Providers: []updater.Provider{p}, + } + if updateKey != "" { + cfg.PublicKey = []byte(updateKey) + } + if err := app.Updater.Init(cfg); err != nil { + logger.Warn("updates are off", "err", err) + return false + } + app.Event.On(updater.EventError, func(e *application.CustomEvent) { + logger.Warn("update", "err", e.Data) + }) + return true +} + +// checkForUpdates is the menu's "Check for updates…": the updater's own +// window says whether there is one and installs it. +func checkForUpdates(app *application.App, logger *slog.Logger) { + go func() { + if err := app.Updater.CheckAndInstall(context.Background()); err != nil { + logger.Warn("update", "err", err) + } + }() +} + +// watchUpdates checks for a newer version in the background, and says so +// with a notification once per version; clicking it starts the install. +func watchUpdates(app *application.App, d *desktop, logger *slog.Logger) { + go func() { + time.Sleep(updateDelay) + told := "" + for { + rel, err := app.Updater.Check(context.Background()) + if err != nil { + logger.Warn("can't check for updates", "err", err) + } else if rel != nil && rel.Version != told { + told = rel.Version + d.notifyUpdate(rel.Version) + } + time.Sleep(updateInterval) + } + }() +} diff --git a/apps/desktop/internal/shell/bridge.go b/apps/desktop/internal/shell/bridge.go new file mode 100644 index 0000000..e59c7fc --- /dev/null +++ b/apps/desktop/internal/shell/bridge.go @@ -0,0 +1,135 @@ +package shell + +import ( + "encoding/json" + "mime" + "net/http" + "strings" + "unicode/utf8" +) + +// bridgeScriptPath serves bridgeScript, which withDesktopScripts adds to +// the client's index.html next to the link script. +const bridgeScriptPath = "/_desktop/bridge.js" + +const ( + // notifyPath shows a system notification: the client's notifications + // store calls window.warpDesktop.notify for new messages and calls. + notifyPath = "/_desktop/notify" + // titlePath takes the page's title, which counts unread rooms, for the + // window title and the tray icon's tooltip. + titlePath = "/_desktop/title" +) + +// bridgeScript is what the client finds of the desktop app: window.warpDesktop +// (see apps/web/src/stores/notifications.ts), and the page title sent along +// whenever it changes. A click on a notification comes back as a +// "warp-desktop:open-room" event, dispatched by the app. +const bridgeScript = `(() => { + if (window.warpDesktop) return + const post = (path, body) => fetch(path, { + method: 'POST', credentials: 'omit', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).catch(() => {}) + window.warpDesktop = { + notify: (n) => post('/_desktop/notify', { id: n.id, title: n.title, body: n.body, roomId: n.roomId }), + } + let last = null + const sendTitle = () => { + if (document.title === last) return + last = document.title + post('/_desktop/title', { title: last }) + } + new MutationObserver(sendTitle).observe(document.head, { subtree: true, childList: true, characterData: true }) + document.addEventListener('DOMContentLoaded', sendTitle) +})() +` + +// Notification is a system notification the client asks for. +type Notification struct { + // ID replaces an earlier notification with the same ID: one per room. + ID string `json:"id"` + Title string `json:"title"` + Body string `json:"body"` + RoomID string `json:"roomId"` +} + +// Longest title and body shown; the system cuts them shorter anyway. +const ( + maxTitle = 200 + maxBody = 1000 +) + +// readJSON decodes a small JSON body. It insists on the JSON content type, +// which a page of another origin can't send without a CORS preflight that +// the shell never answers. +func readJSON(w http.ResponseWriter, r *http.Request, v any) bool { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return false + } + if ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); ct != "application/json" { + http.Error(w, "expected application/json", http.StatusUnsupportedMediaType) + return false + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(v); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return false + } + return true +} + +func (s *Shell) serveNotify(w http.ResponseWriter, r *http.Request) { + var n Notification + if !readJSON(w, r, &n) { + return + } + n.Title = clip(n.Title, maxTitle) + n.Body = clip(n.Body, maxBody) + if n.ID == "" || n.Title == "" { + http.Error(w, "id and title are required", http.StatusBadRequest) + return + } + if s.opts.Notify == nil { + w.WriteHeader(http.StatusNoContent) + return + } + if err := s.opts.Notify(n); err != nil { + s.opts.Logger.Warn("can't show a notification", "err", err) + http.Error(w, "can't show the notification", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Shell) serveTitle(w http.ResponseWriter, r *http.Request) { + var body struct { + Title string `json:"title"` + } + if !readJSON(w, r, &body) { + return + } + if s.opts.SetTitle != nil { + s.opts.SetTitle(clip(body.Title, maxTitle)) + } + w.WriteHeader(http.StatusNoContent) +} + +// clip trims s and cuts it to at most n runes, without control characters. +func clip(s string, n int) string { + s = strings.Map(func(r rune) rune { + if r == '\n' || r == '\t' { + return ' ' + } + if r < 0x20 || r == 0x7f { + return -1 + } + return r + }, strings.TrimSpace(s)) + if utf8.RuneCountInString(s) <= n { + return s + } + return string([]rune(s)[:n-1]) + "…" +} diff --git a/apps/desktop/internal/shell/bridge_test.go b/apps/desktop/internal/shell/bridge_test.go new file mode 100644 index 0000000..edd58ce --- /dev/null +++ b/apps/desktop/internal/shell/bridge_test.go @@ -0,0 +1,60 @@ +package shell + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func postJSON(h http.Handler, target, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestNotify(t *testing.T) { + var got []Notification + s, _ := newShell(t, Options{Notify: func(n Notification) error { got = append(got, n); return nil }}) + + rec := postJSON(s, notifyPath, `{"id":"!a:x","title":" Room\u0007 ","body":"Ann:\nhi","roomId":"!a:x"}`) + if rec.Code != http.StatusNoContent || len(got) != 1 { + t.Fatalf("notify: %d, %v", rec.Code, got) + } + if want := (Notification{ID: "!a:x", Title: "Room", Body: "Ann: hi", RoomID: "!a:x"}); got[0] != want { + t.Errorf("got %+v, want %+v", got[0], want) + } + + // Missing fields, the wrong method and a body that isn't JSON are refused. + if rec := postJSON(s, notifyPath, `{"id":"","title":"x"}`); rec.Code != http.StatusBadRequest { + t.Errorf("no id: %d", rec.Code) + } + if rec := do(s, http.MethodGet, notifyPath, ""); rec.Code != http.StatusMethodNotAllowed { + t.Errorf("GET: %d", rec.Code) + } + if rec := do(s, http.MethodPost, notifyPath, `{"id":"a","title":"b"}`); rec.Code != http.StatusUnsupportedMediaType { + t.Errorf("no content type: %d", rec.Code) + } + if len(got) != 1 { + t.Errorf("refused requests notified: %v", got) + } +} + +func TestTitle(t *testing.T) { + var title string + s, _ := newShell(t, Options{SetTitle: func(v string) { title = v }}) + if rec := postJSON(s, titlePath, `{"title":"(2) Warp"}`); rec.Code != http.StatusNoContent || title != "(2) Warp" { + t.Errorf("title: %d %q", rec.Code, title) + } +} + +func TestClip(t *testing.T) { + if got := clip(" abcdef ", 4); got != "abc…" { + t.Errorf("clip: %q", got) + } + if got := clip("żółw", 4); got != "żółw" { + t.Errorf("clip: %q", got) + } +} diff --git a/apps/desktop/internal/shell/links.go b/apps/desktop/internal/shell/links.go index e563f4e..983b050 100644 --- a/apps/desktop/internal/shell/links.go +++ b/apps/desktop/internal/shell/links.go @@ -6,7 +6,7 @@ import ( "time" ) -// linkScriptPath serves linkScript, which withLinkScript adds to the +// linkScriptPath serves linkScript, which withDesktopScripts 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'). @@ -51,15 +51,18 @@ const linkScript = `(() => { })() ` -// 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 to -// add it to; the client is then returned unchanged. -func withLinkScript(client fs.FS) (_ fs.FS, ok bool, _ error) { +// withDesktopScripts returns the client with script tags for linkScript and +// bridgeScript at the top of index.html's head, so both run before the app. +// ok is false when index.html has no to add them to; the client is +// then returned unchanged. +func withDesktopScripts(client fs.FS) (_ fs.FS, ok bool, _ error) { index, err := fs.ReadFile(client, "index.html") if err != nil { return nil, false, err } - tag := []byte(`` + "\n " + ``) + tag := []byte(`` + + "\n " + `` + + "\n " + ``) patched := bytes.Replace(index, []byte(""), tag, 1) if bytes.Equal(patched, index) { return client, false, nil diff --git a/apps/desktop/internal/shell/settings.go b/apps/desktop/internal/shell/settings.go index 46753af..78706e5 100644 --- a/apps/desktop/internal/shell/settings.go +++ b/apps/desktop/internal/shell/settings.go @@ -15,6 +15,9 @@ 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"` + // TrayHintShown is set once the app has said, the first time its + // window was closed, that it keeps running in the tray. + TrayHintShown bool `json:"tray_hint_shown,omitempty"` } // Store reads and writes Settings as a JSON file. diff --git a/apps/desktop/internal/shell/shell.go b/apps/desktop/internal/shell/shell.go index 1342f7e..5e7ec91 100644 --- a/apps/desktop/internal/shell/shell.go +++ b/apps/desktop/internal/shell/shell.go @@ -54,6 +54,10 @@ type Options struct { // 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 + // Notify shows a system notification; nil drops them. + Notify func(Notification) error + // SetTitle gets the page's title whenever it changes. + SetTitle func(string) } // Shell is the webview's http.Handler. @@ -81,12 +85,12 @@ func New(opts Options) (*Shell, error) { opts.HTTPClient = &http.Client{Timeout: 15 * time.Second} } if opts.Client != nil { - client, ok, err := withLinkScript(opts.Client) + client, ok, err := withDesktopScripts(opts.Client) if err != nil { return nil, err } if !ok { - opts.Logger.Warn("the client's index.html has no ; links to other sites will open in the app's window") + opts.Logger.Warn("the client's index.html has no ; links will open in the app's window, and there are no notifications") } opts.Client = client } @@ -147,12 +151,17 @@ func (s *Shell) ServeHTTP(w http.ResponseWriter, r *http.Request) { case openPath: s.serveOpen(w, r) return + case notifyPath: + s.serveNotify(w, r) + return + case titlePath: + s.serveTitle(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)) + writeScript(w, linkScript) + return + case bridgeScriptPath: + writeScript(w, bridgeScript) return } @@ -170,6 +179,14 @@ func (s *Shell) ServeHTTP(w http.ResponseWriter, r *http.Request) { a.handler.ServeHTTP(w, r) } +func writeScript(w http.ResponseWriter, script string) { + 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(script)) +} + func (s *Shell) serveOpen(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") @@ -232,7 +249,10 @@ func (s *Shell) serveSetup(w http.ResponseWriter, r *http.Request) { 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 rest of the settings stay; a damaged file is replaced. + st, _ := s.opts.Store.Load() + st.Homeserver = base + if err := s.opts.Store.Save(st); err != nil { // The choice still holds for this run. s.opts.Logger.Warn("can't save the settings", "err", err) } diff --git a/apps/desktop/internal/shell/shell_test.go b/apps/desktop/internal/shell/shell_test.go index f16a7e9..20a0d3a 100644 --- a/apps/desktop/internal/shell/shell_test.go +++ b/apps/desktop/internal/shell/shell_test.go @@ -174,13 +174,15 @@ func TestCSPAllowsCallBackends(t *testing.T) { } } -func TestLinkScriptIsAddedToTheClient(t *testing.T) { +func TestDesktopScriptsAreAddedToTheClient(t *testing.T) { s, _ := newShell(t, Options{Homeserver: "http://localhost:8008", Client: fstest.MapFS{ "index.html": {Data: []byte("Warp")}, }}) + want := `` + "\n " + `` + + "\n " + `` 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>`) { + if !strings.Contains(body, want) { t.Errorf("GET %s: %q", p, body) } } @@ -189,6 +191,10 @@ func TestLinkScriptIsAddedToTheClient(t *testing.T) { !strings.Contains(rec.Body.String(), "/_desktop/open") { t.Errorf("links.js: %d %q", rec.Code, rec.Header().Get("Content-Type")) } + rec = do(s, http.MethodGet, bridgeScriptPath, "") + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "window.warpDesktop") { + t.Errorf("bridge.js: %d %q", rec.Code, rec.Body.String()) + } } func TestOpenURL(t *testing.T) { diff --git a/apps/ui/src/components/WComposer.vue b/apps/ui/src/components/WComposer.vue index 5ddbd16..562e73b 100644 --- a/apps/ui/src/components/WComposer.vue +++ b/apps/ui/src/components/WComposer.vue @@ -77,6 +77,8 @@ defineExpose({ focus: () => field.value?.focus() }) @keydown="onKey" /> <WIcon v-if="encrypted" name="lock" :size="16" class="wp-composer-lock mb-3" label="End-to-end encrypted" /> + <!-- More buttons before Send, such as a voice message's record button. --> + <slot name="actions" /> <button type="submit" class="wp-composer-send" aria-label="Send" :disabled="disabled || !model.trim()"> <WIcon name="send" :size="20" /> </button> diff --git a/apps/ui/src/icons/bell-off.svg b/apps/ui/src/icons/bell-off.svg new file mode 100644 index 0000000..5fe8cc0 --- /dev/null +++ b/apps/ui/src/icons/bell-off.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><title>bell-off \ No newline at end of file diff --git a/apps/ui/src/icons/index.ts b/apps/ui/src/icons/index.ts index b929a1f..ba4434d 100644 --- a/apps/ui/src/icons/index.ts +++ b/apps/ui/src/icons/index.ts @@ -5,6 +5,7 @@ export type IconName = | 'arrow-down' | 'attach' | 'bell' + | 'bell-off' | 'check' | 'chevron-left' | 'chevron-right' @@ -29,8 +30,10 @@ export type IconName = | 'minimize' | 'moon' | 'more' + | 'pause' | 'phone' | 'phone-off' + | 'play' | 'plug' | 'plus' | 'react' diff --git a/apps/ui/src/icons/pause.svg b/apps/ui/src/icons/pause.svg new file mode 100644 index 0000000..dfd6840 --- /dev/null +++ b/apps/ui/src/icons/pause.svg @@ -0,0 +1 @@ +pause \ No newline at end of file diff --git a/apps/ui/src/icons/play.svg b/apps/ui/src/icons/play.svg new file mode 100644 index 0000000..a205269 --- /dev/null +++ b/apps/ui/src/icons/play.svg @@ -0,0 +1 @@ +play \ No newline at end of file diff --git a/apps/web/README.md b/apps/web/README.md index 77f166a..a8aaade 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -110,10 +110,34 @@ that one origin. older messages from the backup. Reset encryption when the key is lost. - New rooms (public ones too) and direct messages are end-to-end encrypted by default; Create room has the switch for turning it off. -- Settings: display name, avatar, theme, encryption. +- Voice messages: the microphone button in the composer (while it's + empty) records; the bar shows the time and the level, with discard and + send. They go out as `m.audio` with MSC3245's voice marker and MSC1767's + duration and waveform, Opus in whatever container the engine records + (WebM in Chromium, Ogg in Firefox, MP4 in Safari), encrypted like any file + in an encrypted room. Received ones play in the timeline with their + waveform; click it to seek. +- Notifications for new messages and incoming calls while Warp isn't in + front: the browser's, once you turn them on in Settings (the browser asks + for permission), and the system's own in the [desktop app](../desktop/README.md). + Which messages notify is the account's push rules, the same ones that + decide what a phone is told, evaluated once a message is decrypted, so an + encrypted one shows its text. One notification per room; clicking it + opens the room. +- Simplified Sliding Sync (MSC4186) when the homeserver has it + (`org.matrix.simplified_msc3575` in `/versions`): the first sync asks for + the 20 most recent rooms with a few state events each, then widens to + every room, so the room list appears at once on a big account. It runs + underneath matrix-js-sdk's ordinary `/sync` loop (`src/matrix/sliding.ts` + rewrites each response into the `/sync` shape, as the Android app does), + because the SDK's own sliding sync mode doesn't sort thread replies into + their threads. Settings → This session says which sync is in use and + can switch back to `/sync`. +- Settings: display name, avatar, theme, notifications, encryption. - Phone layout: one pane at a time, the room list is the home screen. -**Not yet:** editing messages, notifications. +**Not yet:** editing messages. Notifications only while a tab is open: +there is no service worker or Web Push. ### Encryption keys in the browser @@ -206,8 +230,8 @@ page saying the client is missing. | Path | What | |---|---| -| `src/matrix/` | everything that touches matrix-js-sdk: the client's lifecycle, the timeline projection, sending, media, encryption (`crypto.ts`, `attachments.ts`) | -| `src/stores/` | session, room list and connection state, encryption and verification (`security.ts`), calls (`call.ts`), profile, toasts (Pinia) | +| `src/matrix/` | everything that touches matrix-js-sdk: the client's lifecycle, sliding sync (`sliding.ts`), the timeline projection, sending, voice recording (`voice.ts`), media, encryption (`crypto.ts`, `attachments.ts`) | +| `src/stores/` | session, room list and connection state, encryption and verification (`security.ts`), calls (`call.ts`), notifications, profile, toasts (Pinia) | | `src/views/` | sign-in, sign-up, home, room | | `src/components/` | timeline, message body, room info, dialogs | | `cmd/warp-web/` | the binary | diff --git a/apps/web/src/components/MessageBody.vue b/apps/web/src/components/MessageBody.vue index 15670d6..a44646d 100644 --- a/apps/web/src/components/MessageBody.vue +++ b/apps/web/src/components/MessageBody.vue @@ -5,6 +5,7 @@ import { formatBytes } from '@/lib/format' import { linkify } from '@/lib/linkify' import { downloadMedia, mediaUrl, useMedia, type MediaSource } from '@/matrix/media' import { mediaSource, type MessageItem } from '@/matrix/timeline' +import VoicePlayer from './VoicePlayer.vue' // What goes inside a bubble: text with links, an image, a player or a file // card. Message text is never rendered as HTML. @@ -92,6 +93,14 @@ async function download() { + + +
- +
+ +
+ + + diff --git a/apps/web/src/components/SettingsDialog.vue b/apps/web/src/components/SettingsDialog.vue index 717c15c..3e9746f 100644 --- a/apps/web/src/components/SettingsDialog.vue +++ b/apps/web/src/components/SettingsDialog.vue @@ -7,7 +7,9 @@ import MxcAvatar from '@/components/MxcAvatar.vue' import SessionsDialog from '@/components/SessionsDialog.vue' import { config } from '@/config' import { errorMessage, localpart } from '@/lib/format' -import { cryptoError } from '@/matrix/client' +import { cryptoError, usingSlidingSync } from '@/matrix/client' +import { setSlidingEnabled, slidingEnabled } from '@/matrix/sliding' +import { useNotifications } from '@/stores/notifications' import { useProfile } from '@/stores/profile' import { useSecurity } from '@/stores/security' import { useSession } from '@/stores/session' @@ -21,6 +23,15 @@ const toasts = useToasts() const router = useRouter() const theme = useTheme() const security = useSecurity() +const notifications = useNotifications() + +// Sliding sync takes effect on the next start of the client. +const sliding = ref(slidingEnabled()) +function toggleSliding() { + sliding.value = !sliding.value + setSlidingEnabled(sliding.value) + window.location.reload() +} const name = ref('') const savingName = ref(false) @@ -137,6 +148,25 @@ const themes: { value: ThemeChoice; label: string; icon: IconName }[] = [ +
+

Notifications

+
+ +
+

{{ notifications.active ? 'Notifications are on' : 'Notifications are off' }}

+

+ + + +

+
+ +
+
+

Encryption

@@ -183,6 +213,12 @@ const themes: { value: ThemeChoice; label: string; icon: IconName }[] = [

This session

Session ID {{ session.deviceId }}

Warp web {{ config.version }}

+

+ Sync: {{ usingSlidingSync() ? 'Simplified Sliding Sync' : '/sync' }} + +

diff --git a/apps/web/src/components/VoicePlayer.vue b/apps/web/src/components/VoicePlayer.vue new file mode 100644 index 0000000..a89e063 --- /dev/null +++ b/apps/web/src/components/VoicePlayer.vue @@ -0,0 +1,115 @@ + + + diff --git a/apps/web/src/layouts/ClientShell.vue b/apps/web/src/layouts/ClientShell.vue index f2de6a5..0eec437 100644 --- a/apps/web/src/layouts/ClientShell.vue +++ b/apps/web/src/layouts/ClientShell.vue @@ -15,8 +15,10 @@ import SpacePicker from '@/components/SpacePicker.vue' import StartChatDialog from '@/components/StartChatDialog.vue' import VerificationDialog from '@/components/VerificationDialog.vue' import { formatListTime, localpart } from '@/lib/format' +import { client } from '@/matrix/client' import { useCall } from '@/stores/call' import { useChat } from '@/stores/chat' +import { useNotifications } from '@/stores/notifications' import { useProfile } from '@/stores/profile' import { useSecurity } from '@/stores/security' import { useSession } from '@/stores/session' @@ -26,6 +28,7 @@ const chat = useChat() const call = useCall() const profile = useProfile() const security = useSecurity() +const notifications = useNotifications() const route = useRoute() const router = useRouter() @@ -79,6 +82,7 @@ async function start() { chat.attach() security.attach() call.attach() + notifications.attach(client()) profile.load() } } catch (e) { diff --git a/apps/web/src/matrix/client.ts b/apps/web/src/matrix/client.ts index 369271e..09901b3 100644 --- a/apps/web/src/matrix/client.ts +++ b/apps/web/src/matrix/client.ts @@ -1,6 +1,7 @@ import { createClient, PendingEventOrdering, type MatrixClient } from 'matrix-js-sdk' import { config } from '@/config' import { bindCrypto, cryptoCallbacks, cryptoDatabasePrefix } from './crypto' +import { slidingEnabled, slidingFeature, slidingFetch } from './sliding' // The one MatrixClient of a signed-in session. It is kept outside Vue's // reactivity on purpose: the SDK's objects mutate themselves and emit @@ -35,6 +36,24 @@ function decodeKey(b64: string): Uint8Array { let current: MatrixClient | null = null let cryptoFailure = '' +let sliding = false + +/** Whether this session syncs with Simplified Sliding Sync (see ./sliding.ts). */ +export function usingSlidingSync(): boolean { + return sliding +} + +/** Whether the homeserver has Simplified Sliding Sync; false when it can't be asked. */ +async function serverHasSliding(baseUrl: string): Promise { + try { + const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/_matrix/client/versions`, { credentials: 'omit' }) + if (!res.ok) return false + const body = (await res.json()) as { unstable_features?: Record } + return body.unstable_features?.[slidingFeature] === true + } catch { + return false + } +} /** The running client. Only call this while signed in. */ export function client(): MatrixClient { @@ -61,6 +80,8 @@ export function anonymousClient(): MatrixClient { export async function startClient(creds: Credentials): Promise { stopClient() + sliding = slidingEnabled() && (await serverHasSliding(creds.baseUrl)) + console.info(sliding ? 'sync: Simplified Sliding Sync' : 'sync: /sync') const c = createClient({ baseUrl: creds.baseUrl, userId: creds.userId, @@ -75,6 +96,7 @@ export async function startClient(creds: Credentials): Promise { // Emoji (SAS), and QR codes both ways: shown here, or scanned with // the camera. verificationMethods: ['m.sas.v1', 'm.qr_code.show.v1', 'm.qr_code.scan.v1', 'm.reciprocate.v1'], + fetchFn: sliding ? slidingFetch(creds.userId) : undefined, }) current = c // End-to-end encryption: the Rust crypto stack (matrix-sdk-crypto-wasm), @@ -110,6 +132,7 @@ export async function startClient(creds: Credentials): Promise { export function stopClient() { stopHeartbeat() + sliding = false if (!current) return bindCrypto(null) current.stopClient() diff --git a/apps/web/src/matrix/send.ts b/apps/web/src/matrix/send.ts index 8515a2b..c26265f 100644 --- a/apps/web/src/matrix/send.ts +++ b/apps/web/src/matrix/send.ts @@ -1,6 +1,7 @@ import { EventType, MsgType, RelationType, type MatrixClient, type MatrixEvent, type Room } from 'matrix-js-sdk' import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types' import { encryptAttachment } from './attachments' +import { extensionFor, type Recording } from './voice' // Sending: text, and files uploaded to the media repository first. Your own // message appears at once as a local echo with status "sending"; the SDK @@ -134,16 +135,7 @@ export async function sendFile( Object.assign(info, { thumbnail_url: up.content_uri, thumbnail_info: thumbInfo }) } } - let source: { url: string } | { file: object } - if (encrypted) { - const { data, file: keys } = await encryptAttachment(await file.arrayBuffer()) - // The name and type stay inside the encrypted message. - const upload = await c.uploadContent(new Blob([data]), { type: 'application/octet-stream', includeFilename: false, progressHandler }) - source = { file: { ...keys, url: upload.content_uri } } - } else { - const upload = await c.uploadContent(file, { name: file.name, type: file.type || 'application/octet-stream', progressHandler }) - source = { url: upload.content_uri } - } + const source = await upload(c, encrypted, file, file.name, progressHandler) const content = { msgtype, body: file.name, @@ -153,3 +145,40 @@ export async function sendFile( } as RoomMessageEventContent return c.sendMessage(room.roomId, target.threadId ?? null, withTarget(content, target)) } + +/** Uploads a file, encrypted first in an encrypted room: the message's url, or its file. */ +async function upload( + c: MatrixClient, + encrypted: boolean, + blob: Blob, + name: string, + progressHandler?: (p: { loaded: number; total: number }) => void, +): Promise<{ url: string } | { file: object }> { + if (encrypted) { + const { data, file: keys } = await encryptAttachment(await blob.arrayBuffer()) + // The name and type stay inside the encrypted message. + const up = await c.uploadContent(new Blob([data]), { type: 'application/octet-stream', includeFilename: false, progressHandler }) + return { file: { ...keys, url: up.content_uri } } + } + const up = await c.uploadContent(blob, { name, type: blob.type || 'application/octet-stream', progressHandler }) + return { url: up.content_uri } +} + +/** + * Sends a voice message: m.audio marked as voice (MSC3245), with its + * duration and waveform (MSC1767) so it draws before it is downloaded. + */ +export async function sendVoice(c: MatrixClient, room: Room, rec: Recording, target: SendTarget = {}) { + const filename = `Voice message.${extensionFor(rec.mimetype)}` + const source = await upload(c, room.hasEncryptionStateEvent(), rec.blob, filename) + const content = { + msgtype: MsgType.Audio, + body: 'Voice message', + filename, + ...source, + info: { mimetype: rec.mimetype, size: rec.blob.size, duration: rec.durationMs }, + 'org.matrix.msc1767.audio': { duration: rec.durationMs, waveform: rec.waveform }, + 'org.matrix.msc3245.voice': {}, + } as unknown as RoomMessageEventContent + return c.sendMessage(room.roomId, target.threadId ?? null, withTarget(content, target)) +} diff --git a/apps/web/src/matrix/sliding.ts b/apps/web/src/matrix/sliding.ts new file mode 100644 index 0000000..abdd887 --- /dev/null +++ b/apps/web/src/matrix/sliding.ts @@ -0,0 +1,297 @@ +// Simplified Sliding Sync (MSC4186), underneath matrix-js-sdk's /sync. +// +// The SDK has a sliding sync mode of its own, but it doesn't sort thread +// replies into their threads, which the room and thread views rely on. +// Instead, the client's fetch answers the SDK's GET /sync with a sliding +// sync request and rewrites the response into the /sync shape. The SDK's +// sync loop, threads, decryption and receipts then work unchanged. The +// Android app does the same (apps/mobile/lib/matrix/sliding_sync.dart). +// +// What it buys is the first sync. A classic initial sync sends every room +// with its full state before anything can be drawn; here the first request +// asks for the 20 most recent rooms with a handful of state events each, +// and the next requests widen the window to every room. +// +// The SDK's sync token becomes `ss1||`. None, or a +// classic one, starts a new connection; so does M_UNKNOWN_POS, the server +// saying it has forgotten the connection (after a restart, or idle time). + +export const slidingFeature = 'org.matrix.simplified_msc3575' + +const firstWindow = 20 +const maxWindow = 1000 +const connId = 'warp-web' +const tokenPrefix = 'ss1|' + +/** The state each room is sent with: what the screens draw, what encryption needs, and calls. */ +export const requiredState: string[][] = [ + ['m.room.create', ''], + ['m.room.name', ''], + ['m.room.avatar', ''], + ['m.room.topic', ''], + ['m.room.canonical_alias', ''], + ['m.room.encryption', ''], + ['m.room.power_levels', ''], + ['m.room.join_rules', ''], + ['m.room.history_visibility', ''], + ['m.room.guest_access', ''], + ['m.room.tombstone', ''], + ['m.room.pinned_events', ''], + ['m.space.child', '*'], + ['m.space.parent', '*'], + ['m.room.member', '$LAZY'], + ['m.room.member', '$ME'], + ['org.matrix.msc3401.call.member', '*'], +] + +export interface SlidingToken { + pos?: string + toDeviceSince?: string +} + +export function parseToken(token: string | null | undefined): SlidingToken { + if (!token?.startsWith(tokenPrefix)) return {} + const [pos, toDevice] = token.slice(tokenPrefix.length).split('|') + return { pos: pos || undefined, toDeviceSince: toDevice || undefined } +} + +export function formatToken(t: SlidingToken): string { + return `${tokenPrefix}${t.pos ?? ''}|${t.toDeviceSince ?? ''}` +} + +/** The request body: rooms and direct chats over the same window, and the extensions encryption needs. */ +export function slidingRequest(window: number, toDeviceSince?: string): Record { + const ranges = [[0, window - 1]] + return { + conn_id: connId, + lists: { + rooms: { ranges, timeline_limit: 10, required_state: requiredState, filters: { is_dm: false } }, + // A direct chat's two members come with it, so its name comes out right. + dms: { ranges, timeline_limit: 10, required_state: [...requiredState, ['m.room.member', '*']], filters: { is_dm: true } }, + }, + extensions: { + to_device: { enabled: true, ...(toDeviceSince ? { since: toDeviceSince } : {}) }, + e2ee: { enabled: true }, + account_data: { enabled: true }, + receipts: { enabled: true }, + typing: { enabled: true }, + }, + } +} + +type Json = Record + +const isObj = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v) +const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : []) + +/** The user's membership as of the last of the events about them. */ +function ownMembership(events: unknown[], userId: string): string | undefined { + let m: string | undefined + for (const e of events) { + if (isObj(e) && e.type === 'm.room.member' && e.state_key === userId && isObj(e.content) && typeof e.content.membership === 'string') { + m = e.content.membership + } + } + return m +} + +/** + * Rewrites a sliding sync response into the /sync response with the same + * news. A room with invite_state is an invite, one with knock_state a + * knock. Any other room is joined, unless the user's own latest membership + * in it is a leave or ban: sliding sync keeps rooms the user left while the + * connection was open in its lists, which is how a client hears they're gone. + */ +export function slidingToSync(response: Json, userId: string, nextBatch: string): Json { + const join: Record = {} + const invite: Json = {} + const knock: Json = {} + const leave: Record = {} + const joined = (roomId: string) => (join[roomId] ??= {}) + + const rooms = isObj(response.rooms) ? response.rooms : {} + for (const [roomId, room] of Object.entries(rooms)) { + if (!isObj(room)) continue + if (Array.isArray(room.invite_state)) { + invite[roomId] = { invite_state: { events: room.invite_state } } + continue + } + if (Array.isArray(room.knock_state)) { + knock[roomId] = { knock_state: { events: room.knock_state } } + continue + } + const state = list(room.required_state) + const timeline = list(room.timeline) + const timelineJson: Json = { events: timeline, limited: room.limited === true } + if (typeof room.prev_batch === 'string') timelineJson.prev_batch = room.prev_batch + + const own = ownMembership([...state, ...timeline], userId) + if (own === 'leave' || own === 'ban') { + leave[roomId] = { state: { events: state }, timeline: timelineJson } + continue + } + const summary: Json = {} + if (Array.isArray(room.heroes)) { + summary['m.heroes'] = room.heroes.filter((h) => isObj(h) && typeof h.user_id === 'string').map((h) => (h as Json).user_id) + } + if (typeof room.joined_count === 'number') summary['m.joined_member_count'] = room.joined_count + if (typeof room.invited_count === 'number') summary['m.invited_member_count'] = room.invited_count + Object.assign(joined(roomId), { + state: { events: state }, + timeline: timelineJson, + summary, + unread_notifications: { + notification_count: typeof room.notification_count === 'number' ? room.notification_count : 0, + highlight_count: typeof room.highlight_count === 'number' ? room.highlight_count : 0, + }, + }) + } + + const out: Json = { next_batch: nextBatch } + const ext = isObj(response.extensions) ? response.extensions : {} + + // Typing and receipts are per-room ephemeral events. + for (const name of ['typing', 'receipts']) { + const e = ext[name] + const byRoom = isObj(e) && isObj(e.rooms) ? e.rooms : {} + for (const [roomId, ev] of Object.entries(byRoom)) { + // Sent as an invite, knock or leave: no ephemeral events there. + if (!join[roomId] && rooms[roomId] !== undefined) continue + const r = joined(roomId) + const eph = (r.ephemeral ??= { events: [] }) as { events: unknown[] } + eph.events.push(ev) + } + } + + const accountData = ext.account_data + if (isObj(accountData)) { + if (Array.isArray(accountData.global)) out.account_data = { events: accountData.global } + if (isObj(accountData.rooms)) { + for (const [roomId, events] of Object.entries(accountData.rooms)) { + if (leave[roomId]) leave[roomId].account_data = { events } + else if (!(roomId in invite) && !(roomId in knock)) joined(roomId).account_data = { events } + } + } + } + + const toDevice = ext.to_device + if (isObj(toDevice) && Array.isArray(toDevice.events)) out.to_device = { events: toDevice.events } + + const e2ee = ext.e2ee + if (isObj(e2ee)) { + if (isObj(e2ee.device_lists)) out.device_lists = e2ee.device_lists + if (isObj(e2ee.device_one_time_keys_count)) out.device_one_time_keys_count = e2ee.device_one_time_keys_count + if (Array.isArray(e2ee.device_unused_fallback_key_types)) out.device_unused_fallback_key_types = e2ee.device_unused_fallback_key_types + } + + const anyRooms = Object.keys(join).length + Object.keys(invite).length + Object.keys(knock).length + Object.keys(leave).length + if (anyRooms) { + const r: Json = {} + if (Object.keys(join).length) r.join = join + if (Object.keys(invite).length) r.invite = invite + if (Object.keys(knock).length) r.knock = knock + if (Object.keys(leave).length) r.leave = leave + out.rooms = r + } + return out +} + +/** + * A fetch for the SDK that answers GET /sync with sliding sync, and passes + * every other request through. + */ +export function slidingFetch(userId: string, base: typeof fetch = globalThis.fetch.bind(globalThis)): typeof fetch { + let window = firstWindow + + async function slide(syncUrl: URL, init: RequestInit | undefined, token: SlidingToken): Promise { + const url = new URL(syncUrl.href.replace(/\/v3\/sync(\?.*)?$/, `/unstable/${slidingFeature}/sync`)) + if (token.pos) { + url.searchParams.set('pos', token.pos) + // A new connection answers at once; only an established one waits. + const timeout = syncUrl.searchParams.get('timeout') + if (timeout) url.searchParams.set('timeout', timeout) + } + const presence = syncUrl.searchParams.get('set_presence') + if (presence) url.searchParams.set('set_presence', presence) + const headers = new Headers(init?.headers) + headers.set('Content-Type', 'application/json') + return base(url, { + method: 'POST', + headers, + body: JSON.stringify(slidingRequest(window, token.toDeviceSince)), + signal: init?.signal, + credentials: init?.credentials, + }) + } + + return async (input, init) => { + const href = input instanceof Request ? input.url : String(input) + const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase() + let syncUrl: URL + try { + syncUrl = new URL(href) + } catch { + return base(input, init) + } + // The SDK loads a room's members "at" its sync token, which here is + // ours, not the server's: without it the server answers as of now. + const at = syncUrl.searchParams.get('at') + if (at?.startsWith(tokenPrefix)) { + syncUrl.searchParams.delete('at') + return base(syncUrl, init) + } + if (method !== 'GET' || !syncUrl.pathname.endsWith('/_matrix/client/v3/sync')) return base(input, init) + + let token = parseToken(syncUrl.searchParams.get('since')) + let res = await slide(syncUrl, init, token) + if (res.status === 400) { + const err = await res.clone().json().catch(() => null) + if (isObj(err) && err.errcode === 'M_UNKNOWN_POS') { + console.info('sliding sync: the connection expired; starting a new one') + window = firstWindow + token = { toDeviceSince: token.toDeviceSince } + res = await slide(syncUrl, init, token) + } + } + // Errors go to the SDK as they are: it knows M_UNKNOWN_TOKEN and backs off on the rest. + if (!res.ok) return res + const body = (await res.json()) as Json + + // Widen the window to every room once the first ones are drawn. + if (isObj(body.lists)) { + let count = 0 + for (const l of Object.values(body.lists)) if (isObj(l) && typeof l.count === 'number') count = Math.max(count, l.count) + if (count > window) window = Math.min(count, maxWindow) + } + const toDevice = isObj(body.extensions) && isObj(body.extensions.to_device) ? body.extensions.to_device : {} + const next: SlidingToken = { + pos: typeof body.pos === 'string' ? body.pos : undefined, + toDeviceSince: typeof toDevice.next_batch === 'string' ? toDevice.next_batch : token.toDeviceSince, + } + return new Response(JSON.stringify(slidingToSync(body, userId, formatToken(next))), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } +} + +// Sliding sync is used when the homeserver has it, unless turned off in +// Settings (for a server whose implementation misbehaves). +const offKey = 'warp.web.slidingSync' + +export function slidingEnabled(): boolean { + try { + return localStorage.getItem(offKey) !== 'off' + } catch { + return true + } +} + +export function setSlidingEnabled(on: boolean) { + try { + if (on) localStorage.removeItem(offKey) + else localStorage.setItem(offKey, 'off') + } catch { + // Not kept: the default (on) holds. + } +} diff --git a/apps/web/src/matrix/timeline.ts b/apps/web/src/matrix/timeline.ts index 8f300dd..f2078f2 100644 --- a/apps/web/src/matrix/timeline.ts +++ b/apps/web/src/matrix/timeline.ts @@ -37,6 +37,10 @@ export interface MediaInfo { w?: number h?: number filename: string + /** Audio and video length, in ms. */ + duration?: number + /** Set on a voice message (MSC3245): its waveform, 0–1024 per point. */ + voice?: { waveform: number[] } } /** The message a reply answers, quoted above it. */ @@ -315,8 +319,13 @@ function mediaOf(content: RoomMessageEventContent): MediaInfo | undefined { thumbnail_url?: string thumbnail_file?: EncryptedFile thumbnail_info?: { mimetype?: string } + duration?: number } + 'org.matrix.msc1767.audio'?: { duration?: number; waveform?: unknown } + 'org.matrix.msc3245.voice'?: object } + const audio = c['org.matrix.msc1767.audio'] + const waveform = Array.isArray(audio?.waveform) ? audio.waveform.filter((n): n is number => typeof n === 'number') : [] return { mxc: c.url ?? c.file?.url, thumbMxc: c.info?.thumbnail_url ?? c.info?.thumbnail_file?.url, @@ -328,6 +337,8 @@ function mediaOf(content: RoomMessageEventContent): MediaInfo | undefined { w: c.info?.w, h: c.info?.h, filename: c.filename || c.body || 'file', + duration: typeof c.info?.duration === 'number' ? c.info.duration : typeof audio?.duration === 'number' ? audio.duration : undefined, + voice: msgtype === MsgType.Audio && c['org.matrix.msc3245.voice'] ? { waveform } : undefined, } } @@ -388,7 +399,7 @@ export function quoteText(ev: MatrixEvent): string { case MsgType.Video: return 'Video' case MsgType.Audio: - return 'Audio' + return content['org.matrix.msc3245.voice'] ? 'Voice message' : 'Audio' case MsgType.File: return `File: ${content.body ?? ''}` } diff --git a/apps/web/src/matrix/voice.ts b/apps/web/src/matrix/voice.ts new file mode 100644 index 0000000..ba59185 --- /dev/null +++ b/apps/web/src/matrix/voice.ts @@ -0,0 +1,135 @@ +// Recording voice messages: the microphone through MediaRecorder, and a +// waveform sampled while it records. A voice message is an m.audio with +// MSC3245's voice marker and MSC1767's duration and waveform, as Element +// and the other clients send it. + +export interface Recording { + blob: Blob + /** Without codec parameters: what the message's info.mimetype says. */ + mimetype: string + durationMs: number + /** 0–1024 per point, as MSC3246 has it. */ + waveform: number[] +} + +// Opus in whichever container the engine records: Ogg (Firefox), WebM +// (Chromium, WebView2) or MP4 (Safari, WKWebView). +const candidates = ['audio/ogg;codecs=opus', 'audio/webm;codecs=opus', 'audio/mp4', 'audio/webm'] + +export function canRecord(): boolean { + return typeof MediaRecorder !== 'undefined' && Boolean(navigator.mediaDevices?.getUserMedia) +} + +/** How many points a sent waveform has. */ +const waveformPoints = 100 +/** The longest voice message; recording stops there. */ +export const maxRecordingMs = 10 * 60_000 + +export class VoiceRecorder { + private stream?: MediaStream + private recorder?: MediaRecorder + private audio?: AudioContext + private analyser?: AnalyserNode + private chunks: Blob[] = [] + private samples: number[] = [] + private timer = 0 + private started = 0 + + /** Called about ten times a second with the elapsed time and the current level (0–1). */ + onTick?: (elapsedMs: number, level: number) => void + /** Called when recording stops by itself at maxRecordingMs. */ + onLimit?: () => void + + async start() { + this.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } }) + const mimeType = candidates.find((t) => MediaRecorder.isTypeSupported(t)) + this.recorder = new MediaRecorder(this.stream, mimeType ? { mimeType, audioBitsPerSecond: 48_000 } : undefined) + this.chunks = [] + this.samples = [] + this.recorder.ondataavailable = (e) => e.data.size && this.chunks.push(e.data) + this.audio = new AudioContext() + this.analyser = this.audio.createAnalyser() + this.analyser.fftSize = 1024 + this.audio.createMediaStreamSource(this.stream).connect(this.analyser) + const buf = new Float32Array(this.analyser.fftSize) + this.recorder.start(1000) + this.started = performance.now() + this.timer = window.setInterval(() => { + this.analyser!.getFloatTimeDomainData(buf) + let sum = 0 + for (const v of buf) sum += v * v + // RMS, lifted so speech fills the range. + const level = Math.min(1, Math.sqrt(sum / buf.length) * 4) + this.samples.push(level) + const elapsed = performance.now() - this.started + this.onTick?.(elapsed, level) + if (elapsed >= maxRecordingMs) this.onLimit?.() + }, 100) + } + + /** Stops and returns the recording. */ + stop(): Promise { + const rec = this.recorder + if (!rec) return Promise.reject(new Error('Not recording')) + const durationMs = Math.round(performance.now() - this.started) + return new Promise((resolve, reject) => { + rec.onstop = () => { + const type = (rec.mimeType || this.chunks[0]?.type || 'audio/webm').split(';')[0]!.trim() + const blob = new Blob(this.chunks, { type }) + const waveform = resample(this.samples, waveformPoints) + this.release() + if (!blob.size) reject(new Error('Nothing was recorded')) + else resolve({ blob, mimetype: type, durationMs, waveform }) + } + rec.stop() + }) + } + + /** Stops and throws the recording away. */ + cancel() { + if (this.recorder && this.recorder.state !== 'inactive') { + this.recorder.onstop = null + this.recorder.stop() + } + this.release() + } + + private release() { + window.clearInterval(this.timer) + this.timer = 0 + for (const t of this.stream?.getTracks() ?? []) t.stop() + void this.audio?.close().catch(() => undefined) + this.stream = undefined + this.recorder = undefined + this.audio = undefined + this.analyser = undefined + } +} + +/** Averages levels (0–1) down to n points of 0–1024. */ +export function resample(levels: number[], n: number): number[] { + if (!levels.length) return [] + const out: number[] = [] + const count = Math.min(n, levels.length) + for (let i = 0; i < count; i++) { + const from = Math.floor((i * levels.length) / count) + const to = Math.max(from + 1, Math.floor(((i + 1) * levels.length) / count)) + let sum = 0 + for (let j = from; j < to; j++) sum += levels[j]! + out.push(Math.round((sum / (to - from)) * 1024)) + } + return out +} + +/** The file extension for a recording's type. */ +export function extensionFor(mimetype: string): string { + if (mimetype === 'audio/ogg') return 'ogg' + if (mimetype === 'audio/mp4') return 'm4a' + return 'webm' +} + +/** m:ss */ +export function formatDuration(ms: number): string { + const s = Math.max(0, Math.round(ms / 1000)) + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` +} diff --git a/apps/web/src/stores/chat.ts b/apps/web/src/stores/chat.ts index c70a7d4..be9a2ca 100644 --- a/apps/web/src/stores/chat.ts +++ b/apps/web/src/stores/chat.ts @@ -91,7 +91,7 @@ export function describeEvent(ev: MatrixEvent, me: string, room: Room, withSende text = 'Video' break case MsgType.Audio: - text = 'Audio' + text = content['org.matrix.msc3245.voice'] ? 'Voice message' : 'Audio' break case MsgType.File: text = `File: ${content.body ?? ''}` diff --git a/apps/web/src/stores/notifications.ts b/apps/web/src/stores/notifications.ts new file mode 100644 index 0000000..0a3b211 --- /dev/null +++ b/apps/web/src/stores/notifications.ts @@ -0,0 +1,186 @@ +import { EventType, MatrixEventEvent, RoomEvent, type MatrixClient, type MatrixEvent, type Room } from 'matrix-js-sdk' +import { defineStore } from 'pinia' +import { computed, ref, watch } from 'vue' +import { router } from '@/router' +import { useCall } from './call' +import { describeEvent } from './chat' + +// Notifications for new messages and incoming calls, while the app isn't +// in front of you. Which messages notify is the account's push rules +// (the same ones that decide what your phone is told), evaluated here on +// each live event once it is decrypted. +// +// In the desktop app they are the system's own notifications, through the +// shell's bridge (window.warpDesktop, see apps/desktop); in a browser they +// are the Notification API's, once you allow them. + +export interface DesktopNotification { + /** Replaces an earlier notification with the same ID: one per room. */ + id: string + title: string + body: string + roomId: string +} + +declare global { + interface Window { + /** Added by the desktop app's shell. */ + warpDesktop?: { notify(n: DesktopNotification): void } + } +} + +/** The desktop app asks the client to open a room (a notification was clicked). */ +const openRoomEvent = 'warp-desktop:open-room' + +export type NotificationPermissionState = NotificationPermission | 'unsupported' | 'desktop' + +const offKey = 'warp.web.notifications' + +function savedEnabled(): boolean { + try { + return localStorage.getItem(offKey) !== 'off' + } catch { + return true + } +} + +function currentPermission(): NotificationPermissionState { + if (window.warpDesktop) return 'desktop' + if (typeof Notification === 'undefined') return 'unsupported' + return Notification.permission +} + +// A message this much older than now is history catching up (after +// sleep, or a room arriving late), not news. +const staleAfterMs = 5 * 60_000 + +export const useNotifications = defineStore('notifications', () => { + const enabled = ref(savedEnabled()) + const permission = ref(currentPermission()) + watch(enabled, (on) => { + try { + if (on) localStorage.removeItem(offKey) + else localStorage.setItem(offKey, 'off') + } catch { + // Not kept: on again after a reload. + } + }) + + /** Whether notifications will actually show. */ + const active = computed(() => enabled.value && (permission.value === 'granted' || permission.value === 'desktop')) + + /** Asks the browser for permission; must run from a click. */ + async function enable() { + enabled.value = true + if (permission.value === 'default') { + permission.value = await Notification.requestPermission() + } + } + + const shown = new Map() + const seen = new Set() + let attached: MatrixClient | null = null + let since = 0 + + function inFront(roomId: string): boolean { + const route = router.currentRoute.value + return document.visibilityState === 'visible' && document.hasFocus() && route.name === 'room' && route.params.roomId === roomId + } + + function show(n: DesktopNotification) { + if (!active.value) return + if (window.warpDesktop) { + window.warpDesktop.notify(n) + return + } + shown.get(n.id)?.close() + const note = new Notification(n.title, { body: n.body, tag: n.id, icon: '/favicon.svg' }) + note.onclick = () => { + window.focus() + openRoom(n.roomId) + note.close() + } + note.onclose = () => { + if (shown.get(n.id) === note) shown.delete(n.id) + } + shown.set(n.id, note) + } + + function openRoom(roomId: string) { + void router.push({ name: 'room', params: { roomId } }) + } + + function consider(c: MatrixClient, ev: MatrixEvent, room: Room) { + const id = ev.getId() + if (!id || seen.has(id)) return + if (ev.getSender() === c.getUserId()) return + if (ev.getTs() < since || Date.now() - ev.getTs() > staleAfterMs) return + // Calls ring through the call store, below. + const type = ev.getType() + if (type === EventType.CallInvite || type === EventType.RTCNotification) return + // Not decrypted yet: decide once it is. + if (type === EventType.RoomMessageEncrypted && !ev.isDecryptionFailure()) { + ev.once(MatrixEventEvent.Decrypted, () => consider(c, ev, room)) + return + } + if (ev.isDecryptionFailure() || ev.isRedacted()) return + seen.add(id) + if (seen.size > 500) seen.delete(seen.values().next().value!) + if (!c.getPushActionsForEvent(ev, true)?.notify) return + if (inFront(room.roomId)) return + const me = c.getUserId() ?? '' + const dms = (c.getAccountData(EventType.Direct)?.getContent() ?? {}) as Record + const direct = Object.values(dms).some((rooms) => Array.isArray(rooms) && rooms.includes(room.roomId)) + const text = describeEvent(ev, me, room, !direct) + if (text === null) return + show({ id: room.roomId, title: room.name, body: text, roomId: room.roomId }) + } + + function onTimeline(ev: MatrixEvent, room: Room | undefined, toStart: boolean | undefined, removed: boolean, data: { liveEvent?: boolean }) { + if (!attached || !room || toStart || removed || !data?.liveEvent) return + if (!attached.isInitialSyncComplete()) return + consider(attached, ev, room) + } + + function onOpenRoom(e: Event) { + const roomId = (e as CustomEvent).detail + if (typeof roomId === 'string' && roomId) openRoom(roomId) + } + + /** Starts watching the running client for things to notify about. */ + function attach(c: MatrixClient) { + if (attached === c) return + attached = c + since = Date.now() - 30_000 + permission.value = currentPermission() + // Thread replies are in their thread's timeline; the room re-emits those. + c.on(RoomEvent.Timeline, onTimeline) + window.addEventListener(openRoomEvent, onOpenRoom) + } + + function detach() { + attached?.off(RoomEvent.Timeline, onTimeline) + attached = null + window.removeEventListener(openRoomEvent, onOpenRoom) + for (const n of shown.values()) n.close() + shown.clear() + seen.clear() + } + + // An incoming call is news even with the app open in another window. + const call = useCall() + watch( + () => call.incoming, + (inc) => { + if (!inc || !attached || (document.visibilityState === 'visible' && document.hasFocus())) return + show({ + id: inc.roomId, + title: inc.roomName, + body: `${inc.callerName}: ${inc.video ? 'Incoming video call' : 'Incoming voice call'}`, + roomId: inc.roomId, + }) + }, + ) + + return { enabled, permission, active, enable, attach, detach } +})