├── icon.ico ├── icon.png ├── icon.icns ├── forge.env.d.ts ├── vite.main.config.ts ├── vite.preload.config.ts ├── vite.renderer.config.ts ├── src ├── app │ ├── main.tsx │ ├── index.css │ ├── MatrixLogin.css │ ├── BridgeListEntry.tsx │ ├── BridgeLoginView.css │ ├── MainView.css │ ├── MainView.tsx │ ├── BridgeStatusView.css │ ├── BridgeStatusView.tsx │ ├── App.tsx │ ├── MatrixLogin.tsx │ └── BridgeLoginView.tsx ├── util │ ├── useInit.ts │ └── urlParse.ts ├── api │ ├── error.ts │ ├── localstorage.ts │ ├── provisionclient.ts │ ├── matrixclient.ts │ ├── baseclient.ts │ ├── loginclient.ts │ └── bridgelist.ts ├── types │ ├── login.ts │ ├── startchat.ts │ ├── whoami.ts │ ├── matrix.ts │ └── loginstep.ts ├── preload.ts ├── electron.ts └── webview.ts ├── tsconfig.json ├── .editorconfig ├── index.html ├── .github └── workflows │ ├── release.yml │ └── build.yml ├── .gitignore ├── package.json ├── .eslintrc.json ├── README.md ├── forge.config.ts └── LICENSE /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mautrix/manager/HEAD/icon.ico -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mautrix/manager/HEAD/icon.png -------------------------------------------------------------------------------- /icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mautrix/manager/HEAD/icon.icns -------------------------------------------------------------------------------- /forge.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /vite.main.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | 3 | // https://vitejs.dev/config 4 | export default defineConfig({}); 5 | -------------------------------------------------------------------------------- /vite.preload.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | 3 | // https://vitejs.dev/config 4 | export default defineConfig({}); 5 | -------------------------------------------------------------------------------- /vite.renderer.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | 3 | // https://vitejs.dev/config 4 | export default defineConfig({}); 5 | -------------------------------------------------------------------------------- /src/app/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom/client" 3 | import App from "./App" 4 | import "./index.css" 5 | 6 | ReactDOM.createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src/util/useInit.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | 3 | const useInit = (callback: () => void) => { 4 | const [initialized, setInitialized] = useState(false) 5 | if (!initialized) { 6 | callback() 7 | setInitialized(true) 8 | } 9 | } 10 | 11 | export default useInit 12 | -------------------------------------------------------------------------------- /src/util/urlParse.ts: -------------------------------------------------------------------------------- 1 | export function getSearch(url: string | undefined): string | undefined { 2 | if (url?.startsWith("mautrix-manager://sso?") || url?.startsWith("mautrix-manager://sso/?")) { 3 | return url.replace("mautrix-manager://sso?", "").replace("mautrix-manager://sso/?", "") 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "commonjs", 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "sourceMap": true, 10 | "baseUrl": ".", 11 | "outDir": "dist", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "jsx": "react" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/api/error.ts: -------------------------------------------------------------------------------- 1 | export class APIError extends Error { 2 | errcode: string 3 | error: string 4 | extra: { [key: string]: unknown } 5 | 6 | constructor( 7 | { errcode, error, ...extra }: { errcode: string, error: string, [key: string]: unknown }, 8 | ) { 9 | super(`${errcode}: ${error}`) 10 | this.errcode = errcode 11 | this.error = error 12 | this.extra = extra 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/types/login.ts: -------------------------------------------------------------------------------- 1 | import type { LoginStepData } from "./loginstep" 2 | 3 | export interface RespLoginFlows { 4 | flows: LoginFlow[] 5 | } 6 | 7 | export interface LoginFlow { 8 | name: string 9 | description: string 10 | id: string 11 | } 12 | 13 | export type RespSubmitLogin = LoginStepData & { 14 | login_id: string 15 | } 16 | 17 | export type RespLogout = Record 18 | -------------------------------------------------------------------------------- /src/types/startchat.ts: -------------------------------------------------------------------------------- 1 | export interface RespResolveIdentifier { 2 | id: string 3 | name?: string 4 | avatar_url?: string 5 | identifiers?: string[] 6 | mxid?: string 7 | dm_room_mxid?: string 8 | } 9 | 10 | export type RespStartChat = RespResolveIdentifier & { 11 | dm_room_mxid: string 12 | } 13 | 14 | export interface RespGetContactList { 15 | contacts: RespResolveIdentifier[] 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.js] 15 | max_line_length = 100 16 | 17 | [*.{yaml,yml}] 18 | indent_style = space 19 | 20 | [.github/workflows/*.yml] 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | mautrix-manager 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release app 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | strategy: 9 | matrix: 10 | os: 11 | - name: Linux amd64 12 | image: ubuntu-24.04 13 | - name: Linux arm64 14 | image: ubuntu-24.04-arm 15 | - name: macOS 16 | image: macos-latest 17 | - name: Windows 18 | image: windows-latest 19 | runs-on: ${{ matrix.os.image }} 20 | name: Build (${{ matrix.os.name }}) 21 | 22 | steps: 23 | - name: Github checkout 24 | uses: actions/checkout@v4 25 | 26 | - name: Use Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: 24 30 | 31 | - name: Install dependencies 32 | run: npm ci --include=dev 33 | 34 | - name: Publish app 35 | run: npm run publish 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | -------------------------------------------------------------------------------- /src/app/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; 3 | margin: 0; 4 | padding: 0; 5 | background-color: #EEE; 6 | 7 | @media (width < 500px) { 8 | background-color: white; 9 | } 10 | } 11 | 12 | #root { 13 | display: flex; 14 | justify-content: center; 15 | } 16 | 17 | main { 18 | background-color: white; 19 | } 20 | 21 | pre, code { 22 | font-family: "Fira Code", monospace; 23 | } 24 | 25 | button { 26 | cursor: pointer; 27 | font-size: 1em; 28 | } 29 | 30 | div.checkbox-wrapper { 31 | display: flex; 32 | align-items: center; 33 | 34 | > label { 35 | user-select: none; 36 | cursor: pointer; 37 | } 38 | 39 | > input[type="checkbox"] { 40 | width: 1rem; 41 | height: 1rem; 42 | cursor: pointer; 43 | } 44 | } 45 | 46 | :root { 47 | --primary-color: #00c853; 48 | --primary-color-light: #92ffc0; 49 | --primary-color-dark: #00b24a; 50 | --error-color: red; 51 | --error-color-light: #ff6666; 52 | } 53 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build app 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | build: 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | os: 12 | - name: Linux amd64 13 | image: ubuntu-24.04 14 | - name: Linux arm64 15 | image: ubuntu-24.04-arm 16 | - name: macOS 17 | image: macos-latest 18 | - name: Windows 19 | image: windows-latest 20 | runs-on: ${{ matrix.os.image }} 21 | name: Build (${{ matrix.os.name }}) 22 | 23 | steps: 24 | - name: Github checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: Use Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 24 31 | 32 | - name: Install dependencies 33 | run: npm ci --include=dev 34 | 35 | - name: Build app 36 | run: npm run make 37 | 38 | - name: Upload build artifacts 39 | uses: actions/upload-artifact@v4 40 | with: 41 | path: out/make/ 42 | name: ${{ matrix.os.name }} 43 | -------------------------------------------------------------------------------- /src/types/whoami.ts: -------------------------------------------------------------------------------- 1 | import type { LoginFlow } from "./login" 2 | 3 | export interface RespWhoami { 4 | network: BridgeName 5 | login_flows: LoginFlow[] 6 | homeserver: string 7 | bridge_bot: string 8 | command_prefix: string 9 | management_room: string 10 | logins: RespWhoamiLogin[] 11 | } 12 | 13 | export interface BridgeName { 14 | displayname: string 15 | network_url: string 16 | network_icon: string 17 | network_id: string 18 | beeper_bridge_type: string 19 | default_port?: number 20 | default_command_prefix?: number 21 | } 22 | 23 | export type RemoteStateEvent = 24 | "CONNECTING" | 25 | "CONNECTED" | 26 | "TRANSIENT_DISCONNECT" | 27 | "BAD_CREDENTIALS" | 28 | "UNKNOWN_ERROR" 29 | 30 | export interface RemoteProfile { 31 | name?: string 32 | username?: string 33 | phone?: string 34 | email?: string 35 | avatar?: string 36 | } 37 | 38 | export interface BridgeState { 39 | state_event: RemoteStateEvent 40 | timestamp: number 41 | error?: string 42 | message?: string 43 | reason?: string 44 | info?: Record 45 | } 46 | 47 | export interface RespWhoamiLogin { 48 | state: BridgeState 49 | id: string 50 | name: string 51 | profile?: RemoteProfile 52 | space_room: string 53 | relogin_flow_ids?: string[] 54 | } 55 | -------------------------------------------------------------------------------- /src/app/MatrixLogin.css: -------------------------------------------------------------------------------- 1 | main.matrix-login { 2 | max-width: 30rem; 3 | width: 100%; 4 | padding: 3rem 6rem; 5 | 6 | box-shadow: 0 0 1rem rgba(0, 0, 0, 0.25); 7 | margin: 2rem; 8 | 9 | @media (width < 800px) { 10 | padding: 2rem 4rem; 11 | } 12 | 13 | @media (width < 500px) { 14 | padding: 1rem; 15 | box-shadow: none; 16 | margin: 0 !important; 17 | } 18 | 19 | h1 { 20 | margin: 0 0 2rem; 21 | text-align: center; 22 | } 23 | 24 | button, input { 25 | margin-top: .5rem; 26 | padding: 1rem; 27 | font-size: 1rem; 28 | width: 100%; 29 | display: block; 30 | border-radius: .25rem; 31 | box-sizing: border-box; 32 | } 33 | 34 | input { 35 | border: 1px solid var(--primary-color); 36 | 37 | &:hover { 38 | outline: 1px solid var(--primary-color); 39 | } 40 | 41 | &:focus { 42 | outline: 3px solid var(--primary-color); 43 | } 44 | } 45 | 46 | form { 47 | margin: 2rem 0; 48 | } 49 | 50 | button { 51 | background-color: var(--primary-color); 52 | color: white; 53 | font-weight: bold; 54 | border: none; 55 | 56 | &:hover, &:focus { 57 | background-color: var(--primary-color-dark); 58 | } 59 | } 60 | 61 | div.error { 62 | border: 2px solid var(--error-color); 63 | border-radius: .25rem; 64 | padding: 1rem; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/app/BridgeListEntry.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react" 2 | import type { BridgeMeta } from "../api/bridgelist" 3 | import type { MatrixClient } from "../api/matrixclient" 4 | 5 | interface BridgeEntryProps { 6 | matrixClient: MatrixClient 7 | meta: BridgeMeta 8 | switchBridge: (server: string) => void 9 | active: boolean 10 | showBotMXID: boolean 11 | } 12 | 13 | const BridgeListEntry = ( 14 | { matrixClient, meta, switchBridge, active, showBotMXID }: BridgeEntryProps, 15 | ) => { 16 | const onClick = useCallback(() => switchBridge(meta.server), [meta.server, switchBridge]) 17 | const className = "bridge-list-entry" + (active ? " active" : "") 18 | if (!meta.whoami) { 19 | if (meta.error) { 20 | return
{meta.server} ❌
21 | } 22 | return
Loading {meta.server}...
23 | } 24 | return 31 | } 32 | 33 | export default BridgeListEntry 34 | -------------------------------------------------------------------------------- /src/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from "electron" 2 | import type { LoginCookieOutput, LoginCookiesParams } from "./types/loginstep" 3 | 4 | export interface AccessTokenChangedParams { 5 | homeserverURL: string 6 | accessToken: string 7 | } 8 | 9 | contextBridge.exposeInMainWorld("mautrixAPI", { 10 | openWebview: (params: LoginCookiesParams) => 11 | ipcRenderer.invoke("mautrix:open-webview", params), 12 | closeWebview: () => ipcRenderer.invoke("mautrix:close-webview"), 13 | accessTokenChanged: (newDetails: AccessTokenChangedParams) => 14 | ipcRenderer.invoke("mautrix:access-token-changed", newDetails), 15 | openInBrowser: (url: string) => ipcRenderer.invoke("mautrix:open-in-browser", url), 16 | isDevBuild: process.env.NODE_ENV === "development", 17 | }) 18 | 19 | declare global { 20 | interface Window { 21 | mautrixAPI: { 22 | openWebview: (params: LoginCookiesParams) => Promise<{ cookies: LoginCookieOutput }> 23 | closeWebview: () => Promise 24 | accessTokenChanged: (newDetails: AccessTokenChangedParams) => Promise 25 | openInBrowser: (url: string) => Promise 26 | isDevBuild: boolean, 27 | } 28 | } 29 | } 30 | ipcRenderer.invoke("mautrix:access-token-changed", { 31 | homeserverURL: localStorage.matrix_homeserver_url || "", 32 | accessToken: localStorage.access_token || "", 33 | }) 34 | -------------------------------------------------------------------------------- /src/app/BridgeLoginView.css: -------------------------------------------------------------------------------- 1 | div.bridge-login-view { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | 6 | button { 7 | padding: 6px 1rem; 8 | border-radius: .25rem; 9 | cursor: pointer; 10 | box-sizing: border-box; 11 | background-color: white; 12 | 13 | &.submit-button, &.close-button { 14 | background-color: var(--primary-color); 15 | border: 2px solid transparent; 16 | color: white; 17 | 18 | &:hover, &:focus { 19 | background-color: var(--primary-color-dark); 20 | } 21 | } 22 | 23 | &.cancel-button { 24 | border: 2px solid var(--error-color); 25 | 26 | &:hover, &:focus { 27 | color: white; 28 | background-color: var(--error-color); 29 | } 30 | } 31 | } 32 | } 33 | 34 | div.login-form.type-complete { 35 | display: flex; 36 | justify-content: right; 37 | } 38 | 39 | form.login-form.type-user-input { 40 | > div.login-form-buttons { 41 | justify-content: space-between; 42 | display: flex; 43 | gap: .5rem; 44 | } 45 | 46 | > div.login-form-table { 47 | display: table; 48 | width: 100%; 49 | 50 | > div.login-form-field { 51 | display: table-row; 52 | 53 | > label { 54 | display: table-cell; 55 | padding: 0 .5rem 1rem 0; 56 | width: 0; 57 | white-space: nowrap; 58 | } 59 | 60 | > input { 61 | display: table-cell; 62 | padding: .5rem; 63 | border-radius: .25rem; 64 | border: 1px solid #CCC; 65 | width: 100%; 66 | margin-bottom: .5rem; 67 | box-sizing: border-box; 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/api/localstorage.ts: -------------------------------------------------------------------------------- 1 | import type { RespWhoami } from "../types/whoami" 2 | import type { Credentials } from "../app/App" 3 | 4 | type BridgeStore = Record 5 | 6 | export default class TypedLocalStorage { 7 | static get bridges(): BridgeStore { 8 | const cache = localStorage.bridges 9 | if (!cache) { 10 | return {} 11 | } 12 | return JSON.parse(cache) 13 | } 14 | 15 | static set bridges(bridges: BridgeStore) { 16 | localStorage.bridges = JSON.stringify(bridges) 17 | } 18 | 19 | static get homeserverURL(): string { 20 | return localStorage.matrix_homeserver_url ?? "" 21 | } 22 | 23 | static set homeserverURL(url: string) { 24 | localStorage.matrix_homeserver_url = url 25 | this.updateElectron() 26 | } 27 | 28 | static get credentials(): Credentials | null { 29 | const creds = { 30 | user_id: localStorage.user_id, 31 | access_token: localStorage.access_token, 32 | } 33 | if (creds.user_id && creds.access_token) { 34 | return creds 35 | } 36 | return null 37 | } 38 | 39 | static set credentials(creds: Credentials | null) { 40 | if (creds === null) { 41 | delete localStorage.user_id 42 | delete localStorage.access_token 43 | } else { 44 | localStorage.user_id = creds.user_id 45 | localStorage.access_token = creds.access_token 46 | } 47 | this.updateElectron() 48 | } 49 | 50 | private static updateElectron() { 51 | window.mautrixAPI.accessTokenChanged({ 52 | accessToken: this.credentials?.access_token ?? "", 53 | homeserverURL: this.homeserverURL, 54 | }) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | .DS_Store 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # TypeScript cache 43 | *.tsbuildinfo 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | .env.test 63 | 64 | # parcel-bundler cache (https://parceljs.org/) 65 | .cache 66 | 67 | # next.js build output 68 | .next 69 | 70 | # nuxt.js build output 71 | .nuxt 72 | 73 | # vuepress build output 74 | .vuepress/dist 75 | 76 | # Serverless directories 77 | .serverless/ 78 | 79 | # FuseBox cache 80 | .fusebox/ 81 | 82 | # DynamoDB Local files 83 | .dynamodb/ 84 | 85 | # Webpack 86 | .webpack/ 87 | 88 | # Vite 89 | .vite/ 90 | 91 | # Electron-Forge 92 | out/ 93 | -------------------------------------------------------------------------------- /src/types/matrix.ts: -------------------------------------------------------------------------------- 1 | export type SpecVersion = 2 | "r0.0.0" 3 | | "r0.0.1" 4 | | "r0.1.0" 5 | | "r0.2.0" 6 | | "r0.3.0" 7 | | "r0.4.0" 8 | | "r0.5.0" 9 | | "r0.6.0" 10 | | "r0.6.1" 11 | | "v1.1" 12 | | "v1.2" 13 | | "v1.3" 14 | | "v1.4" 15 | | "v1.5" 16 | | "v1.6" 17 | | "v1.7" 18 | | "v1.8" 19 | | "v1.9" 20 | | "v1.10" 21 | | "v1.11" 22 | 23 | export interface RespVersions { 24 | unstable_features: Record 25 | versions: SpecVersion[] 26 | } 27 | 28 | export interface RespWhoami { 29 | user_id: string 30 | is_guest?: boolean 31 | device_id?: string 32 | } 33 | 34 | export interface RespOpenIDToken { 35 | access_token: string 36 | expires_in: number 37 | matrix_server_name: string 38 | token_type: "Bearer" 39 | } 40 | 41 | export interface LoginFlow { 42 | type: string 43 | } 44 | 45 | export interface RespLoginFlows { 46 | flows: LoginFlow[] 47 | } 48 | 49 | export interface UserIdentifier { 50 | type: "m.id.user" 51 | user: string 52 | } 53 | 54 | interface ReqLoginPassword { 55 | type: "m.login.password" 56 | identifier: UserIdentifier 57 | password: string 58 | } 59 | 60 | interface ReqLoginToken { 61 | type: "m.login.token" 62 | token: string 63 | } 64 | 65 | export type ReqLogin = ReqLoginPassword | ReqLoginToken 66 | 67 | export interface RespClientWellKnown { 68 | "m.homeserver"?: { 69 | base_url?: string 70 | } 71 | } 72 | 73 | export interface RespMautrixWellKnown { 74 | "fi.mau.bridges"?: string[] 75 | "fi.mau.external_bridge_servers"?: string[] 76 | } 77 | 78 | export interface RespLogin { 79 | access_token: string 80 | device_id: string 81 | user_id: string 82 | refresh_token?: string 83 | expires_in_ms?: number 84 | well_known?: RespClientWellKnown 85 | } 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mautrix-manager", 3 | "productName": "mautrix-manager", 4 | "version": "0.1.2", 5 | "description": "A frontend for managing mautrix bridges", 6 | "main": ".vite/build/electron.js", 7 | "scripts": { 8 | "start": "electron-forge start", 9 | "package": "electron-forge package", 10 | "make": "electron-forge make", 11 | "publish": "electron-forge publish", 12 | "lint": "tsc --noEmit && eslint --ext .ts,.tsx ." 13 | }, 14 | "devDependencies": { 15 | "@electron-forge/cli": "^7.8.1", 16 | "@electron-forge/maker-deb": "^7.8.1", 17 | "@electron-forge/maker-dmg": "^7.8.1", 18 | "@electron-forge/maker-rpm": "^7.8.1", 19 | "@electron-forge/maker-squirrel": "^7.8.1", 20 | "@electron-forge/maker-zip": "^7.8.1", 21 | "@electron-forge/plugin-auto-unpack-natives": "^7.8.1", 22 | "@electron-forge/plugin-fuses": "^7.8.1", 23 | "@electron-forge/plugin-vite": "^7.8.1", 24 | "@electron-forge/publisher-github": "^7.8.1", 25 | "@electron/fuses": "^1.8.0", 26 | "@types/react": "^19.1.0", 27 | "@types/react-dom": "^19.1.0", 28 | "@typescript-eslint/eslint-plugin": "^5.62.0", 29 | "@typescript-eslint/parser": "^5.62", 30 | "electron": "37.2.4", 31 | "eslint": "^8.57.1", 32 | "eslint-plugin-import": "^2.25.0", 33 | "eslint-plugin-react-hooks": "^5.2.0", 34 | "ts-node": "^10.9.2", 35 | "typescript": "~4.5.4", 36 | "vite": "^5.4.19" 37 | }, 38 | "keywords": [], 39 | "author": { 40 | "name": "Tulir Asokan", 41 | "email": "tulir@maunium.net" 42 | }, 43 | "license": "AGPL-3.0-or-later", 44 | "dependencies": { 45 | "electron-squirrel-startup": "^1.0.1", 46 | "qrcode.react": "^4.2.0", 47 | "react": "^19.1.0", 48 | "react-dom": "^19.1.0", 49 | "react-spinners": "^0.17.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:import/recommended", 12 | "plugin:import/electron", 13 | "plugin:import/typescript", 14 | "plugin:react-hooks/recommended" 15 | ], 16 | "parser": "@typescript-eslint/parser", 17 | "rules": { 18 | "indent": ["error", "tab", { 19 | "FunctionDeclaration": {"parameters": "first"}, 20 | "FunctionExpression": {"parameters": "first"}, 21 | "CallExpression": {"arguments": "first"}, 22 | "ArrayExpression": "first", 23 | "ObjectExpression": "first", 24 | "ImportDeclaration": "first" 25 | }], 26 | "object-curly-newline": ["error", { 27 | "consistent": true 28 | }], 29 | "object-curly-spacing": ["error", "always", { 30 | "arraysInObjects": false, 31 | "objectsInObjects": false 32 | }], 33 | "array-bracket-spacing": ["error", "never"], 34 | "one-var-declaration-per-line": ["error", "initializations"], 35 | "quotes": ["error", "double"], 36 | "semi": ["error", "never"], 37 | "comma-dangle": ["error", "always-multiline"], 38 | "max-len": ["warn", 100], 39 | "space-before-function-paren": ["error", { 40 | "anonymous": "never", 41 | "named": "never", 42 | "asyncArrow": "always" 43 | }], 44 | "func-style": ["warn", "declaration", {"allowArrowFunctions": true}], 45 | "id-length": ["warn", {"max": 40, "exceptions": ["i", "j", "x", "y", "_"]}], 46 | "new-cap": ["warn", { 47 | "newIsCap": true, 48 | "capIsNew": true 49 | }], 50 | "no-empty": ["error", { 51 | "allowEmptyCatch": true 52 | }], 53 | "eol-last": ["error", "always"], 54 | "no-console": "off", 55 | "@typescript-eslint/no-non-null-assertion": "off" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mautrix-manager 2 | An Electron app to help with logging into bridges. 3 | 4 | All bridges using the new bridgev2 framework in mautrix-go are supported, as 5 | well as any bridges implementing the same provisioning API. Note that old 6 | mautrix bridges are not supported. 7 | 8 | ## Discussion 9 | Matrix room: [#manager:maunium.net](https://matrix.to/#/#manager:maunium.net) 10 | 11 | ## Auto-configuration 12 | You can always add bridge URLs inside mautrix-manager, but to make setup easier 13 | for users, the server admin can configure a `.well-known` file which is used to 14 | auto-discover available bridges. 15 | 16 | On startup, mautrix-manager will fetch `/.well-known/matrix/mautrix` and read a 17 | list of URLs in the `fi.mau.bridges` property. For example, if you had Signal 18 | and Slack bridges and `bridges.example.com` configured to proxy `/signal/*` and 19 | `/slack/*` to the corresponding bridges, you'd probably want a well-known file 20 | like this: 21 | 22 | ```json 23 | { 24 | "fi.mau.bridges": [ 25 | "https://bridges.example.com/signal", 26 | "https://bridges.example.com/slack" 27 | ] 28 | } 29 | ``` 30 | 31 | The list MUST NOT include bridges that are connected to other servers, as the 32 | manager will send your Matrix access token to the bridges. If you want to add 33 | bridges on other servers, use the `fi.mau.external_bridge_servers` property 34 | instead. When specified, mautrix-manager will go through the list and fetch the 35 | .well-known file for each server. It will not recurse into the external servers 36 | list of an already-external server. 37 | 38 | ```json 39 | { 40 | "fi.mau.external_bridge_servers": [ 41 | "anotherserver.example.org" 42 | ] 43 | } 44 | ``` 45 | 46 | For bridges on external servers, the manager will generate an OpenID token and 47 | exchange it for a temporary bridge-specific auth token rather than sending your 48 | Matrix access token to the bridge. 49 | -------------------------------------------------------------------------------- /src/app/MainView.css: -------------------------------------------------------------------------------- 1 | main.main-view { 2 | display: grid; 3 | width: 100%; 4 | grid-template: 5 | "header header" 3rem 6 | "sidebar content" 1fr 7 | / 15rem 1fr; 8 | 9 | position: absolute; 10 | overflow: hidden; 11 | top: 0; 12 | bottom: 0; 13 | left: 0; 14 | right: 0; 15 | } 16 | 17 | main.main-view > div.logged-in-as { 18 | grid-area: header; 19 | display: flex; 20 | justify-content: space-between; 21 | align-items: center; 22 | padding: .5rem 1rem; 23 | background-color: var(--primary-color); 24 | color: white; 25 | 26 | button { 27 | padding: .25rem 1rem; 28 | box-sizing: border-box; 29 | border: none; 30 | background-color: white; 31 | 32 | &:hover, &:focus { 33 | background-color: #DDD; 34 | } 35 | } 36 | } 37 | 38 | main.main-view > div.bridge-view { 39 | grid-area: content; 40 | overflow: auto; 41 | } 42 | 43 | main.main-view > div.bridge-list { 44 | overflow-y: auto; 45 | grid-area: sidebar; 46 | 47 | > .bridge-list-entry { 48 | background-color: white; 49 | display: flex; 50 | align-items: center; 51 | width: 100%; 52 | height: 3rem; 53 | gap: .25rem; 54 | 55 | border: none; 56 | 57 | &.active { 58 | background-color: var(--primary-color-light); 59 | font-weight: bold; 60 | } 61 | 62 | > img { 63 | height: 2.5rem; 64 | } 65 | 66 | > div.bridge-list-name { 67 | display: flex; 68 | text-align: left; 69 | flex-direction: column; 70 | 71 | > small.bridge-bot-id { 72 | font-weight: normal !important; 73 | } 74 | } 75 | } 76 | 77 | > form.new-bridge { 78 | display: flex; 79 | flex-direction: column; 80 | gap: .5rem; 81 | padding: 1rem; 82 | background-color: white; 83 | 84 | input[type="text"] { 85 | padding: .5rem; 86 | border: 1px solid #CCC; 87 | border-radius: .25rem; 88 | } 89 | 90 | > button { 91 | padding: .5rem; 92 | border: none; 93 | background-color: var(--primary-color); 94 | color: white; 95 | font-weight: bold; 96 | border-radius: .25rem; 97 | 98 | &:hover, &:focus { 99 | background-color: var(--primary-color-dark); 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/api/provisionclient.ts: -------------------------------------------------------------------------------- 1 | import type { RespWhoami } from "../types/whoami" 2 | import type { RespLoginFlows, RespLogout, RespSubmitLogin } from "../types/login" 3 | import type { RespGetContactList, RespResolveIdentifier, RespStartChat } from "../types/startchat" 4 | import type { MatrixClient } from "./matrixclient" 5 | import { LoginClient } from "./loginclient" 6 | import { BaseAPIClient } from "./baseclient" 7 | 8 | export class ProvisioningClient extends BaseAPIClient { 9 | declare readonly userID: string 10 | 11 | constructor( 12 | baseURL: string, 13 | readonly matrixClient: MatrixClient, 14 | readonly external: boolean, 15 | loginID?: string, 16 | ) { 17 | if (!matrixClient.userID) { 18 | throw new Error("MatrixClient must be logged in") 19 | } 20 | super(baseURL, "/_matrix/provision", matrixClient.userID, undefined, loginID) 21 | } 22 | 23 | getToken(): Promise { 24 | if (this.external) { 25 | return this.matrixClient?.getCachedOpenIDToken() 26 | } else { 27 | return this.matrixClient?.getToken() 28 | } 29 | } 30 | 31 | withLogin(loginID: string): ProvisioningClient { 32 | return new ProvisioningClient(this.baseURL, this.matrixClient, this.external, loginID) 33 | } 34 | 35 | whoami(signal?: AbortSignal): Promise { 36 | return this.request("GET", "/v3/whoami", { signal }) 37 | } 38 | 39 | getLoginFlows(): Promise { 40 | return this.request("GET", "/v3/login/flows") 41 | } 42 | 43 | async startLogin(flowID: string, refresh: () => void): Promise { 44 | const resp: RespSubmitLogin = await this.request( 45 | "POST", `/v3/login/start/${encodeURIComponent(flowID)}`, {}, 46 | ) 47 | return new LoginClient(this, resp, refresh) 48 | } 49 | 50 | logout(loginID: string | "all"): Promise { 51 | return this.request("POST", `/v3/logout/${encodeURIComponent(loginID)}`, {}) 52 | } 53 | 54 | getContacts(): Promise { 55 | return this.request("GET", "/v3/contacts") 56 | } 57 | 58 | resolveIdentifier(identifier: string): Promise { 59 | return this.request("GET", `/v3/resolve_identifier/${encodeURIComponent(identifier)}`) 60 | } 61 | 62 | startChat(identifier: string): Promise { 63 | return this.request("POST", `/v3/create_dm/${encodeURIComponent(identifier)}`, {}) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /forge.config.ts: -------------------------------------------------------------------------------- 1 | import type { ForgeConfig } from "@electron-forge/shared-types" 2 | import { MakerSquirrel } from "@electron-forge/maker-squirrel" 3 | import { MakerDMG } from "@electron-forge/maker-dmg" 4 | import { MakerDeb } from "@electron-forge/maker-deb" 5 | import { PublisherGithub } from "@electron-forge/publisher-github" 6 | import { VitePlugin } from "@electron-forge/plugin-vite" 7 | import { FusesPlugin } from "@electron-forge/plugin-fuses" 8 | import { FuseV1Options, FuseVersion } from "@electron/fuses" 9 | 10 | const config: ForgeConfig = { 11 | packagerConfig: { 12 | asar: true, 13 | protocols: [ 14 | { 15 | name: "mautrix-manager", 16 | schemes: ["mautrix-manager"], 17 | }, 18 | ], 19 | icon: "icon", 20 | }, 21 | rebuildConfig: {}, 22 | makers: [ 23 | new MakerSquirrel({}), 24 | new MakerDMG({}), 25 | new MakerDeb({ 26 | options: { 27 | mimeType: ["x-scheme-handler/mautrix-manager"], 28 | icon: "icon.png", 29 | }, 30 | }), 31 | ], 32 | plugins: [ 33 | new VitePlugin({ 34 | // `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc. 35 | // If you are familiar with Vite configuration, it will look really familiar. 36 | build: [ 37 | { 38 | // `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`. 39 | entry: "src/electron.ts", 40 | config: "vite.main.config.ts", 41 | target: "main", 42 | }, 43 | { 44 | entry: "src/preload.ts", 45 | config: "vite.preload.config.ts", 46 | target: "preload", 47 | }, 48 | ], 49 | renderer: [ 50 | { 51 | name: "main_window", 52 | config: "vite.renderer.config.ts", 53 | }, 54 | ], 55 | }), 56 | // Fuses are used to enable/disable various Electron functionality 57 | // at package time, before code signing the application 58 | new FusesPlugin({ 59 | version: FuseVersion.V1, 60 | [FuseV1Options.RunAsNode]: false, 61 | [FuseV1Options.EnableCookieEncryption]: true, 62 | [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, 63 | [FuseV1Options.EnableNodeCliInspectArguments]: false, 64 | [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, 65 | [FuseV1Options.OnlyLoadAppFromAsar]: true, 66 | }), 67 | ], 68 | publishers: [ 69 | new PublisherGithub({ 70 | repository: { 71 | name: "manager", 72 | owner: "mautrix", 73 | }, 74 | draft: true, 75 | }), 76 | ], 77 | } 78 | 79 | export default config 80 | -------------------------------------------------------------------------------- /src/types/loginstep.ts: -------------------------------------------------------------------------------- 1 | export type LoginCookieOutput = { 2 | [id: string]: string 3 | } 4 | 5 | export type LoginStepData = 6 | LoginStepUserInput | 7 | LoginStepDisplayAndWait | 8 | LoginStepCookies | 9 | LoginStepComplete 10 | 11 | interface baseLoginStep { 12 | step_id: string 13 | instructions?: string 14 | } 15 | 16 | export type LoginStepUserInput = baseLoginStep & { 17 | type: "user_input", 18 | user_input: LoginUserInputParams, 19 | } 20 | 21 | export interface LoginUserInputParams { 22 | fields: LoginInputDataField[] 23 | } 24 | 25 | export type LoginInputFieldType = "username" | "phone_number" | "email" | "password" | "2fa_code" 26 | 27 | export interface LoginInputDataField { 28 | type: LoginInputFieldType 29 | id: string 30 | name: string 31 | description?: string 32 | pattern?: string 33 | } 34 | 35 | export type LoginStepDisplayAndWait = baseLoginStep & { 36 | type: "display_and_wait", 37 | display_and_wait: LoginDisplayAndWaitParams, 38 | } 39 | 40 | export type LoginDisplayAndWaitParams = 41 | LoginDisplayAndWaitNothingParams | 42 | LoginDisplayAndWaitEmojiParams | 43 | LoginDisplayAndWaitQROrCodeParams 44 | 45 | export interface LoginDisplayAndWaitNothingParams { 46 | type: "nothing" 47 | } 48 | 49 | export interface LoginDisplayAndWaitEmojiParams { 50 | type: "emoji" 51 | data: string 52 | image_url?: string 53 | } 54 | 55 | export interface LoginDisplayAndWaitQROrCodeParams { 56 | type: "qr" | "code" 57 | data: string 58 | } 59 | 60 | export type LoginStepCookies = baseLoginStep & { 61 | type: "cookies", 62 | cookies: LoginCookiesParams, 63 | } 64 | 65 | export interface LoginCookiesParams { 66 | url: string 67 | user_agent?: string 68 | fields: LoginCookieField[] 69 | extract_js?: string 70 | } 71 | 72 | export interface LoginCookieField { 73 | id: string 74 | required: boolean 75 | sources: LoginCookieFieldSource[] 76 | pattern?: string 77 | } 78 | 79 | export type LoginCookieFieldSource = 80 | LoginCookieFieldSourceCookie | 81 | LoginCookieFieldSourceRequest | 82 | LoginCookieFieldSourceLocalStorage | 83 | LoginCookieFieldSourceSpecial 84 | 85 | export interface LoginCookieFieldSourceCookie { 86 | type: "cookie" 87 | name: string 88 | cookie_domain: string 89 | } 90 | 91 | export interface LoginCookieFieldSourceRequest { 92 | type: "request_header" | "request_body" 93 | name: string 94 | request_url_regex: string 95 | } 96 | 97 | export interface LoginCookieFieldSourceLocalStorage { 98 | type: "local_storage" 99 | name: string 100 | } 101 | 102 | export interface LoginCookieFieldSourceSpecial { 103 | type: "special" 104 | name: string 105 | } 106 | 107 | export type LoginStepComplete = baseLoginStep & { 108 | type: "complete", 109 | complete: LoginCompleteParams, 110 | } 111 | 112 | export interface LoginCompleteParams { 113 | user_login_id: string 114 | } 115 | -------------------------------------------------------------------------------- /src/api/matrixclient.ts: -------------------------------------------------------------------------------- 1 | import { BaseAPIClient } from "./baseclient" 2 | import { 3 | ReqLogin, 4 | RespLogin, 5 | RespLoginFlows, 6 | RespOpenIDToken, 7 | RespVersions, 8 | RespWhoami, 9 | } from "../types/matrix" 10 | 11 | const mxcRegex = /^mxc:\/\/([^/]+?)\/([a-zA-Z0-9_-]+)$/ 12 | 13 | type OpenIDTokenCache = RespOpenIDToken & { 14 | expires_at: number 15 | } 16 | 17 | export class MatrixClient extends BaseAPIClient { 18 | private openIDTokenCache: Promise | undefined 19 | 20 | constructor( 21 | baseURL: string, 22 | userID?: string, 23 | token?: string, 24 | ) { 25 | super(baseURL, "/_matrix/client", userID, token) 26 | } 27 | 28 | versions(): Promise { 29 | return this.request("GET", "/versions") 30 | } 31 | 32 | whoami(): Promise { 33 | return this.request("GET", "/v3/account/whoami") 34 | } 35 | 36 | get hasToken(): boolean { 37 | return !!this.staticToken 38 | } 39 | 40 | get ssoRedirectURL(): string { 41 | let redirectURL: string 42 | if (window.mautrixAPI.isDevBuild) { 43 | const wrappedRedirectURL = new URL(window.location.toString()) 44 | wrappedRedirectURL.hash = "" 45 | wrappedRedirectURL.search = "" 46 | redirectURL = wrappedRedirectURL.toString() 47 | } else { 48 | redirectURL = "mautrix-manager://sso" 49 | } 50 | const url = new URL(this.baseURL) 51 | url.pathname = `${url.pathname}${this.pathPrefix}/v3/login/sso/redirect` 52 | url.searchParams.set("redirectUrl", redirectURL) 53 | return url.toString() 54 | } 55 | 56 | getMediaURL(mxc: string): string { 57 | const [, server, mediaID] = mxc.match(mxcRegex) ?? [] 58 | if (!server || !mediaID) { 59 | throw new Error("Invalid mxc URL") 60 | } 61 | const url = new URL(this.baseURL) 62 | url.pathname = `${url.pathname}${this.pathPrefix}/v1/media/download/${server}/${mediaID}` 63 | return url.toString() 64 | } 65 | 66 | getLoginFlows(): Promise { 67 | return this.request("GET", "/v3/login") 68 | } 69 | 70 | login(req: ReqLogin): Promise { 71 | return this.request("POST", "/v3/login", req) 72 | } 73 | 74 | logout(): Promise> { 75 | return this.request("POST", "/v3/logout", {}) 76 | } 77 | 78 | async getCachedOpenIDToken(): Promise { 79 | const cache = await this.openIDTokenCache 80 | if (cache && cache.expires_at > Date.now()) { 81 | return cache.access_token 82 | } 83 | this.openIDTokenCache = this.getOpenIDToken().then(resp => ({ 84 | ...resp, 85 | access_token: `openid:${resp.access_token}`, 86 | expires_at: Date.now() + (resp.expires_in / 60 * 59000), 87 | }), err => { 88 | console.error("Failed to get OpenID token", err) 89 | this.openIDTokenCache = undefined 90 | throw err 91 | }) 92 | return this.openIDTokenCache.then(res => res.access_token) 93 | } 94 | 95 | getOpenIDToken(): Promise { 96 | return this.request("POST", `/v3/user/${this.userID}/openid/request_token`, {}) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/app/MainView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useMemo, useState } from "react" 2 | import type { MatrixClient } from "../api/matrixclient" 3 | import { BridgeList, BridgeMap } from "../api/bridgelist" 4 | import BridgeListEntry from "./BridgeListEntry" 5 | import BridgeStatusView from "./BridgeStatusView" 6 | import "./MainView.css" 7 | 8 | interface MainScreenProps { 9 | matrixClient: MatrixClient 10 | logout: () => void 11 | } 12 | 13 | export interface LoginInProgress { 14 | cancel: () => void 15 | } 16 | 17 | const MainView = ({ matrixClient, logout }: MainScreenProps) => { 18 | const bridgeList = useMemo(() => new BridgeList(matrixClient), [matrixClient]) 19 | const [bridges, setBridges] = useState({}) 20 | const [viewingBridge, setViewingBridge] = useState("") 21 | useEffect(() => { 22 | bridgeList.listen(setBridges) 23 | bridgeList.initialLoad() 24 | return () => bridgeList.stopListen(setBridges) 25 | }, [bridgeList]) 26 | 27 | const addBridge = useCallback((evt: React.FormEvent) => { 28 | evt.preventDefault() 29 | const form = evt.currentTarget as HTMLFormElement 30 | const data = Array.from(form.elements).reduce((acc, elem) => { 31 | if (elem instanceof HTMLInputElement) { 32 | if (elem.type === "checkbox") { 33 | acc[elem.name] = elem.checked 34 | elem.checked = false 35 | } else { 36 | acc[elem.name] = elem.value 37 | elem.value = "" 38 | } 39 | } 40 | return acc 41 | }, {} as Record) 42 | if (!data.server || typeof data.server !== "string" || typeof data.external !== "boolean") { 43 | return 44 | } 45 | bridgeList.checkAndAdd(data.server, data.external) 46 | .catch(err => { 47 | alert(`Failed to add bridge: ${err}`) 48 | }) 49 | }, [bridgeList]) 50 | 51 | return
52 |
53 | Logged in as {matrixClient.userID} 54 | 55 |
56 |
57 | {Object.values(bridges).map(bridge => )} 65 |
66 | 67 |
68 | 69 | 70 |
71 | 72 |
73 |
74 | {Object.values(bridges).map(bridge => 75 |
81 | } 82 | 83 | export default MainView 84 | -------------------------------------------------------------------------------- /src/app/BridgeStatusView.css: -------------------------------------------------------------------------------- 1 | div.bridge-view { 2 | padding: .5rem; 3 | display: flex; 4 | flex-direction: column; 5 | gap: .5rem; 6 | 7 | > div.user-login-entry, > div.new-login, > div.bridge-login-view { 8 | padding: .25rem .5rem; 9 | box-sizing: border-box; 10 | } 11 | 12 | &.hidden { 13 | display: none; 14 | } 15 | } 16 | 17 | div.user-login-entry:hover { 18 | background-color: #EEE; 19 | } 20 | 21 | div.new-login { 22 | height: 3.5rem; 23 | display: flex; 24 | align-items: center; 25 | gap: .5rem; 26 | 27 | > button { 28 | border: 2px solid var(--primary-color); 29 | background-color: inherit; 30 | padding: 6px 1rem; 31 | border-radius: .25rem; 32 | box-sizing: border-box; 33 | height: 2.5rem; 34 | 35 | &:hover, &:focus { 36 | background-color: var(--primary-color); 37 | color: white; 38 | } 39 | } 40 | } 41 | 42 | div.user-login-entry > pre.details { 43 | &.hidden { 44 | display: none; 45 | } 46 | white-space: pre-wrap; 47 | max-height: 400px; 48 | overflow-y: auto; 49 | margin: .5rem 0; 50 | } 51 | 52 | div.user-login-entry > div.header { 53 | display: flex; 54 | justify-content: space-between; 55 | align-items: center; 56 | height: 3rem; 57 | 58 | > div.profile, > div.controls { 59 | display: flex; 60 | align-items: center; 61 | gap: .5rem; 62 | height: 100%; 63 | 64 | > button { 65 | padding: .5rem 1rem; 66 | border-radius: .25rem; 67 | cursor: pointer; 68 | box-sizing: border-box; 69 | border: 2px solid transparent; 70 | } 71 | 72 | > button.logout { 73 | border: 2px solid var(--error-color-light); 74 | background-color: inherit; 75 | 76 | &:hover, &:focus { 77 | border: 2px solid transparent; 78 | color: white; 79 | background-color: var(--error-color); 80 | } 81 | } 82 | 83 | > button.relogin { 84 | color: white; 85 | background-color: var(--primary-color); 86 | 87 | &:hover, &:focus { 88 | background-color: var(--primary-color-dark); 89 | } 90 | } 91 | } 92 | 93 | > div.profile { 94 | flex-grow: 1; 95 | user-select: none; 96 | > img { 97 | height: 2.5rem; 98 | width: 2.5rem; 99 | border-radius: 100%; 100 | } 101 | } 102 | 103 | .login-state { 104 | font-weight: bold; 105 | 106 | &.state-connected, &.state-backfilling { 107 | color: var(--primary-color); 108 | 109 | &::before { 110 | content: "✅ "; 111 | } 112 | } 113 | 114 | &.state-connecting { 115 | color: var(--primary-color); 116 | 117 | &::before { 118 | content: "⏳️ "; 119 | } 120 | } 121 | 122 | &.state-transient-disconnect { 123 | color: yellow; 124 | 125 | &::before { 126 | content: "⚠️ "; 127 | } 128 | } 129 | 130 | &.state-unset { 131 | &::before { 132 | content: "❓️ "; 133 | } 134 | } 135 | 136 | &.state-bad-credentials, &.state-unknown-error { 137 | color: var(--error-color); 138 | 139 | &::before { 140 | content: "❌ "; 141 | } 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/api/baseclient.ts: -------------------------------------------------------------------------------- 1 | import { APIError } from "./error" 2 | 3 | export interface ExtraParams { 4 | signal?: AbortSignal 5 | pathPrefix?: string 6 | } 7 | 8 | export class BaseAPIClient { 9 | readonly baseURL: string 10 | readonly pathPrefix: string 11 | protected readonly staticToken: string | undefined 12 | 13 | constructor( 14 | baseURL: string, 15 | pathPrefix: string, 16 | readonly userID?: string, 17 | token?: string, 18 | readonly loginID?: string, 19 | ) { 20 | if (!baseURL.endsWith("/")) { 21 | baseURL += "/" 22 | } 23 | if (pathPrefix.startsWith("/")) { 24 | pathPrefix = pathPrefix.slice(1) 25 | } 26 | if (pathPrefix.endsWith("/")) { 27 | pathPrefix = pathPrefix.slice(0, -1) 28 | } 29 | this.pathPrefix = pathPrefix 30 | this.baseURL = baseURL 31 | this.staticToken = token 32 | } 33 | 34 | async getToken(): Promise { 35 | return this.staticToken 36 | } 37 | 38 | async request( 39 | method: "GET" | "HEAD", 40 | path: string, 41 | extraParams?: ExtraParams, 42 | ): Promise 43 | async request( 44 | method: "DELETE", 45 | path: string, 46 | reqData?: RequestType, 47 | extraParams?: ExtraParams, 48 | ): Promise 49 | async request( 50 | method: "POST" | "PUT", 51 | path: string, 52 | reqData: RequestType, 53 | extraParams?: ExtraParams, 54 | ): Promise 55 | 56 | async request( 57 | method: "GET" | "HEAD" | "DELETE" | "POST" | "PUT", 58 | path: string, 59 | reqData?: RequestType | ExtraParams, 60 | extraParams?: ExtraParams, 61 | ): Promise { 62 | if (method === "GET" || method === "HEAD") { 63 | reqData = extraParams 64 | } 65 | const reqParams: RequestInit & { headers: Record } = { 66 | method, 67 | headers: {}, 68 | signal: extraParams?.signal, 69 | } 70 | const token = await this.getToken() 71 | if (token) { 72 | reqParams.headers.Authorization = `Bearer ${token}` 73 | } 74 | if (reqData) { 75 | reqParams.body = JSON.stringify(reqData) 76 | reqParams.headers["Content-Type"] = "application/json" 77 | } 78 | const url = new URL(this.baseURL) 79 | url.pathname = `${url.pathname}${this.pathPrefix}${path}` 80 | if (this.userID) { 81 | url.searchParams.set("user_id", this.userID) 82 | } 83 | if (this.loginID && !path.startsWith("/v3/login/")) { 84 | url.searchParams.set("login_id", this.loginID) 85 | } 86 | const resp = await fetch(url.toString(), reqParams) 87 | let respData 88 | try { 89 | respData = await resp.json() 90 | } catch (err) { 91 | const text = await resp.text() 92 | if (resp.status >= 400) { 93 | console.error("Got non-OK status", resp.status, "with non-JSON response", text) 94 | throw new Error(`HTTP ${resp.status} ${resp.statusText}`) 95 | } else { 96 | console.error("Failed to parse JSON response", text) 97 | throw err 98 | } 99 | } 100 | if (respData.errcode) { 101 | console.error("Got API error", respData) 102 | throw new APIError(respData) 103 | } else if (respData.status >= 400) { 104 | console.error("Got non-OK status", resp.status, "with JSON response", respData) 105 | throw new Error(`HTTP ${respData.status} ${respData.statusText}`) 106 | } 107 | return respData 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/electron.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, ipcMain, shell } from "electron" 2 | import path from "path" 3 | import "./webview.ts" 4 | import type { AccessTokenChangedParams } from "./preload" 5 | import { getSearch } from "./util/urlParse" 6 | import started from "electron-squirrel-startup" 7 | 8 | // Handle creating/removing shortcuts on Windows when installing/uninstalling. 9 | if (started) { 10 | app.quit() 11 | } 12 | 13 | let homeserverURL = "" 14 | let accessToken = "" 15 | 16 | ipcMain.handle("mautrix:access-token-changed", (event, newDetails: AccessTokenChangedParams) => { 17 | homeserverURL = newDetails.homeserverURL 18 | accessToken = newDetails.accessToken 19 | }) 20 | 21 | ipcMain.handle("mautrix:open-in-browser", (event, url: string) => { 22 | if (!url.startsWith("https://")) { 23 | throw new Error("URL must start with https://") 24 | } 25 | return shell.openExternal(url) 26 | }) 27 | 28 | let mainWindow: BrowserWindow | undefined 29 | 30 | function loadIndexPage(search?: string) { 31 | if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { 32 | if (search) { 33 | search = `?${search}` 34 | } 35 | return mainWindow!.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}${search || ""}`) 36 | } else { 37 | return mainWindow!.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`), { 38 | search, 39 | }) 40 | } 41 | } 42 | 43 | const createWindow = () => { 44 | mainWindow = new BrowserWindow({ 45 | width: MAIN_WINDOW_VITE_DEV_SERVER_URL ? 1600 : 1280, 46 | height: 800, 47 | autoHideMenuBar: true, 48 | icon: "icon.png", 49 | webPreferences: { 50 | preload: path.join(__dirname, "preload.js"), 51 | }, 52 | }) 53 | 54 | mainWindow.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => { 55 | if ( 56 | details.resourceType !== "image" || 57 | !details.url.startsWith(`${homeserverURL}/_matrix/client/v1/media/download/`) 58 | ) { 59 | callback({}) 60 | return 61 | } 62 | callback({ 63 | requestHeaders: { 64 | ...details.requestHeaders, 65 | Authorization: `Bearer ${accessToken}`, 66 | }, 67 | }) 68 | }) 69 | 70 | loadIndexPage() 71 | if (process.env.NODE_ENV === "development") { 72 | mainWindow.webContents.openDevTools() 73 | } 74 | } 75 | 76 | if (process.defaultApp) { 77 | if (process.argv.length >= 2) { 78 | app.setAsDefaultProtocolClient("mautrix-manager", process.execPath, [path.resolve(process.argv[1])]) 79 | } 80 | } else { 81 | app.setAsDefaultProtocolClient("mautrix-manager") 82 | } 83 | 84 | if (!app.requestSingleInstanceLock()) { 85 | app.quit() 86 | } else { 87 | app.on("second-instance", (event, commandLine, workingDirectory) => { 88 | if (mainWindow) { 89 | if (mainWindow.isMinimized()) mainWindow.restore() 90 | mainWindow.focus() 91 | } 92 | 93 | const arg = commandLine.pop() 94 | const search = getSearch(arg) 95 | if (search){ 96 | loadIndexPage(search) 97 | } 98 | }) 99 | 100 | app.on("window-all-closed", () => { 101 | mainWindow = undefined 102 | if (process.platform !== "darwin") { 103 | app.quit() 104 | } 105 | }) 106 | 107 | app.on("activate", () => { 108 | if (BrowserWindow.getAllWindows().length === 0) { 109 | createWindow() 110 | } 111 | }) 112 | app.whenReady().then(createWindow) 113 | 114 | app.on("open-url", (event, url) => { 115 | const search = getSearch(url) 116 | if (search){ 117 | loadIndexPage(search) 118 | } 119 | }) 120 | } 121 | -------------------------------------------------------------------------------- /src/app/BridgeStatusView.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEvent, useCallback, useState } from "react" 2 | import type { BridgeMeta } from "../api/bridgelist" 3 | import type { LoginClient } from "../api/loginclient" 4 | import type { RespWhoamiLogin } from "../types/whoami" 5 | import type { MatrixClient } from "../api/matrixclient" 6 | import BridgeLoginView from "./BridgeLoginView" 7 | import "./BridgeStatusView.css" 8 | 9 | interface BridgeViewProps { 10 | bridge: BridgeMeta 11 | hidden: boolean 12 | } 13 | 14 | interface UserLoginViewProps { 15 | login: RespWhoamiLogin 16 | mxClient: MatrixClient 17 | doLogout: (evt: MouseEvent) => void 18 | } 19 | 20 | const UserLoginView = ({ login, mxClient, doLogout }: UserLoginViewProps) => { 21 | const [expandDetails, setExpandDetails] = useState(false) 22 | const stateEvtClass = login.state?.state_event.toLowerCase().replace("_", "-") || "unset" 23 | return
24 |
25 |
setExpandDetails(!expandDetails)}> 26 | {login.profile?.avatar && 27 | } 28 | {login.name || {login.id}} 29 |
30 |
31 | 32 | {login.state?.state_event || "NO STATE"} 33 | 34 | {login.state?.state_event === "BAD_CREDENTIALS" && 35 | } 36 | 38 |
39 |
40 |
41 | 			{JSON.stringify(login, null, 2)}
42 | 		
43 |
44 | } 45 | 46 | const BridgeStatusView = ({ bridge, hidden }: BridgeViewProps) => { 47 | const mxClient = bridge.client.matrixClient 48 | const [login, setLogin] = useState(null) 49 | 50 | const onLoginComplete = useCallback(() => { 51 | setTimeout(bridge.refresh, 500) 52 | setLogin(null) 53 | }, [bridge]) 54 | const onLoginCancel = useCallback(() => setLogin(null), []) 55 | 56 | const startLogin = useCallback((evt: MouseEvent) => { 57 | // TODO catch errors? 58 | bridge.client.startLogin( 59 | evt.currentTarget.getAttribute("data-flow-id")!, 60 | bridge.refresh, 61 | ).then(setLogin) 62 | }, [setLogin, bridge]) 63 | const doLogout = useCallback((evt: MouseEvent) => { 64 | const loginID = evt.currentTarget.getAttribute("data-login-id")! 65 | bridge.client.logout(loginID).then(() => setTimeout(bridge.refresh, 500)) 66 | }, [bridge]) 67 | 68 | if (!bridge.whoami) { 69 | return
BridgeView spinner
70 | } 71 | 72 | return
73 | {bridge.whoami.logins.map(login => 74 | , 75 | )} 76 | {login ? :
82 | New login: 83 | {bridge.whoami.login_flows.map(flow => 84 | )} 92 |
} 93 |
94 | } 95 | 96 | export default BridgeStatusView 97 | -------------------------------------------------------------------------------- /src/app/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo, useCallback } from "react" 2 | import type { RespLogin } from "../types/matrix" 3 | import useInit from "../util/useInit" 4 | import { MatrixClient } from "../api/matrixclient" 5 | import { APIError } from "../api/error" 6 | import TypedLocalStorage from "../api/localstorage" 7 | import MatrixLogin from "./MatrixLogin" 8 | import MainView from "./MainView" 9 | 10 | export interface Credentials { 11 | user_id: string 12 | access_token: string 13 | } 14 | 15 | function useCredentials() { 16 | const [credentials, setCredentials] = useState(() => TypedLocalStorage.credentials) 17 | const setCredentialsProxy = useCallback((newCredentials: Credentials | null) => { 18 | TypedLocalStorage.credentials = newCredentials 19 | setCredentials(newCredentials) 20 | }, [setCredentials]) 21 | return [credentials, setCredentialsProxy] as const 22 | } 23 | 24 | function useHomeserverURL() { 25 | const [homeserverURL, setHomeserverURL] = useState(() => TypedLocalStorage.homeserverURL) 26 | const setHomeserverURLProxy = useCallback((url: string) => { 27 | TypedLocalStorage.homeserverURL = url 28 | setHomeserverURL(url) 29 | }, [setHomeserverURL]) 30 | return [homeserverURL, setHomeserverURLProxy] as const 31 | } 32 | 33 | const App = () => { 34 | const [error, setError] = useState("") 35 | const [homeserverURL, setHomeserverURL] = useHomeserverURL() 36 | const [credentials, setCredentials] = useCredentials() 37 | const [isLoggedIn, setIsLoggedIn] = useState(null) 38 | const matrixClient = useMemo(() => new MatrixClient( 39 | homeserverURL, credentials?.user_id, credentials?.access_token, 40 | ), [credentials, homeserverURL]) 41 | const onLoggedIn = useCallback((resp: RespLogin) => { 42 | setCredentials({ 43 | user_id: resp.user_id, 44 | access_token: resp.access_token, 45 | }) 46 | if (resp.well_known?.["m.homeserver"]?.base_url) { 47 | setHomeserverURL(resp.well_known["m.homeserver"].base_url) 48 | } 49 | setIsLoggedIn(true) 50 | }, [setCredentials, setHomeserverURL]) 51 | const logout = useCallback(() => { 52 | TypedLocalStorage.bridges = {} 53 | matrixClient.logout().then( 54 | () => { 55 | setCredentials(null) 56 | setIsLoggedIn(false) 57 | }, 58 | err => setError(`Failed to logout: ${err}`), 59 | ) 60 | }, [matrixClient, setCredentials]) 61 | 62 | useInit(() => { 63 | const loc = new URL(window.location.href) 64 | const loginToken = loc.searchParams.get("loginToken") 65 | if (loginToken) { 66 | loc.searchParams.delete("loginToken") 67 | window.history.replaceState({}, "", loc.toString()) 68 | matrixClient.login({ 69 | type: "m.login.token", 70 | token: loginToken, 71 | }).then( 72 | onLoggedIn, 73 | err => setError(`Failed to login with token: ${err}`), 74 | ) 75 | } else if (matrixClient.hasToken) { 76 | matrixClient.whoami().then( 77 | () => setIsLoggedIn(true), 78 | err => { 79 | if (err instanceof APIError && err.errcode === "M_UNKNOWN_TOKEN") { 80 | setIsLoggedIn(false) 81 | setCredentials(null) 82 | } else { 83 | setError(`Failed to check token: ${err}`) 84 | } 85 | }, 86 | ) 87 | } else { 88 | setIsLoggedIn(false) 89 | } 90 | }) 91 | 92 | if (error) { 93 | return
{error} 3:
94 | } else if (isLoggedIn === null) { 95 | return
spinner
96 | } else if (!isLoggedIn) { 97 | return 103 | } else { 104 | return 105 | } 106 | } 107 | 108 | export default App 109 | -------------------------------------------------------------------------------- /src/app/MatrixLogin.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useRef, useState } from "react" 2 | import type { RespClientWellKnown, RespLogin } from "../types/matrix" 3 | import type { MatrixClient } from "../api/matrixclient" 4 | import "./MatrixLogin.css" 5 | 6 | interface LoginScreenProps { 7 | homeserverURL: string 8 | setHomeserverURL: (url: string) => void 9 | matrixClient: MatrixClient 10 | onLoggedIn: (resp: RespLogin) => void 11 | } 12 | 13 | const LoginScreen = ({ 14 | homeserverURL, setHomeserverURL, matrixClient, onLoggedIn, 15 | }: LoginScreenProps) => { 16 | const [username, setUsername] = useState("") 17 | const [password, setPassword] = useState("") 18 | const [error, setError] = useState("") 19 | const [loginFlows, setLoginFlows] = useState | null>(null) 20 | const forceResolveImmediate = useRef(true) 21 | 22 | const login = useCallback((evt: React.FormEvent) => { 23 | evt.preventDefault() 24 | matrixClient.login({ 25 | type: "m.login.password", 26 | identifier: { 27 | type: "m.id.user", 28 | user: username, 29 | }, 30 | password, 31 | }).then( 32 | onLoggedIn, 33 | err => setError(err.toString()), 34 | ) 35 | }, [username, password, onLoggedIn, matrixClient]) 36 | const loginSSO = useCallback(() => { 37 | if (window.mautrixAPI.isDevBuild) { 38 | window.location.href = matrixClient.ssoRedirectURL 39 | } else { 40 | window.mautrixAPI.openInBrowser(matrixClient.ssoRedirectURL) 41 | .catch(err => window.alert(`Failed to open SSO URL in browser: ${err}`)) 42 | } 43 | }, [matrixClient]) 44 | 45 | const resolveHomeserver = useCallback(() => { 46 | if (homeserverURL.startsWith("https://") || homeserverURL.startsWith("http://")) { 47 | matrixClient.getLoginFlows().then( 48 | resp => { 49 | setLoginFlows(new Set(resp.flows.map(flow => flow.type))) 50 | setError("") 51 | }, 52 | err => { 53 | setLoginFlows(null) 54 | setError(`Failed to get login flows: ${err}`) 55 | }, 56 | ) 57 | } else if (homeserverURL) { 58 | fetch(`https://${homeserverURL}/.well-known/matrix/client`) 59 | .then(resp => resp.json()) 60 | .then((resp: RespClientWellKnown) => { 61 | const baseURL = resp?.["m.homeserver"]?.base_url 62 | if (baseURL) { 63 | forceResolveImmediate.current = true 64 | setHomeserverURL(baseURL) 65 | } else { 66 | setLoginFlows(null) 67 | setError("Couldn't find homeserver URL in well-known") 68 | } 69 | }) 70 | .catch(err => { 71 | setLoginFlows(null) 72 | setError(`Failed to resolve homeserver URL: ${err}`) 73 | }) 74 | } 75 | }, [homeserverURL, matrixClient, setHomeserverURL]) 76 | 77 | useEffect(() => { 78 | if (forceResolveImmediate.current) { 79 | forceResolveImmediate.current = false 80 | resolveHomeserver() 81 | return 82 | } 83 | const timeoutMS = homeserverURL.includes(".") ? 500 : 2000 84 | const timeout = setTimeout(resolveHomeserver, timeoutMS) 85 | return () => { 86 | clearTimeout(timeout) 87 | } 88 | }, [homeserverURL, resolveHomeserver, forceResolveImmediate]) 89 | 90 | return
91 |

mautrix-manager

92 | { 98 | setHomeserverURL(evt.target.value) 99 | setLoginFlows(null) 100 | }} 101 | /> 102 | {!loginFlows?.size &&
} 103 | {loginFlows?.has("m.login.password") &&
104 | setUsername(evt.target.value)} 110 | /> 111 | setPassword(evt.target.value)} 117 | /> 118 | 119 |
} 120 | {loginFlows?.has("m.login.sso") && <> 121 | 122 | } 123 | {error &&
124 | {error} 125 |
} 126 |
127 | } 128 | 129 | export default LoginScreen 130 | -------------------------------------------------------------------------------- /src/app/BridgeLoginView.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from "react" 2 | import type { 3 | LoginDisplayAndWaitParams, 4 | LoginInputDataField, 5 | LoginInputFieldType, 6 | LoginStepData, 7 | } from "../types/loginstep" 8 | import type { LoginClient } from "../api/loginclient" 9 | import { QRCodeSVG } from "qrcode.react" 10 | import GridLoader from "react-spinners/GridLoader" 11 | import "./BridgeLoginView.css" 12 | 13 | interface LoginViewProps { 14 | client: LoginClient 15 | onLoginCancel: () => void 16 | onLoginComplete: () => void 17 | } 18 | 19 | interface LoginStepProps { 20 | step: LoginStepData 21 | onSubmit: (data: Record) => void 22 | onCancel: () => void 23 | onLoginComplete: () => void 24 | } 25 | 26 | function loginInputFieldTypeToHTMLType(type: LoginInputFieldType): string { 27 | switch (type) { 28 | case "email": 29 | return "email" 30 | case "phone_number": 31 | return "tel" 32 | case "password": 33 | return "password" 34 | case "username": 35 | case "2fa_code": 36 | return "text" 37 | } 38 | } 39 | 40 | const LoginStepField = ({ field }: { field: LoginInputDataField }) => { 41 | return
42 | 43 | 49 |
50 | } 51 | 52 | const DisplayAndWaitStep = ({ params }: { params: LoginDisplayAndWaitParams }) => { 53 | switch (params.type) { 54 | case "emoji": 55 | if (params.image_url) { 56 | return {params.data} 61 | } else { 62 | return

{params.data}

63 | } 64 | case "code": 65 | return

{params.data}

66 | case "qr": 67 | return 68 | case "nothing": 69 | return
70 | 71 |
72 | default: 73 | return
74 | Unknown display type {(params as { type: string }).type} 75 |
76 | } 77 | } 78 | 79 | const LoginStep = ({ step, onSubmit, onLoginComplete, onCancel }: LoginStepProps) => { 80 | const submitForm = useCallback((evt: React.FormEvent) => { 81 | evt.preventDefault() 82 | const form = evt.currentTarget as HTMLFormElement 83 | const data = Array.from(form.elements).reduce((acc, elem) => { 84 | if (elem instanceof HTMLInputElement) { 85 | acc[elem.name] = elem.value 86 | } 87 | return acc 88 | }, {} as Record) 89 | onSubmit(data) 90 | }, [onSubmit]) 91 | switch (step.type) { 92 | case "cookies": 93 | return
94 | 95 |
96 | case "display_and_wait": 97 | return
98 | 99 | 100 |
101 | case "user_input": 102 | return
103 |
104 | {step.user_input.fields.map(field => 105 | )} 106 |
107 |
108 | 109 | 110 |
111 |
112 | case "complete": 113 | return
114 | 115 |
116 | } 117 | } 118 | 119 | const BridgeLoginView = ({ client, onLoginCancel, onLoginComplete }: LoginViewProps) => { 120 | const [error, setError] = useState("") 121 | const [loading, setLoading] = useState(client.loading) 122 | const [step, setStep] = useState(client.step) 123 | useEffect(() => { 124 | const onError = (err: Error) => setError(err.message) 125 | client.listen(setStep, setLoading, onError) 126 | return () => client.stopListen(setStep, setLoading, onError) 127 | }, [client]) 128 | const cancelLogin = useCallback(() => { 129 | client.cancel() 130 | onLoginCancel() 131 | }, [client, onLoginCancel]) 132 | 133 | let instructionsToRender = step.instructions 134 | if (step.type === "cookies") { 135 | instructionsToRender = "Please complete the login in the webview" 136 | } 137 | 138 | return
139 |
140 | {error ? `Login failed :( ${error}` : instructionsToRender} 141 |
142 | 148 |
149 | } 150 | 151 | export default BridgeLoginView 152 | -------------------------------------------------------------------------------- /src/api/loginclient.ts: -------------------------------------------------------------------------------- 1 | import type { LoginStepData } from "../types/loginstep" 2 | import type { RespSubmitLogin } from "../types/login" 3 | import type { ProvisioningClient } from "./provisionclient" 4 | 5 | const baseChromeUserAgent = window.navigator.userAgent 6 | .replace(/ mautrix-manager\/\S+/, "") 7 | .replace(/ Electron\/\S+/, "") 8 | 9 | export class LoginClient { 10 | public readonly loginID: string 11 | #step: LoginStepData 12 | #error: Error | null = null 13 | private submitInProgress = false 14 | 15 | private abortController: AbortController 16 | 17 | private stepListener: ((ev: LoginStepData) => void) | null = null 18 | private loadingListener: ((loading: boolean) => void) | null = null 19 | private errorListener: ((ev: Error) => void) | null = null 20 | 21 | constructor( 22 | public readonly client: ProvisioningClient, 23 | step: RespSubmitLogin, 24 | private readonly onCompleteRefresh: () => void, 25 | ) { 26 | this.abortController = new AbortController() 27 | this.#step = step 28 | this.loginID = step.login_id 29 | this.processStep() 30 | } 31 | 32 | get step() { 33 | return this.#step 34 | } 35 | 36 | get error() { 37 | return this.#error 38 | } 39 | 40 | get loading() { 41 | return this.submitInProgress 42 | } 43 | 44 | listen( 45 | onStep: (ev: LoginStepData) => void, 46 | onLoading: (loading: boolean) => void, 47 | onError: (ev: Error) => void, 48 | ) { 49 | this.stepListener = onStep 50 | this.loadingListener = onLoading 51 | this.errorListener = onError 52 | if (this.#error) { 53 | onError(this.#error) 54 | } else { 55 | onLoading(this.submitInProgress) 56 | onStep(this.#step) 57 | } 58 | } 59 | 60 | stopListen( 61 | onStep: (ev: LoginStepData) => void, 62 | onLoading: (loading: boolean) => void, 63 | onError: (ev: Error) => void, 64 | ) { 65 | if (this.stepListener === onStep) { 66 | this.stepListener = null 67 | } 68 | if (this.loadingListener === onLoading) { 69 | this.loadingListener = null 70 | } 71 | if (this.errorListener === onError) { 72 | this.errorListener = null 73 | } 74 | } 75 | 76 | cancel = () => { 77 | this.abortController.abort() 78 | this.onError(new Error("Login was cancelled")) 79 | } 80 | 81 | private processStep() { 82 | if (this.#step.type === "cookies") { 83 | const closeWebview = () => window.mautrixAPI.closeWebview() 84 | const removeListener = () => this.abortController.signal.removeEventListener("abort", closeWebview) 85 | this.abortController.signal.addEventListener("abort", closeWebview) 86 | if (!this.#step.cookies.user_agent) { 87 | this.#step.cookies.user_agent = baseChromeUserAgent 88 | } 89 | window.mautrixAPI.openWebview(this.#step.cookies).then( 90 | res => this.submitCookies(res.cookies), 91 | this.onError, 92 | ).finally(removeListener) 93 | } else if (this.#step.type === "display_and_wait") { 94 | this.wait() 95 | } else if (this.#step.type === "complete") { 96 | setTimeout(this.onCompleteRefresh, 200) 97 | } 98 | } 99 | 100 | submitUserInput = (params: Record) => { 101 | return this.submitStep(params, "user_input") 102 | } 103 | 104 | submitCookies = (params: Record) => { 105 | return this.submitStep(params, "cookies") 106 | } 107 | 108 | wait = () => { 109 | return this.submitStep({}, "display_and_wait") 110 | } 111 | 112 | private submitStep( 113 | params: ParamsType, 114 | expectedType: "user_input" | "cookies" | "display_and_wait", 115 | ) { 116 | if (this.abortController.signal.aborted) { 117 | throw new Error("Login was cancelled") 118 | } else if (this.submitInProgress) { 119 | throw new Error("Cannot submit multiple steps concurrently") 120 | } else if (this.#step.type !== expectedType) { 121 | throw new Error(`Mismatching step type for submit call, called ${expectedType} but current step is ${this.#step.type}`) 122 | } 123 | this.onLoading(true) 124 | this.client.request( 125 | "POST", 126 | `/v3/login/step/${this.loginID}/${this.#step.step_id}/${this.#step.type}`, 127 | params, 128 | { signal: this.abortController.signal }, 129 | ).then(this.onStep, this.onError) 130 | } 131 | 132 | private onLoading = (loading: boolean) => { 133 | this.submitInProgress = loading 134 | this.loadingListener?.(loading) 135 | } 136 | 137 | private onStep = (step: RespSubmitLogin | unknown) => { 138 | this.onLoading(false) 139 | if (this.#error) { 140 | console.warn("Ignoring login step after an error", step) 141 | return 142 | } 143 | this.#step = step as RespSubmitLogin 144 | this.stepListener?.(this.#step) 145 | this.processStep() 146 | } 147 | 148 | private onError = (err: Error | unknown) => { 149 | this.onLoading(false) 150 | if (this.#error) { 151 | console.warn("Ignoring login error after previous error", err) 152 | return 153 | } 154 | if (err instanceof Error) { 155 | this.#error = err 156 | } else { 157 | this.#error = new Error(`${err}`) 158 | } 159 | this.errorListener?.(this.#error) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/api/bridgelist.ts: -------------------------------------------------------------------------------- 1 | import type { MatrixClient } from "./matrixclient" 2 | import type { RespMautrixWellKnown } from "../types/matrix" 3 | import type { RespWhoami } from "../types/whoami" 4 | import { ProvisioningClient } from "./provisionclient" 5 | import TypedLocalStorage from "./localstorage" 6 | 7 | export class BridgeMeta { 8 | constructor( 9 | public server: string, 10 | public refreshing: boolean, 11 | public client: ProvisioningClient, 12 | public updateState: (bridge: BridgeMeta) => void, 13 | public whoami?: RespWhoami, 14 | public error?: Error, 15 | ) { 16 | } 17 | 18 | clone = ({ whoami, error, refreshing }: { 19 | whoami?: RespWhoami | null, 20 | error?: Error | null, 21 | refreshing?: boolean, 22 | }): BridgeMeta => { 23 | return new BridgeMeta( 24 | this.server, 25 | refreshing ?? this.refreshing, 26 | this.client, 27 | this.updateState, 28 | whoami !== undefined ? (whoami ?? undefined) : this.whoami, 29 | error !== undefined ? (error ?? undefined) : this.error, 30 | ) 31 | } 32 | 33 | refresh = () => { 34 | if (!this.refreshing) { 35 | this.updateState(this.clone({ refreshing: true })) 36 | } 37 | this.refreshAndClone().then(this.updateState) 38 | } 39 | 40 | private async refreshAndClone(): Promise { 41 | try { 42 | const whoami = await this.client.whoami() 43 | return this.clone({ whoami, refreshing: false }) 44 | } catch (err) { 45 | let error: Error 46 | if (!(err instanceof Error)) { 47 | error = new Error(`${err}`) 48 | } else { 49 | error = err 50 | } 51 | return this.clone({ error, refreshing: false }) 52 | } 53 | } 54 | } 55 | 56 | export type BridgeMap = Record 57 | export type BridgeListChangeListener = (bridges: BridgeMap) => void 58 | 59 | export class BridgeList { 60 | bridges: BridgeMap 61 | changeListener: BridgeListChangeListener | undefined 62 | private initialLoadDone = false 63 | 64 | constructor(private matrixClient: MatrixClient) { 65 | this.bridges = {} 66 | for (const [server, { external, whoami }] of Object.entries(TypedLocalStorage.bridges)) { 67 | this.add(server, whoami, external) 68 | } 69 | } 70 | 71 | hasMultiple = (bridgeType?: string) => { 72 | if (!bridgeType) { 73 | return false 74 | } 75 | let count = 0 76 | for (const bridge of Object.values(this.bridges)) { 77 | if (bridge.whoami?.network.beeper_bridge_type === bridgeType) { 78 | count++ 79 | if (count > 1) { 80 | return true 81 | } 82 | } 83 | } 84 | return false 85 | } 86 | 87 | private add(server: string, whoami?: RespWhoami | null, external = false, refresh = true) { 88 | if (this.bridges[server]) { 89 | return false 90 | } 91 | const provisioningClient = new ProvisioningClient( 92 | server, this.matrixClient, external, 93 | ) 94 | this.bridges[server] = new BridgeMeta( 95 | server, 96 | refresh, 97 | provisioningClient, 98 | updatedBridge => { 99 | this.bridges[server] = updatedBridge 100 | this.onChange() 101 | }, 102 | whoami ?? undefined, 103 | ) 104 | if (refresh) { 105 | this.bridges[server].refresh() 106 | } else { 107 | this.onChange() 108 | } 109 | return true 110 | } 111 | 112 | addMany = (servers: string[], external = false) => { 113 | const changed = servers 114 | .map(server => this.add(server, undefined, external)) 115 | .some(x => x) 116 | if (changed) { 117 | this.onChange() 118 | } 119 | } 120 | 121 | checkAndAdd = async (server: string, external = false) => { 122 | const provisioningClient = new ProvisioningClient( 123 | server, this.matrixClient, external, 124 | ) 125 | const whoami = await provisioningClient.whoami() 126 | this.add(server, whoami, external, false) 127 | } 128 | 129 | delete = (...servers: string[]) => { 130 | let changed = false 131 | for (const server of servers) { 132 | if (this.bridges[server]) { 133 | changed = true 134 | delete this.bridges[server] 135 | } 136 | } 137 | if (changed) { 138 | this.onChange() 139 | } 140 | } 141 | 142 | initialLoad = () => { 143 | if (this.initialLoadDone) { 144 | return 145 | } 146 | this.initialLoadDone = true 147 | this.refreshWellKnown() 148 | } 149 | 150 | private refreshWellKnown(server?: string) { 151 | let isExternal = true 152 | if (server === undefined) { 153 | const match = /@.*:(.*)/.exec(this.matrixClient.userID!) 154 | if (!match) { 155 | return 156 | } 157 | server = match[1] 158 | isExternal = false 159 | } 160 | console.info(`Fetching bridge list from .well-known of ${server}`) 161 | fetch(`https://${server}/.well-known/matrix/mautrix`) 162 | .then(resp => resp.json()) 163 | .then((resp: RespMautrixWellKnown) => { 164 | const bridges = resp?.["fi.mau.bridges"] 165 | ?.filter?.(br => typeof br === "string") 166 | if (bridges) { 167 | this.addMany(bridges, isExternal) 168 | } 169 | const externalServers = resp?.["fi.mau.external_bridge_servers"] 170 | ?.filter?.(br => typeof br === "string") 171 | if (!isExternal && externalServers) { 172 | for (const extServer of externalServers) { 173 | this.refreshWellKnown(extServer) 174 | } 175 | } 176 | }) 177 | .catch(err => { 178 | console.error(`Failed to fetch bridge list from .well-known of ${server}:`, err) 179 | }) 180 | } 181 | 182 | listen = (listener: BridgeListChangeListener) => { 183 | this.changeListener = listener 184 | listener({ ...this.bridges }) 185 | } 186 | 187 | stopListen = (listener: BridgeListChangeListener) => { 188 | if (this.changeListener === listener) { 189 | this.changeListener = undefined 190 | } 191 | } 192 | 193 | private onChange() { 194 | TypedLocalStorage.bridges = Object.fromEntries( 195 | Object.entries(this.bridges).map(([server, bridge]) => 196 | [server, { whoami: bridge.whoami, external: bridge.client.external }]), 197 | ) 198 | this.changeListener?.({ ...this.bridges }) 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/webview.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ipcMain, 3 | BrowserWindow, 4 | OnBeforeSendHeadersListenerDetails, 5 | BeforeSendResponse, 6 | } from "electron" 7 | import type { 8 | LoginCookieField, 9 | LoginCookieFieldSource, 10 | LoginCookieFieldSourceCookie, 11 | LoginCookieFieldSourceLocalStorage, 12 | LoginCookieFieldSourceRequest, 13 | LoginCookieOutput, 14 | LoginCookiesParams, 15 | } from "./types/loginstep" 16 | 17 | ipcMain.handle("mautrix:open-webview", (event, args: LoginCookiesParams) => { 18 | console.log("Received open webview request from", event.senderFrame?.url) 19 | const parent = BrowserWindow.fromWebContents(event.sender) 20 | if (!parent) { 21 | throw new Error("No parent window found") 22 | } 23 | return new Promise<{ cookies: LoginCookieOutput }>((resolve, reject) => { 24 | try { 25 | openWebview(args, parent, resolve, reject) 26 | } catch (err) { 27 | reject(err) 28 | } 29 | }) 30 | }) 31 | 32 | ipcMain.handle("mautrix:close-webview", (event) => { 33 | closeWebview() 34 | }) 35 | 36 | interface fieldWithSource { 37 | field: LoginCookieField 38 | source: T 39 | } 40 | 41 | interface requestFieldList { 42 | pattern: RegExp 43 | fields: fieldWithSource[] 44 | } 45 | 46 | type onBeforeSendHeaders = ( 47 | details: OnBeforeSendHeadersListenerDetails, 48 | callback: (params: BeforeSendResponse) => void, 49 | ) => void 50 | 51 | type parsedRequestBody = { [key: string]: unknown } 52 | 53 | const contentDispositionPattern = /Content-Disposition: form-data; name="(.+)"/ 54 | 55 | function parseMultipart(data: string, boundary: string): parsedRequestBody { 56 | const parts = data.split(`--${boundary}`) 57 | const output: parsedRequestBody = {} 58 | for (const part of parts) { 59 | if (!part) { 60 | continue 61 | } else if (part.trim() === "--") { 62 | break 63 | } 64 | const [headers, ...data] = part.split("\r\n\r\n") 65 | const name = headers.match(contentDispositionPattern)?.[1] 66 | if (name) { 67 | output[name] = data.join("\r\n").slice(0, -2) 68 | } 69 | } 70 | return output 71 | } 72 | 73 | function parseRequestBody(details: OnBeforeSendHeadersListenerDetails): parsedRequestBody | null { 74 | if ( 75 | details.resourceType !== "xhr" || 76 | details.method === "GET" || 77 | details.method === "HEAD" 78 | ) { 79 | return null 80 | } 81 | const contentType = details.requestHeaders["Content-Type"].split(";")[0] 82 | if ( 83 | contentType !== "application/json" && 84 | contentType !== "application/x-www-form-urlencoded" && 85 | contentType !== "multipart/form-data" 86 | ) { 87 | return null 88 | } 89 | const body = details.uploadData?.find((data) => data.bytes) 90 | if (!body) { 91 | return null 92 | } 93 | const bodyString = Buffer.from(body.bytes).toString("utf8") 94 | if (contentType === "application/json") { 95 | return JSON.parse(bodyString) 96 | } else if (contentType === "application/x-www-form-urlencoded") { 97 | return Object.fromEntries(new URLSearchParams(bodyString)) 98 | } else if (contentType === "multipart/form-data") { 99 | const boundary = details.requestHeaders["Content-Type"].split("; boundary=")[1] 100 | return parseMultipart(bodyString, boundary) 101 | } else { 102 | return null 103 | } 104 | } 105 | 106 | interface parsedLoginCookiesParams { 107 | requiredFields: string[] 108 | cookiesByDomain: Map[]> 109 | localStorageKeys: fieldWithSource[] 110 | requestKeysByPattern: Map 111 | } 112 | 113 | function parseLoginCookiesParams(args: LoginCookiesParams): parsedLoginCookiesParams { 114 | const requiredFields: string[] = [] 115 | const cookiesByDomain: Map[]> = new Map() 116 | const localStorageKeys: fieldWithSource[] = [] 117 | const requestKeysByPattern: Map = new Map() 118 | for (const field of args.fields) { 119 | if (field.required) { 120 | requiredFields.push(field.id) 121 | } 122 | let hasAnySupported = false 123 | for (const source of field.sources) { 124 | switch (source.type) { 125 | case "cookie": 126 | if (!cookiesByDomain.has(source.cookie_domain)) { 127 | cookiesByDomain.set(source.cookie_domain, []) 128 | } 129 | cookiesByDomain.get(source.cookie_domain)!.push({ field, source }) 130 | break 131 | case "local_storage": 132 | localStorageKeys.push({ field, source }) 133 | break 134 | case "request_header": 135 | case "request_body": 136 | if (!requestKeysByPattern.has(source.request_url_regex)) { 137 | requestKeysByPattern.set(source.request_url_regex, { 138 | pattern: new RegExp(source.request_url_regex), 139 | fields: [], 140 | }) 141 | } 142 | requestKeysByPattern.get(source.request_url_regex)!.fields.push({ field, source }) 143 | break 144 | case "special": 145 | continue 146 | default: 147 | continue 148 | } 149 | hasAnySupported = true 150 | } 151 | if (!hasAnySupported) { 152 | throw new Error(`No supported sources for field ${field.id}`) 153 | } 154 | } 155 | return { requiredFields, cookiesByDomain, localStorageKeys, requestKeysByPattern } 156 | } 157 | 158 | function makeCookieWatcher( 159 | cookiesByDomain: Map[]>, 160 | webview: BrowserWindow, 161 | output: LoginCookieOutput, 162 | checkIfAllFieldsPresent: () => void, 163 | ): (() => Promise) | null { 164 | if (!cookiesByDomain.size) { 165 | return null 166 | } 167 | return async () => { 168 | let foundAny = false 169 | for (const [domain, fields] of cookiesByDomain) { 170 | const cookies = await webview.webContents.session.cookies.get({ domain }) 171 | for (const { field, source } of fields) { 172 | const cookie = cookies.find(({ name }) => name === source.name) 173 | if (cookie) { 174 | foundAny = true 175 | output[field.id] = decodeURIComponent(cookie.value) 176 | } 177 | } 178 | } 179 | if (foundAny) { 180 | checkIfAllFieldsPresent() 181 | } 182 | } 183 | } 184 | 185 | function makeRequestWatcher( 186 | requestKeysByPattern: Map, 187 | output: LoginCookieOutput, 188 | checkIfAllFieldsPresent: () => void, 189 | ): onBeforeSendHeaders | null { 190 | if (!requestKeysByPattern.size) { 191 | return null 192 | } 193 | return (details, callback) => { 194 | let parsedRequestBody: parsedRequestBody | undefined | null 195 | const getRequestBody = () => { 196 | if (parsedRequestBody === undefined) { 197 | parsedRequestBody = parseRequestBody(details) 198 | } 199 | return parsedRequestBody 200 | } 201 | let foundAny = false 202 | for (const fieldList of requestKeysByPattern.values()) { 203 | if (!fieldList.pattern.test(details.url)) { 204 | continue 205 | } 206 | for (const { field, source } of fieldList.fields) { 207 | if (source.type == "request_header") { 208 | if (details.requestHeaders && source.name in details.requestHeaders) { 209 | output[field.id] = details.requestHeaders[source.name] 210 | foundAny = true 211 | } 212 | } else if (source.type == "request_body") { 213 | const value = getRequestBody()?.[source.name] 214 | if (value) { 215 | switch (typeof value) { 216 | case "string": 217 | output[field.id] = value 218 | break 219 | case "number": 220 | output[field.id] = value.toString() 221 | break 222 | case "boolean": 223 | output[field.id] = value ? "true" : "false" 224 | break 225 | case "undefined": 226 | default: 227 | continue 228 | } 229 | foundAny = true 230 | } 231 | } 232 | } 233 | } 234 | if (foundAny) { 235 | checkIfAllFieldsPresent() 236 | } 237 | callback({}) 238 | } 239 | } 240 | 241 | function makeLocalStorageWatcher( 242 | localStorageKeys: fieldWithSource[], 243 | webview: BrowserWindow, 244 | output: LoginCookieOutput, 245 | checkIfAllFieldsPresent: () => void, 246 | ): (() => void) | null { 247 | if (!localStorageKeys.length) { 248 | return null 249 | } 250 | // NOTE: This function is stringified and executed inside the webview 251 | const watcher = (watchKeys: { [outputKey: string]: string }) => { 252 | return new Promise(resolve => { 253 | const output: { 254 | [key: string]: string | null 255 | } = Object.fromEntries(Object.keys(watchKeys).map(key => [key, null])) 256 | const origSetItem = window.localStorage.setItem.bind(localStorage) 257 | let checkInterval: number | undefined = undefined 258 | 259 | function lookForKeys() { 260 | let foundAll = true 261 | for (const [outputKey, localStorageKey] of Object.entries(watchKeys)) { 262 | output[outputKey] = localStorage.getItem(localStorageKey) 263 | if (!output[outputKey]) { 264 | foundAll = false 265 | } 266 | } 267 | if (foundAll) { 268 | if (checkInterval !== undefined) { 269 | window.clearInterval(checkInterval) 270 | } 271 | window.localStorage.setItem = origSetItem 272 | resolve(output) 273 | } 274 | } 275 | 276 | checkInterval = window.setInterval(lookForKeys, 3000) 277 | window.localStorage.setItem = (key: string, ...rest) => { 278 | if (key in watchKeys) { 279 | lookForKeys() 280 | } 281 | origSetItem.apply(localStorage, [key, ...rest]) 282 | } 283 | }) 284 | } 285 | const watchKeys = JSON.stringify(Object.fromEntries(localStorageKeys.map(({ 286 | field, 287 | source, 288 | }) => [field.id, source.name]))) 289 | return () => { 290 | webview.webContents 291 | .executeJavaScript(`(${watcher.toString()})(${watchKeys})`) 292 | .then((result) => { 293 | removeExtraPromiseFields(result) 294 | console.log("Local storage extract script returned") 295 | Object.assign(output, result) 296 | checkIfAllFieldsPresent() 297 | }) 298 | } 299 | } 300 | 301 | let currentWebview: BrowserWindow | null = null 302 | 303 | function closeWebview() { 304 | console.log("Closing webview by request") 305 | currentWebview?.destroy() 306 | } 307 | 308 | const extraPromiseFields = new Set([ 309 | "_bitField", 310 | "_fulfillmentHandler0", 311 | "_rejectionHandler0", 312 | "_promise0", 313 | "_receiver0", 314 | "_settledValue", 315 | ]) 316 | 317 | function removeExtraPromiseFields(obj: Record) { 318 | for (const key of Object.keys(obj)) { 319 | if (extraPromiseFields.has(key)) { 320 | delete obj[key] 321 | } 322 | } 323 | } 324 | 325 | const persistentPartition = "persist:mautrix-webview-debug" 326 | 327 | function openWebview( 328 | args: LoginCookiesParams, 329 | parent: BrowserWindow, 330 | resolve: (output: { cookies: LoginCookieOutput }) => void, 331 | reject: (err: Error) => void, 332 | ) { 333 | const { 334 | requiredFields, 335 | cookiesByDomain, 336 | requestKeysByPattern, 337 | localStorageKeys, 338 | } = parseLoginCookiesParams(args) 339 | 340 | const partition = process.env.MAUTRIX_PERSISTENT_AUTH_WEBVIEW ? persistentPartition : Math.random().toString() 341 | if (partition === persistentPartition) { 342 | console.info("Using persistent partition for webview") 343 | } 344 | const webview = new BrowserWindow({ 345 | parent, 346 | modal: true, 347 | autoHideMenuBar: true, 348 | icon: "icon.png", 349 | webPreferences: { 350 | sandbox: true, 351 | partition, 352 | }, 353 | }) 354 | if (currentWebview) { 355 | console.warn("Closing previous webview") 356 | currentWebview.destroy() 357 | } 358 | currentWebview = webview 359 | const output: LoginCookieOutput = {} 360 | let resolved = false 361 | const checkIfAllFieldsPresent = () => { 362 | for (const field of requiredFields) { 363 | if (!(field in output)) { 364 | console.log("Still missing", field) 365 | return 366 | } 367 | } 368 | console.log("All fields found, resolving webview") 369 | resolved = true 370 | webview.close() 371 | resolve({ cookies: output }) 372 | } 373 | const closeOnError = (err: Error) => { 374 | if (!resolved) { 375 | resolved = true 376 | webview.destroy() 377 | reject(err) 378 | } 379 | } 380 | 381 | const requestWatcher = makeRequestWatcher( 382 | requestKeysByPattern, output, checkIfAllFieldsPresent, 383 | ) 384 | if (requestWatcher) { 385 | webview.webContents.session.webRequest.onBeforeSendHeaders(requestWatcher) 386 | } 387 | 388 | const cookieWatcher = makeCookieWatcher( 389 | cookiesByDomain, webview, output, checkIfAllFieldsPresent, 390 | ) 391 | if (cookieWatcher) { 392 | webview.webContents.on("did-finish-load", cookieWatcher) 393 | webview.webContents.on("did-navigate-in-page", cookieWatcher) 394 | } 395 | 396 | const registerLocalStorageWatcher = makeLocalStorageWatcher( 397 | localStorageKeys, webview, output, checkIfAllFieldsPresent, 398 | ) 399 | if (registerLocalStorageWatcher) { 400 | webview.webContents.on("did-finish-load", registerLocalStorageWatcher) 401 | // webview.webContents.on("did-navigate-in-page", registerLocalStorageWatcher) 402 | } 403 | 404 | if (args.extract_js) { 405 | const registerSpecialExtractJS = () => { 406 | webview.webContents.executeJavaScript(args.extract_js!).then((result) => { 407 | removeExtraPromiseFields(result) 408 | console.log("Special extract JS script returned") 409 | Object.assign(output, result) 410 | checkIfAllFieldsPresent() 411 | }, err => { 412 | console.error("Failed to execute extract_js", err) 413 | closeOnError(err) 414 | }) 415 | } 416 | webview.webContents.on("did-finish-load", registerSpecialExtractJS) 417 | } 418 | 419 | webview.on("closed", () => { 420 | if (currentWebview === webview) { 421 | currentWebview = null 422 | } 423 | console.log("Webview closed") 424 | if (!resolved) { 425 | reject(new Error("Webview closed before all fields were found")) 426 | } 427 | }) 428 | 429 | webview.webContents.setWindowOpenHandler((details) => { 430 | console.log("Overriding window open", details.url, "with a redirect") 431 | webview.webContents.executeJavaScript(`window.location = ${JSON.stringify(details.url)}`) 432 | return { action: "deny" } 433 | }) 434 | webview.webContents.on("will-navigate", (evt, url) => { 435 | if (url.startsWith("slack://")) { 436 | console.log("Preventing slack:// navigation") 437 | evt.preventDefault() 438 | } 439 | }) 440 | 441 | if (args.user_agent) { 442 | webview.webContents.setUserAgent(args.user_agent) 443 | } 444 | webview.loadURL(args.url, { userAgent: args.user_agent }) 445 | .then( 446 | () => { 447 | console.log("Webview for", args.url, "loaded") 448 | }, 449 | err => { 450 | console.error("Failed to load webview for", args.url, err) 451 | closeOnError(err) 452 | }, 453 | ) 454 | } 455 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published by 637 | the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | --------------------------------------------------------------------------------