mirror of
https://github.com/vector-im/hydrogen-web.git
synced 2024-11-20 03:25:52 +01:00
Merge branch 'master' into thirdroom/dev
This commit is contained in:
commit
02c7b79d50
7
.github/CODEOWNERS
vendored
Normal file
7
.github/CODEOWNERS
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
# Docker related files are not maintained by the core Hydrogen team
|
||||
/.dockerignore @hughns @sandhose
|
||||
/Dockerfile @hughns @sandhose
|
||||
/Dockerfile-dev @hughns @sandhose
|
||||
/.github/workflows/docker-publish.yml @hughns @sandhose
|
||||
/docker/ @hughns @sandhose
|
||||
/doc/docker.md @hughns @sandhose
|
12
.github/workflows/docker-publish.yml
vendored
12
.github/workflows/docker-publish.yml
vendored
@ -21,13 +21,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@ -35,14 +38,15 @@ jobs:
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -10,3 +10,4 @@ lib
|
||||
*.tar.gz
|
||||
.eslintcache
|
||||
.tmp
|
||||
playwright/synapselogs
|
||||
|
@ -20,6 +20,10 @@ module.exports = {
|
||||
rules: {
|
||||
"@typescript-eslint/no-floating-promises": 2,
|
||||
"@typescript-eslint/no-misused-promises": 2,
|
||||
"semi": ["error", "always"]
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["warn"],
|
||||
"no-undef": "off",
|
||||
"semi": ["error", "always"],
|
||||
"@typescript-eslint/explicit-function-return-type": ["error"]
|
||||
}
|
||||
};
|
||||
|
24
Dockerfile
24
Dockerfile
@ -1,24 +1,26 @@
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/node:16.13-alpine3.15 as builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/node:alpine as builder
|
||||
RUN apk add --no-cache git python3 build-base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install the dependencies first
|
||||
COPY yarn.lock package.json ./
|
||||
# Copy package.json and yarn.lock and install dependencies first to speed up subsequent builds
|
||||
COPY package.json yarn.lock /app/
|
||||
RUN yarn install
|
||||
|
||||
# Copy the rest and build the app
|
||||
COPY . .
|
||||
COPY . /app
|
||||
RUN yarn build
|
||||
|
||||
# Remove the default config, replace it with a symlink to somewhere that will be updated at runtime
|
||||
RUN rm -f target/config.json \
|
||||
# Because we will be running as an unprivileged user, we need to make sure that the config file is writable
|
||||
# So, we will copy the default config to the /tmp folder that will be writable at runtime
|
||||
RUN mv -f target/config.json /config.json.bundled \
|
||||
&& ln -sf /tmp/config.json target/config.json
|
||||
|
||||
FROM --platform=${TARGETPLATFORM} docker.io/nginxinc/nginx-unprivileged:1.21-alpine
|
||||
FROM --platform=${TARGETPLATFORM} docker.io/nginxinc/nginx-unprivileged:alpine
|
||||
|
||||
# Copy the config template as well as the templating script
|
||||
COPY ./docker/config.json.tmpl /config.json.tmpl
|
||||
COPY ./docker/config-template.sh /docker-entrypoint.d/99-config-template.sh
|
||||
# Copy the dynamic config script
|
||||
COPY ./docker/dynamic-config.sh /docker-entrypoint.d/99-dynamic-config.sh
|
||||
# And the bundled config file
|
||||
COPY --from=builder /config.json.bundled /config.json.bundled
|
||||
|
||||
# Copy the built app from the first build stage
|
||||
COPY --from=builder /app/target /usr/share/nginx/html
|
||||
|
@ -1,7 +1,12 @@
|
||||
FROM docker.io/node:alpine
|
||||
RUN apk add --no-cache git python3 build-base
|
||||
COPY . /code
|
||||
|
||||
WORKDIR /code
|
||||
|
||||
# Copy package.json and yarn.lock and install dependencies first to speed up subsequent builds
|
||||
COPY package.json yarn.lock /code/
|
||||
RUN yarn install
|
||||
|
||||
COPY . /code
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["yarn", "start"]
|
||||
|
@ -10,6 +10,10 @@ TorBrowser ships a crippled IndexedDB implementation and will not work. At some
|
||||
|
||||
It used work in pre-webkit Edge, to have it work on Windows Phone, but that support has probably bit-rotted as it isn't tested anymore.
|
||||
|
||||
The following browser extensions are known to break Hydrogen
|
||||
- uBlock Origin (Some custom filters seem to block the service worker script)
|
||||
- Try locating the filter that is blocking the service worker script in the uBlock Origin logger, and disabling that filter. Otherwise, the easiest solution is to disable uBlock Origin for the Hydrogen site (by opening the uBlock Origin popup and clicking the large power button symbol). It is possible to re-enable it after logging in, but it may possibly break again when there is an update.
|
||||
|
||||
## Is there a way to run the app as a desktop app?
|
||||
|
||||
You can install Hydrogen as a PWA using Chrome/Chromium on any platform or Edge on Windows. Gnome Web/Ephiphany also allows to "Install site as web application". There is no Electron build of Hydrogen, and there will likely be none in the near future, as Electron complicates the release process considerably. Once Hydrogen is more mature and feature complete, we might reconsider and use [Tauri](https://tauri.studio) if there are compelling use cases not possible with PWAs. For now though, we want to keep development and releasing fast and nimble ;)
|
||||
@ -32,4 +36,4 @@ Published builds can be found at https://github.com/vector-im/hydrogen-web/relea
|
||||
|
||||
## I want to embed Hydrogen in my website, how should I do that?
|
||||
|
||||
Hydrogen aims to be usable as an SDK, and while it is still early days, you can find some documentation how to do that in [SDK.md](SDK.md).
|
||||
Hydrogen aims to be usable as an SDK, and while it is still early days, you can find some documentation how to do that in [SDK.md](doc/SDK.md).
|
@ -39,6 +39,8 @@ You can run Hydrogen locally by the following commands in the terminal:
|
||||
|
||||
Now point your browser to `http://localhost:3000`. If you prefer, you can also [use docker](doc/docker.md).
|
||||
|
||||
PS: You need nodejs, running yarn on top of any other js platform is not supported.
|
||||
|
||||
# FAQ
|
||||
|
||||
Some frequently asked questions are answered [here](doc/FAQ.md).
|
||||
Some frequently asked questions are answered [here](FAQ.md).
|
||||
|
@ -1,8 +0,0 @@
|
||||
goal:
|
||||
|
||||
write client that works on lumia 950 phone, so I can use matrix on my phone.
|
||||
|
||||
try approach offline to indexeddb. go low-memory, and test the performance of storing every event individually in indexeddb.
|
||||
|
||||
try to use little bandwidth, mainly by being an offline application and storing all requested data in indexeddb.
|
||||
be as functional as possible while offline
|
@ -85,7 +85,7 @@ async function main() {
|
||||
room,
|
||||
ownUserId: session.userId,
|
||||
platform,
|
||||
urlCreator: urlRouter,
|
||||
urlRouter: urlRouter,
|
||||
navigation,
|
||||
});
|
||||
await vm.load();
|
||||
|
@ -1,22 +0,0 @@
|
||||
# Replacing javascript files
|
||||
|
||||
Any source file can be replaced at build time by mapping the path in a JSON file passed in to the build command, e.g. `yarn build --override-imports customizations.json`. The file should be written like so:
|
||||
|
||||
```json
|
||||
{
|
||||
"src/platform/web/ui/session/room/timeline/TextMessageView.js": "src/platform/web/ui/session/room/timeline/MyTextMessageView.js"
|
||||
}
|
||||
```
|
||||
The paths are relative to the location of the mapping file, but the mapping file should be in a parent directory of the files you want to replace.
|
||||
|
||||
You should see a "replacing x with y" line (twice actually, for the normal and legacy build).
|
||||
|
||||
# Injecting CSS
|
||||
|
||||
You can override the location of the main css file with the `--override-css <file>` option to the build script. The default is `src/platform/web/ui/css/main.css`, which you probably want to import from your custom css file like so:
|
||||
|
||||
```css
|
||||
@import url('src/platform/web/ui/css/main.css');
|
||||
|
||||
/* additions */
|
||||
```
|
77
doc/TODO.md
77
doc/TODO.md
@ -1,77 +0,0 @@
|
||||
# Minimal thing to get working
|
||||
|
||||
- DONE: finish summary store
|
||||
- DONE: move "sdk" bits over to "matrix" directory
|
||||
- DONE: add eventemitter
|
||||
- DONE: make sync work
|
||||
- DONE: store summaries
|
||||
- DONE: setup editorconfig
|
||||
- DONE: setup linting (also in editor)
|
||||
- DONE: store timeline
|
||||
- DONE: store state
|
||||
- DONE: make summary work better (name and joined/inviteCount doesn't seem to work well)
|
||||
- DONE: timeline doesn't seem to recover it's key well upon loading, the query in load seems to never yield an event in the persister
|
||||
- DONE: map DOMException to something better
|
||||
- it's pretty opaque now when something idb related fails. DOMException has these fields:
|
||||
code: 0
|
||||
message: "Key already exists in the object store."
|
||||
name: "ConstraintError"
|
||||
- DONE: emit events so we can start showing something on the screen maybe?
|
||||
- DONE: move session._rooms over to Map, so we can iterate over it, ...
|
||||
- DONE: build a very basic interface with
|
||||
- DONE: a start/stop sync button
|
||||
- DONE: a room list sorted alphabetically
|
||||
- DONE: do some preprocessing on sync response which can then be used by persister, summary, timeline
|
||||
- DONE: support timeline
|
||||
- DONE: clicking on a room list, you see messages (userId -> body)
|
||||
- DONE: style minimal UI
|
||||
- DONE: implement gap filling and fragments (see FRAGMENTS.md)
|
||||
- DONE: allow collection items (especially tiles) to self-update
|
||||
- improve fragmentidcomparer::add
|
||||
- DONE: better UI
|
||||
- fix MappedMap update mechanism
|
||||
- see if in BaseObservableMap we need to change ...params
|
||||
- DONE: put sync button and status label inside SessionView
|
||||
- fix some errors:
|
||||
- find out if `(this._emitCollectionUpdate)(this)` is different than `this._emitCollectionUpdate(this)`
|
||||
- got "database tried to mutate when not allowed" or something error as well
|
||||
- find out why when RoomPersister.(\_createGapEntry/\_createEventEntry) we remove .buffer the transaction fails (good), but upon fixing and refreshing is missing a message! syncToken should not be saved, so why isn't this again in the sync response and now the txn does succeed?
|
||||
- DONE: take access token out of IDB? this way it can be stored in a more secure thing for non-web clients, together wit encryption key for olm sessions ... ? like macos keychain, gnome keyring, ... maybe using https://www.npmjs.com/package/keytar
|
||||
- DONE: experiment with using just a normal array with 2 numbers for sortkeys, to work in Edge as well.
|
||||
- DONE: send messages
|
||||
- DONE: fill gaps with call to /messages
|
||||
|
||||
- DONE: build script
|
||||
- DONE: take dev index.html, run some dom modifications to change script tag with `parse5`.
|
||||
- DONE: create js bundle, rollup
|
||||
- DONE: create css bundle, postcss, probably just need postcss-import for now, but good to have more options
|
||||
- DONE: put all in /target
|
||||
- have option to run it locally to test
|
||||
|
||||
- deploy script
|
||||
- upload /target to github pages
|
||||
|
||||
- DONE: offline available
|
||||
- both offline mechanisms have (filelist, version) as input for their template:
|
||||
- create appcache manifest with (index.html, brawl.js, brawl.css) and print version number in it
|
||||
- create service worker wit file list to cache (at top const files = "%%FILES_ARRAY%%", version = "%%VERSION%%")
|
||||
- write web manifest
|
||||
- DONE: delete and clear sessions from picker
|
||||
- option to close current session and go back to picker
|
||||
|
||||
- accept invite
|
||||
- member list
|
||||
- e2e encryption
|
||||
- sync retry strategy
|
||||
- instead of stopping sync on fetch error, show spinner and status and have auto retry strategy
|
||||
|
||||
- create room
|
||||
- join room
|
||||
- leave room
|
||||
- unread rooms, badge count, sort rooms by activity
|
||||
|
||||
- DONE: create sync filter
|
||||
- DONE: lazy loading members
|
||||
- decide denormalized data in summary vs reading from multiple stores PER room on load
|
||||
- allow Room/Summary class to be subclassed and store additional data?
|
||||
- store account data, support read markers
|
90
doc/api.md
90
doc/api.md
@ -1,90 +0,0 @@
|
||||
Session
|
||||
properties:
|
||||
rooms -> Rooms
|
||||
|
||||
# storage
|
||||
Storage
|
||||
key...() -> KeyRange
|
||||
start...Txn() -> Transaction
|
||||
Transaction
|
||||
store(name) -> ObjectStore
|
||||
finish()
|
||||
rollback()
|
||||
ObjectStore : QueryTarget
|
||||
index(name)
|
||||
Index : QueryTarget
|
||||
|
||||
|
||||
Rooms: EventEmitter, Iterator<RoomSummary>
|
||||
get(id) -> RoomSummary ?
|
||||
InternalRoom: EventEmitter
|
||||
applySync(roomResponse, membership, txn)
|
||||
- this method updates the room summary
|
||||
- persists the room summary
|
||||
- persists room state & timeline with RoomPersister
|
||||
- updates the OpenRoom if present
|
||||
|
||||
|
||||
applyAndPersistSync(roomResponse, membership, txn) {
|
||||
this._summary.applySync(roomResponse, membership);
|
||||
this._summary.persist(txn);
|
||||
this._roomPersister.persist(roomResponse, membership, txn);
|
||||
if (this._openRoom) {
|
||||
this._openRoom.applySync(roomResponse);
|
||||
}
|
||||
}
|
||||
|
||||
RoomPersister
|
||||
RoomPersister (persists timeline and room state)
|
||||
RoomSummary (persists room summary)
|
||||
RoomSummary : EventEmitter
|
||||
methods:
|
||||
async open()
|
||||
id
|
||||
name
|
||||
lastMessage
|
||||
unreadCount
|
||||
mentionCount
|
||||
isEncrypted
|
||||
isDirectMessage
|
||||
membership
|
||||
|
||||
should this have a custom reducer for custom fields?
|
||||
|
||||
events
|
||||
propChange(fieldName)
|
||||
|
||||
OpenRoom : EventEmitter
|
||||
properties:
|
||||
timeline
|
||||
events:
|
||||
|
||||
|
||||
RoomState: EventEmitter
|
||||
[room_id, event_type, state_key] -> [sort_key, event]
|
||||
Timeline: EventEmitter
|
||||
// should have a cache of recently lookup sender members?
|
||||
// can we disambiguate members like this?
|
||||
methods:
|
||||
lastEvents(amount)
|
||||
firstEvents(amount)
|
||||
eventsAfter(sortKey, amount)
|
||||
eventsBefore(sortKey, amount)
|
||||
events:
|
||||
eventsApppended
|
||||
|
||||
RoomMembers : EventEmitter, Iterator
|
||||
// no order, but need to be able to get all members somehow, needs to map to a ReactiveMap or something
|
||||
events:
|
||||
added(ids, values)
|
||||
removed(ids, values)
|
||||
changed(id, fieldName)
|
||||
RoomMember: EventEmitter
|
||||
properties:
|
||||
id
|
||||
name
|
||||
powerLevel
|
||||
membership
|
||||
avatar
|
||||
events:
|
||||
propChange(fieldName)
|
@ -80,6 +80,7 @@ Currently supported operations are:
|
||||
| -------- | -------- | -------- |
|
||||
| darker | percentage | color |
|
||||
| lighter | percentage | color |
|
||||
| alpha | alpha percentage | color |
|
||||
|
||||
## Aliases
|
||||
It is possible give aliases to variables in the `theme.css` file:
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
58
doc/architecture/updates.md
Normal file
58
doc/architecture/updates.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Updates
|
||||
|
||||
How updates flow from the model to the view model to the UI.
|
||||
|
||||
## EventEmitter, single values
|
||||
|
||||
When interested in updates from a single object, chances are it inherits from `EventEmitter` and it supports a `change` event.
|
||||
|
||||
`ViewModel` by default follows this pattern, but it can be overwritten, see Collections below.
|
||||
|
||||
### Parameters
|
||||
|
||||
Often a `parameters` or `params` argument is passed with the name of the field who's value has now changed. This parameter is currently only sometimes used, e.g. when it is too complicated or costly to check every possible field. An example of this is `TilesListView.onUpdate` to see if the `shape` property of a tile changed and hence the view needs to be recreated. Other than that, bindings in the web UI just reevaluate all bindings when receiving an update. This is a soft convention that could probably be more standardized, and it's not always clear what to pass (e.g. when multiple fields are being updated).
|
||||
|
||||
Another reason to keep this convention around is that if one day we decide to add support for a different platform with a different UI, it may not be feasible to reevaluate all data-bindings in the UI for a given view model when receiving an update.
|
||||
|
||||
## Collections
|
||||
|
||||
As an optimization, Hydrogen uses a pattern to let updates flow over an observable collection where this makes sense. There is an `update` event for this in both `ObservableMap` and `ObservableList`. This prevents having to listen for updates on each individual item in large collections. The `update` event uses the same `params` argument as explained above.
|
||||
|
||||
Some values like `BaseRoom` emit both with a `change` event on the event emitter and also over the collection. This way consumers can use what fits best for their case: the left panel can listen for updates on the room over the collection to power the room list, and the room view model can listen to the event emitter to get updates from the current room only.
|
||||
|
||||
### MappedMap and mapping models to `ViewModel`s
|
||||
|
||||
This can get a little complicated when using `MappedMap`, e.g. when mapping a model from `matrix/`
|
||||
to a view model in `domain/`. Often, view models will want to emit updates _spontanously_,
|
||||
e.g. without a prior update being sent from the lower-lying model. An example would be to change the value of a field after the view has called a method on the view model.
|
||||
To support this pattern while having updates still flow over the collection requires some extra work;
|
||||
`ViewModel` has a `emitChange` option which you can pass in to override
|
||||
what `ViewModel.emitChange` does (by default it emits the `change` event on the view model).
|
||||
`MappedMap` passes a callback to emit an update over the collection to the mapper function.
|
||||
You can pass this callback as the `emitChange` option and updates will now flow over the collection.
|
||||
|
||||
`MappedMap` also accepts an updater function, which you can use to make the view model respond to updates
|
||||
from the lower-lying model.
|
||||
|
||||
Here is an example:
|
||||
|
||||
```ts
|
||||
const viewModels = someCollection.mapValues(
|
||||
(model, emitChange) => new SomeViewModel(this.childOptions({
|
||||
model,
|
||||
// will make ViewModel.emitChange go over
|
||||
// the collection rather than emit a "change" event
|
||||
emitChange,
|
||||
})),
|
||||
// an update came in from the model, let the vm know
|
||||
(vm: SomeViewModel) => vm.onUpdate(),
|
||||
);
|
||||
```
|
||||
|
||||
### `ListView` & the `parentProvidesUpdates` flag.
|
||||
|
||||
`ObservableList` is always rendered in the UI using `ListView`. When receiving an update over the collection, it will find the child view for the given index and call `update(params)` on it. Views will typically need to be told whether they should listen to the `change` event in their view model or rather wait for their `update()` method to be called by their parent view, `ListView`. That's why the `mount(args)` method on a view supports a `parentProvidesUpdates` flag. If `true`, the view should not subscribe to its view model, but rather updates the DOM when its `update()` method is called. Also see `BaseUpdateView` and `TemplateView` for how this is implemented in the child view.
|
||||
|
||||
## `ObservableValue`
|
||||
|
||||
When some method wants to return an object that can be updated, often an `ObservableValue` is used rather than an `EventEmitter`. It's not 100% clear cut when to use the former or the latter, but `ObservableValue` is often used when the returned value in it's entirety will change rather than just a property on it. `ObservableValue` also has some nice facilities like lazy evaluation when subscribed to and the `waitFor` method to work with promises.
|
@ -41,11 +41,11 @@ export DOCKER_BUILDKIT=1
|
||||
docker build -t hydrogen .
|
||||
```
|
||||
|
||||
Or, pull the Docker image the GitHub Container Registry:
|
||||
Or, pull the docker image from GitHub Container Registry:
|
||||
|
||||
```
|
||||
docker pull ghcr.io/vector-im/hydrogen
|
||||
docker tag ghcr.io/vector-im/hydrogen hydrogen
|
||||
docker pull ghcr.io/vector-im/hydrogen-web
|
||||
docker tag ghcr.io/vector-im/hydrogen-web hydrogen
|
||||
```
|
||||
|
||||
### Start container image
|
||||
@ -55,6 +55,32 @@ Then, start up a container from that image:
|
||||
```
|
||||
docker run \
|
||||
--name hydrogen \
|
||||
--publish 8080:80 \
|
||||
--publish 8080:8080 \
|
||||
hydrogen
|
||||
```
|
||||
|
||||
n.b. the image is now based on the unprivileged nginx base, so the port is now `8080` instead of `80` and you need a writable `/tmp` volume.
|
||||
|
||||
You can override the default `config.json` using the `CONFIG_OVERRIDE` environment variable. For example to specify a different Homeserver and :
|
||||
|
||||
```
|
||||
docker run \
|
||||
--name hydrogen \
|
||||
--publish 8080:8080 \
|
||||
--env CONFIG_OVERRIDE='{
|
||||
"push": {
|
||||
"appId": "io.element.hydrogen.web",
|
||||
"gatewayUrl": "https://matrix.org",
|
||||
"applicationServerKey": "BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM"
|
||||
},
|
||||
"defaultHomeServer": "https://fosdem.org",
|
||||
"themeManifests": [
|
||||
"assets/theme-element.json"
|
||||
],
|
||||
"defaultTheme": {
|
||||
"light": "element-light",
|
||||
"dark": "element-dark"
|
||||
}
|
||||
}' \
|
||||
hydrogen
|
||||
```
|
||||
|
15
doc/error-handling.md
Normal file
15
doc/error-handling.md
Normal file
@ -0,0 +1,15 @@
|
||||
# Error handling
|
||||
|
||||
Ideally, every error that is unexpected and can't be automatically recovered from without degrading the experience is shown in the UI. This is the task of the view model, and you can use `ErrorReportViewModel` for this purpose, a dedicated base view model class. It exposes a child view model, `ErrorViewModel`, when `reportError` is called which can be paired with `ErrorView` in the view to present an error message from which debug logs can also be sent.
|
||||
|
||||
Methods on classes from the `matrix` layer can often throw errors and those errors should be caught in the view model and reported to the UI. When inheriting from `ErrorReportViewModel`, there is the low-level `reportError` method, but typically you'd use the convenience method `logAndCatch`. The latter makes it easy to get both error handlikng and logging right. You would typically use `logAndCatch` for every public method in the view model (e.g methods called from the view or from the parent view model). It calls a callback within a log item and also a try catch that reports the error.
|
||||
|
||||
## Sync errors & ErrorBoundary
|
||||
|
||||
There are some errors that are thrown during background processes though, most notably the sync loop. These processes are not triggered by the view model directly, and hence there is not always a method call they can wrap in a try/catch. For this, there is the `ErrorBoundary` utility class. Since almost all aspects of the client can be updated through the sync loop, it is also not too helpful if there is only one try/catch around the whole sync and we stop sync if something goes wrong.
|
||||
|
||||
Instead, it's more helpful to split up the error handling into different scopes, where errors are stored and not rethrown when leaving the scope. One example is to have a scope per room. In this way, we can isolate an error occuring during sync to a specific room, and report it in the UI of that room. This is typically where you would use `reportError` from `ErrorReportViewModel` rather than `logAndCatch`. You observe changes from your model in the view model (see docs on updates), and if the `error` property is set (by the `ErrorBoundary`), you call reportError with it. You can do this repeatedly without problems, if the same error is already reported, it's a No-Op.
|
||||
|
||||
### `writeSync` and preventing data loss when dealing with errors.
|
||||
|
||||
There is an extra complication though. The `writeSync` sync lifecycle step should not swallow any errors, or data loss can occur. This is because the whole `writeSync` lifecycle step writes all changes (for all rooms, the session, ...) for a sync response in one transaction (including the sync token), and aborts the transaction and stops sync if there is an error thrown during this step. So if there is an error in `writeSync` of a given room, it's fair to assume not all changes it was planning to write were passed to the transaction, as it got interrupted by the exception. Therefore, if we would swallow the error, data loss can occur as we'd not get another chance to write these changes to disk as we would have advanced the sync token. Therefore, code in the `writeSync` lifecycle step should be written defensively but always throw.
|
@ -6,6 +6,10 @@ We could do top to bottom gradients in default avatars to make them look a bit c
|
||||
|
||||
Can take ideas/adopt from OOCSS and SMACSS.
|
||||
|
||||
## Documentation
|
||||
|
||||
Whether we use OOCSS, SMACSS or BEM, we should write a tool that uses a JS parser (acorn?) to find all css classes used in the view code by looking for a `{className: "..."}` pattern. E.g. if using BEM, use all the found classes to construct a doc with a section for every block, with therein all elements and modifiers.
|
||||
|
||||
### Root
|
||||
- maybe we should not assume `body` is the root, but rather a `.brawl` class. The root is where we'd set root level css variables, fonts?, etc. Should we scope all css to this root class? That could get painful with just vanilla css. We could use something like https://github.com/domwashburn/postcss-parent-selector to only do this at build time. Other useful plugin for postcss: https://github.com/postcss/postcss-selector-parser
|
||||
|
@ -237,7 +237,7 @@ room.sendEvent(eventEntry.eventType, replacement);
|
||||
## Replies
|
||||
|
||||
```js
|
||||
const reply = eventEntry.reply({});
|
||||
const reply = eventEntry.createReplyContent({});
|
||||
room.sendEvent("m.room.message", reply);
|
||||
```
|
||||
|
@ -30,7 +30,7 @@ if (loginOptions.sso) {
|
||||
// store the homeserver for when we get redirected back after the sso flow
|
||||
platform.settingsStorage.setString("sso_homeserver", loginOptions.homeserver);
|
||||
// create the redirect url
|
||||
const callbackUrl = urlCreator.createSSOCallbackURL(); // will just return the document url without any fragment
|
||||
const callbackUrl = urlRouter.createSSOCallbackURL(); // will just return the document url without any fragment
|
||||
const redirectUrl = sso.createRedirectUrl(callbackUrl, provider);
|
||||
// and open it
|
||||
platform.openURL(redirectUrl);
|
55
doc/implementation planning/room-types.ts
Normal file
55
doc/implementation planning/room-types.ts
Normal file
@ -0,0 +1,55 @@
|
||||
/*
|
||||
different room types create different kind of "sync listeners", who implement the sync lifecycle handlers
|
||||
|
||||
they would each have a factory,
|
||||
*/
|
||||
|
||||
interface IRoomSyncHandler {
|
||||
prepareSync()
|
||||
afterPrepareSync()
|
||||
writeSync()
|
||||
afterSync()
|
||||
afterSyncCompleted()
|
||||
}
|
||||
|
||||
interface IRoom extends IRoomSyncHandler {
|
||||
start(): void;
|
||||
load(): void;
|
||||
get id(): string;
|
||||
}
|
||||
|
||||
interface IRoomFactory<T extends IRoom> {
|
||||
createRoom(type, roomId, syncResponse): T
|
||||
createSchema(db, txn, oldVersion, version, log)
|
||||
get storesForSync(): string[];
|
||||
get rooms(): ObservableMap<string, T>
|
||||
}
|
||||
|
||||
class InstantMessageRoom implements IRoom {
|
||||
}
|
||||
|
||||
class InstantMessageRoomFactory implements IRoomFactory<InstantMessageRoom>{
|
||||
loadLastMessages(): Promise<void>
|
||||
/*
|
||||
get all room ids and sort them according to idb sorting order
|
||||
open cursor 'f' on `timelineFragments`
|
||||
open a cursor 'e' on `timelineEvents`
|
||||
for each room:
|
||||
with cursor 'f', go to last fragment id and go up from there to find live fragment
|
||||
with cursor 'e', go to last event index for fragment id and room id and go up until we have acceptable event type
|
||||
for encrypted rooms:
|
||||
decrypt message if needed (m.room.encrypted is likely something we want to display)
|
||||
*/
|
||||
}
|
||||
|
||||
class SpaceRoom implements IRoom {}
|
||||
|
||||
class SpaceRoomFactory implements IRoomFactory<SpaceRoom> {
|
||||
createRoom(type, roomId, syncResponse): IRoomSyncHandler
|
||||
}
|
||||
|
||||
class Session {
|
||||
constructor(roomFactoriesByType: Map<string, IRoomFactory>) {
|
||||
|
||||
}
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
view hierarchy:
|
||||
```
|
||||
BrawlView
|
||||
SwitchView
|
||||
SessionView
|
||||
SyncStatusBar
|
||||
ListView(left-panel)
|
||||
RoomTile
|
||||
SwitchView
|
||||
RoomPlaceholderView
|
||||
RoomView
|
||||
MiddlePanel
|
||||
ListView(timeline)
|
||||
event tiles (see ui/session/room/timeline/)
|
||||
ComposerView
|
||||
RightPanel
|
||||
SessionPickView
|
||||
ListView
|
||||
SessionPickerItemView
|
||||
LoginView
|
||||
```
|
11
docker/dynamic-config.sh
Executable file
11
docker/dynamic-config.sh
Executable file
@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eux
|
||||
|
||||
if [ -n "${CONFIG_OVERRIDE:-}" ]; then
|
||||
# Use config override environment variable if set
|
||||
echo "$CONFIG_OVERRIDE" > /tmp/config.json
|
||||
else
|
||||
# Otherwise, use the default config that was bundled in the image
|
||||
cp /config.json.bundled /tmp/config.json
|
||||
fi
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hydrogen-web",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.8",
|
||||
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
|
||||
"directories": {
|
||||
"doc": "doc"
|
||||
@ -18,7 +18,8 @@
|
||||
"start": "vite --port 3000",
|
||||
"build": "vite build && ./scripts/cleanup.sh",
|
||||
"build:sdk": "./scripts/sdk/build.sh",
|
||||
"watch:sdk": "./scripts/sdk/build.sh && yarn run vite build -c vite.sdk-lib-config.js --watch"
|
||||
"watch:sdk": "./scripts/sdk/build.sh && yarn run vite build -c vite.sdk-lib-config.js --watch",
|
||||
"test:app": "./scripts/test-app.sh"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -32,6 +33,7 @@
|
||||
"homepage": "https://github.com/vector-im/hydrogen-web/#readme",
|
||||
"devDependencies": {
|
||||
"@matrixdotorg/structured-logviewer": "^0.0.3",
|
||||
"@playwright/test": "^1.27.1",
|
||||
"@typescript-eslint/eslint-plugin": "^4.29.2",
|
||||
"@typescript-eslint/parser": "^4.29.2",
|
||||
"acorn": "^8.6.0",
|
||||
|
21
playwright.config.ts
Normal file
21
playwright.config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type { PlaywrightTestConfig } from "@playwright/test";
|
||||
|
||||
const BASE_URL = process.env["BASE_URL"] ?? "http://127.0.0.1:3000";
|
||||
|
||||
const config: PlaywrightTestConfig = {
|
||||
use: {
|
||||
headless: false,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
ignoreHTTPSErrors: true,
|
||||
video: "on-first-retry",
|
||||
baseURL: BASE_URL,
|
||||
},
|
||||
testDir: "./playwright/tests",
|
||||
globalSetup: require.resolve("./playwright/global-setup"),
|
||||
webServer: {
|
||||
command: "yarn start",
|
||||
url: `${BASE_URL}/#/login`,
|
||||
},
|
||||
workers: 1
|
||||
};
|
||||
export default config;
|
28
playwright/global-setup.ts
Normal file
28
playwright/global-setup.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
const env = {
|
||||
SYNAPSE_IP_ADDRESS: "172.18.0.5",
|
||||
SYNAPSE_PORT: "8008",
|
||||
DEX_IP_ADDRESS: "172.18.0.4",
|
||||
DEX_PORT: "5556",
|
||||
}
|
||||
|
||||
export default function setupEnvironmentVariables() {
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
108
playwright/plugins/dex/index.ts
Normal file
108
playwright/plugins/dex/index.ts
Normal file
@ -0,0 +1,108 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/// <reference types="cypress" />
|
||||
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import * as fse from "fs-extra";
|
||||
|
||||
import {dockerRun, dockerStop } from "../docker";
|
||||
|
||||
// A cypress plugins to add command to start & stop dex instances
|
||||
|
||||
interface DexConfig {
|
||||
configDir: string;
|
||||
baseUrl: string;
|
||||
port: number;
|
||||
host: string;
|
||||
}
|
||||
|
||||
export interface DexInstance extends DexConfig {
|
||||
dexId: string;
|
||||
}
|
||||
|
||||
const dexConfigs = new Map<string, DexInstance>();
|
||||
|
||||
async function produceConfigWithSynapseURLAdded(): Promise<DexConfig> {
|
||||
const templateDir = path.join(__dirname, "template");
|
||||
|
||||
const stats = await fse.stat(templateDir);
|
||||
if (!stats?.isDirectory) {
|
||||
throw new Error(`Template directory at ${templateDir} not found!`);
|
||||
}
|
||||
const tempDir = await fse.mkdtemp(path.join(os.tmpdir(), 'hydrogen-testing-dex-'));
|
||||
|
||||
// copy the contents of the template dir, omitting config.yaml as we'll template that
|
||||
console.log(`Copy ${templateDir} -> ${tempDir}`);
|
||||
await fse.copy(templateDir, tempDir, { filter: f => path.basename(f) !== 'config.yaml' });
|
||||
|
||||
// now copy config.yaml, applying substitutions
|
||||
console.log(`Gen ${path.join(templateDir, "config.yaml")}`);
|
||||
let hsYaml = await fse.readFile(path.join(templateDir, "config.yaml"), "utf8");
|
||||
const synapseHost = process.env.SYNAPSE_IP_ADDRESS;
|
||||
const synapsePort = process.env.SYNAPSE_PORT;
|
||||
const synapseAddress = `${synapseHost}:${synapsePort}`;
|
||||
hsYaml = hsYaml.replace(/{{SYNAPSE_ADDRESS}}/g, synapseAddress);
|
||||
const dexHost = process.env.DEX_IP_ADDRESS!;
|
||||
const dexPort = parseInt(process.env.DEX_PORT!, 10);
|
||||
const dexAddress = `${dexHost}:${dexPort}`;
|
||||
hsYaml = hsYaml.replace(/{{DEX_ADDRESS}}/g, dexAddress);
|
||||
await fse.writeFile(path.join(tempDir, "config.yaml"), hsYaml);
|
||||
|
||||
const baseUrl = `http://${dexHost}:${dexPort}`;
|
||||
return {
|
||||
host: dexHost,
|
||||
port: dexPort,
|
||||
baseUrl,
|
||||
configDir: tempDir,
|
||||
};
|
||||
}
|
||||
|
||||
export async function dexStart(): Promise<DexInstance> {
|
||||
const dexCfg = await produceConfigWithSynapseURLAdded();
|
||||
console.log(`Starting dex with config dir ${dexCfg.configDir}...`);
|
||||
const dexId = await dockerRun({
|
||||
image: "bitnami/dex:latest",
|
||||
containerName: "hydrogen-dex",
|
||||
dockerParams: [
|
||||
"--rm",
|
||||
"-v", `${dexCfg.configDir}:/data`,
|
||||
`--ip=${dexCfg.host}`,
|
||||
"-p", `${dexCfg.port}:5556/tcp`,
|
||||
"--network=hydrogen"
|
||||
],
|
||||
applicationParams: [
|
||||
"serve",
|
||||
"data/config.yaml",
|
||||
]
|
||||
});
|
||||
|
||||
console.log(`Started dex with id ${dexId} on port ${dexCfg.port}.`);
|
||||
|
||||
const dex: DexInstance = { dexId, ...dexCfg };
|
||||
dexConfigs.set(dexId, dex);
|
||||
return dex;
|
||||
}
|
||||
|
||||
export async function dexStop(id: string): Promise<void> {
|
||||
const dexCfg = dexConfigs.get(id);
|
||||
if (!dexCfg) throw new Error("Unknown dex ID");
|
||||
await dockerStop({ containerId: id, });
|
||||
await fse.remove(dexCfg.configDir);
|
||||
dexConfigs.delete(id);
|
||||
console.log(`Stopped dex id ${id}.`);
|
||||
}
|
56
playwright/plugins/dex/template/config.yaml
Executable file
56
playwright/plugins/dex/template/config.yaml
Executable file
@ -0,0 +1,56 @@
|
||||
issuer: http://{{DEX_ADDRESS}}/dex
|
||||
|
||||
storage:
|
||||
type: sqlite3
|
||||
config:
|
||||
file: data/dev.db
|
||||
|
||||
|
||||
# Configuration for the HTTP endpoints.
|
||||
web:
|
||||
http: 0.0.0.0:5556
|
||||
# Uncomment for HTTPS options.
|
||||
# https: 127.0.0.1:5554
|
||||
# tlsCert: /etc/dex/tls.crt
|
||||
# tlsKey: /etc/dex/tls.key
|
||||
|
||||
# Configuration for telemetry
|
||||
telemetry:
|
||||
http: 0.0.0.0:5558
|
||||
# enableProfiling: true
|
||||
|
||||
staticClients:
|
||||
- id: synapse
|
||||
secret: secret
|
||||
redirectURIs:
|
||||
- 'http://{{SYNAPSE_ADDRESS}}/_synapse/client/oidc/callback'
|
||||
name: 'Synapse'
|
||||
connectors:
|
||||
- type: mockCallback
|
||||
id: mock
|
||||
name: Example
|
||||
# - type: google
|
||||
# id: google
|
||||
# name: Google
|
||||
# config:
|
||||
# issuer: https://accounts.google.com
|
||||
# # Connector config values starting with a "$" will read from the environment.
|
||||
# clientID: $GOOGLE_CLIENT_ID
|
||||
# clientSecret: $GOOGLE_CLIENT_SECRET
|
||||
# redirectURI: http://127.0.0.1:5556/dex/callback
|
||||
# hostedDomains:
|
||||
# - $GOOGLE_HOSTED_DOMAIN
|
||||
|
||||
# Let dex keep a list of passwords which can be used to login to dex.
|
||||
enablePasswordDB: true
|
||||
|
||||
# A static list of passwords to login the end user. By identifying here, dex
|
||||
# won't look in its underlying storage for passwords.
|
||||
#
|
||||
# If this option isn't chosen users may be added through the gRPC API.
|
||||
staticPasswords:
|
||||
- email: "admin@example.com"
|
||||
# bcrypt hash of the string "password": $(echo password | htpasswd -BinC 10 admin | cut -d: -f2)
|
||||
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
|
||||
username: "admin"
|
||||
userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"
|
BIN
playwright/plugins/dex/template/dev.db
Executable file
BIN
playwright/plugins/dex/template/dev.db
Executable file
Binary file not shown.
151
playwright/plugins/docker/index.ts
Normal file
151
playwright/plugins/docker/index.ts
Normal file
@ -0,0 +1,151 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/// <reference types="cypress" />
|
||||
|
||||
import * as os from "os";
|
||||
import * as childProcess from "child_process";
|
||||
import * as fse from "fs-extra";
|
||||
|
||||
export function dockerRun(args: {
|
||||
image: string;
|
||||
containerName: string;
|
||||
dockerParams?: string[];
|
||||
applicationParams?: string[];
|
||||
}): Promise<string> {
|
||||
const userInfo = os.userInfo();
|
||||
const params = args.dockerParams ?? [];
|
||||
const appParams = args.applicationParams ?? [];
|
||||
|
||||
if (userInfo.uid >= 0) {
|
||||
// On *nix we run the docker container as our uid:gid otherwise cleaning it up its media_store can be difficult
|
||||
params.push("-u", `${userInfo.uid}:${userInfo.gid}`);
|
||||
}
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
childProcess.execFile('docker', [
|
||||
"run",
|
||||
"--name", args.containerName,
|
||||
"-d",
|
||||
...params,
|
||||
args.image,
|
||||
... appParams
|
||||
], (err, stdout) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
resolve(stdout.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function dockerExec(args: {
|
||||
containerId: string;
|
||||
params: string[];
|
||||
}): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
childProcess.execFile("docker", [
|
||||
"exec", args.containerId,
|
||||
...args.params,
|
||||
], { encoding: 'utf8' }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.log(stdout);
|
||||
console.log(stderr);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a docker network; does not fail if network already exists
|
||||
*/
|
||||
export function dockerCreateNetwork(args: {
|
||||
networkName: string;
|
||||
}): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
childProcess.execFile("docker", [
|
||||
"network",
|
||||
"create",
|
||||
args.networkName
|
||||
], { encoding: 'utf8' }, (err, stdout, stderr) => {
|
||||
if(err) {
|
||||
if (stderr.includes(`network with name ${args.networkName} already exists`)) {
|
||||
// Don't consider this as error
|
||||
resolve();
|
||||
}
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function dockerLogs(args: {
|
||||
containerId: string;
|
||||
stdoutFile?: string;
|
||||
stderrFile?: string;
|
||||
}): Promise<void> {
|
||||
const stdoutFile = args.stdoutFile ? await fse.open(args.stdoutFile, "w") : "ignore";
|
||||
const stderrFile = args.stderrFile ? await fse.open(args.stderrFile, "w") : "ignore";
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
childProcess.spawn("docker", [
|
||||
"logs",
|
||||
args.containerId,
|
||||
], {
|
||||
stdio: ["ignore", stdoutFile, stderrFile],
|
||||
}).once('close', resolve);
|
||||
});
|
||||
|
||||
if (args.stdoutFile) await fse.close(<number>stdoutFile);
|
||||
if (args.stderrFile) await fse.close(<number>stderrFile);
|
||||
}
|
||||
|
||||
export function dockerStop(args: {
|
||||
containerId: string;
|
||||
}): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
childProcess.execFile('docker', [
|
||||
"stop",
|
||||
args.containerId,
|
||||
], err => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function dockerRm(args: {
|
||||
containerId: string;
|
||||
}): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
childProcess.execFile('docker', [
|
||||
"rm",
|
||||
args.containerId,
|
||||
], err => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
203
playwright/plugins/synapsedocker/index.ts
Normal file
203
playwright/plugins/synapsedocker/index.ts
Normal file
@ -0,0 +1,203 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/// <reference types="cypress" />
|
||||
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import * as crypto from "crypto";
|
||||
import * as fse from "fs-extra";
|
||||
|
||||
import {dockerCreateNetwork, dockerExec, dockerLogs, dockerRun, dockerStop} from "../docker";
|
||||
import {request} from "@playwright/test";
|
||||
|
||||
|
||||
// A cypress plugins to add command to start & stop synapses in
|
||||
// docker with preset templates.
|
||||
|
||||
interface SynapseConfig {
|
||||
configDir: string;
|
||||
registrationSecret: string;
|
||||
// Synapse must be configured with its public_baseurl so we have to allocate a port & url at this stage
|
||||
baseUrl: string;
|
||||
port: number;
|
||||
host: string;
|
||||
}
|
||||
|
||||
export interface SynapseInstance extends SynapseConfig {
|
||||
synapseId: string;
|
||||
}
|
||||
|
||||
const synapses = new Map<string, SynapseInstance>();
|
||||
|
||||
function randB64Bytes(numBytes: number): string {
|
||||
return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, "");
|
||||
}
|
||||
|
||||
async function cfgDirFromTemplate(template: string): Promise<SynapseConfig> {
|
||||
const templateDir = path.join(__dirname, "templates", template);
|
||||
|
||||
const stats = await fse.stat(templateDir);
|
||||
if (!stats?.isDirectory) {
|
||||
throw new Error(`No such template: ${template}`);
|
||||
}
|
||||
const tempDir = await fse.mkdtemp(path.join(os.tmpdir(), 'synapsedocker-'));
|
||||
|
||||
// copy the contents of the template dir, omitting homeserver.yaml as we'll template that
|
||||
console.log(`Copy ${templateDir} -> ${tempDir}`);
|
||||
await fse.copy(templateDir, tempDir, { filter: f => path.basename(f) !== 'homeserver.yaml' });
|
||||
|
||||
const registrationSecret = randB64Bytes(16);
|
||||
const macaroonSecret = randB64Bytes(16);
|
||||
const formSecret = randB64Bytes(16);
|
||||
|
||||
const synapseHost = process.env["SYNAPSE_IP_ADDRESS"]!!;
|
||||
const synapsePort = parseInt(process.env["SYNAPSE_PORT"]!, 10);
|
||||
const baseUrl = `http://${synapseHost}:${synapsePort}`;
|
||||
|
||||
|
||||
// now copy homeserver.yaml, applying substitutions
|
||||
console.log(`Gen ${path.join(templateDir, "homeserver.yaml")}`);
|
||||
let hsYaml = await fse.readFile(path.join(templateDir, "homeserver.yaml"), "utf8");
|
||||
hsYaml = hsYaml.replace(/{{REGISTRATION_SECRET}}/g, registrationSecret);
|
||||
hsYaml = hsYaml.replace(/{{MACAROON_SECRET_KEY}}/g, macaroonSecret);
|
||||
hsYaml = hsYaml.replace(/{{FORM_SECRET}}/g, formSecret);
|
||||
hsYaml = hsYaml.replace(/{{PUBLIC_BASEURL}}/g, baseUrl);
|
||||
|
||||
const dexHost = process.env["DEX_IP_ADDRESS"];
|
||||
const dexPort = process.env["DEX_PORT"];
|
||||
const dexUrl = `http://${dexHost}:${dexPort}/dex`;
|
||||
hsYaml = hsYaml.replace(/{{OIDC_ISSUER}}/g, dexUrl);
|
||||
await fse.writeFile(path.join(tempDir, "homeserver.yaml"), hsYaml);
|
||||
|
||||
// now generate a signing key (we could use synapse's config generation for
|
||||
// this, or we could just do this...)
|
||||
// NB. This assumes the homeserver.yaml specifies the key in this location
|
||||
const signingKey = randB64Bytes(32);
|
||||
console.log(`Gen ${path.join(templateDir, "localhost.signing.key")}`);
|
||||
await fse.writeFile(path.join(tempDir, "localhost.signing.key"), `ed25519 x ${signingKey}`);
|
||||
|
||||
return {
|
||||
port: synapsePort,
|
||||
host: synapseHost,
|
||||
baseUrl,
|
||||
configDir: tempDir,
|
||||
registrationSecret,
|
||||
};
|
||||
}
|
||||
|
||||
// Start a synapse instance: the template must be the name of
|
||||
// one of the templates in the cypress/plugins/synapsedocker/templates
|
||||
// directory
|
||||
export async function synapseStart(template: string): Promise<SynapseInstance> {
|
||||
const synCfg = await cfgDirFromTemplate(template);
|
||||
console.log(`Starting synapse with config dir ${synCfg.configDir}...`);
|
||||
await dockerCreateNetwork({ networkName: "hydrogen" });
|
||||
const synapseId = await dockerRun({
|
||||
image: "matrixdotorg/synapse:develop",
|
||||
containerName: `hydrogen-synapse`,
|
||||
dockerParams: [
|
||||
"--rm",
|
||||
"-v", `${synCfg.configDir}:/data`,
|
||||
`--ip=${synCfg.host}`,
|
||||
/**
|
||||
* When using -p flag with --ip, the docker internal port must be used to access from the host
|
||||
*/
|
||||
"-p", `${synCfg.port}:8008/tcp`,
|
||||
"--network=hydrogen",
|
||||
],
|
||||
applicationParams: [
|
||||
"run"
|
||||
]
|
||||
});
|
||||
|
||||
console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}.`);
|
||||
|
||||
// Await Synapse healthcheck
|
||||
await dockerExec({
|
||||
containerId: synapseId,
|
||||
params: [
|
||||
"curl",
|
||||
"--connect-timeout", "30",
|
||||
"--retry", "30",
|
||||
"--retry-delay", "1",
|
||||
"--retry-all-errors",
|
||||
"--silent",
|
||||
"http://localhost:8008/health",
|
||||
],
|
||||
});
|
||||
|
||||
const synapse: SynapseInstance = { synapseId, ...synCfg };
|
||||
synapses.set(synapseId, synapse);
|
||||
return synapse;
|
||||
}
|
||||
|
||||
export async function synapseStop(id: string): Promise<void> {
|
||||
const synCfg = synapses.get(id);
|
||||
|
||||
if (!synCfg) throw new Error("Unknown synapse ID");
|
||||
|
||||
const synapseLogsPath = path.join("playwright", "synapselogs", id);
|
||||
await fse.ensureDir(synapseLogsPath);
|
||||
|
||||
await dockerLogs({
|
||||
containerId: id,
|
||||
stdoutFile: path.join(synapseLogsPath, "stdout.log"),
|
||||
stderrFile: path.join(synapseLogsPath, "stderr.log"),
|
||||
});
|
||||
|
||||
await dockerStop({
|
||||
containerId: id,
|
||||
});
|
||||
|
||||
await fse.remove(synCfg.configDir);
|
||||
synapses.delete(id);
|
||||
console.log(`Stopped synapse id ${id}.`);
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface Credentials {
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
homeServer: string;
|
||||
}
|
||||
|
||||
export async function registerUser(synapse: SynapseInstance, username: string, password: string, displayName?: string,): Promise<Credentials> {
|
||||
const url = `${synapse.baseUrl}/_synapse/admin/v1/register`;
|
||||
const context = await request.newContext({ baseURL: url });
|
||||
const { nonce } = await (await context.get(url)).json();
|
||||
const mac = crypto.createHmac('sha1', synapse.registrationSecret).update(
|
||||
`${nonce}\0${username}\0${password}\0notadmin`,
|
||||
).digest('hex');
|
||||
const response = await (await context.post(url, {
|
||||
data: {
|
||||
nonce,
|
||||
username,
|
||||
password,
|
||||
mac,
|
||||
admin: false,
|
||||
displayname: displayName,
|
||||
}
|
||||
})).json();
|
||||
return {
|
||||
homeServer: response.home_server,
|
||||
accessToken: response.access_token,
|
||||
userId: response.user_id,
|
||||
deviceId: response.device_id,
|
||||
};
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
# Meta-template for synapse templates
|
||||
|
||||
To make another template, you can copy this directory
|
@ -0,0 +1,72 @@
|
||||
server_name: "localhost"
|
||||
pid_file: /data/homeserver.pid
|
||||
# XXX: This won't actually be right: it lets docker allocate an ephemeral port,
|
||||
# so we have a chicken-and-egg problem
|
||||
public_baseurl: http://localhost:8008/
|
||||
# Listener is always port 8008 (configured in the container)
|
||||
listeners:
|
||||
- port: 8008
|
||||
tls: false
|
||||
bind_addresses: ['::']
|
||||
type: http
|
||||
x_forwarded: true
|
||||
|
||||
resources:
|
||||
- names: [client, federation, consent]
|
||||
compress: false
|
||||
|
||||
# An sqlite in-memory database is fast & automatically wipes each time
|
||||
database:
|
||||
name: "sqlite3"
|
||||
args:
|
||||
database: ":memory:"
|
||||
|
||||
# Needs to be configured to log to the console like a good docker process
|
||||
log_config: "/data/log.config"
|
||||
|
||||
rc_messages_per_second: 10000
|
||||
rc_message_burst_count: 10000
|
||||
rc_registration:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
rc_login:
|
||||
address:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
account:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
failed_attempts:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
media_store_path: "/data/media_store"
|
||||
uploads_path: "/data/uploads"
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
disable_msisdn_registration: false
|
||||
# These placeholders will be be replaced with values generated at start
|
||||
registration_shared_secret: "{{REGISTRATION_SECRET}}"
|
||||
report_stats: false
|
||||
macaroon_secret_key: "{{MACAROON_SECRET_KEY}}"
|
||||
form_secret: "{{FORM_SECRET}}"
|
||||
# Signing key must be here: it will be generated to this file
|
||||
signing_key_path: "/data/localhost.signing.key"
|
||||
email:
|
||||
enable_notifs: false
|
||||
smtp_host: "localhost"
|
||||
smtp_port: 25
|
||||
smtp_user: "exampleusername"
|
||||
smtp_pass: "examplepassword"
|
||||
require_transport_security: False
|
||||
notif_from: "Your Friendly %(app)s homeserver <noreply@example.com>"
|
||||
app_name: Matrix
|
||||
notif_template_html: notif_mail.html
|
||||
notif_template_text: notif_mail.txt
|
||||
notif_for_new_users: True
|
||||
client_base_url: "http://localhost/element"
|
||||
|
||||
trusted_key_servers:
|
||||
- server_name: "matrix.org"
|
||||
suppress_key_server_warning: true
|
50
playwright/plugins/synapsedocker/templates/COPYME/log.config
Normal file
50
playwright/plugins/synapsedocker/templates/COPYME/log.config
Normal file
@ -0,0 +1,50 @@
|
||||
# Log configuration for Synapse.
|
||||
#
|
||||
# This is a YAML file containing a standard Python logging configuration
|
||||
# dictionary. See [1] for details on the valid settings.
|
||||
#
|
||||
# Synapse also supports structured logging for machine readable logs which can
|
||||
# be ingested by ELK stacks. See [2] for details.
|
||||
#
|
||||
# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema
|
||||
# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html
|
||||
|
||||
version: 1
|
||||
|
||||
formatters:
|
||||
precise:
|
||||
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
|
||||
|
||||
handlers:
|
||||
# A handler that writes logs to stderr. Unused by default, but can be used
|
||||
# instead of "buffer" and "file" in the logger handlers.
|
||||
console:
|
||||
class: logging.StreamHandler
|
||||
formatter: precise
|
||||
|
||||
loggers:
|
||||
synapse.storage.SQL:
|
||||
# beware: increasing this to DEBUG will make synapse log sensitive
|
||||
# information such as access tokens.
|
||||
level: INFO
|
||||
|
||||
twisted:
|
||||
# We send the twisted logging directly to the file handler,
|
||||
# to work around https://github.com/matrix-org/synapse/issues/3471
|
||||
# when using "buffer" logger. Use "console" to log to stderr instead.
|
||||
handlers: [console]
|
||||
propagate: false
|
||||
|
||||
root:
|
||||
level: INFO
|
||||
|
||||
# Write logs to the `buffer` handler, which will buffer them together in memory,
|
||||
# then write them to a file.
|
||||
#
|
||||
# Replace "buffer" with "console" to log to stderr instead. (Note that you'll
|
||||
# also need to update the configuration for the `twisted` logger above, in
|
||||
# this case.)
|
||||
#
|
||||
handlers: [console]
|
||||
|
||||
disable_existing_loggers: false
|
@ -0,0 +1 @@
|
||||
A synapse configured with user privacy consent enabled
|
@ -0,0 +1,84 @@
|
||||
server_name: "localhost"
|
||||
pid_file: /data/homeserver.pid
|
||||
public_baseurl: "{{PUBLIC_BASEURL}}"
|
||||
listeners:
|
||||
- port: 8008
|
||||
tls: false
|
||||
bind_addresses: ['::']
|
||||
type: http
|
||||
x_forwarded: true
|
||||
|
||||
resources:
|
||||
- names: [client, federation, consent]
|
||||
compress: false
|
||||
|
||||
database:
|
||||
name: "sqlite3"
|
||||
args:
|
||||
database: ":memory:"
|
||||
|
||||
log_config: "/data/log.config"
|
||||
|
||||
rc_messages_per_second: 10000
|
||||
rc_message_burst_count: 10000
|
||||
rc_registration:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
rc_login:
|
||||
address:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
account:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
failed_attempts:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
media_store_path: "/data/media_store"
|
||||
uploads_path: "/data/uploads"
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
disable_msisdn_registration: false
|
||||
registration_shared_secret: "{{REGISTRATION_SECRET}}"
|
||||
report_stats: false
|
||||
macaroon_secret_key: "{{MACAROON_SECRET_KEY}}"
|
||||
form_secret: "{{FORM_SECRET}}"
|
||||
signing_key_path: "/data/localhost.signing.key"
|
||||
email:
|
||||
enable_notifs: false
|
||||
smtp_host: "localhost"
|
||||
smtp_port: 25
|
||||
smtp_user: "exampleusername"
|
||||
smtp_pass: "examplepassword"
|
||||
require_transport_security: False
|
||||
notif_from: "Your Friendly %(app)s homeserver <noreply@example.com>"
|
||||
app_name: Matrix
|
||||
notif_template_html: notif_mail.html
|
||||
notif_template_text: notif_mail.txt
|
||||
notif_for_new_users: True
|
||||
client_base_url: "http://localhost/element"
|
||||
|
||||
user_consent:
|
||||
template_dir: /data/res/templates/privacy
|
||||
version: 1.0
|
||||
server_notice_content:
|
||||
msgtype: m.text
|
||||
body: >-
|
||||
To continue using this homeserver you must review and agree to the
|
||||
terms and conditions at %(consent_uri)s
|
||||
send_server_notice_to_guests: True
|
||||
block_events_error: >-
|
||||
To continue using this homeserver you must review and agree to the
|
||||
terms and conditions at %(consent_uri)s
|
||||
require_at_registration: true
|
||||
|
||||
server_notices:
|
||||
system_mxid_localpart: notices
|
||||
system_mxid_display_name: "Server Notices"
|
||||
system_mxid_avatar_url: "mxc://localhost:5005/oumMVlgDnLYFaPVkExemNVVZ"
|
||||
room_name: "Server Notices"
|
||||
trusted_key_servers:
|
||||
- server_name: "matrix.org"
|
||||
suppress_key_server_warning: true
|
@ -0,0 +1,50 @@
|
||||
# Log configuration for Synapse.
|
||||
#
|
||||
# This is a YAML file containing a standard Python logging configuration
|
||||
# dictionary. See [1] for details on the valid settings.
|
||||
#
|
||||
# Synapse also supports structured logging for machine readable logs which can
|
||||
# be ingested by ELK stacks. See [2] for details.
|
||||
#
|
||||
# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema
|
||||
# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html
|
||||
|
||||
version: 1
|
||||
|
||||
formatters:
|
||||
precise:
|
||||
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
|
||||
|
||||
handlers:
|
||||
# A handler that writes logs to stderr. Unused by default, but can be used
|
||||
# instead of "buffer" and "file" in the logger handlers.
|
||||
console:
|
||||
class: logging.StreamHandler
|
||||
formatter: precise
|
||||
|
||||
loggers:
|
||||
synapse.storage.SQL:
|
||||
# beware: increasing this to DEBUG will make synapse log sensitive
|
||||
# information such as access tokens.
|
||||
level: DEBUG
|
||||
|
||||
twisted:
|
||||
# We send the twisted logging directly to the file handler,
|
||||
# to work around https://github.com/matrix-org/synapse/issues/3471
|
||||
# when using "buffer" logger. Use "console" to log to stderr instead.
|
||||
handlers: [console]
|
||||
propagate: false
|
||||
|
||||
root:
|
||||
level: DEBUG
|
||||
|
||||
# Write logs to the `buffer` handler, which will buffer them together in memory,
|
||||
# then write them to a file.
|
||||
#
|
||||
# Replace "buffer" with "console" to log to stderr instead. (Note that you'll
|
||||
# also need to update the configuration for the `twisted` logger above, in
|
||||
# this case.)
|
||||
#
|
||||
handlers: [console]
|
||||
|
||||
disable_existing_loggers: false
|
@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Test Privacy policy</title>
|
||||
</head>
|
||||
<body>
|
||||
{% if has_consented %}
|
||||
<p>
|
||||
Thank you, you've already accepted the license.
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
Please accept the license!
|
||||
</p>
|
||||
<form method="post" action="consent">
|
||||
<input type="hidden" name="v" value="{{version}}"/>
|
||||
<input type="hidden" name="u" value="{{user}}"/>
|
||||
<input type="hidden" name="h" value="{{userhmac}}"/>
|
||||
<input type="submit" value="Sure thing!"/>
|
||||
</form>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Test Privacy policy</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Danke schon</p>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1 @@
|
||||
A synapse configured with user privacy consent disabled
|
@ -0,0 +1,76 @@
|
||||
server_name: "localhost"
|
||||
pid_file: /data/homeserver.pid
|
||||
public_baseurl: "{{PUBLIC_BASEURL}}"
|
||||
listeners:
|
||||
- port: 8008
|
||||
tls: false
|
||||
bind_addresses: ['::']
|
||||
type: http
|
||||
x_forwarded: true
|
||||
|
||||
resources:
|
||||
- names: [client]
|
||||
compress: false
|
||||
|
||||
database:
|
||||
name: "sqlite3"
|
||||
args:
|
||||
database: ":memory:"
|
||||
|
||||
log_config: "/data/log.config"
|
||||
|
||||
rc_messages_per_second: 10000
|
||||
rc_message_burst_count: 10000
|
||||
rc_registration:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
rc_joins:
|
||||
local:
|
||||
per_second: 9999
|
||||
burst_count: 9999
|
||||
remote:
|
||||
per_second: 9999
|
||||
burst_count: 9999
|
||||
rc_joins_per_room:
|
||||
per_second: 9999
|
||||
burst_count: 9999
|
||||
rc_3pid_validation:
|
||||
per_second: 1000
|
||||
burst_count: 1000
|
||||
|
||||
rc_invites:
|
||||
per_room:
|
||||
per_second: 1000
|
||||
burst_count: 1000
|
||||
per_user:
|
||||
per_second: 1000
|
||||
burst_count: 1000
|
||||
|
||||
rc_login:
|
||||
address:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
account:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
failed_attempts:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
media_store_path: "/data/media_store"
|
||||
uploads_path: "/data/uploads"
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
disable_msisdn_registration: false
|
||||
registration_shared_secret: "{{REGISTRATION_SECRET}}"
|
||||
report_stats: false
|
||||
macaroon_secret_key: "{{MACAROON_SECRET_KEY}}"
|
||||
form_secret: "{{FORM_SECRET}}"
|
||||
signing_key_path: "/data/localhost.signing.key"
|
||||
|
||||
trusted_key_servers:
|
||||
- server_name: "matrix.org"
|
||||
suppress_key_server_warning: true
|
||||
|
||||
ui_auth:
|
||||
session_timeout: "300s"
|
@ -0,0 +1,50 @@
|
||||
# Log configuration for Synapse.
|
||||
#
|
||||
# This is a YAML file containing a standard Python logging configuration
|
||||
# dictionary. See [1] for details on the valid settings.
|
||||
#
|
||||
# Synapse also supports structured logging for machine readable logs which can
|
||||
# be ingested by ELK stacks. See [2] for details.
|
||||
#
|
||||
# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema
|
||||
# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html
|
||||
|
||||
version: 1
|
||||
|
||||
formatters:
|
||||
precise:
|
||||
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
|
||||
|
||||
handlers:
|
||||
# A handler that writes logs to stderr. Unused by default, but can be used
|
||||
# instead of "buffer" and "file" in the logger handlers.
|
||||
console:
|
||||
class: logging.StreamHandler
|
||||
formatter: precise
|
||||
|
||||
loggers:
|
||||
synapse.storage.SQL:
|
||||
# beware: increasing this to DEBUG will make synapse log sensitive
|
||||
# information such as access tokens.
|
||||
level: DEBUG
|
||||
|
||||
twisted:
|
||||
# We send the twisted logging directly to the file handler,
|
||||
# to work around https://github.com/matrix-org/synapse/issues/3471
|
||||
# when using "buffer" logger. Use "console" to log to stderr instead.
|
||||
handlers: [console]
|
||||
propagate: false
|
||||
|
||||
root:
|
||||
level: DEBUG
|
||||
|
||||
# Write logs to the `buffer` handler, which will buffer them together in memory,
|
||||
# then write them to a file.
|
||||
#
|
||||
# Replace "buffer" with "console" to log to stderr instead. (Note that you'll
|
||||
# also need to update the configuration for the `twisted` logger above, in
|
||||
# this case.)
|
||||
#
|
||||
handlers: [console]
|
||||
|
||||
disable_existing_loggers: false
|
@ -0,0 +1,89 @@
|
||||
server_name: "localhost"
|
||||
pid_file: /data/homeserver.pid
|
||||
public_baseurl: "{{PUBLIC_BASEURL}}"
|
||||
listeners:
|
||||
- port: 8008
|
||||
tls: false
|
||||
bind_addresses: ['::']
|
||||
type: http
|
||||
x_forwarded: true
|
||||
|
||||
resources:
|
||||
- names: [client]
|
||||
compress: false
|
||||
|
||||
database:
|
||||
name: "sqlite3"
|
||||
args:
|
||||
database: ":memory:"
|
||||
|
||||
log_config: "/data/log.config"
|
||||
|
||||
rc_messages_per_second: 10000
|
||||
rc_message_burst_count: 10000
|
||||
rc_registration:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
rc_joins:
|
||||
local:
|
||||
per_second: 9999
|
||||
burst_count: 9999
|
||||
remote:
|
||||
per_second: 9999
|
||||
burst_count: 9999
|
||||
rc_joins_per_room:
|
||||
per_second: 9999
|
||||
burst_count: 9999
|
||||
rc_3pid_validation:
|
||||
per_second: 1000
|
||||
burst_count: 1000
|
||||
|
||||
rc_invites:
|
||||
per_room:
|
||||
per_second: 1000
|
||||
burst_count: 1000
|
||||
per_user:
|
||||
per_second: 1000
|
||||
burst_count: 1000
|
||||
|
||||
rc_login:
|
||||
address:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
account:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
failed_attempts:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
media_store_path: "/data/media_store"
|
||||
uploads_path: "/data/uploads"
|
||||
enable_registration: true
|
||||
enable_registration_without_verification: true
|
||||
disable_msisdn_registration: false
|
||||
registration_shared_secret: "{{REGISTRATION_SECRET}}"
|
||||
report_stats: false
|
||||
macaroon_secret_key: "{{MACAROON_SECRET_KEY}}"
|
||||
form_secret: "{{FORM_SECRET}}"
|
||||
signing_key_path: "/data/localhost.signing.key"
|
||||
|
||||
trusted_key_servers:
|
||||
- server_name: "matrix.org"
|
||||
suppress_key_server_warning: true
|
||||
|
||||
ui_auth:
|
||||
session_timeout: "300s"
|
||||
|
||||
oidc_providers:
|
||||
- idp_id: dex
|
||||
idp_name: "My Dex server"
|
||||
skip_verification: true # This is needed as Dex is served on an insecure endpoint
|
||||
issuer: "{{OIDC_ISSUER}}"
|
||||
client_id: "synapse"
|
||||
client_secret: "secret"
|
||||
scopes: ["openid", "profile"]
|
||||
user_mapping_provider:
|
||||
config:
|
||||
localpart_template: "{{ user.name }}"
|
||||
display_name_template: "{{ user.name|capitalize }}"
|
50
playwright/plugins/synapsedocker/templates/sso/log.config
Normal file
50
playwright/plugins/synapsedocker/templates/sso/log.config
Normal file
@ -0,0 +1,50 @@
|
||||
# Log configuration for Synapse.
|
||||
#
|
||||
# This is a YAML file containing a standard Python logging configuration
|
||||
# dictionary. See [1] for details on the valid settings.
|
||||
#
|
||||
# Synapse also supports structured logging for machine readable logs which can
|
||||
# be ingested by ELK stacks. See [2] for details.
|
||||
#
|
||||
# [1]: https://docs.python.org/3.7/library/logging.config.html#configuration-dictionary-schema
|
||||
# [2]: https://matrix-org.github.io/synapse/latest/structured_logging.html
|
||||
|
||||
version: 1
|
||||
|
||||
formatters:
|
||||
precise:
|
||||
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
|
||||
|
||||
handlers:
|
||||
# A handler that writes logs to stderr. Unused by default, but can be used
|
||||
# instead of "buffer" and "file" in the logger handlers.
|
||||
console:
|
||||
class: logging.StreamHandler
|
||||
formatter: precise
|
||||
|
||||
loggers:
|
||||
synapse.storage.SQL:
|
||||
# beware: increasing this to DEBUG will make synapse log sensitive
|
||||
# information such as access tokens.
|
||||
level: DEBUG
|
||||
|
||||
twisted:
|
||||
# We send the twisted logging directly to the file handler,
|
||||
# to work around https://github.com/matrix-org/synapse/issues/3471
|
||||
# when using "buffer" logger. Use "console" to log to stderr instead.
|
||||
handlers: [console]
|
||||
propagate: false
|
||||
|
||||
root:
|
||||
level: DEBUG
|
||||
|
||||
# Write logs to the `buffer` handler, which will buffer them together in memory,
|
||||
# then write them to a file.
|
||||
#
|
||||
# Replace "buffer" with "console" to log to stderr instead. (Note that you'll
|
||||
# also need to update the configuration for the `twisted` logger above, in
|
||||
# this case.)
|
||||
#
|
||||
handlers: [console]
|
||||
|
||||
disable_existing_loggers: false
|
59
playwright/tests/login.spec.ts
Normal file
59
playwright/tests/login.spec.ts
Normal file
@ -0,0 +1,59 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import {test} from '@playwright/test';
|
||||
import {synapseStart, synapseStop, registerUser} from "../plugins/synapsedocker";
|
||||
import {dexStart, dexStop} from "../plugins/dex";
|
||||
import type {DexInstance} from "../plugins/dex";
|
||||
import type {SynapseInstance} from "../plugins/synapsedocker";
|
||||
|
||||
test.describe("Login", () => {
|
||||
let synapse: SynapseInstance;
|
||||
let dex: DexInstance;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
dex = await dexStart();
|
||||
synapse = await synapseStart("sso");
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await synapseStop(synapse.synapseId);
|
||||
await dexStop(dex.dexId);
|
||||
});
|
||||
|
||||
test("Login using username/password", async ({ page }) => {
|
||||
const username = "foobaraccount";
|
||||
const password = "password123";
|
||||
await registerUser(synapse, username, password);
|
||||
await page.goto("/");
|
||||
await page.locator("#homeserver").fill("");
|
||||
await page.locator("#homeserver").type(synapse.baseUrl);
|
||||
await page.locator("#username").type(username);
|
||||
await page.locator("#password").type(password);
|
||||
await page.getByText('Log In', { exact: true }).click();
|
||||
await page.locator(".SessionView").waitFor();
|
||||
});
|
||||
|
||||
test("Login using SSO", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.locator("#homeserver").fill("");
|
||||
await page.locator("#homeserver").type(synapse.baseUrl);
|
||||
await page.locator(".StartSSOLoginView_button").click();
|
||||
await page.getByText("Log in with Example").click();
|
||||
await page.locator(".dex-btn-text", {hasText: "Grant Access"}).click();
|
||||
await page.locator(".primary-button", {hasText: "Continue"}).click();
|
||||
await page.locator(".SessionView").waitFor();
|
||||
});
|
||||
});
|
21
playwright/tests/startup.spec.ts
Normal file
21
playwright/tests/startup.spec.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
import {test} from '@playwright/test';
|
||||
|
||||
test("App has no startup errors that prevent UI render", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByText("Log In", { exact: true }).waitFor();
|
||||
});
|
@ -13,7 +13,13 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
const path = require('path').posix;
|
||||
// Use the path implementation native to the platform so paths from disk play
|
||||
// well with resolving against the relative location (think Windows `C:\` and
|
||||
// backslashes).
|
||||
const path = require('path');
|
||||
// Use the posix (forward slash) implementation when working with `import` paths
|
||||
// to reference resources
|
||||
const posixPath = require('path').posix;
|
||||
const {optimize} = require('svgo');
|
||||
|
||||
async function readCSSSource(location) {
|
||||
@ -238,7 +244,7 @@ module.exports = function buildThemes(options) {
|
||||
switch (file) {
|
||||
case "index.js": {
|
||||
const isDark = variants[variant].dark;
|
||||
return `import "${path.resolve(`${location}/theme.css`)}${isDark? "?dark=true": ""}";` +
|
||||
return `import "${posixPath.resolve(`${location}/theme.css`)}${isDark? "?dark=true": ""}";` +
|
||||
`import "@theme/${theme}/${variant}/variables.css"`;
|
||||
}
|
||||
case "variables.css": {
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@thirdroom/hydrogen-view-sdk",
|
||||
"description": "Embeddable matrix client library, including view components",
|
||||
"version": "0.0.29",
|
||||
"version": "0.1.1",
|
||||
"main": "./lib-build/hydrogen.cjs.js",
|
||||
"exports": {
|
||||
".": {
|
||||
|
19
scripts/test-app.sh
Executable file
19
scripts/test-app.sh
Executable file
@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Make sure docker is available
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
echo "You need to intall docker before you can run the tests!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop running containers
|
||||
if docker stop hydrogen-synapse > /dev/null 2>&1; then
|
||||
echo "Existing 'hydrogen-synapse' container stopped ✔"
|
||||
fi
|
||||
|
||||
if docker stop hydrogen-dex > /dev/null 2>&1; then
|
||||
echo "Existing 'hydrogen-dex' container stopped ✔"
|
||||
fi
|
||||
|
||||
# Run playwright
|
||||
yarn playwright test
|
72
src/domain/ErrorReportViewModel.ts
Normal file
72
src/domain/ErrorReportViewModel.ts
Normal file
@ -0,0 +1,72 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ViewModel } from "./ViewModel";
|
||||
import type { Options as BaseOptions } from "./ViewModel";
|
||||
import type { Session } from "../matrix/Session";
|
||||
import { ErrorViewModel } from "./ErrorViewModel";
|
||||
import type { LogCallback, LabelOrValues } from "../logging/types";
|
||||
|
||||
export type Options<N extends object> = BaseOptions<N> & {
|
||||
session: Session
|
||||
};
|
||||
|
||||
/** Base class for view models that need to report errors to the UI. */
|
||||
export class ErrorReportViewModel<N extends object, O extends Options<N> = Options<N>> extends ViewModel<N, O> {
|
||||
private _errorViewModel?: ErrorViewModel<N>;
|
||||
|
||||
get errorViewModel(): ErrorViewModel<N> | undefined {
|
||||
return this._errorViewModel;
|
||||
}
|
||||
|
||||
/** Typically you'd want to use `logAndCatch` when implementing a view model method.
|
||||
* Use `reportError` when showing errors on your model that were set by
|
||||
* background processes using `ErrorBoundary` or you have some other
|
||||
* special low-level need to write your try/catch yourself. */
|
||||
protected reportError(error: Error) {
|
||||
if (this._errorViewModel?.error === error) {
|
||||
return;
|
||||
}
|
||||
this.disposeTracked(this._errorViewModel);
|
||||
this._errorViewModel = this.track(new ErrorViewModel(this.childOptions({
|
||||
error,
|
||||
onClose: () => {
|
||||
this._errorViewModel = this.disposeTracked(this._errorViewModel);
|
||||
this.emitChange("errorViewModel");
|
||||
}
|
||||
})));
|
||||
this.emitChange("errorViewModel");
|
||||
}
|
||||
|
||||
/** Combines logging and error reporting in one method.
|
||||
* Wrap the implementation of public view model methods
|
||||
* with this to ensure errors are logged and reported.*/
|
||||
protected logAndCatch<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, errorValue: T = undefined as unknown as T): T {
|
||||
try {
|
||||
let result = this.logger.run(labelOrValues, callback);
|
||||
if (result instanceof Promise) {
|
||||
result = result.catch(err => {
|
||||
this.reportError(err);
|
||||
return errorValue;
|
||||
}) as unknown as T;
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.reportError(err);
|
||||
return errorValue;
|
||||
}
|
||||
}
|
||||
}
|
49
src/domain/ErrorViewModel.ts
Normal file
49
src/domain/ErrorViewModel.ts
Normal file
@ -0,0 +1,49 @@
|
||||
/*
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ViewModel, Options as BaseOptions } from "./ViewModel";
|
||||
import {submitLogsFromSessionToDefaultServer} from "./rageshake";
|
||||
import type { Session } from "../matrix/Session";
|
||||
import type {SegmentType} from "./navigation/index";
|
||||
|
||||
type Options<N extends object> = {
|
||||
error: Error
|
||||
session: Session,
|
||||
onClose: () => void
|
||||
} & BaseOptions<N>;
|
||||
|
||||
export class ErrorViewModel<N extends object = SegmentType, O extends Options<N> = Options<N>> extends ViewModel<N, O> {
|
||||
get message(): string {
|
||||
return this.error.message;
|
||||
}
|
||||
|
||||
get error(): Error {
|
||||
return this.getOption("error");
|
||||
}
|
||||
|
||||
close() {
|
||||
this.getOption("onClose")();
|
||||
}
|
||||
|
||||
async submitLogs(): Promise<boolean> {
|
||||
try {
|
||||
await submitLogsFromSessionToDefaultServer(this.getOption("session"), this.platform);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
81
src/domain/ForcedLogoutViewModel.ts
Normal file
81
src/domain/ForcedLogoutViewModel.ts
Normal file
@ -0,0 +1,81 @@
|
||||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {Options as BaseOptions, ViewModel} from "./ViewModel";
|
||||
import {Client} from "../matrix/Client.js";
|
||||
import {SegmentType} from "./navigation/index";
|
||||
|
||||
type Options = { sessionId: string; } & BaseOptions;
|
||||
|
||||
export class ForcedLogoutViewModel extends ViewModel<SegmentType, Options> {
|
||||
private _sessionId: string;
|
||||
private _error?: Error;
|
||||
private _logoutPromise: Promise<void>;
|
||||
private _showStatus: boolean = false;
|
||||
private _showSpinner: boolean = false;
|
||||
|
||||
constructor(options: Options) {
|
||||
super(options);
|
||||
this._sessionId = options.sessionId;
|
||||
// Start the logout process immediately without any user interaction
|
||||
this._logoutPromise = this.forceLogout();
|
||||
}
|
||||
|
||||
async forceLogout(): Promise<void> {
|
||||
try {
|
||||
const client = new Client(this.platform);
|
||||
await client.startForcedLogout(this._sessionId);
|
||||
}
|
||||
catch (err) {
|
||||
this._error = err;
|
||||
// Show the error in the UI
|
||||
this._showSpinner = false;
|
||||
this._showStatus = true;
|
||||
this.emitChange("error");
|
||||
}
|
||||
}
|
||||
|
||||
async proceed(): Promise<void> {
|
||||
/**
|
||||
* The logout should already be completed because we started it from the ctor.
|
||||
* In case the logout is still proceeding, we will show a message with a spinner.
|
||||
*/
|
||||
this._showSpinner = true;
|
||||
this._showStatus = true;
|
||||
this.emitChange("showStatus");
|
||||
await this._logoutPromise;
|
||||
// At this point, the logout is completed for sure.
|
||||
if (!this._error) {
|
||||
this.navigation.push("login", true);
|
||||
}
|
||||
}
|
||||
|
||||
get status(): string {
|
||||
if (this._error) {
|
||||
return this.i18n`Could not log out of device: ${this._error.message}`;
|
||||
} else {
|
||||
return this.i18n`Logging out… Please don't close the app.`;
|
||||
}
|
||||
}
|
||||
|
||||
get showStatus(): boolean {
|
||||
return this._showStatus;
|
||||
}
|
||||
|
||||
get showSpinner(): boolean {
|
||||
return this._showSpinner;
|
||||
}
|
||||
}
|
@ -43,7 +43,7 @@ export class LogoutViewModel extends ViewModel<SegmentType, Options> {
|
||||
}
|
||||
|
||||
get cancelUrl(): string | undefined {
|
||||
return this.urlCreator.urlForSegment("session", true);
|
||||
return this.urlRouter.urlForSegment("session", true);
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
|
@ -19,6 +19,7 @@ import {SessionViewModel} from "./session/SessionViewModel.js";
|
||||
import {SessionLoadViewModel} from "./SessionLoadViewModel.js";
|
||||
import {LoginViewModel} from "./login/LoginViewModel";
|
||||
import {LogoutViewModel} from "./LogoutViewModel";
|
||||
import {ForcedLogoutViewModel} from "./ForcedLogoutViewModel";
|
||||
import {SessionPickerViewModel} from "./SessionPickerViewModel.js";
|
||||
import {ViewModel} from "./ViewModel";
|
||||
|
||||
@ -30,6 +31,7 @@ export class RootViewModel extends ViewModel {
|
||||
this._sessionLoadViewModel = null;
|
||||
this._loginViewModel = null;
|
||||
this._logoutViewModel = null;
|
||||
this._forcedLogoutViewModel = null;
|
||||
this._sessionViewModel = null;
|
||||
this._pendingClient = null;
|
||||
}
|
||||
@ -39,12 +41,14 @@ export class RootViewModel extends ViewModel {
|
||||
this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation()));
|
||||
this.track(this.navigation.observe("sso").subscribe(() => this._applyNavigation()));
|
||||
this.track(this.navigation.observe("oidc").subscribe(() => this._applyNavigation()));
|
||||
this.track(this.navigation.observe("logout").subscribe(() => this._applyNavigation()));
|
||||
this._applyNavigation(true);
|
||||
}
|
||||
|
||||
async _applyNavigation(shouldRestoreLastUrl) {
|
||||
const isLogin = this.navigation.path.get("login");
|
||||
const logoutSessionId = this.navigation.path.get("logout")?.value;
|
||||
const isForcedLogout = this.navigation.path.get("forced")?.value;
|
||||
const sessionId = this.navigation.path.get("session")?.value;
|
||||
const loginToken = this.navigation.path.get("sso")?.value;
|
||||
const oidcCallback = this.navigation.path.get("oidc")?.value;
|
||||
@ -52,6 +56,10 @@ export class RootViewModel extends ViewModel {
|
||||
if (this.activeSection !== "login") {
|
||||
this._showLogin();
|
||||
}
|
||||
} else if (logoutSessionId && isForcedLogout) {
|
||||
if (this.activeSection !== "forced-logout") {
|
||||
this._showForcedLogout(logoutSessionId);
|
||||
}
|
||||
} else if (logoutSessionId) {
|
||||
if (this.activeSection !== "logout") {
|
||||
this._showLogout(logoutSessionId);
|
||||
@ -77,7 +85,7 @@ export class RootViewModel extends ViewModel {
|
||||
}
|
||||
}
|
||||
} else if (loginToken) {
|
||||
this.urlCreator.normalizeUrl();
|
||||
this.urlRouter.normalizeUrl();
|
||||
if (this.activeSection !== "login") {
|
||||
this._showLogin({loginToken});
|
||||
}
|
||||
@ -95,7 +103,7 @@ export class RootViewModel extends ViewModel {
|
||||
}
|
||||
else {
|
||||
try {
|
||||
if (!(shouldRestoreLastUrl && this.urlCreator.tryRestoreLastUrl())) {
|
||||
if (!(shouldRestoreLastUrl && this.urlRouter.tryRestoreLastUrl())) {
|
||||
const sessionInfos = await this.platform.sessionInfoStorage.getAll();
|
||||
if (sessionInfos.length === 0) {
|
||||
this.navigation.push("login");
|
||||
@ -150,6 +158,12 @@ export class RootViewModel extends ViewModel {
|
||||
});
|
||||
}
|
||||
|
||||
_showForcedLogout(sessionId) {
|
||||
this._setSection(() => {
|
||||
this._forcedLogoutViewModel = new ForcedLogoutViewModel(this.childOptions({sessionId}));
|
||||
});
|
||||
}
|
||||
|
||||
_showSession(client) {
|
||||
this._setSection(() => {
|
||||
this._sessionViewModel = new SessionViewModel(this.childOptions({client}));
|
||||
@ -158,7 +172,7 @@ export class RootViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
_showSessionLoader(sessionId) {
|
||||
const client = new Client(this.platform);
|
||||
const client = new Client(this.platform, this.features);
|
||||
client.startWithExistingSession(sessionId);
|
||||
this._setSection(() => {
|
||||
this._sessionLoadViewModel = new SessionLoadViewModel(this.childOptions({
|
||||
@ -178,6 +192,8 @@ export class RootViewModel extends ViewModel {
|
||||
return "login";
|
||||
} else if (this._logoutViewModel) {
|
||||
return "logout";
|
||||
} else if (this._forcedLogoutViewModel) {
|
||||
return "forced-logout";
|
||||
} else if (this._sessionPickerViewModel) {
|
||||
return "picker";
|
||||
} else if (this._sessionLoadViewModel) {
|
||||
@ -194,6 +210,7 @@ export class RootViewModel extends ViewModel {
|
||||
this._sessionLoadViewModel = this.disposeTracked(this._sessionLoadViewModel);
|
||||
this._loginViewModel = this.disposeTracked(this._loginViewModel);
|
||||
this._logoutViewModel = this.disposeTracked(this._logoutViewModel);
|
||||
this._forcedLogoutViewModel = this.disposeTracked(this._forcedLogoutViewModel);
|
||||
this._sessionViewModel = this.disposeTracked(this._sessionViewModel);
|
||||
// now set it again
|
||||
setter();
|
||||
@ -201,6 +218,7 @@ export class RootViewModel extends ViewModel {
|
||||
this._sessionLoadViewModel && this.track(this._sessionLoadViewModel);
|
||||
this._loginViewModel && this.track(this._loginViewModel);
|
||||
this._logoutViewModel && this.track(this._logoutViewModel);
|
||||
this._forcedLogoutViewModel && this.track(this._forcedLogoutViewModel);
|
||||
this._sessionViewModel && this.track(this._sessionViewModel);
|
||||
this.emitChange("activeSection");
|
||||
}
|
||||
@ -209,6 +227,7 @@ export class RootViewModel extends ViewModel {
|
||||
get sessionViewModel() { return this._sessionViewModel; }
|
||||
get loginViewModel() { return this._loginViewModel; }
|
||||
get logoutViewModel() { return this._logoutViewModel; }
|
||||
get forcedLogoutViewModel() { return this._forcedLogoutViewModel; }
|
||||
get sessionPickerViewModel() { return this._sessionPickerViewModel; }
|
||||
get sessionLoadViewModel() { return this._sessionLoadViewModel; }
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ export class SessionLoadViewModel extends ViewModel {
|
||||
this._deleteSessionOnCancel = deleteSessionOnCancel;
|
||||
this._loading = false;
|
||||
this._error = null;
|
||||
this.backUrl = this.urlCreator.urlForSegment("session", true);
|
||||
this.backUrl = this.urlRouter.urlForSegment("session", true);
|
||||
this._accountSetupViewModel = undefined;
|
||||
|
||||
}
|
||||
@ -154,8 +154,7 @@ export class SessionLoadViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
async logout() {
|
||||
const sessionId = this.navigation.path.get("session")?.value;
|
||||
await this._client.startLogout(sessionId);
|
||||
await this._client.startLogout(this.navigation.path.get("session").value);
|
||||
this.navigation.push("session", true);
|
||||
}
|
||||
|
||||
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {SortedArray} from "../observable/index";
|
||||
import {SortedArray} from "../observable";
|
||||
import {ViewModel} from "./ViewModel";
|
||||
import {avatarInitials, getIdentifierColorNumber} from "./avatar";
|
||||
|
||||
@ -38,7 +38,7 @@ class SessionItemViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
get openUrl() {
|
||||
return this.urlCreator.urlForSegment("session", this.id);
|
||||
return this.urlRouter.urlForSegment("session", this.id);
|
||||
}
|
||||
|
||||
get label() {
|
||||
@ -94,6 +94,6 @@ export class SessionPickerViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
get cancelUrl() {
|
||||
return this.urlCreator.urlForSegment("login");
|
||||
return this.urlRouter.urlForSegment("login");
|
||||
}
|
||||
}
|
||||
|
@ -29,13 +29,16 @@ import type {ILogger} from "../logging/types";
|
||||
import type {Navigation} from "./navigation/Navigation";
|
||||
import type {SegmentType} from "./navigation/index";
|
||||
import type {IURLRouter} from "./navigation/URLRouter";
|
||||
import type { ITimeFormatter } from "../platform/types/types";
|
||||
import type { FeatureSet } from "../features";
|
||||
|
||||
export type Options<T extends object = SegmentType> = {
|
||||
platform: Platform;
|
||||
logger: ILogger;
|
||||
urlCreator: IURLRouter<T>;
|
||||
urlRouter: IURLRouter<T>;
|
||||
navigation: Navigation<T>;
|
||||
emitChange?: (params: any) => void;
|
||||
features: FeatureSet
|
||||
}
|
||||
|
||||
|
||||
@ -49,7 +52,7 @@ export class ViewModel<N extends object = SegmentType, O extends Options<N> = Op
|
||||
this._options = options;
|
||||
}
|
||||
|
||||
childOptions<T extends Object>(explicitOptions: T): T & Options<N> {
|
||||
childOptions<T extends Object>(explicitOptions: T): T & O {
|
||||
return Object.assign({}, this._options, explicitOptions);
|
||||
}
|
||||
|
||||
@ -137,12 +140,20 @@ export class ViewModel<N extends object = SegmentType, O extends Options<N> = Op
|
||||
return this.platform.logger;
|
||||
}
|
||||
|
||||
get urlCreator(): IURLRouter<N> {
|
||||
return this._options.urlCreator;
|
||||
get urlRouter(): IURLRouter<N> {
|
||||
return this._options.urlRouter;
|
||||
}
|
||||
|
||||
get features(): FeatureSet {
|
||||
return this._options.features;
|
||||
}
|
||||
|
||||
get navigation(): Navigation<N> {
|
||||
// typescript needs a little help here
|
||||
return this._options.navigation as unknown as Navigation<N>;
|
||||
}
|
||||
|
||||
get timeFormatter(): ITimeFormatter {
|
||||
return this._options.platform.timeFormatter;
|
||||
}
|
||||
}
|
||||
|
@ -58,3 +58,11 @@ export function getAvatarHttpUrl(avatarUrl: string | undefined, cssSize: number,
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// move to AvatarView.js when converting to typescript
|
||||
export interface IAvatarContract {
|
||||
avatarLetter: string;
|
||||
avatarColorNumber: number;
|
||||
avatarUrl: (size: number) => string | undefined;
|
||||
avatarTitle: string;
|
||||
}
|
||||
|
@ -14,11 +14,24 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../ViewModel";
|
||||
import {Options as BaseOptions, ViewModel} from "../ViewModel";
|
||||
import {LoginFailure} from "../../matrix/Client.js";
|
||||
import type {TokenLoginMethod} from "../../matrix/login";
|
||||
import { Client } from "../../matrix/Client.js";
|
||||
|
||||
type Options = {
|
||||
client: Client;
|
||||
attemptLogin: (loginMethod: TokenLoginMethod) => Promise<null>;
|
||||
loginToken: string;
|
||||
} & BaseOptions
|
||||
|
||||
export class CompleteSSOLoginViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
private _loginToken: string;
|
||||
private _client: Client;
|
||||
private _attemptLogin: (loginMethod: TokenLoginMethod) => Promise<null>;
|
||||
private _errorMessage = "";
|
||||
|
||||
constructor(options: Options) {
|
||||
super(options);
|
||||
const {
|
||||
loginToken,
|
||||
@ -29,22 +42,22 @@ export class CompleteSSOLoginViewModel extends ViewModel {
|
||||
this._client = client;
|
||||
this._attemptLogin = attemptLogin;
|
||||
this._errorMessage = "";
|
||||
this.performSSOLoginCompletion();
|
||||
void this.performSSOLoginCompletion();
|
||||
}
|
||||
|
||||
get errorMessage() { return this._errorMessage; }
|
||||
get errorMessage(): string { return this._errorMessage; }
|
||||
|
||||
_showError(message) {
|
||||
_showError(message: string): void {
|
||||
this._errorMessage = message;
|
||||
this.emitChange("errorMessage");
|
||||
}
|
||||
|
||||
async performSSOLoginCompletion() {
|
||||
async performSSOLoginCompletion(): Promise<void> {
|
||||
if (!this._loginToken) {
|
||||
return;
|
||||
}
|
||||
const homeserver = await this.platform.settingsStorage.getString("sso_ongoing_login_homeserver");
|
||||
let loginOptions;
|
||||
let loginOptions: { token?: (loginToken: string) => TokenLoginMethod; };
|
||||
try {
|
||||
loginOptions = await this._client.queryLogin(homeserver).result;
|
||||
}
|
@ -64,20 +64,20 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
|
||||
this._ready = ready;
|
||||
this._loginToken = loginToken;
|
||||
this._oidc = oidc;
|
||||
this._client = new Client(this.platform);
|
||||
this._client = new Client(this.platform, this.features);
|
||||
this._homeserver = defaultHomeserver;
|
||||
this._initViewModels();
|
||||
}
|
||||
|
||||
get passwordLoginViewModel(): PasswordLoginViewModel {
|
||||
get passwordLoginViewModel(): PasswordLoginViewModel | undefined {
|
||||
return this._passwordLoginViewModel;
|
||||
}
|
||||
|
||||
get startSSOLoginViewModel(): StartSSOLoginViewModel {
|
||||
get startSSOLoginViewModel(): StartSSOLoginViewModel | undefined {
|
||||
return this._startSSOLoginViewModel;
|
||||
}
|
||||
|
||||
get completeSSOLoginViewModel(): CompleteSSOLoginViewModel {
|
||||
get completeSSOLoginViewModel(): CompleteSSOLoginViewModel | undefined {
|
||||
return this._completeSSOLoginViewModel;
|
||||
}
|
||||
|
||||
@ -331,7 +331,7 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
|
||||
type ReadyFn = (client: Client) => void;
|
||||
|
||||
// TODO: move to Client.js when its converted to typescript.
|
||||
type LoginOptions = {
|
||||
export type LoginOptions = {
|
||||
homeserver: string;
|
||||
password?: (username: string, password: string) => PasswordLoginMethod;
|
||||
sso?: SSOLoginHelper;
|
||||
|
@ -14,43 +14,53 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../ViewModel";
|
||||
import {LoginFailure} from "../../matrix/Client.js";
|
||||
import type {PasswordLoginMethod} from "../../matrix/login";
|
||||
import {Options as BaseOptions, ViewModel} from "../ViewModel";
|
||||
import type {LoginOptions} from "./LoginViewModel";
|
||||
|
||||
type Options = {
|
||||
loginOptions: LoginOptions | undefined;
|
||||
attemptLogin: (loginMethod: PasswordLoginMethod) => Promise<null>;
|
||||
} & BaseOptions
|
||||
|
||||
export class PasswordLoginViewModel extends ViewModel {
|
||||
constructor(options) {
|
||||
private _loginOptions?: LoginOptions;
|
||||
private _attemptLogin: (loginMethod: PasswordLoginMethod) => Promise<null>;
|
||||
private _isBusy = false;
|
||||
private _errorMessage = "";
|
||||
|
||||
constructor(options: Options) {
|
||||
super(options);
|
||||
const {loginOptions, attemptLogin} = options;
|
||||
this._loginOptions = loginOptions;
|
||||
this._attemptLogin = attemptLogin;
|
||||
this._isBusy = false;
|
||||
this._errorMessage = "";
|
||||
}
|
||||
|
||||
get isBusy() { return this._isBusy; }
|
||||
get errorMessage() { return this._errorMessage; }
|
||||
get isBusy(): boolean { return this._isBusy; }
|
||||
get errorMessage(): string { return this._errorMessage; }
|
||||
|
||||
setBusy(status) {
|
||||
setBusy(status: boolean): void {
|
||||
this._isBusy = status;
|
||||
this.emitChange("isBusy");
|
||||
}
|
||||
|
||||
_showError(message) {
|
||||
_showError(message: string): void {
|
||||
this._errorMessage = message;
|
||||
this.emitChange("errorMessage");
|
||||
}
|
||||
|
||||
async login(username, password) {
|
||||
async login(username: string, password: string): Promise<void>{
|
||||
this._errorMessage = "";
|
||||
this.emitChange("errorMessage");
|
||||
const status = await this._attemptLogin(this._loginOptions.password(username, password));
|
||||
const status = await this._attemptLogin(this._loginOptions!.password!(username, password));
|
||||
let error = "";
|
||||
switch (status) {
|
||||
case LoginFailure.Credentials:
|
||||
error = this.i18n`Your username and/or password don't seem to be correct.`;
|
||||
break;
|
||||
case LoginFailure.Connection:
|
||||
error = this.i18n`Can't connect to ${this._loginOptions.homeserver}.`;
|
||||
error = this.i18n`Can't connect to ${this._loginOptions!.homeserver}.`;
|
||||
break;
|
||||
case LoginFailure.Unknown:
|
||||
error = this.i18n`Something went wrong while checking your login and password.`;
|
@ -14,25 +14,35 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ViewModel} from "../ViewModel";
|
||||
import type {SSOLoginHelper} from "../../matrix/login";
|
||||
import {Options as BaseOptions, ViewModel} from "../ViewModel";
|
||||
import type {LoginOptions} from "./LoginViewModel";
|
||||
|
||||
|
||||
type Options = {
|
||||
loginOptions: LoginOptions | undefined;
|
||||
} & BaseOptions;
|
||||
|
||||
export class StartSSOLoginViewModel extends ViewModel{
|
||||
constructor(options) {
|
||||
private _sso?: SSOLoginHelper;
|
||||
private _isBusy = false;
|
||||
|
||||
constructor(options: Options) {
|
||||
super(options);
|
||||
this._sso = options.loginOptions.sso;
|
||||
this._sso = options.loginOptions!.sso;
|
||||
this._isBusy = false;
|
||||
}
|
||||
|
||||
get isBusy() { return this._isBusy; }
|
||||
|
||||
setBusy(status) {
|
||||
|
||||
get isBusy(): boolean { return this._isBusy; }
|
||||
|
||||
setBusy(status: boolean): void {
|
||||
this._isBusy = status;
|
||||
this.emitChange("isBusy");
|
||||
}
|
||||
|
||||
async startSSOLogin() {
|
||||
await this.platform.settingsStorage.setString("sso_ongoing_login_homeserver", this._sso.homeserver);
|
||||
const link = this._sso.createSSORedirectURL(this.urlCreator.createSSOCallbackURL());
|
||||
async startSSOLogin(): Promise<void> {
|
||||
await this.platform.settingsStorage.setString("sso_ongoing_login_homeserver", this._sso!.homeserver);
|
||||
const link = this._sso!.createSSORedirectURL(this.urlRouter.createSSOCallbackURL());
|
||||
this.platform.openUrl(link);
|
||||
}
|
||||
}
|
@ -14,9 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ObservableValue} from "../../observable/value/ObservableValue";
|
||||
import {BaseObservableValue} from "../../observable/value/BaseObservableValue";
|
||||
|
||||
import {BaseObservableValue, ObservableValue} from "../../observable/value";
|
||||
|
||||
type AllowsChild<T> = (parent: Segment<T> | undefined, child: Segment<T>) => boolean;
|
||||
|
||||
|
@ -147,7 +147,7 @@ export class URLRouter<T extends {session: string | boolean}> implements IURLRou
|
||||
|
||||
openRoomActionUrl(roomId: string): string {
|
||||
// not a segment to navigation knowns about, so append it manually
|
||||
const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`;
|
||||
const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${encodeURIComponent(roomId)}`;
|
||||
return this._history.pathAsUrl(urlPath);
|
||||
}
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user