Desktop: notifications, tray, auto-update, installer; web: sliding sync, voice messages, notifications
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 / Desktop client (ubuntu-24.04) (push) Canceled after 0s
CI / Desktop client (windows-latest) (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
CI / Desktop client (ubuntu-24.04) (push) Canceled after 0s
CI / Desktop client (windows-latest) (push) Canceled after 0s
Web client (also what the desktop app runs): - Simplified Sliding Sync (MSC4186) when the homeserver advertises org.matrix.simplified_msc3575. src/matrix/sliding.ts answers the SDK's GET /sync with a sliding sync request and rewrites the response into the /sync shape, as the Android app does; the SDK's own sliding sync mode doesn't sort thread replies into threads. 20 rooms first, then every room (up to 1000); to_device, e2ee, account data, receipts and typing extensions; M_UNKNOWN_POS starts a new connection. The SDK's /members?at=<sync token> drops the token, which here is ours. Settings can switch back to /sync. - Notifications for new messages and incoming calls while Warp isn't in front, by the account's push rules, evaluated after decryption. The browser's Notification API once turned on in Settings, or the desktop app's bridge (window.warpDesktop). One per room; a click opens it. - Voice messages: record from the composer, sent as m.audio with MSC3245's voice marker and MSC1767's duration and waveform (encrypted in encrypted rooms); a waveform player in the timeline. - @warp/ui: a slot for extra composer buttons; bell-off, play and pause icons. Desktop app: - System notifications through Wails' notifications service, fed by /_desktop/bridge.js (/_desktop/notify, JSON only); clicking one shows the window and opens the room. - Tray icon with Open and Quit; closing the window hides it (a one-time notification says so), File > Quit ends it. The page title goes to the window and the tray tooltip, the unread count to the taskbar or Dock badge. - Auto-update from a Wails update manifest at DESKTOP_UPDATE_URL: a quiet check a minute after start and every six hours, a notification when a newer version exists, Help > Check for updates opens the update window. Optional signature key (DESKTOP_UPDATE_KEY). Linux package installs leave updates to the package manager. build/manifest writes the manifest. - Per-user NSIS installer (make desktop-windows-installer), the macOS bundle zipped for the updater, a desktop CI job, and a Desktop release workflow that builds all three platforms and the manifest. Tested on Windows against a local homeserver: sliding sync with E2EE, threads and a server restart; notifications (toast history), close to tray, relaunch restoring the window; voice messages recorded, sent and played, encrypted too; the update check finding a newer version. Not tested: installing an update, the NSIS installer, CI, macOS and Linux. Code signing needs certificates and isn't done. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8ff2730676
commit
829b236efe
33 files changed
+2067
-51
No files matched your search
@@ -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
|
||||
@@ -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/
|
||||
@@ -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/.
|
||||
|
||||
+73
-11
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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]) + "…"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 <head> 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 <head> 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(`<head>` + "\n " + `<script src="` + linkScriptPath + `"></script>`)
|
||||
tag := []byte(`<head>` +
|
||||
"\n " + `<script src="` + linkScriptPath + `"></script>` +
|
||||
"\n " + `<script src="` + bridgeScriptPath + `"></script>`)
|
||||
patched := bytes.Replace(index, []byte("<head>"), tag, 1)
|
||||
if bytes.Equal(patched, index) {
|
||||
return client, false, nil
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <head>; links to other sites will open in the app's window")
|
||||
opts.Logger.Warn("the client's index.html has no <head>; 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)
|
||||
}
|
||||
|
||||
@@ -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("<!doctype html><html><head><title>Warp</title></head></html>")},
|
||||
}})
|
||||
want := `<head>` + "\n " + `<script src="/_desktop/links.js"></script>` +
|
||||
"\n " + `<script src="/_desktop/bridge.js"></script><title>`
|
||||
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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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</title><path d="M8.6 5.6A6 6 0 0 1 18 11v4"/><path d="M6 11v5.5L4.5 18H18"/><path d="M10 20.5a2 2 0 0 0 4 0"/><path d="M4 4l16 16"/></svg>
|
||||
|
After Width: | Height: | Size: 315 B |
@@ -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'
|
||||
|
||||
@@ -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>pause</title><path d="M8.5 5.5v13"/><path d="M15.5 5.5v13"/></svg>
|
||||
|
After Width: | Height: | Size: 234 B |
@@ -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>play</title><path d="M7.5 5.5v13l10.5-6.5z"/></svg>
|
||||
|
After Width: | Height: | Size: 219 B |
+28
-4
@@ -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 |
|
||||
|
||||
@@ -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() {
|
||||
<span v-else class="flex h-full items-center justify-center"><WSpinner /></span>
|
||||
</button>
|
||||
|
||||
<!-- Voice message -->
|
||||
<VoicePlayer
|
||||
v-else-if="item.msgtype === 'm.audio' && media?.mxc && media.voice"
|
||||
:source="mediaSource(media)!"
|
||||
:duration-ms="media.duration"
|
||||
:waveform="media.voice.waveform"
|
||||
/>
|
||||
|
||||
<!-- Video and audio -->
|
||||
<div v-else-if="(item.msgtype === 'm.video' || item.msgtype === 'm.audio') && media?.mxc" class="flex flex-col gap-2">
|
||||
<video v-if="playerUrl && item.msgtype === 'm.video'" :src="playerUrl" controls autoplay class="max-h-80 max-w-full rounded-md" />
|
||||
|
||||
@@ -5,7 +5,8 @@ import { WBanner, WComposer, WIcon, WIconButton, WSpinner } from '@warp/ui'
|
||||
import { drafts } from '@/lib/drafts'
|
||||
import { errorMessage, formatBytes } from '@/lib/format'
|
||||
import { client, cryptoError } from '@/matrix/client'
|
||||
import { sendFile, sendText } from '@/matrix/send'
|
||||
import { sendFile, sendText, sendVoice } from '@/matrix/send'
|
||||
import { canRecord, formatDuration, VoiceRecorder } from '@/matrix/voice'
|
||||
import type { MessageItem } from '@/matrix/timeline'
|
||||
import { useChat } from '@/stores/chat'
|
||||
import { useToasts } from '@/stores/toasts'
|
||||
@@ -111,6 +112,58 @@ async function sendFiles(list: FileList | File[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// Voice messages: the record button replaces typing until it is sent or
|
||||
// thrown away.
|
||||
const recordable = canRecord()
|
||||
const recording = ref(false)
|
||||
const elapsed = ref(0)
|
||||
const levels = ref<number[]>([])
|
||||
let recorder: VoiceRecorder | null = null
|
||||
|
||||
async function startRecording() {
|
||||
if (recording.value || !canWrite.value) return
|
||||
const rec = new VoiceRecorder()
|
||||
rec.onTick = (ms, level) => {
|
||||
elapsed.value = ms
|
||||
levels.value = [...levels.value.slice(-47), level]
|
||||
}
|
||||
rec.onLimit = () => void stopRecording(true)
|
||||
try {
|
||||
await rec.start()
|
||||
} catch (e) {
|
||||
rec.cancel()
|
||||
toasts.error(e instanceof DOMException && e.name === 'NotAllowedError' ? 'Allow the microphone to record a voice message.' : `Can’t record: ${errorMessage(e)}`)
|
||||
return
|
||||
}
|
||||
recorder = rec
|
||||
elapsed.value = 0
|
||||
levels.value = []
|
||||
recording.value = true
|
||||
}
|
||||
|
||||
async function stopRecording(send: boolean) {
|
||||
const rec = recorder
|
||||
recorder = null
|
||||
recording.value = false
|
||||
if (!rec) return
|
||||
if (!send) {
|
||||
rec.cancel()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const voice = await rec.stop()
|
||||
if (voice.durationMs < 700) return
|
||||
const to = target()
|
||||
replyTo.value = null
|
||||
const id = ++uploadId
|
||||
uploads.value.push({ id, name: 'voice message', loaded: 0, total: 0 })
|
||||
await sendVoice(client(), props.room, voice, to).finally(() => (uploads.value = uploads.value.filter((x) => x.id !== id)))
|
||||
} catch (e) {
|
||||
toasts.error(`Can’t send the voice message: ${errorMessage(e)}`)
|
||||
}
|
||||
}
|
||||
onBeforeUnmount(() => recorder?.cancel())
|
||||
|
||||
function onPaste(e: ClipboardEvent) {
|
||||
const files = e.clipboardData?.files
|
||||
if (files && files.length) {
|
||||
@@ -120,6 +173,11 @@ function onPaste(e: ClipboardEvent) {
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && recording.value) {
|
||||
e.stopPropagation()
|
||||
void stopRecording(false)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape' && replyTo.value) {
|
||||
e.stopPropagation()
|
||||
replyTo.value = null
|
||||
@@ -150,7 +208,22 @@ defineExpose({ sendFiles, canWrite, focus: () => composer.value?.focus() })
|
||||
</p>
|
||||
<WIconButton icon="close" label="Cancel reply" @click="replyTo = null" />
|
||||
</div>
|
||||
<WComposer ref="composer" v-model="text" :placeholder="placeholder" :encrypted="encrypted" @send="send" @attach="sendFiles" />
|
||||
<div v-if="recording" class="wp-composer items-center" role="group" aria-label="Recording a voice message">
|
||||
<WIconButton icon="trash" label="Discard the voice message" @click="stopRecording(false)" />
|
||||
<span class="size-2.5 shrink-0 animate-pulse rounded-full bg-danger" aria-hidden="true" />
|
||||
<span class="body tabular-nums">{{ formatDuration(elapsed) }}</span>
|
||||
<div class="flex h-6 min-w-0 flex-1 items-center justify-end gap-[2px] overflow-hidden" aria-hidden="true">
|
||||
<span v-for="(l, i) in levels" :key="i" class="w-[3px] shrink-0 rounded-full bg-ink-muted" :style="{ height: `${Math.max(12, Math.round(l * 100))}%` }" />
|
||||
</div>
|
||||
<button type="button" class="wp-composer-send" aria-label="Send the voice message" @click="stopRecording(true)">
|
||||
<WIcon name="send" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<WComposer v-else ref="composer" v-model="text" :placeholder="placeholder" :encrypted="encrypted" @send="send" @attach="sendFiles">
|
||||
<template v-if="recordable && !text.trim()" #actions>
|
||||
<WIconButton icon="mic" label="Record a voice message" @click="startRecording" />
|
||||
</template>
|
||||
</WComposer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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 }[] = [
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2 border-t border-line pt-4">
|
||||
<p class="body-strong m-0">Notifications</p>
|
||||
<div class="flex items-start gap-3">
|
||||
<WIcon :name="notifications.active ? 'bell' : 'bell-off'" :class="['mt-[2px] shrink-0', notifications.active ? 'text-teal-text' : 'text-ink-muted']" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="body m-0">{{ notifications.active ? 'Notifications are on' : 'Notifications are off' }}</p>
|
||||
<p class="small m-0 text-ink-muted">
|
||||
<template v-if="notifications.permission === 'unsupported'">This browser can’t show notifications.</template>
|
||||
<template v-else-if="notifications.permission === 'denied'">The browser blocks notifications from this site. Allow them in its site settings.</template>
|
||||
<template v-else>New messages and calls, when Warp isn’t in front. Your push rules decide which messages.</template>
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="notifications.permission !== 'unsupported' && notifications.permission !== 'denied'">
|
||||
<WButton v-if="!notifications.active" size="sm" variant="primary" icon="bell" @click="notifications.enable()">Turn on</WButton>
|
||||
<WButton v-else size="sm" variant="ghost" @click="notifications.enabled = false">Turn off</WButton>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-3 border-t border-line pt-4">
|
||||
<p class="body-strong m-0">Encryption</p>
|
||||
<WBanner v-if="cryptoError()" tone="warning" title="End-to-end encryption didn’t start">
|
||||
@@ -183,6 +213,12 @@ const themes: { value: ThemeChoice; label: string; icon: IconName }[] = [
|
||||
<p class="body-strong m-0">This session</p>
|
||||
<p class="small m-0 text-ink-muted">Session ID <span class="mxid text-ink">{{ session.deviceId }}</span></p>
|
||||
<p class="small m-0 text-ink-muted">Warp web {{ config.version }}</p>
|
||||
<p class="small m-0 text-ink-muted">
|
||||
Sync: {{ usingSlidingSync() ? 'Simplified Sliding Sync' : '/sync' }}
|
||||
<button type="button" class="ml-1 font-semibold text-teal-text hover:underline" @click="toggleSliding">
|
||||
{{ sliding ? 'Use /sync instead' : 'Use sliding sync when the server has it' }}
|
||||
</button>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<EncryptionDialog v-model:open="encOpen" :mode="encMode" />
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
|
||||
import { WIcon, WSpinner } from '@warp/ui'
|
||||
import { mediaUrl, type MediaSource } from '@/matrix/media'
|
||||
import { formatDuration } from '@/matrix/voice'
|
||||
|
||||
// A voice message: play/pause, its waveform filling in as it plays (click
|
||||
// to seek), and the time. The file loads on the first play.
|
||||
|
||||
const props = defineProps<{ source: MediaSource; durationMs?: number; waveform: number[] }>()
|
||||
|
||||
const bars = 40
|
||||
const heights = computed(() => {
|
||||
const w = props.waveform
|
||||
if (!w.length) return Array.from({ length: bars }, () => 0.3)
|
||||
return Array.from({ length: bars }, (_, i) => {
|
||||
const v = w[Math.floor((i * w.length) / bars)] ?? 0
|
||||
return Math.max(0.12, Math.min(1, v / 1024))
|
||||
})
|
||||
})
|
||||
|
||||
const audio = ref<HTMLAudioElement>()
|
||||
const src = ref<string>()
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
const playing = ref(false)
|
||||
const position = ref(0)
|
||||
const length = ref(props.durationMs ? props.durationMs / 1000 : 0)
|
||||
|
||||
const progress = computed(() => (length.value ? Math.min(1, position.value / length.value) : 0))
|
||||
const shownTime = computed(() => formatDuration((playing.value || position.value ? position.value : length.value) * 1000))
|
||||
|
||||
async function load(): Promise<boolean> {
|
||||
if (src.value) return true
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
src.value = await mediaUrl(props.source)
|
||||
// Let the <audio> pick up the new src before it is played.
|
||||
await nextTick()
|
||||
return true
|
||||
} catch {
|
||||
failed.value = true
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (playing.value) {
|
||||
audio.value?.pause()
|
||||
return
|
||||
}
|
||||
if (!(await load()) || !audio.value) return
|
||||
await audio.value.play().catch(() => (failed.value = true))
|
||||
}
|
||||
|
||||
async function seek(e: MouseEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const frac = Math.min(1, Math.max(0, (e.clientX - el.getBoundingClientRect().left) / el.clientWidth))
|
||||
if (!(await load()) || !audio.value) return
|
||||
const total = Number.isFinite(audio.value.duration) ? audio.value.duration : length.value
|
||||
audio.value.currentTime = frac * total
|
||||
position.value = audio.value.currentTime
|
||||
}
|
||||
|
||||
function onMeta() {
|
||||
const d = audio.value?.duration
|
||||
// WebM recordings often report Infinity until played through.
|
||||
if (d && Number.isFinite(d)) length.value = d
|
||||
}
|
||||
|
||||
function onEnded() {
|
||||
playing.value = false
|
||||
position.value = 0
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => audio.value?.pause())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-[220px] items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-9 shrink-0 items-center justify-center rounded-full border border-current/40 hover:bg-current/10"
|
||||
:aria-label="playing ? 'Pause voice message' : 'Play voice message'"
|
||||
@click="toggle"
|
||||
>
|
||||
<WSpinner v-if="loading" :size="16" />
|
||||
<WIcon v-else :name="playing ? 'pause' : 'play'" :size="18" />
|
||||
</button>
|
||||
<div class="flex h-8 flex-1 cursor-pointer items-center gap-[2px]" role="presentation" @click="seek">
|
||||
<span
|
||||
v-for="(h, i) in heights"
|
||||
:key="i"
|
||||
:class="['w-[3px] shrink-0 rounded-full bg-current', i / bars < progress ? 'opacity-100' : 'opacity-35']"
|
||||
:style="{ height: `${Math.round(h * 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="caption w-10 shrink-0 text-right opacity-75 tabular-nums">{{ failed ? '—' : shownTime }}</span>
|
||||
<audio
|
||||
ref="audio"
|
||||
:src="src"
|
||||
preload="auto"
|
||||
class="hidden"
|
||||
@play="playing = true"
|
||||
@pause="playing = false"
|
||||
@ended="onEnded"
|
||||
@timeupdate="position = audio?.currentTime ?? 0"
|
||||
@loadedmetadata="onMeta"
|
||||
@durationchange="onMeta"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<string, boolean> }
|
||||
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<MatrixClient> {
|
||||
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<MatrixClient> {
|
||||
// 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<MatrixClient> {
|
||||
|
||||
export function stopClient() {
|
||||
stopHeartbeat()
|
||||
sliding = false
|
||||
if (!current) return
|
||||
bindCrypto(null)
|
||||
current.stopClient()
|
||||
|
||||
+39
-10
@@ -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))
|
||||
}
|
||||
@@ -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|<pos>|<to-device since>`. 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<string, unknown> {
|
||||
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<string, unknown>
|
||||
|
||||
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<string, Json> = {}
|
||||
const invite: Json = {}
|
||||
const knock: Json = {}
|
||||
const leave: Record<string, Json> = {}
|
||||
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<Response> {
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -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 ?? ''}`
|
||||
}
|
||||
|
||||
@@ -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<Recording> {
|
||||
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')}`
|
||||
}
|
||||
@@ -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 ?? ''}`
|
||||
|
||||
@@ -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<NotificationPermissionState>(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<string, Notification>()
|
||||
const seen = new Set<string>()
|
||||
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<string, string[]>
|
||||
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<string>).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 }
|
||||
})
|
||||
Reference in new issue
Block a user