Compare commits

..

No commits in common. "main" and "v0.3.0" have entirely different histories.
main ... v0.3.0

445 changed files with 15330 additions and 57532 deletions

17
.codeclimate.yml Normal file
View file

@ -0,0 +1,17 @@
version: "2"
checks:
method-count:
config:
threshold: 50
engines:
golint:
enabled: true
checks:
GoLint/Comments/DocComments:
enabled: false
gofmt:
enabled: true
govet:
enabled: true

View file

@ -7,7 +7,6 @@ end_of_line = lf
charset = utf-8 charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
max_line_length = 120
[.gitlab-ci.yml] [.gitlab-ci.yml]
indent_size = 2 indent_size = 2

1
.envrc
View file

@ -1 +0,0 @@
use flake

View file

@ -2,41 +2,52 @@ name: Go
on: [push, pull_request] on: [push, pull_request]
env:
GOTOOLCHAIN: local
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false
matrix: matrix:
go-version: ["1.23", "1.24"] go-version: [1.19]
name: Lint Go ${{ matrix.go-version == '1.24' && '(latest)' || '(old)' }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: Set up Go - name: Set up Go ${{ matrix.go-version }}
uses: actions/setup-go@v5 uses: actions/setup-go@v3
with: with:
go-version: ${{ matrix.go-version }} go-version: ${{ matrix.go-version }}
cache: true
- name: Install dependencies - name: Install goimports
run: | run: |
sudo apt-get update
sudo apt-get install libolm-dev libolm3 libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev
go install golang.org/x/tools/cmd/goimports@latest go install golang.org/x/tools/cmd/goimports@latest
go install honnef.co/go/tools/cmd/staticcheck@latest
export PATH="$HOME/go/bin:$PATH" export PATH="$HOME/go/bin:$PATH"
mkdir -p web/dist
touch web/dist/empty - name: Install pre-commit
run: pip install pre-commit
- name: Lint
run: pre-commit run -a
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go-version: [1.18, 1.19]
steps:
- uses: actions/checkout@v3
- name: Set up Go ${{ matrix.go-version }}
uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go-version }}
- name: Install libolm
run: sudo apt-get install libolm-dev libolm3
- name: Build - name: Build
run: go build -v ./... run: go build -v ./...
- name: Lint
uses: pre-commit/action@v3.0.1
- name: Test - name: Test
run: go test -v ./... run: go test -v ./...

View file

@ -1,20 +0,0 @@
name: JS
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./web
name: Lint JS
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci --include=dev
- name: Run ESLint
run: npm run lint

View file

@ -1,29 +0,0 @@
name: 'Lock old issues'
on:
schedule:
- cron: '0 13 * * *'
workflow_dispatch:
permissions:
issues: write
# pull-requests: write
# discussions: write
concurrency:
group: lock-threads
jobs:
lock-stale:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v5
id: lock
with:
issue-inactive-days: 90
process-only: issues
- name: Log processed threads
run: |
if [ '${{ steps.lock.outputs.issues }}' ]; then
echo "Issues:" && echo '${{ steps.lock.outputs.issues }}' | jq -r '.[] | "https://github.com/\(.owner)/\(.repo)/issues/\(.issue_number)"'
fi

6
.gitignore vendored
View file

@ -1,14 +1,10 @@
.idea/ .idea/
target/ target/
.tmp/ .tmp/
/gomuks gomuks
start
run
*.exe *.exe
*.deb *.deb
coverage.out coverage.out
coverage.html coverage.html
deb/usr deb/usr
*.prof *.prof
*.db*
*.log

View file

@ -1,8 +1,6 @@
stages: stages:
- frontend
- build - build
- build desktop - package
- docker
default: default:
before_script: before_script:
@ -13,87 +11,54 @@ cache:
paths: paths:
- .cache - .cache
variables:
GOTOOLCHAIN: local
frontend:
image: node:22-alpine
stage: frontend
cache:
paths:
- web/node_modules
script:
- cd web
- npm install --include=dev
- npm run build
artifacts:
paths:
- web/dist
expire_in: 1 hour
.build-linux: &build-linux .build-linux: &build-linux
stage: build stage: build
cache:
paths:
- .cache
before_script: before_script:
- mkdir -p .cache - export GO_LDFLAGS="-s -w -linkmode external -extldflags -static -X main.Tag=$CI_COMMIT_TAG -X main.Commit=$CI_COMMIT_SHA -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'"
- export GOPATH="$CI_PROJECT_DIR/.cache"
- export GOCACHE="$CI_PROJECT_DIR/.cache/build"
- export MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
- export GO_LDFLAGS="-s -w -linkmode external -extldflags -static -X go.mau.fi/gomuks/version.Tag=$CI_COMMIT_TAG -X go.mau.fi/gomuks/version.Commit=$CI_COMMIT_SHA -X 'go.mau.fi/gomuks/version.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
script: script:
- go build -ldflags "$GO_LDFLAGS" ./cmd/gomuks - go build -ldflags "$GO_LDFLAGS" -o gomuks
artifacts: artifacts:
paths: paths:
- gomuks - gomuks
dependencies:
- frontend
needs:
- frontend
.build-docker: &build-docker
image: docker:stable
stage: docker
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
script:
- docker pull $CI_REGISTRY_IMAGE:latest || true
- docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-$DOCKER_ARCH . --file Dockerfile.ci
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-$DOCKER_ARCH
after_script:
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-$DOCKER_ARCH
linux/amd64: linux/amd64:
<<: *build-linux <<: *build-linux
image: dock.mau.dev/tulir/gomuks-build-docker:linux-amd64 image: dock.mau.dev/tulir/gomuks-build-docker:linux-amd64
tags:
- linux
- amd64
linux/arm: linux/arm:
<<: *build-linux <<: *build-linux
image: dock.mau.dev/tulir/gomuks-build-docker:linux-arm image: dock.mau.dev/tulir/gomuks-build-docker:linux-arm
tags:
- linux
- amd64
linux/arm64: linux/arm64:
<<: *build-linux <<: *build-linux
image: dock.mau.dev/tulir/gomuks-build-docker:linux-arm64-native image: dock.mau.dev/tulir/gomuks-build-docker:linux-arm64
tags:
- linux
- arm64
windows/amd64: windows/amd64:
<<: *build-linux
image: dock.mau.dev/tulir/gomuks-build-docker:windows-amd64 image: dock.mau.dev/tulir/gomuks-build-docker:windows-amd64
stage: build
script:
- go build -o gomuks.exe
artifacts: artifacts:
paths: paths:
- gomuks.exe - gomuks.exe
macos/amd64:
stage: build
tags: tags:
- linux - macos
- amd64 - amd64
before_script:
- export GO_LDFLAGS="-X main.Tag=$CI_COMMIT_TAG -X main.Commit=$CI_COMMIT_SHA -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'"
script:
- mkdir gomuks-macos-amd64
- go build -ldflags "$GO_LDFLAGS" -o gomuks-macos-amd64/gomuks
- install_name_tool -change /usr/local/opt/libolm/lib/libolm.3.dylib @rpath/libolm.3.dylib gomuks-macos-amd64/gomuks
- install_name_tool -add_rpath @executable_path gomuks-macos-amd64/gomuks
- install_name_tool -add_rpath /usr/local/opt/libolm/lib gomuks-macos-amd64/gomuks
- cp /usr/local/opt/libolm/lib/libolm.3.dylib gomuks-macos-amd64/
artifacts:
paths:
- gomuks-macos-amd64
macos/arm64: macos/arm64:
stage: build stage: build
@ -101,156 +66,55 @@ macos/arm64:
- macos - macos
- arm64 - arm64
before_script: before_script:
- export LIBRARY_PATH=/opt/homebrew/lib
- export CPATH=/opt/homebrew/include
- export PATH=/opt/homebrew/bin:$PATH - export PATH=/opt/homebrew/bin:$PATH
- export MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }') - export GO_LDFLAGS="-X main.Tag=$CI_COMMIT_TAG -X main.Commit=$CI_COMMIT_SHA -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'"
- export GO_LDFLAGS="-X go.mau.fi/gomuks/version.Tag=$CI_COMMIT_TAG -X go.mau.fi/gomuks/version.Commit=$CI_COMMIT_SHA -X 'go.mau.fi/gomuks/version.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
- export LIBRARY_PATH=$(brew --prefix)/lib
- export CPATH=$(brew --prefix)/include
script: script:
- go build -ldflags "$GO_LDFLAGS" -o gomuks ./cmd/gomuks - mkdir gomuks-macos-arm64
- install_name_tool -change $(brew --prefix)/opt/libolm/lib/libolm.3.dylib @rpath/libolm.3.dylib gomuks - go build -ldflags "$GO_LDFLAGS" -o gomuks-macos-arm64/gomuks
- install_name_tool -add_rpath @executable_path gomuks - install_name_tool -change /opt/homebrew/opt/libolm/lib/libolm.3.dylib @rpath/libolm.3.dylib gomuks-macos-arm64/gomuks
- install_name_tool -add_rpath /opt/homebrew/opt/libolm/lib gomuks - install_name_tool -add_rpath @executable_path gomuks-macos-arm64/gomuks
- install_name_tool -add_rpath /usr/local/opt/libolm/lib gomuks - install_name_tool -add_rpath /opt/homebrew/opt/libolm/lib gomuks-macos-arm64/gomuks
- cp $(brew --prefix)/opt/libolm/lib/libolm.3.dylib . - install_name_tool -add_rpath /usr/local/opt/libolm/lib gomuks-macos-arm64/gomuks
- cp /opt/homebrew/opt/libolm/lib/libolm.3.dylib gomuks-macos-arm64/
artifacts: artifacts:
paths: paths:
- gomuks - gomuks-macos-arm64
- libolm.3.dylib
dependencies:
- frontend
needs:
- frontend
docker/amd64: macos/universal:
<<: *build-docker stage: package
tags:
- linux
- amd64
dependencies:
- linux/amd64
needs:
- linux/amd64
variables:
DOCKER_ARCH: amd64
docker/arm64:
<<: *build-docker
tags:
- linux
- arm64
dependencies:
- linux/arm64
needs:
- linux/arm64
variables:
DOCKER_ARCH: arm64
docker/manifest:
stage: docker
variables:
GIT_STRATEGY: none
before_script:
- "mkdir -p $HOME/.docker && echo '{\"experimental\": \"enabled\"}' > $HOME/.docker/config.json"
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
needs:
- docker/amd64
- docker/arm64
script:
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
- |
if [[ "$CI_COMMIT_BRANCH" == "main" ]]; then
export MANIFEST_NAME="$CI_REGISTRY_IMAGE:latest"
else
export MANIFEST_NAME="$CI_REGISTRY_IMAGE:${CI_COMMIT_REF_NAME//\//_}"
fi
docker manifest create $MANIFEST_NAME $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
docker manifest push $MANIFEST_NAME
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
.build-desktop: &build-desktop
stage: build desktop
cache:
paths:
- .cache
before_script:
- mkdir -p .cache
- export GOPATH="$CI_PROJECT_DIR/.cache"
- export GOCACHE="$CI_PROJECT_DIR/.cache/build"
script:
- cd desktop
- wails3 task $PLATFORM:package
- ls bin
artifacts:
paths:
- desktop/bin/*
dependencies:
- frontend
needs:
- frontend
desktop/linux/amd64:
<<: *build-desktop
image: dock.mau.dev/tulir/gomuks-build-docker/wails:linux-amd64
variables:
PLATFORM: linux
after_script:
- mv desktop/bin/gomuks-desktop .
- mv desktop/build/nfpm/bin/gomuks-desktop.deb .
artifacts:
paths:
- gomuks-desktop
- gomuks-desktop.deb
tags:
- linux
- amd64
desktop/linux/arm64:
<<: *build-desktop
image: dock.mau.dev/tulir/gomuks-build-docker/wails:linux-arm64-native
variables:
PLATFORM: linux
after_script:
- mv desktop/bin/gomuks-desktop .
- mv desktop/build/nfpm/bin/gomuks-desktop.deb .
artifacts:
paths:
- gomuks-desktop
- gomuks-desktop.deb
tags:
- linux
- arm64
desktop/windows/amd64:
<<: *build-desktop
image: dock.mau.dev/tulir/gomuks-build-docker/wails:windows-amd64
after_script:
- mv desktop/bin/gomuks-desktop.exe .
artifacts:
paths:
- gomuks-desktop.exe
variables:
PLATFORM: windows
desktop/macos/arm64:
<<: *build-desktop
cache: {}
before_script:
- export PATH=/opt/homebrew/bin:/usr/local/bin:$PATH
- export LIBRARY_PATH=$(brew --prefix)/lib
- export CPATH=$(brew --prefix)/include
after_script:
- hdiutil create -srcFolder ./desktop/bin/gomuks-desktop.app/ -o ./gomuks-desktop.dmg
- codesign -s - --timestamp -i fi.mau.gomuks.desktop.mac gomuks-desktop.dmg
- mv desktop/bin/gomuks-desktop .
artifacts:
paths:
- gomuks-desktop
# TODO generate proper dmgs
#- gomuks-desktop.dmg
variables:
PLATFORM: darwin
tags: tags:
- macos - macos
- arm64 dependencies:
- macos/amd64
- macos/arm64
needs:
- macos/amd64
- macos/arm64
variables:
GIT_STRATEGY: none
script:
- lipo -create -output libolm.3.dylib gomuks-macos-arm64/libolm.3.dylib gomuks-macos-amd64/libolm.3.dylib
- lipo -create -output gomuks gomuks-macos-arm64/gomuks gomuks-macos-amd64/gomuks
artifacts:
name: gomuks-macos-universal
paths:
- libolm.3.dylib
- gomuks
debian:
image: debian
stage: package
dependencies:
- linux/amd64
only:
- tags
script:
- mkdir -p deb/usr/bin
- cp gomuks deb/usr/bin/gomuks
- chmod -R -s deb/DEBIAN && chmod -R 0755 deb/DEBIAN
- dpkg-deb --build deb gomuks.deb
artifacts:
paths:
- gomuks.deb

View file

@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v4.1.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
exclude_types: [markdown] exclude_types: [markdown]
@ -9,31 +9,6 @@ repos:
- id: check-added-large-files - id: check-added-large-files
- repo: https://github.com/tekwizely/pre-commit-golang - repo: https://github.com/tekwizely/pre-commit-golang
rev: v1.0.0-rc.1 rev: v1.0.0-beta.5
hooks: hooks:
- id: go-imports-repo - id: go-imports-repo
args:
- "-local"
- "go.mau.fi/gomuks"
- "-w"
- id: go-mod-tidy
- id: go-vet-repo-mod
- id: go-staticcheck-repo-mod
- repo: https://github.com/beeper/pre-commit-go
rev: v0.4.2
hooks:
- id: prevent-literal-http-methods
- repo: local
hooks:
- id: eslint
name: eslint
entry: ./.pre-commit-eslint.sh
language: script
types_or: [ts, tsx]
- id: typescript
name: typescript
entry: ./.pre-commit-tsc.sh
language: script
types_or: [ts, tsx]

View file

@ -1,6 +0,0 @@
#!/usr/bin/env bash
cd web > /dev/null
if [[ -f "./node_modules/.bin/eslint" ]]; then
ARGS=("$@")
./node_modules/.bin/eslint --fix ${ARGS[@]/#web\// }
fi

View file

@ -1,5 +0,0 @@
#!/usr/bin/env bash
cd web > /dev/null
if [[ -f "./node_modules/.bin/tsc" ]]; then
./node_modules/.bin/tsc --build --noEmit
fi

View file

@ -1,11 +1,3 @@
# v0.3.1 (2024-07-16)
* Bumped minimum Go version to 1.21.
* Added support for authenticated media.
* Added `/powerlevel` command for managing power levels.
* Disabled logging by default.
* Changed default log directory to `~/.local/state/gomuks` on Linux.
# v0.3.0 (2022-11-19) # v0.3.0 (2022-11-19)
* Bumped minimum Go version to 1.18. * Bumped minimum Go version to 1.18.

View file

@ -1,11 +0,0 @@
FROM alpine:3.21
RUN apk add --no-cache ca-certificates jq curl ffmpeg
ARG EXECUTABLE=./gomuks
COPY $EXECUTABLE /usr/bin/gomuks
VOLUME /data
WORKDIR /data
ENV GOMUKS_ROOT=/data
CMD ["/usr/bin/gomuks"]

View file

@ -2,20 +2,16 @@
![Languages](https://img.shields.io/github/languages/top/tulir/gomuks.svg) ![Languages](https://img.shields.io/github/languages/top/tulir/gomuks.svg)
[![License](https://img.shields.io/github/license/tulir/gomuks.svg)](LICENSE) [![License](https://img.shields.io/github/license/tulir/gomuks.svg)](LICENSE)
[![Release](https://img.shields.io/github/release/tulir/gomuks/all.svg)](https://github.com/tulir/gomuks/releases) [![Release](https://img.shields.io/github/release/tulir/gomuks/all.svg)](https://github.com/tulir/gomuks/releases)
[![GitLab CI](https://mau.dev/tulir/gomuks/badges/main/pipeline.svg)](https://mau.dev/tulir/gomuks/pipelines) [![GitLab CI](https://mau.dev/tulir/gomuks/badges/master/pipeline.svg)](https://mau.dev/tulir/gomuks/pipelines)
[![Maintainability](https://img.shields.io/codeclimate/maintainability/tulir/gomuks.svg)](https://codeclimate.com/github/tulir/gomuks)
[![Packaging status](https://repology.org/badge/tiny-repos/gomuks.svg)](https://repology.org/project/gomuks/versions)
A Matrix client written in Go using [mautrix](https://github.com/mautrix/go). ![Chat Preview](chat-preview.png)
This branch contains gomuks web. For legacy gomuks terminal, see the A terminal Matrix client written in Go using [mautrix](https://github.com/tulir/mautrix-go) and [mauview](https://github.com/tulir/mauview).
[master branch](https://github.com/tulir/gomuks/tree/master). The new
version will get a terminal frontend in the future. See also:
<https://github.com/tulir/gomuks/issues/476>.
## Sponsors ## Docs
* [conduwuit](https://github.com/girlbossceo/conduwuit)
## Documentation
For installation and usage instructions, see [docs.mau.fi](https://docs.mau.fi/gomuks/). For installation and usage instructions, see [docs.mau.fi](https://docs.mau.fi/gomuks/).
## Discussion ## Discussion
Matrix room: [#gomuks:gomuks.app](https://matrix.to/#/#gomuks:gomuks.app) Matrix room: [#gomuks:maunium.net](https://matrix.to/#/#gomuks:maunium.net)

View file

@ -1,4 +1,2 @@
#!/usr/bin/env bash #!/bin/sh
go generate ./web go build -ldflags "-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'" "$@"
export MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | head -n1 | awk '{ print $2 }')
go build -ldflags "-X go.mau.fi/gomuks/version.Tag=$(git describe --exact-match --tags 2>/dev/null) -X go.mau.fi/gomuks/version.Commit=$(git rev-parse HEAD) -X 'go.mau.fi/gomuks/version.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" ./cmd/gomuks "$@" || exit 2

BIN
chat-preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

View file

@ -1,63 +0,0 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"fmt"
"os"
"go.mau.fi/util/exhttp"
flag "maunium.net/go/mauflag"
"go.mau.fi/gomuks/pkg/gomuks"
"go.mau.fi/gomuks/pkg/hicli"
"go.mau.fi/gomuks/version"
"go.mau.fi/gomuks/web"
)
var wantHelp, _ = flag.MakeHelpFlag()
var wantVersion = flag.MakeFull("v", "version", "View gomuks version and quit.", "false").Bool()
func main() {
hicli.InitialDeviceDisplayName = "gomuks web"
exhttp.AutoAllowCORS = false
flag.SetHelpTitles(
"gomuks - A Matrix client written in Go.",
"gomuks [-hv]",
)
err := flag.Parse()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
flag.PrintHelp()
os.Exit(1)
} else if *wantHelp {
flag.PrintHelp()
os.Exit(0)
} else if *wantVersion {
fmt.Println(version.Description)
os.Exit(0)
}
gmx := gomuks.NewGomuks()
gmx.Version = version.Version
gmx.Commit = version.Commit
gmx.LinkifiedVersion = version.LinkifiedVersion
gmx.BuildTime = version.ParsedBuildTime
gmx.FrontendFS = web.Frontend
gmx.Run()
}

402
config/config.go Normal file
View file

@ -0,0 +1,402 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package config
import (
_ "embed"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strconv"
"strings"
"gopkg.in/yaml.v3"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/pushrules"
"go.mau.fi/cbind"
"go.mau.fi/tcell"
"maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/matrix/rooms"
)
type AuthCache struct {
NextBatch string `yaml:"next_batch"`
FilterID string `yaml:"filter_id"`
FilterVersion int `yaml:"filter_version"`
InitialSyncDone bool `yaml:"initial_sync_done"`
}
type UserPreferences struct {
HideUserList bool `yaml:"hide_user_list"`
HideRoomList bool `yaml:"hide_room_list"`
HideTimestamp bool `yaml:"hide_timestamp"`
BareMessageView bool `yaml:"bare_message_view"`
DisableImages bool `yaml:"disable_images"`
DisableTypingNotifs bool `yaml:"disable_typing_notifs"`
DisableEmojis bool `yaml:"disable_emojis"`
DisableMarkdown bool `yaml:"disable_markdown"`
DisableHTML bool `yaml:"disable_html"`
DisableDownloads bool `yaml:"disable_downloads"`
DisableNotifications bool `yaml:"disable_notifications"`
DisableShowURLs bool `yaml:"disable_show_urls"`
AltEnterToSend bool `yaml:"alt_enter_to_send"`
InlineURLMode string `yaml:"inline_url_mode"`
}
var InlineURLsProbablySupported bool
func init() {
vteVersion, _ := strconv.Atoi(os.Getenv("VTE_VERSION"))
term := os.Getenv("TERM")
// Enable inline URLs by default on VTE 0.50.0+
InlineURLsProbablySupported = vteVersion > 5000 ||
os.Getenv("TERM_PROGRAM") == "iTerm.app" ||
term == "foot" ||
term == "xterm-kitty"
}
func (up *UserPreferences) EnableInlineURLs() bool {
return up.InlineURLMode == "enable" || (InlineURLsProbablySupported && up.InlineURLMode != "disable")
}
type Keybind struct {
Mod tcell.ModMask
Key tcell.Key
Ch rune
}
type ParsedKeybindings struct {
Main map[Keybind]string
Room map[Keybind]string
Modal map[Keybind]string
Visual map[Keybind]string
}
type RawKeybindings struct {
Main map[string]string `yaml:"main,omitempty"`
Room map[string]string `yaml:"room,omitempty"`
Modal map[string]string `yaml:"modal,omitempty"`
Visual map[string]string `yaml:"visual,omitempty"`
}
// Config contains the main config of gomuks.
type Config struct {
UserID id.UserID `yaml:"mxid"`
DeviceID id.DeviceID `yaml:"device_id"`
AccessToken string `yaml:"access_token"`
HS string `yaml:"homeserver"`
RoomCacheSize int `yaml:"room_cache_size"`
RoomCacheAge int64 `yaml:"room_cache_age"`
NotifySound bool `yaml:"notify_sound"`
SendToVerifiedOnly bool `yaml:"send_to_verified_only"`
Backspace1RemovesWord bool `yaml:"backspace1_removes_word"`
Backspace2RemovesWord bool `yaml:"backspace2_removes_word"`
AlwaysClearScreen bool `yaml:"always_clear_screen"`
Dir string `yaml:"-"`
DataDir string `yaml:"data_dir"`
CacheDir string `yaml:"cache_dir"`
HistoryPath string `yaml:"history_path"`
RoomListPath string `yaml:"room_list_path"`
MediaDir string `yaml:"media_dir"`
DownloadDir string `yaml:"download_dir"`
StateDir string `yaml:"state_dir"`
Preferences UserPreferences `yaml:"-"`
AuthCache AuthCache `yaml:"-"`
Rooms *rooms.RoomCache `yaml:"-"`
PushRules *pushrules.PushRuleset `yaml:"-"`
Keybindings ParsedKeybindings `yaml:"-"`
nosave bool
}
// NewConfig creates a config that loads data from the given directory.
func NewConfig(configDir, dataDir, cacheDir, downloadDir string) *Config {
return &Config{
Dir: configDir,
DataDir: dataDir,
CacheDir: cacheDir,
DownloadDir: downloadDir,
HistoryPath: filepath.Join(cacheDir, "history.db"),
RoomListPath: filepath.Join(cacheDir, "rooms.gob.gz"),
StateDir: filepath.Join(cacheDir, "state"),
MediaDir: filepath.Join(cacheDir, "media"),
RoomCacheSize: 32,
RoomCacheAge: 1 * 60,
NotifySound: true,
SendToVerifiedOnly: false,
Backspace1RemovesWord: true,
AlwaysClearScreen: true,
}
}
// Clear clears the session cache and removes all history.
func (config *Config) Clear() {
_ = os.Remove(config.HistoryPath)
_ = os.Remove(config.RoomListPath)
_ = os.RemoveAll(config.StateDir)
_ = os.RemoveAll(config.MediaDir)
_ = os.RemoveAll(config.CacheDir)
config.nosave = true
}
// ClearData clears non-temporary session data.
func (config *Config) ClearData() {
_ = os.RemoveAll(config.DataDir)
}
func (config *Config) CreateCacheDirs() {
_ = os.MkdirAll(config.CacheDir, 0700)
_ = os.MkdirAll(config.DataDir, 0700)
_ = os.MkdirAll(config.StateDir, 0700)
_ = os.MkdirAll(config.MediaDir, 0700)
}
func (config *Config) DeleteSession() {
config.AuthCache.NextBatch = ""
config.AuthCache.InitialSyncDone = false
config.AccessToken = ""
config.DeviceID = ""
config.Rooms = rooms.NewRoomCache(config.RoomListPath, config.StateDir, config.RoomCacheSize, config.RoomCacheAge, config.GetUserID)
config.PushRules = nil
config.ClearData()
config.Clear()
config.nosave = false
config.CreateCacheDirs()
}
func (config *Config) LoadAll() {
config.Load()
config.Rooms = rooms.NewRoomCache(config.RoomListPath, config.StateDir, config.RoomCacheSize, config.RoomCacheAge, config.GetUserID)
config.LoadAuthCache()
config.LoadPushRules()
config.LoadPreferences()
config.LoadKeybindings()
err := config.Rooms.LoadList()
if err != nil {
panic(err)
}
}
// Load loads the config from config.yaml in the directory given to the config struct.
func (config *Config) Load() {
err := config.load("config", config.Dir, "config.yaml", config)
if err != nil {
panic(fmt.Errorf("failed to load config.yaml: %w", err))
}
config.CreateCacheDirs()
}
func (config *Config) SaveAll() {
config.Save()
config.SaveAuthCache()
config.SavePushRules()
config.SavePreferences()
err := config.Rooms.SaveList()
if err != nil {
panic(err)
}
config.Rooms.SaveLoadedRooms()
}
// Save saves this config to config.yaml in the directory given to the config struct.
func (config *Config) Save() {
config.save("config", config.Dir, "config.yaml", config)
}
func (config *Config) LoadPreferences() {
_ = config.load("user preferences", config.CacheDir, "preferences.yaml", &config.Preferences)
}
func (config *Config) SavePreferences() {
config.save("user preferences", config.CacheDir, "preferences.yaml", &config.Preferences)
}
//go:embed keybindings.yaml
var DefaultKeybindings string
func parseKeybindings(input map[string]string) (output map[Keybind]string) {
output = make(map[Keybind]string, len(input))
for shortcut, action := range input {
mod, key, ch, err := cbind.Decode(shortcut)
if err != nil {
panic(fmt.Errorf("failed to parse keybinding %s -> %s: %w", shortcut, action, err))
}
// TODO find out if other keys are parsed incorrectly like this
if key == tcell.KeyEscape {
ch = 0
}
parsedShortcut := Keybind{
Mod: mod,
Key: key,
Ch: ch,
}
output[parsedShortcut] = action
}
return
}
func (config *Config) LoadKeybindings() {
var inputConfig RawKeybindings
err := yaml.Unmarshal([]byte(DefaultKeybindings), &inputConfig)
if err != nil {
panic(fmt.Errorf("failed to unmarshal default keybindings: %w", err))
}
_ = config.load("keybindings", config.Dir, "keybindings.yaml", &inputConfig)
config.Keybindings.Main = parseKeybindings(inputConfig.Main)
config.Keybindings.Room = parseKeybindings(inputConfig.Room)
config.Keybindings.Modal = parseKeybindings(inputConfig.Modal)
config.Keybindings.Visual = parseKeybindings(inputConfig.Visual)
}
func (config *Config) SaveKeybindings() {
config.save("keybindings", config.Dir, "keybindings.yaml", &config.Keybindings)
}
func (config *Config) LoadAuthCache() {
err := config.load("auth cache", config.CacheDir, "auth-cache.yaml", &config.AuthCache)
if err != nil {
panic(fmt.Errorf("failed to load auth-cache.yaml: %w", err))
}
}
func (config *Config) SaveAuthCache() {
config.save("auth cache", config.CacheDir, "auth-cache.yaml", &config.AuthCache)
}
func (config *Config) LoadPushRules() {
_ = config.load("push rules", config.CacheDir, "pushrules.json", &config.PushRules)
}
func (config *Config) SavePushRules() {
if config.PushRules == nil {
return
}
config.save("push rules", config.CacheDir, "pushrules.json", &config.PushRules)
}
func (config *Config) load(name, dir, file string, target interface{}) error {
err := os.MkdirAll(dir, 0700)
if err != nil {
debug.Print("Failed to create", dir)
return err
}
path := filepath.Join(dir, file)
data, err := ioutil.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
debug.Print("Failed to read", name, "from", path)
return err
}
if strings.HasSuffix(file, ".yaml") {
err = yaml.Unmarshal(data, target)
} else {
err = json.Unmarshal(data, target)
}
if err != nil {
debug.Print("Failed to parse", name, "at", path)
return err
}
return nil
}
func (config *Config) save(name, dir, file string, source interface{}) {
if config.nosave {
return
}
err := os.MkdirAll(dir, 0700)
if err != nil {
debug.Print("Failed to create", dir)
panic(err)
}
var data []byte
if strings.HasSuffix(file, ".yaml") {
data, err = yaml.Marshal(source)
} else {
data, err = json.Marshal(source)
}
if err != nil {
debug.Print("Failed to marshal", name)
panic(err)
}
path := filepath.Join(dir, file)
err = ioutil.WriteFile(path, data, 0600)
if err != nil {
debug.Print("Failed to write", name, "to", path)
panic(err)
}
}
func (config *Config) GetUserID() id.UserID {
return config.UserID
}
const FilterVersion = 1
func (config *Config) SaveFilterID(_ id.UserID, filterID string) {
config.AuthCache.FilterID = filterID
config.AuthCache.FilterVersion = FilterVersion
config.SaveAuthCache()
}
func (config *Config) LoadFilterID(_ id.UserID) string {
if config.AuthCache.FilterVersion != FilterVersion {
return ""
}
return config.AuthCache.FilterID
}
func (config *Config) SaveNextBatch(_ id.UserID, nextBatch string) {
config.AuthCache.NextBatch = nextBatch
config.SaveAuthCache()
}
func (config *Config) LoadNextBatch(_ id.UserID) string {
return config.AuthCache.NextBatch
}
func (config *Config) SaveRoom(_ *mautrix.Room) {
panic("SaveRoom is not supported")
}
func (config *Config) LoadRoom(_ id.RoomID) *mautrix.Room {
panic("LoadRoom is not supported")
}

2
config/doc.go Normal file
View file

@ -0,0 +1,2 @@
// Package config contains the wrappers for gomuks configurations and sessions.
package config

42
config/keybindings.yaml Normal file
View file

@ -0,0 +1,42 @@
main:
'Ctrl+Down': next_room
'Ctrl+Up': prev_room
'Ctrl+k': search_rooms
'Ctrl+Home': scroll_up
'Ctrl+End': scroll_down
'Ctrl+Enter': add_newline
'Ctrl+l': show_bare
'Alt+Down': next_room
'Alt+Up': prev_room
'Alt+k': search_rooms
'Alt+Home': scroll_up
'Alt+End': scroll_down
'Alt+Enter': add_newline
'Alt+a': next_active_room
'Alt+l': show_bare
modal:
'Tab': select_next
'Down': select_next
'Backtab': select_prev
'Up': select_prev
'Enter': confirm
'Escape': cancel
visual:
'Escape': clear
'h': clear
'Up': select_prev
'k': select_prev
'Down': select_next
'j': select_next
'Enter': confirm
'l': confirm
room:
'Escape': clear
'Ctrl+p': scroll_up
'Ctrl+n': scroll_down
'PageUp': scroll_up
'PageDown': scroll_down
'Enter': send

7
deb/DEBIAN/control Normal file
View file

@ -0,0 +1,7 @@
Package: gomuks
Version: 0.3.0-1
Section: net
Priority: optional
Architecture: amd64
Maintainer: Tulir Asokan <tulir@maunium.net>
Description: A terminal based Matrix client written in Go.

156
debug/debug.go Normal file
View file

@ -0,0 +1,156 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package debug
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"runtime/debug"
"time"
"github.com/sasha-s/go-deadlock"
)
var writer io.Writer
var RecoverPrettyPanic bool
var DeadlockDetection bool
var WriteLogs bool
var OnRecover func()
var LogDirectory = filepath.Join(os.TempDir(), "gomuks")
func Initialize() {
err := os.MkdirAll(LogDirectory, 0750)
if err != nil {
RecoverPrettyPanic = false
DeadlockDetection = false
WriteLogs = false
return
}
if WriteLogs {
writer, err = os.OpenFile(filepath.Join(LogDirectory, "debug.log"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0640)
if err != nil {
panic(err)
}
_, _ = fmt.Fprintf(writer, "======================= Debug init @ %s =======================\n", time.Now().Format("2006-01-02 15:04:05"))
}
if DeadlockDetection {
deadlocks, err := os.OpenFile(filepath.Join(LogDirectory, "deadlock.log"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0640)
if err != nil {
panic(err)
}
deadlock.Opts.LogBuf = deadlocks
deadlock.Opts.OnPotentialDeadlock = func() {
if OnRecover != nil {
OnRecover()
}
_, _ = fmt.Fprintf(os.Stderr, "Potential deadlock detected. See %s/deadlock.log for more information.", LogDirectory)
os.Exit(88)
}
_, err = fmt.Fprintf(deadlocks, "======================= Debug init @ %s =======================\n", time.Now().Format("2006-01-02 15:04:05"))
if err != nil {
panic(err)
}
} else {
deadlock.Opts.Disable = true
}
}
func Printf(text string, args ...interface{}) {
if writer != nil {
_, _ = fmt.Fprintf(writer, time.Now().Format("[2006-01-02 15:04:05] "))
_, _ = fmt.Fprintf(writer, text+"\n", args...)
}
}
func Print(text ...interface{}) {
if writer != nil {
_, _ = fmt.Fprintf(writer, time.Now().Format("[2006-01-02 15:04:05] "))
_, _ = fmt.Fprintln(writer, text...)
}
}
func PrintStack() {
if writer != nil {
_, _ = writer.Write(debug.Stack())
}
}
// Recover recovers a panic, runs the OnRecover handler and either re-panics or
// shows an user-friendly message about the panic depending on whether or not
// the pretty panic mode is enabled.
func Recover() {
if p := recover(); p != nil {
if OnRecover != nil {
OnRecover()
}
if RecoverPrettyPanic {
PrettyPanic(p)
} else {
panic(p)
}
}
}
const Oops = ` __________
< Oh noes! >
\
\ ^__^
\ (XX)\_______
(__)\ )\/\
U ||----W |
|| ||
A fatal error has occurred.
`
func PrettyPanic(panic interface{}) {
fmt.Print(Oops)
traceFile := fmt.Sprintf(filepath.Join(LogDirectory, "panic-%s.txt"), time.Now().Format("2006-01-02--15-04-05"))
var buf bytes.Buffer
_, _ = fmt.Fprintln(&buf, panic)
buf.Write(debug.Stack())
err := ioutil.WriteFile(traceFile, buf.Bytes(), 0640)
if err != nil {
fmt.Println("Saving the stack trace to", traceFile, "failed:")
fmt.Println("--------------------------------------------------------------------------------")
fmt.Println(err)
fmt.Println("--------------------------------------------------------------------------------")
fmt.Println("")
fmt.Println("You can file an issue at https://github.com/tulir/gomuks/issues.")
fmt.Println("Please provide the file save error (above) and the stack trace of the original error (below) when filing an issue.")
fmt.Println("")
fmt.Println("--------------------------------------------------------------------------------")
fmt.Println(panic)
debug.PrintStack()
fmt.Println("--------------------------------------------------------------------------------")
} else {
fmt.Println("The stack trace has been saved to", traceFile)
fmt.Println("")
fmt.Println("You can file an issue at https://github.com/tulir/gomuks/issues.")
fmt.Println("Please provide the contents of that file when filing an issue.")
}
os.Exit(1)
}

2
debug/doc.go Normal file
View file

@ -0,0 +1,2 @@
// Package debug contains utilities to log debug messages and display panics nicely.
package debug

3
desktop/.gitignore vendored
View file

@ -1,3 +0,0 @@
.task
bin
build/appimage

View file

@ -1,54 +0,0 @@
version: '3'
includes:
common: ./build/Taskfile.common.yml
windows: ./build/Taskfile.windows.yml
darwin: ./build/Taskfile.darwin.yml
linux: ./build/Taskfile.linux.yml
vars:
APP_NAME: "gomuks-desktop"
BIN_DIR: "bin"
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
tasks:
build:
summary: Builds the application
cmds:
- task: "{{OS}}:build"
package:
summary: Packages a production build of the application
cmds:
- task: "{{OS}}:package"
run:
summary: Runs the application
cmds:
- task: "{{OS}}:run"
dev:
summary: Runs the application in development mode
cmds:
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
darwin:build:universal:
summary: Builds darwin universal binary (arm64 + amd64)
cmds:
- task: darwin:build
vars:
ARCH: amd64
- mv {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}-amd64
- task: darwin:build
vars:
ARCH: arm64
- mv {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}-arm64
- lipo -create -output {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}-amd64 {{.BIN_DIR}}/{{.APP_NAME}}-arm64
- rm {{.BIN_DIR}}/{{.APP_NAME}}-amd64 {{.BIN_DIR}}/{{.APP_NAME}}-arm64
darwin:package:universal:
summary: Packages darwin universal binary (arm64 + amd64)
deps:
- darwin:build:universal
cmds:
- task: darwin:create:app:bundle

View file

@ -1,32 +0,0 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>gomuks desktop</string>
<key>CFBundleExecutable</key>
<string>gomuks-desktop</string>
<key>CFBundleIdentifier</key>
<string>fi.mau.gomuks.desktop</string>
<key>CFBundleVersion</key>
<string>0.4.0</string>
<key>CFBundleGetInfoString</key>
<string></string>
<key>CFBundleShortVersionString</key>
<string>0.4.0</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2024, gomuks authors</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>

View file

@ -1,27 +0,0 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>gomuks desktop</string>
<key>CFBundleExecutable</key>
<string>gomuks-desktop</string>
<key>CFBundleIdentifier</key>
<string>fi.mau.gomuks.desktop</string>
<key>CFBundleVersion</key>
<string>0.4.0</string>
<key>CFBundleGetInfoString</key>
<string></string>
<key>CFBundleShortVersionString</key>
<string>0.4.0</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2024, gomuks authors</string>
</dict>
</plist>

View file

@ -1,75 +0,0 @@
version: '3'
tasks:
go:mod:tidy:
summary: Runs `go mod tidy`
internal: true
generates:
- go.sum
sources:
- go.mod
cmds:
- go mod tidy
install:frontend:deps:
summary: Install frontend dependencies
dir: ../web
sources:
- package.json
- package-lock.json
generates:
- node_modules/*
preconditions:
- sh: npm version
msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
cmds:
- npm install
build:frontend:
summary: Build the frontend project
dir: ../web
sources:
- "**/*"
generates:
- dist/*
deps:
- task: install:frontend:deps
#- task: generate:bindings
cmds:
- npm run build -q
generate:bindings:
summary: Generates bindings for the frontend
sources:
- "**/*.go"
- go.mod
- go.sum
generates: []
#- "frontend/bindings/**/*"
cmds: []
#- wails3 generate bindings -f '{{.BUILD_FLAGS}}'{{if .UseTypescript}} -ts{{end}}
generate:icons:
summary: Generates Windows `.ico` and Mac `.icns` files from an image
dir: build
sources:
- "appicon.png"
generates:
- "icons.icns"
- "icons.ico"
cmds:
- wails3 generate icons -input appicon.png
dev:frontend:
summary: Runs the frontend in development mode
dir: ../web
deps:
- task: install:frontend:deps
cmds:
- npm run dev -- --port {{.VITE_PORT}} --strictPort
update:build-assets:
summary: Updates the build assets
dir: build
cmds:
- wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .

View file

@ -1,47 +0,0 @@
version: '3'
includes:
common: Taskfile.common.yml
tasks:
build:
summary: Creates a production build of the application
deps: []
#- task: common:go:mod:tidy
#- task: common:build:frontend
#- task: common:generate:icons
cmds:
- MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
- GO_LDFLAGS="-s -w -X go.mau.fi/gomuks/version.Tag=$CI_COMMIT_TAG -X go.mau.fi/gomuks/version.Commit=$CI_COMMIT_SHA -X 'go.mau.fi/gomuks/version.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
- go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath{{else}}-gcflags=all="-l"{{end}}'
env:
GOOS: darwin
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
CGO_CFLAGS: "-mmacosx-version-min=11.0"
CGO_LDFLAGS: "-mmacosx-version-min=11.0"
MACOSX_DEPLOYMENT_TARGET: "11.0"
PRODUCTION: '{{.PRODUCTION | default "false"}}'
package:
summary: Packages a production build of the application into a `.app` bundle
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: create:app:bundle
create:app:bundle:
summary: Creates an `.app` bundle
cmds:
- mkdir -p {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/{MacOS,Resources}
- cp build/icons.icns {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources
- cp {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS
- cp build/Info.plist {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}'

View file

@ -1,117 +0,0 @@
version: '3'
includes:
common: Taskfile.common.yml
tasks:
build:
summary: Builds the application for Linux
deps: []
#- task: common:go:mod:tidy
#- task: common:build:frontend
#- task: common:generate:icons
cmds:
- MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
- GO_LDFLAGS="-s -w -X go.mau.fi/gomuks/version.Tag=$CI_COMMIT_TAG -X go.mau.fi/gomuks/version.Commit=$CI_COMMIT_SHA -X 'go.mau.fi/gomuks/version.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
- go build {{.BUILD_FLAGS}} -ldflags "$GO_LDFLAGS" -o {{.BIN_DIR}}/{{.APP_NAME}}
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath{{else}}-gcflags=all="-l"{{end}}'
env:
GOOS: linux
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
PRODUCTION: '{{.PRODUCTION | default "false"}}'
package:
summary: Packages a production build of the application for Linux
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
#- task: create:appimage
- task: create:deb
#- task: create:rpm
#- task: create:aur
create:appimage:
summary: Creates an AppImage
dir: build/appimage
deps:
- task: build
vars:
PRODUCTION: "true"
- task: generate:dotdesktop
cmds:
- cp {{.APP_BINARY}} {{.APP_NAME}}
- cp ../appicon.png appicon.png
- wails3 generate appimage -binary {{.APP_NAME}} -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/appimage
vars:
APP_NAME: '{{.APP_NAME}}'
APP_BINARY: '../../bin/{{.APP_NAME}}'
ICON: '../appicon.png'
DESKTOP_FILE: '{{.APP_NAME}}.desktop'
OUTPUT_DIR: '../../bin'
create:deb:
summary: Creates a deb package
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: generate:dotdesktop
- task: generate:deb
create:rpm:
summary: Creates a rpm package
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: generate:dotdesktop
- task: generate:rpm
create:aur:
summary: Creates a arch linux packager package
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: generate:dotdesktop
- task: generate:aur
generate:deb:
summary: Creates a deb package
cmds:
- wails3 tool package -name {{.APP_NAME}} -format deb -config ./build/nfpm/nfpm.yaml
generate:rpm:
summary: Creates a rpm package
cmds:
- wails3 tool package -name {{.APP_NAME}} -format rpm -config ./build/nfpm/nfpm.yaml
generate:aur:
summary: Creates a arch linux packager package
cmds:
- wails3 tool package -name {{.APP_NAME}} -format arch -config ./build/nfpm/nfpm.yaml
generate:dotdesktop:
summary: Generates a `.desktop` file
dir: build
cmds:
- mkdir -p {{.ROOT_DIR}}/build/nfpm/bin
- wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile {{.ROOT_DIR}}/build/{{.APP_NAME}}.desktop -categories "{{.CATEGORIES}}"
- cp {{.ROOT_DIR}}/build/{{.APP_NAME}}.desktop {{.ROOT_DIR}}/build/nfpm/bin/{{.APP_NAME}}.desktop
vars:
APP_NAME: '{{.APP_NAME}}'
EXEC: '{{.APP_NAME}}'
ICON: 'appicon'
CATEGORIES: 'Network;InstantMessaging;Chat;'
OUTPUTFILE: '{{.ROOT_DIR}}/build/{{.APP_NAME}}.desktop'
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}'

View file

@ -1,62 +0,0 @@
version: '3'
includes:
common: Taskfile.common.yml
tasks:
build:
summary: Builds the application for Windows
deps:
#- task: common:go:mod:tidy
#- task: common:build:frontend
#- task: common:generate:icons
- task: generate:syso
cmds:
- MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
- GO_LDFLAGS="-s -w -H windowsgui -X go.mau.fi/gomuks/version.Tag=$CI_COMMIT_TAG -X go.mau.fi/gomuks/version.Commit=$CI_COMMIT_SHA -X 'go.mau.fi/gomuks/version.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
- go build {{.BUILD_FLAGS}} -ldflags "$GO_LDFLAGS" -o {{.BIN_DIR}}/{{.APP_NAME}}.exe
- cmd: powershell Remove-item *.syso
platforms: [windows]
- cmd: rm -f *.syso
platforms: [linux, darwin]
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath{{else}}-gcflags=all="-l"{{end}}'
env:
GOOS: windows
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
PRODUCTION: '{{.PRODUCTION | default "false"}}'
package:
summary: Packages a production build of the application into a `.exe` bundle
cmds:
- task: build
vars:
PRODUCTION: "true"
#cmds:
# - task: create:nsis:installer
generate:syso:
summary: Generates Windows `.syso` file
dir: build
cmds:
- wails3 generate syso -arch {{.ARCH}} -icon icon.ico -manifest wails.exe.manifest -info info.json -out ../wails_windows_{{.ARCH}}.syso
vars:
ARCH: '{{.ARCH | default ARCH}}'
create:nsis:installer:
summary: Creates an NSIS installer
dir: build/nsis
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi
vars:
ARCH: '{{.ARCH | default ARCH}}'
ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
run:
cmds:
- '{{.BIN_DIR}}\\{{.APP_NAME}}.exe'

View file

@ -1 +0,0 @@
../../web/public/gomuks.png

View file

@ -1,25 +0,0 @@
#!/usr/bin/env bash
# Copyright (c) 2018-Present Lea Anthony
# SPDX-License-Identifier: MIT
# Fail script on any error
set -euxo pipefail
# Define variables
APP_DIR="${APP_NAME}.AppDir"
# Create AppDir structure
mkdir -p "${APP_DIR}/usr/bin"
cp -r "${APP_BINARY}" "${APP_DIR}/usr/bin/"
cp "${ICON_PATH}" "${APP_DIR}/"
cp "${DESKTOP_FILE}" "${APP_DIR}/"
# Download linuxdeploy and make it executable
wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
chmod +x linuxdeploy-x86_64.AppImage
# Run linuxdeploy to bundle the application
./linuxdeploy-x86_64.AppImage --appdir "${APP_DIR}" --output appimage
# Rename the generated AppImage
mv "${APP_NAME}*.AppImage" "${APP_NAME}.AppImage"

View file

@ -1,60 +0,0 @@
# This file contains the configuration for this project.
# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets.
# Note that this will overwrite any changes you have made to the assets.
version: '3'
info:
companyName: ""
productName: "gomuks desktop"
productIdentifier: "fi.mau.gomuks.desktop"
description: "A Matrix client written in Go and React"
copyright: "© 2024, gomuks authors"
comments: ""
version: "0.4.0"
# Dev mode configuration
dev_mode:
root_path: .
log_level: warn
debounce: 1000
ignore:
dir:
- .git
- node_modules
- frontend
- bin
file:
- .DS_Store
- .gitignore
- .gitkeep
watched_extension:
- "*.go"
git_ignore: true
executes:
- cmd: wails3 task common:install:frontend:deps
type: once
- cmd: wails3 task common:dev:frontend
type: background
- cmd: go mod tidy
type: blocking
- cmd: wails3 task build
type: blocking
- cmd: wails3 task run
type: primary
# File Associations
# More information at: https://v3alpha.wails.io/noit/done/yet
fileAssociations:
# - ext: wails
# name: Wails
# description: Wails Application File
# iconName: wailsFileIcon
# role: Editor
# - ext: jpg
# name: JPEG
# description: Image File
# iconName: jpegFileIcon
# role: Editor
# Other data
other: []

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

View file

@ -1,15 +0,0 @@
{
"fixed": {
"file_version": "0.4.0"
},
"info": {
"0000": {
"ProductVersion": "0.4.0",
"CompanyName": "",
"FileDescription": "A Matrix client written in Go and React",
"LegalCopyright": "© 2024, gomuks authors",
"ProductName": "gomuks desktop",
"Comments": ""
}
}
}

View file

@ -1,50 +0,0 @@
# Feel free to remove those if you don't want/need to use them.
# Make sure to check the documentation at https://nfpm.goreleaser.com
#
# The lines below are called `modelines`. See `:help modeline`
name: "gomuks-desktop"
arch: ${GOARCH}
platform: "linux"
version: "0.4.0"
section: "default"
priority: "extra"
maintainer: Tulir Asokan <tulir@maunium.net>
description: "A Matrix client written in Go and React"
vendor: ""
homepage: "https://wails.io"
license: "MIT"
release: "1"
contents:
- src: "./bin/gomuks-desktop"
dst: "/usr/local/bin/gomuks-desktop"
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/gomuks-desktop.png"
- src: "./build/gomuks-desktop.desktop"
dst: "/usr/share/applications/gomuks-desktop.desktop"
depends:
- gtk3
- libwebkit2gtk
# replaces:
# - foobar
# provides:
# - bar
# depends:
# - gtk3
# - libwebkit2gtk
recommends:
- ffmpeg
# suggests:
# - something-else
# conflicts:
# - not-foo
# - not-bar
# changelog: "changelog.yaml"
# scripts:
# preinstall: ./build/nfpm/scripts/preinstall.sh
# postinstall: ./build/nfpm/scripts/postinstall.sh
# preremove: ./build/nfpm/scripts/preremove.sh
# postremove: ./build/nfpm/scripts/postremove.sh

View file

@ -1 +0,0 @@
#!/bin/bash

View file

@ -1 +0,0 @@
#!/bin/bash

View file

@ -1 +0,0 @@
#!/bin/bash

View file

@ -1 +0,0 @@
#!/bin/bash

View file

@ -1,112 +0,0 @@
Unicode true
####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows you to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the wails_tools.nsh file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME "my-project" # Default "gomuks-desktop"
## !define INFO_COMPANYNAME "My Company" # Default ""
## !define INFO_PRODUCTNAME "My Product Name" # Default "gomuks desktop"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "0.1.0"
## !define INFO_COPYRIGHT "(c) Now, My Company" # Default "© gomuks authors"
###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
####
## Include the wails tools
####
!include "wails_tools.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uninstalling page
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
SetOutPath $INSTDIR
!insertmacro wails.files
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
!insertmacro wails.writeUninstaller
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.deleteUninstaller
SectionEnd

View file

@ -1,212 +0,0 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "gomuks-desktop"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME ""
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "gomuks desktop"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "0.4.0"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© 2024, gomuks authors"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef REQUEST_EXECUTION_LEVEL
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!ifdef ARG_WAILS_AMD64_BINARY
!define SUPPORTS_AMD64
!endif
!ifdef ARG_WAILS_ARM64_BINARY
!define SUPPORTS_ARM64
!endif
!ifdef SUPPORTS_AMD64
!ifdef SUPPORTS_ARM64
!define ARCH "amd64_arm64"
!else
!define ARCH "amd64"
!endif
!else
!ifdef SUPPORTS_ARM64
!define ARCH "arm64"
!else
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
!endif
!endif
!macro wails.checkArchitecture
!ifndef WAILS_WIN10_REQUIRED
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
!endif
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
!endif
${If} ${AtLeastWin10}
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
Goto ok
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
Goto ok
${EndIf}
!endif
IfSilent silentArch notSilentArch
silentArch:
SetErrorLevel 65
Abort
notSilentArch:
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
Quit
${else}
IfSilent silentWin notSilentWin
silentWin:
SetErrorLevel 64
Abort
notSilentWin:
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
Quit
${EndIf}
ok:
!macroend
!macro wails.files
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
${EndIf}
!endif
!macroend
!macro wails.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
DeleteRegKey HKLM "${UNINST_KEY}"
!macroend
!macro wails.setShellContext
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
SetShellVarContext all
${else}
SetShellVarContext current
${EndIf}
!macroend
# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
!endif
SetRegView 64
# If the admin key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${EndIf}
SetDetailsPrint both
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
SetDetailsPrint listonly
InitPluginsDir
CreateDirectory "$pluginsdir\webview2bootstrapper"
SetOutPath "$pluginsdir\webview2bootstrapper"
File "MicrosoftEdgeWebview2Setup.exe"
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
SetDetailsPrint both
ok:
!macroend
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend
!macro APP_UNASSOCIATE EXT FILECLASS
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend
!macro wails.associateFiles
; Create file associations
!macroend
!macro wails.unassociateFiles
; Delete app associations
!macroend

View file

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="fi.mau.gomuks.desktop" version="0.4.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
</assembly>

View file

@ -1,86 +0,0 @@
module go.mau.fi/gomuks/desktop
go 1.23.0
toolchain go1.23.5
require github.com/wailsapp/wails/v3 v3.0.0-alpha.9
require (
go.mau.fi/gomuks v0.4.0
go.mau.fi/util v0.8.6
)
require (
dario.cat/mergo v1.0.1 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v1.0.0 // indirect
github.com/adrg/xdg v0.5.0 // indirect
github.com/alecthomas/chroma/v2 v2.15.0 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/buckket/go-blurhash v1.1.0 // indirect
github.com/chzyer/readline v1.5.1 // indirect
github.com/cloudflare/circl v1.3.8 // indirect
github.com/coder/websocket v1.8.13 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cyphar/filepath-securejoin v0.2.5 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/ebitengine/purego v0.4.0-alpha.4 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.0 // indirect
github.com/go-git/go-git/v5 v5.12.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/u v1.1.0 // indirect
github.com/lmittmann/tint v1.0.4 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/samber/lo v1.38.1 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.2.2 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/wailsapp/go-webview2 v1.0.19 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
go.mau.fi/webp v0.2.0 // indirect
go.mau.fi/zeroconfig v0.1.3 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/image v0.25.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
maunium.net/go/mautrix v0.23.3-0.20250414200811-99ff0c0964e4 // indirect
mvdan.cc/xurls/v2 v2.6.0 // indirect
)
replace go.mau.fi/gomuks => ../

View file

@ -1,266 +0,0 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/adrg/xdg v0.5.0 h1:dDaZvhMXatArP1NPHhnfaQUqWBLBsmx1h1HXQdMoFCY=
github.com/adrg/xdg v0.5.0/go.mod h1:dDdY4M4DF9Rjy4kHPeNL+ilVF+p2lK8IdM9/rTSGcI4=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
github.com/buckket/go-blurhash v1.1.0/go.mod h1:aT2iqo5W9vu9GpyoLErKfTHwgODsZp3bQfXjXJUxNb8=
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.3.8 h1:j+V8jJt09PoeMFIu2uh5JUyEaIHTXVOHslFoLNAKqwI=
github.com/cloudflare/circl v1.3.8/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo=
github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/ebitengine/purego v0.4.0-alpha.4 h1:Y7yIV06Yo5M2BAdD7EVPhfp6LZ0tEcQo5770OhYUVes=
github.com/ebitengine/purego v0.4.0-alpha.4/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8=
github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI=
github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lmittmann/tint v1.0.4 h1:LeYihpJ9hyGvE0w+K2okPTGUdVLfng1+nDNVR4vWISc=
github.com/lmittmann/tint v1.0.4/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a h1:S+AGcmAESQ0pXCUNnRH7V+bOUIgkSX5qVt2cNKCrm0Q=
github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGKbLGBPtR/8/oO74W6hmz0qE5q0z9aqSAewaaM=
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A=
github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU=
github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v3 v3.0.0-alpha.9 h1:b8CfRrhPno8Fra0xFp4Ifyj+ogmXBc35rsQWvcrHtsI=
github.com/wailsapp/wails/v3 v3.0.0-alpha.9/go.mod h1:dSv6s722nSWaUyUiapAM1DHc5HKggNGY1a79shO85/g=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54=
go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE=
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.23.3-0.20250414200811-99ff0c0964e4 h1:H0nImAaVSHbTfhW0rGZbwRTkHJFV6hMyXBDaS2T6MvA=
maunium.net/go/mautrix v0.23.3-0.20250414200811-99ff0c0964e4/go.mod h1:pCYLHmo02Jauak/9VlTkbGPrBMvLXsGqTGMNOx+L2PE=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=

View file

@ -1,168 +0,0 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"net/http"
"os"
"runtime"
"github.com/wailsapp/wails/v3/pkg/application"
"go.mau.fi/util/exhttp"
"go.mau.fi/gomuks/pkg/gomuks"
"go.mau.fi/gomuks/pkg/hicli"
"go.mau.fi/gomuks/version"
"go.mau.fi/gomuks/web"
)
type PointableHandler struct {
handler http.Handler
}
var _ http.Handler = (*PointableHandler)(nil)
func (p *PointableHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p.handler.ServeHTTP(w, r)
}
type CommandHandler struct {
Gomuks *gomuks.Gomuks
Ctx context.Context
App *application.App
}
func (c *CommandHandler) HandleCommand(cmd *hicli.JSONCommand) *hicli.JSONCommand {
return c.Gomuks.Client.SubmitJSONCommand(c.Ctx, cmd)
}
func (c *CommandHandler) Init() {
c.Gomuks.Log.Info().Msg("Sending initial state to client")
c.App.EmitEvent("hicli_event", &hicli.JSONCommandCustom[*hicli.ClientState]{
Command: "client_state",
Data: c.Gomuks.Client.State(),
})
c.App.EmitEvent("hicli_event", &hicli.JSONCommandCustom[*hicli.SyncStatus]{
Command: "sync_status",
Data: c.Gomuks.Client.SyncStatus.Load(),
})
if c.Gomuks.Client.IsLoggedIn() {
go func() {
log := c.Gomuks.Log
ctx := log.WithContext(context.TODO())
var roomCount int
for payload := range c.Gomuks.Client.GetInitialSync(ctx, 100) {
roomCount += len(payload.Rooms)
marshaledPayload, err := json.Marshal(&payload)
if err != nil {
log.Err(err).Msg("Failed to marshal initial rooms to send to client")
return
}
c.App.EmitEvent("hicli_event", &hicli.JSONCommand{
Command: "sync_complete",
RequestID: 0,
Data: marshaledPayload,
})
}
if ctx.Err() != nil {
return
}
c.App.EmitEvent("hicli_event", &hicli.JSONCommand{
Command: "init_complete",
RequestID: 0,
})
log.Info().Int("room_count", roomCount).Msg("Sent initial rooms to client")
}()
}
}
func main() {
gmx := gomuks.NewGomuks()
gmx.Version = version.Version
gmx.Commit = version.Commit
gmx.LinkifiedVersion = version.LinkifiedVersion
gmx.BuildTime = version.ParsedBuildTime
gmx.DisableAuth = true
exhttp.AutoAllowCORS = false
hicli.InitialDeviceDisplayName = "gomuks desktop"
gmx.InitDirectories()
err := gmx.LoadConfig()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to load config:", err)
os.Exit(9)
}
gmx.SetupLog()
gmx.Log.Info().
Str("version", gmx.Version).
Str("go_version", runtime.Version()).
Time("built_at", gmx.BuildTime).
Msg("Initializing gomuks desktop")
gmx.StartClient()
gmx.Log.Info().Msg("Initialization complete, starting desktop app")
cmdCtx, cancelCmdCtx := context.WithCancel(context.Background())
ch := &CommandHandler{Gomuks: gmx, Ctx: cmdCtx}
app := application.New(application.Options{
Name: "gomuks-desktop",
Description: "A Matrix client written in Go and React",
Services: []application.Service{
application.NewService(
&PointableHandler{gmx.CreateAPIRouter()},
application.ServiceOptions{Route: "/_gomuks"},
),
application.NewService(ch),
},
Assets: application.AssetOptions{
Handler: application.AssetFileServerFS(web.Frontend),
},
Mac: application.MacOptions{
ApplicationShouldTerminateAfterLastWindowClosed: true,
},
OnShutdown: func() {
cancelCmdCtx()
gmx.Log.Info().Msg("Shutting down...")
gmx.DirectStop()
gmx.Log.Info().Msg("Shutdown complete")
},
})
ch.App = app
app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{
Title: "gomuks desktop",
Mac: application.MacWindow{
InvisibleTitleBarHeight: 50,
Backdrop: application.MacBackdropTranslucent,
TitleBar: application.MacTitleBarHiddenInset,
},
BackgroundColour: application.NewRGB(27, 38, 54),
URL: "/",
})
gmx.EventBuffer.Subscribe(0, nil, func(command *hicli.JSONCommand) {
app.EmitEvent("hicli_event", command)
})
err = app.Run()
if err != nil {
panic(err)
}
}

61
flake.lock generated
View file

@ -1,61 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1732521221,
"narHash": "sha256-2ThgXBUXAE1oFsVATK1ZX9IjPcS4nKFOAjhPNKuiMn0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4633a7c72337ea8fd23a4f2ba3972865e3ec685d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View file

@ -1,40 +0,0 @@
{
description = "Gomuks development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
(flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
config.permittedInsecurePackages = [ "olm-3.2.16" ];
};
in {
devShells = {
default = pkgs.mkShell {
packages = with pkgs; [
glib-networking
go-task
go-tools
gotools
gst_all_1.gstreamer
gst_all_1.gst-plugins-base
gst_all_1.gst-plugins-good
gst_all_1.gst-plugins-bad
gst_all_1.gst-plugins-ugly
gst_all_1.gst-libav
gst_all_1.gst-vaapi
libsoup
olm
pkg-config
pre-commit
webkitgtk_4_1
];
};
};
}));
}

75
go.mod
View file

@ -1,47 +1,50 @@
module go.mau.fi/gomuks module maunium.net/go/gomuks
go 1.23.0 go 1.18
toolchain go1.24.1
require ( require (
github.com/alecthomas/chroma/v2 v2.15.0 github.com/alecthomas/chroma v0.10.0
github.com/buckket/go-blurhash v1.1.0
github.com/chzyer/readline v1.5.1
github.com/coder/websocket v1.8.13
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/gabriel-vasile/mimetype v1.4.8 github.com/gabriel-vasile/mimetype v1.4.1
github.com/kyokomi/emoji/v2 v2.2.10
github.com/lithammer/fuzzysearch v1.1.5
github.com/lucasb-eyer/go-colorful v1.2.0 github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-sqlite3 v1.14.24 github.com/mattn/go-runewidth v0.0.14
github.com/rivo/uniseg v0.4.7 github.com/mattn/go-sqlite3 v1.14.16
github.com/rs/zerolog v1.34.0 github.com/rivo/uniseg v0.4.2
github.com/tidwall/gjson v1.18.0 github.com/sasha-s/go-deadlock v0.3.1
github.com/tidwall/sjson v1.2.5 github.com/yuin/goldmark v1.5.3
github.com/yuin/goldmark v1.7.8 github.com/zyedidia/clipboard v1.0.4
go.mau.fi/util v0.8.6 go.etcd.io/bbolt v1.3.6
go.mau.fi/webp v0.2.0 go.mau.fi/cbind v0.0.0-20220415094356-e1d579b7925e
go.mau.fi/zeroconfig v0.1.3 go.mau.fi/mauview v0.2.1
golang.org/x/crypto v0.36.0 go.mau.fi/tcell v0.4.0
golang.org/x/image v0.25.0 golang.org/x/image v0.1.0
golang.org/x/net v0.38.0 golang.org/x/net v0.2.0
golang.org/x/text v0.23.0 gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2
gopkg.in/vansante/go-ffprobe.v2 v2.1.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0 maunium.net/go/mautrix v0.11.0
maunium.net/go/mautrix v0.23.3-0.20250414200811-99ff0c0964e4 mvdan.cc/xurls/v2 v2.4.0
mvdan.cc/xurls/v2 v2.6.0
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/gdamore/encoding v1.0.0 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect github.com/gorilla/mux v1.8.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/gorilla/websocket v1.5.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect
github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a // indirect github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/tidwall/gjson v1.14.1 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect github.com/tidwall/sjson v1.2.4 // indirect
golang.org/x/sys v0.32.0 // indirect golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect golang.org/x/sys v0.2.0 // indirect
golang.org/x/term v0.2.0 // indirect
golang.org/x/text v0.4.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
maunium.net/go/maulogger/v2 v2.3.2 // indirect
) )
replace github.com/mattn/go-runewidth => github.com/tulir/go-runewidth v0.0.14-0.20221113132156-dc2fc6d28246

200
go.sum
View file

@ -1,105 +1,127 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.15.0 h1:LxXTQHFoYrstG2nnV9y2X5O94sOBzf0CIUpSTbpxvMc=
github.com/alecthomas/chroma/v2 v2.15.0/go.mod h1:gUhVLrPDXPtp/f+L1jo9xepo9gL4eLwRuGAunSZMkio=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
github.com/buckket/go-blurhash v1.1.0/go.mod h1:aT2iqo5W9vu9GpyoLErKfTHwgODsZp3bQfXjXJUxNb8=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kyokomi/emoji/v2 v2.2.10 h1:1z5eMVcxFifsmEoNpdeq4UahbcicgQ4FEHuzrCVwmiI=
github.com/kyokomi/emoji/v2 v2.2.10/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
github.com/lithammer/fuzzysearch v1.1.5 h1:Ag7aKU08wp0R9QCfF4GoGST9HbmAIeLP7xwMrOBEp1c=
github.com/lithammer/fuzzysearch v1.1.5/go.mod h1:1R1LRNk7yKid1BaQkmuLQaHruxcC4HmAH30Dh61Ih1Q=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a h1:S+AGcmAESQ0pXCUNnRH7V+bOUIgkSX5qVt2cNKCrm0Q=
github.com/petermattis/goid v0.0.0-20250319124200-ccd6737f222a/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.1 h1:iymTbGkQBhveq21bEvAQ81I0LEBork8BFe1CUZXdyuo=
github.com/tidwall/gjson v1.14.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/sjson v1.2.4 h1:cuiLzLnaMeBhRmEv00Lpk3tkYrcxpmbU81tAY4Dw0tc=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.4/go.mod h1:098SZ494YoMWPmMO6ct4dcFnqxwj9r/gF0Etp19pSNM=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tulir/go-runewidth v0.0.14-0.20221113132156-dc2fc6d28246 h1:WjkNcgoEaoL7i9mJuH+ff/hZHkSBR1KDdvoOoLpG6vs=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tulir/go-runewidth v0.0.14-0.20221113132156-dc2fc6d28246/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.5.3 h1:3HUJmBFbQW9fhQOzMgseU134xfi6hU+mjWywx5Ty+/M=
go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54= github.com/yuin/goldmark v1.5.3/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE= github.com/zyedidia/clipboard v1.0.4 h1:r6GUQOyPtIaApRLeD56/U+2uJbXis6ANGbKWCljULEo=
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg= github.com/zyedidia/clipboard v1.0.4/go.mod h1:zykFnZUXX0ErxqvYLUFEq7QDJKId8rmh2FgD0/Y8cjA=
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q= go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= go.mau.fi/cbind v0.0.0-20220415094356-e1d579b7925e h1:zY4TZmHAaUhrMFJQfh02dqxDYSfnnXlw/qRoFanxZTw=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= go.mau.fi/cbind v0.0.0-20220415094356-e1d579b7925e/go.mod h1:9nnzlslhUo/xO+8tsQgkFqG/W+SgD+r0iTYAuglzlmA=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= go.mau.fi/mauview v0.2.1 h1:Sv+L3MQoo0VWuqgO/SIzhTzDcd7iqPGZgxH3au2kUGw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= go.mau.fi/mauview v0.2.1/go.mod h1:aTb1VjsjFmZ5YsdMQTIHrma9Ki2O0WwkS2Er7bIgoUs=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= go.mau.fi/tcell v0.4.0 h1:IPFKhkzF3yZkcRYjzgYBWWiW0JWPTwEBoXlWTBT8o/4=
go.mau.fi/tcell v0.4.0/go.mod h1:77zV/6KL4Zip1u9ndjswACmu/LWwZ/oe3BE188uWMrA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9 h1:NUzdAbFtCJSXU20AOXgeqaUwg8Ypg4MPYmL+d+rsB5c=
golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= golang.org/x/image v0.1.0 h1:r8Oj8ZA2Xy12/b5KZYj3tuv7NG/fBz3TwQVvpJ9l8Rk=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/image v0.1.0/go.mod h1:iyPr49SD/G/TBxYVB/9RRtGUT5eNbo2u4NamWeQcD5c=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2 h1:MZF6J7CV6s/h0HBkfqebrYfKCVEo5iN+wzE4QhV3Evo=
gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2/go.mod h1:s1Sn2yZos05Qfs7NKt867Xe18emOmtsO3eAKbDaon0o=
gopkg.in/vansante/go-ffprobe.v2 v2.1.1 h1:DIh5fMn+tlBvG7pXyUZdemVmLdERnf2xX6XOFF+0BBU=
gopkg.in/vansante/go-ffprobe.v2 v2.1.1/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/maulogger/v2 v2.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
maunium.net/go/mautrix v0.23.3-0.20250414200811-99ff0c0964e4 h1:H0nImAaVSHbTfhW0rGZbwRTkHJFV6hMyXBDaS2T6MvA= maunium.net/go/mautrix v0.11.0 h1:B1FBHcvE4Mud+AC+zgNQQOw0JxSVrt40watCejhVA7w=
maunium.net/go/mautrix v0.23.3-0.20250414200811-99ff0c0964e4/go.mod h1:pCYLHmo02Jauak/9VlTkbGPrBMvLXsGqTGMNOx+L2PE= maunium.net/go/mautrix v0.11.0/go.mod h1:K29EcHwsNg6r7fMfwvi0GHQ9o5wSjqB9+Q8RjCIQEjA=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.4.0 h1:tzxjVAj+wSBmDcF6zBB7/myTy3gX9xvi8Tyr28AuQgc=
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= mvdan.cc/xurls/v2 v2.4.0/go.mod h1:+GEjq9uNjqs8LQfM9nVnM8rff0OQ5Iash5rzX+N1CSg=

173
gomuks.go Normal file
View file

@ -0,0 +1,173 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/debug"
ifc "maunium.net/go/gomuks/interface"
"maunium.net/go/gomuks/matrix"
)
// Information to find out exactly which commit gomuks was built from.
// These are filled at build time with the -X linker flag.
var (
Tag = "unknown"
Commit = "unknown"
BuildTime = "unknown"
)
var (
// Version is the version number of gomuks. Changed manually when making a release.
Version = "0.3.0"
// VersionString is the gomuks version, plus commit information. Filled in init() using the build-time values.
VersionString = ""
)
func init() {
if len(Tag) > 0 && Tag[0] == 'v' {
Tag = Tag[1:]
}
if Tag != Version {
suffix := ""
if !strings.HasSuffix(Version, "+dev") {
suffix = "+dev"
}
if len(Commit) > 8 {
Version = fmt.Sprintf("%s%s.%s", Version, suffix, Commit[:8])
} else {
Version = fmt.Sprintf("%s%s.unknown", Version, suffix)
}
}
VersionString = fmt.Sprintf("gomuks %s (%s)", Version, BuildTime)
}
// Gomuks is the wrapper for everything.
type Gomuks struct {
ui ifc.GomuksUI
matrix *matrix.Container
config *config.Config
stop chan bool
}
// NewGomuks creates a new Gomuks instance with everything initialized,
// but does not start it.
func NewGomuks(uiProvider ifc.UIProvider, configDir, dataDir, cacheDir, downloadDir string) *Gomuks {
gmx := &Gomuks{
stop: make(chan bool, 1),
}
gmx.config = config.NewConfig(configDir, dataDir, cacheDir, downloadDir)
gmx.ui = uiProvider(gmx)
gmx.matrix = matrix.NewContainer(gmx)
gmx.config.LoadAll()
gmx.ui.Init()
debug.OnRecover = gmx.ui.Finish
return gmx
}
func (gmx *Gomuks) Version() string {
return Version
}
// Save saves the active session and message history.
func (gmx *Gomuks) Save() {
gmx.config.SaveAll()
}
// StartAutosave calls Save() every minute until it receives a stop signal
// on the Gomuks.stop channel.
func (gmx *Gomuks) StartAutosave() {
defer debug.Recover()
ticker := time.NewTicker(time.Minute)
for {
select {
case <-ticker.C:
if gmx.config.AuthCache.InitialSyncDone {
gmx.Save()
}
case val := <-gmx.stop:
if val {
return
}
}
}
}
// Stop stops the Matrix syncer, the tview app and the autosave goroutine,
// then saves everything and calls os.Exit(0).
func (gmx *Gomuks) Stop(save bool) {
go gmx.internalStop(save)
}
func (gmx *Gomuks) internalStop(save bool) {
debug.Print("Disconnecting from Matrix...")
gmx.matrix.Stop()
debug.Print("Cleaning up UI...")
gmx.ui.Stop()
gmx.stop <- true
if save {
gmx.Save()
}
debug.Print("Exiting process")
os.Exit(0)
}
// Start opens a goroutine for the autosave loop and starts the tview app.
//
// If the tview app returns an error, it will be passed into panic(), which
// will be recovered as specified in Recover().
func (gmx *Gomuks) Start() {
_ = gmx.matrix.InitClient()
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
go func() {
<-c
gmx.Stop(true)
}()
go gmx.StartAutosave()
if err := gmx.ui.Start(); err != nil {
panic(err)
}
}
// Matrix returns the MatrixContainer instance.
func (gmx *Gomuks) Matrix() ifc.MatrixContainer {
return gmx.matrix
}
// Config returns the Gomuks config instance.
func (gmx *Gomuks) Config() *config.Config {
return gmx.config
}
// UI returns the Gomuks UI instance.
func (gmx *Gomuks) UI() ifc.GomuksUI {
return gmx.ui
}

2
interface/doc.go Normal file
View file

@ -0,0 +1,2 @@
// Package ifc contains interfaces to allow circular function calls without circular imports.
package ifc

View file

@ -1,5 +1,5 @@
// gomuks - A Matrix client written in Go. // gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan // Copyright (C) 2020 Tulir Asokan
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by // it under the terms of the GNU Affero General Public License as published by
@ -13,10 +13,20 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import DeleteIcon from "../../../icons/delete.svg?react"
const RedactedBody = () => { package ifc
return <div className="redacted-body"><DeleteIcon/> Message deleted</div>
import (
"maunium.net/go/gomuks/config"
)
// Gomuks is the wrapper for everything.
type Gomuks interface {
Matrix() MatrixContainer
UI() GomuksUI
Config() *config.Config
Version() string
Start()
Stop(save bool)
} }
export default RedactedBody

92
interface/matrix.go Normal file
View file

@ -0,0 +1,92 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package ifc
import (
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/gomuks/config"
"maunium.net/go/gomuks/matrix/muksevt"
"maunium.net/go/gomuks/matrix/rooms"
)
type Relation struct {
Type event.RelationType
Event *muksevt.Event
}
type UploadedMediaInfo struct {
*mautrix.RespMediaUpload
EncryptionInfo *attachment.EncryptedFile
MsgType event.MessageType
Name string
Info *event.FileInfo
}
type MatrixContainer interface {
Client() *mautrix.Client
Preferences() *config.UserPreferences
InitClient() error
Initialized() bool
Start()
Stop()
Login(user, password string) error
Logout()
UIAFallback(authType mautrix.AuthType, sessionID string) error
SendPreferencesToMatrix()
PrepareMarkdownMessage(roomID id.RoomID, msgtype event.MessageType, text, html string, relation *Relation) *muksevt.Event
PrepareMediaMessage(room *rooms.Room, path string, relation *Relation) (*muksevt.Event, error)
SendEvent(evt *muksevt.Event) (id.EventID, error)
Redact(roomID id.RoomID, eventID id.EventID, reason string) error
SendTyping(roomID id.RoomID, typing bool)
MarkRead(roomID id.RoomID, eventID id.EventID)
JoinRoom(roomID id.RoomID, server string) (*rooms.Room, error)
LeaveRoom(roomID id.RoomID) error
CreateRoom(req *mautrix.ReqCreateRoom) (*rooms.Room, error)
FetchMembers(room *rooms.Room) error
GetHistory(room *rooms.Room, limit int, dbPointer uint64) ([]*muksevt.Event, uint64, error)
GetEvent(room *rooms.Room, eventID id.EventID) (*muksevt.Event, error)
GetRoom(roomID id.RoomID) *rooms.Room
GetOrCreateRoom(roomID id.RoomID) *rooms.Room
UploadMedia(path string, encrypt bool) (*UploadedMediaInfo, error)
Download(uri id.ContentURI, file *attachment.EncryptedFile) ([]byte, error)
DownloadToDisk(uri id.ContentURI, file *attachment.EncryptedFile, target string) (string, error)
GetDownloadURL(uri id.ContentURI) string
GetCachePath(uri id.ContentURI) string
Crypto() Crypto
}
type Crypto interface {
Load() error
FlushStore() error
ProcessSyncResponse(resp *mautrix.RespSync, since string) bool
ProcessInRoomVerification(evt *event.Event) error
HandleMemberEvent(*event.Event)
DecryptMegolmEvent(*event.Event) (*event.Event, error)
EncryptMegolmEvent(id.RoomID, event.Type, interface{}) (*event.EncryptedEventContent, error)
ShareGroupSession(id.RoomID, []id.UserID) error
Fingerprint() string
}

89
interface/ui.go Normal file
View file

@ -0,0 +1,89 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package ifc
import (
"time"
"maunium.net/go/gomuks/matrix/muksevt"
"maunium.net/go/gomuks/matrix/rooms"
"maunium.net/go/mautrix/id"
"maunium.net/go/mautrix/pushrules"
)
type UIProvider func(gmx Gomuks) GomuksUI
type GomuksUI interface {
Render()
HandleNewPreferences()
OnLogin()
OnLogout()
MainView() MainView
Init()
Start() error
Stop()
Finish()
}
type SyncingModal interface {
SetIndeterminate()
SetMessage(string)
SetSteps(int)
Step()
Close()
}
type MainView interface {
GetRoom(roomID id.RoomID) RoomView
AddRoom(room *rooms.Room)
RemoveRoom(room *rooms.Room)
SetRooms(rooms *rooms.RoomCache)
Bump(room *rooms.Room)
UpdateTags(room *rooms.Room)
SetTyping(roomID id.RoomID, users []id.UserID)
OpenSyncingModal() SyncingModal
NotifyMessage(room *rooms.Room, message Message, should pushrules.PushActionArrayShould)
}
type RoomView interface {
MxRoom() *rooms.Room
SetCompletions(completions []string)
SetTyping(users []id.UserID)
UpdateUserList()
AddEvent(evt *muksevt.Event) Message
AddRedaction(evt *muksevt.Event)
AddEdit(evt *muksevt.Event)
AddReaction(evt *muksevt.Event, key string)
GetEvent(eventID id.EventID) Message
AddServiceMessage(message string)
}
type Message interface {
ID() id.EventID
Time() time.Time
NotificationSenderName() string
NotificationContent() string
SetIsHighlight(highlight bool)
SetID(id id.EventID)
}

297
lib/ansimage/ansimage.go Normal file
View file

@ -0,0 +1,297 @@
// ___ _____ ____
// / _ \/ _/ |/_/ /____ ______ _
// / ___// /_> </ __/ -_) __/ ' \
// /_/ /___/_/|_|\__/\__/_/ /_/_/_/
//
// Copyright 2017 Eliuk Blau
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package ansimage
import (
"errors"
"image"
"image/color"
"image/draw"
_ "image/gif" // initialize decoder
_ "image/jpeg" // initialize decoder
_ "image/png" // initialize decoder
"io"
"os"
"github.com/disintegration/imaging"
_ "golang.org/x/image/bmp" // initialize decoder
_ "golang.org/x/image/tiff" // initialize decoder
_ "golang.org/x/image/webp" // initialize decoder
"go.mau.fi/tcell"
"maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/ui/messages/tstring"
)
var (
// ErrHeightNonMoT happens when ANSImage height is not a Multiple of Two value.
ErrHeightNonMoT = errors.New("ANSImage: height must be a Multiple of Two value")
// ErrInvalidBoundsMoT happens when ANSImage height or width are invalid values (Multiple of Two).
ErrInvalidBoundsMoT = errors.New("ANSImage: height or width must be >=2")
// ErrOutOfBounds happens when ANSI-pixel coordinates are out of ANSImage bounds.
ErrOutOfBounds = errors.New("ANSImage: out of bounds")
)
// ANSIpixel represents a pixel of an ANSImage.
type ANSIpixel struct {
Brightness uint8
R, G, B uint8
upper bool
source *ANSImage
}
// ANSImage represents an image encoded in ANSI escape codes.
type ANSImage struct {
h, w int
maxprocs int
bgR uint8
bgG uint8
bgB uint8
pixmap [][]*ANSIpixel
}
func (ai *ANSImage) Pixmap() [][]*ANSIpixel {
return ai.pixmap
}
// Height gets total rows of ANSImage.
func (ai *ANSImage) Height() int {
return ai.h
}
// Width gets total columns of ANSImage.
func (ai *ANSImage) Width() int {
return ai.w
}
// SetMaxProcs sets the maximum number of parallel goroutines to render the ANSImage
// (user should manually sets `runtime.GOMAXPROCS(max)` before to this change takes effect).
func (ai *ANSImage) SetMaxProcs(max int) {
ai.maxprocs = max
}
// GetMaxProcs gets the maximum number of parallels goroutines to render the ANSImage.
func (ai *ANSImage) GetMaxProcs() int {
return ai.maxprocs
}
// SetAt sets ANSI-pixel color (RBG) and brightness in coordinates (y,x).
func (ai *ANSImage) SetAt(y, x int, r, g, b, brightness uint8) error {
if y >= 0 && y < ai.h && x >= 0 && x < ai.w {
ai.pixmap[y][x].R = r
ai.pixmap[y][x].G = g
ai.pixmap[y][x].B = b
ai.pixmap[y][x].Brightness = brightness
ai.pixmap[y][x].upper = y%2 == 0
return nil
}
return ErrOutOfBounds
}
// GetAt gets ANSI-pixel in coordinates (y,x).
func (ai *ANSImage) GetAt(y, x int) (*ANSIpixel, error) {
if y >= 0 && y < ai.h && x >= 0 && x < ai.w {
return &ANSIpixel{
R: ai.pixmap[y][x].R,
G: ai.pixmap[y][x].G,
B: ai.pixmap[y][x].B,
Brightness: ai.pixmap[y][x].Brightness,
upper: ai.pixmap[y][x].upper,
source: ai.pixmap[y][x].source,
},
nil
}
return nil, ErrOutOfBounds
}
// Render returns the ANSI-compatible string form of ANSImage.
// (Nice info for ANSI True Colour - https://gist.github.com/XVilka/8346728)
func (ai *ANSImage) Render() []tstring.TString {
type renderData struct {
row int
render tstring.TString
}
rows := make([]tstring.TString, ai.h/2)
for y := 0; y < ai.h; y += ai.maxprocs {
ch := make(chan renderData, ai.maxprocs)
for n, row := 0, y; (n <= ai.maxprocs) && (2*row+1 < ai.h); n, row = n+1, y+n {
go func(row, y int) {
defer func() {
err := recover()
if err != nil {
debug.Print("Panic rendering ANSImage:", err)
ch <- renderData{row: row, render: tstring.NewColorTString("ERROR", tcell.ColorRed)}
}
}()
str := make(tstring.TString, ai.w)
for x := 0; x < ai.w; x++ {
topPixel := ai.pixmap[y][x]
topColor := tcell.NewRGBColor(int32(topPixel.R), int32(topPixel.G), int32(topPixel.B))
bottomPixel := ai.pixmap[y+1][x]
bottomColor := tcell.NewRGBColor(int32(bottomPixel.R), int32(bottomPixel.G), int32(bottomPixel.B))
str[x] = tstring.Cell{
Char: '▄',
Style: tcell.StyleDefault.Background(topColor).Foreground(bottomColor),
}
}
ch <- renderData{row: row, render: str}
}(row, 2*row)
}
for n, row := 0, y; (n <= ai.maxprocs) && (2*row+1 < ai.h); n, row = n+1, y+n {
data := <-ch
rows[data.row] = data.render
}
}
return rows
}
// New creates a new empty ANSImage ready to draw on it.
func New(h, w int, bg color.Color) (*ANSImage, error) {
if h%2 != 0 {
return nil, ErrHeightNonMoT
}
if h < 2 || w < 2 {
return nil, ErrInvalidBoundsMoT
}
r, g, b, _ := bg.RGBA()
ansimage := &ANSImage{
h: h, w: w,
maxprocs: 1,
bgR: uint8(r),
bgG: uint8(g),
bgB: uint8(b),
pixmap: nil,
}
ansimage.pixmap = func() [][]*ANSIpixel {
v := make([][]*ANSIpixel, h)
for y := 0; y < h; y++ {
v[y] = make([]*ANSIpixel, w)
for x := 0; x < w; x++ {
v[y][x] = &ANSIpixel{
R: 0,
G: 0,
B: 0,
Brightness: 0,
source: ansimage,
upper: y%2 == 0,
}
}
}
return v
}()
return ansimage, nil
}
// NewFromReader creates a new ANSImage from an io.Reader.
// Background color is used to fill when image has transparency or dithering mode is enabled
// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
func NewFromReader(reader io.Reader, bg color.Color) (*ANSImage, error) {
img, _, err := image.Decode(reader)
if err != nil {
return nil, err
}
return createANSImage(img, bg)
}
// NewScaledFromReader creates a new scaled ANSImage from an io.Reader.
// Background color is used to fill when image has transparency or dithering mode is enabled
// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
func NewScaledFromReader(reader io.Reader, y, x int, bg color.Color) (*ANSImage, error) {
img, _, err := image.Decode(reader)
if err != nil {
return nil, err
}
img = imaging.Resize(img, x, y, imaging.Lanczos)
return createANSImage(img, bg)
}
// NewFromFile creates a new ANSImage from a file.
// Background color is used to fill when image has transparency or dithering mode is enabled
// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
func NewFromFile(name string, bg color.Color) (*ANSImage, error) {
reader, err := os.Open(name)
if err != nil {
return nil, err
}
defer reader.Close()
return NewFromReader(reader, bg)
}
// NewScaledFromFile creates a new scaled ANSImage from a file.
// Background color is used to fill when image has transparency or dithering mode is enabled
// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
func NewScaledFromFile(name string, y, x int, bg color.Color) (*ANSImage, error) {
reader, err := os.Open(name)
if err != nil {
return nil, err
}
defer reader.Close()
return NewScaledFromReader(reader, y, x, bg)
}
// createANSImage loads data from an image and returns an ANSImage.
// Background color is used to fill when image has transparency or dithering mode is enabled
// Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements).
func createANSImage(img image.Image, bg color.Color) (*ANSImage, error) {
var rgbaOut *image.RGBA
bounds := img.Bounds()
// do compositing only if background color has no transparency (thank you @disq for the idea!)
// (info - http://stackoverflow.com/questions/36595687/transparent-pixel-color-go-lang-image)
if _, _, _, a := bg.RGBA(); a >= 0xffff {
rgbaOut = image.NewRGBA(bounds)
draw.Draw(rgbaOut, bounds, image.NewUniform(bg), image.ZP, draw.Src)
draw.Draw(rgbaOut, bounds, img, image.ZP, draw.Over)
} else {
if v, ok := img.(*image.RGBA); ok {
rgbaOut = v
} else {
rgbaOut = image.NewRGBA(bounds)
draw.Draw(rgbaOut, bounds, img, image.ZP, draw.Src)
}
}
yMin, xMin := bounds.Min.Y, bounds.Min.X
yMax, xMax := bounds.Max.Y, bounds.Max.X
// always sets an even number of ANSIPixel rows...
yMax = yMax - yMax%2 // one for upper pixel and another for lower pixel --> without dithering
ansimage, err := New(yMax, xMax, bg)
if err != nil {
return nil, err
}
for y := yMin; y < yMax; y++ {
for x := xMin; x < xMax; x++ {
v := rgbaOut.RGBAAt(x, y)
if err := ansimage.SetAt(y, x, v.R, v.G, v.B, 0); err != nil {
return nil, err
}
}
}
return ansimage, nil
}

11
lib/ansimage/doc.go Normal file
View file

@ -0,0 +1,11 @@
// Package ansimage is a simplified version of the ansimage package
// in https://github.com/eliukblau/pixterm focused in rendering images
// to a tcell-based TUI app.
//
// ___ _____ ____
// / _ \/ _/ |/_/ /____ ______ _
// / ___// /_> </ __/ -_) __/ ' \
// /_/ /___/_/|_|\__/\__/_/ /_/_/_/
//
// This package is licensed under the Mozilla Public License v2.0.
package ansimage

View file

@ -1,5 +1,5 @@
// gomuks - A Matrix client written in Go. // gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan // Copyright (C) 2022 Tulir Asokan
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by // it under the terms of the GNU Affero General Public License as published by
@ -13,25 +13,37 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { InputHTMLAttributes } from "react"
import "./Toggle.css"
export interface ToggleProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> { package filepicker
disabledColor?: string
enabledColor?: string import (
"bytes"
"errors"
"os/exec"
"strings"
)
var zenity string
func init() {
zenity, _ = exec.LookPath("zenity")
} }
const Toggle = (props: ToggleProps) => { func IsSupported() bool {
const extraStyle = { return len(zenity) > 0
"--disabled-color": props.disabledColor, }
"--enabled-color": props.enabledColor,
func Open() (string, error) {
cmd := exec.Command(zenity, "--file-selection")
var output bytes.Buffer
cmd.Stdout = &output
err := cmd.Run()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return "", nil
} }
return <input return "", err
{...props} }
type="checkbox" return strings.TrimSpace(output.String()), nil
className={props.className ? `toggle ${props.className}` : "toggle"}
style={{ ...(props.style ?? {}), ...extraStyle }}
/>
} }
export default Toggle

2
lib/notification/doc.go Normal file
View file

@ -0,0 +1,2 @@
// Package notification contains a simple cross-platform desktop notification sending function.
package notification

View file

@ -0,0 +1,65 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"fmt"
"os/exec"
)
var terminalNotifierAvailable = false
func init() {
if err := exec.Command("which", "terminal-notifier").Run(); err != nil {
terminalNotifierAvailable = false
}
terminalNotifierAvailable = true
}
const sendScript = `on run {notifText, notifTitle}
display notification notifText with title "gomuks" subtitle notifTitle
end run`
func Send(title, text string, critical, sound bool) error {
if terminalNotifierAvailable {
args := []string{"-title", "gomuks", "-subtitle", title, "-message", text}
if critical {
args = append(args, "-timeout", "15")
} else {
args = append(args, "-timeout", "4")
}
if sound {
args = append(args, "-sound", "default")
}
//if len(iconPath) > 0 {
// args = append(args, "-appIcon", iconPath)
//}
return exec.Command("terminal-notifier", args...).Run()
}
cmd := exec.Command("osascript", "-", text, title)
if stdin, err := cmd.StdinPipe(); err != nil {
return fmt.Errorf("failed to get stdin pipe for osascript: %w", err)
} else if _, err = stdin.Write([]byte(sendScript)); err != nil {
return fmt.Errorf("failed to write notification script to osascript: %w", err)
} else if err = cmd.Run(); err != nil {
return fmt.Errorf("failed to run notification script: %w", err)
} else if !cmd.ProcessState.Success() {
return fmt.Errorf("notification script exited unsuccessfully")
} else {
return nil
}
}

View file

@ -1,5 +1,5 @@
// gomuks - A Matrix client written in Go. // gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan // Copyright (C) 2020 Tulir Asokan
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by // it under the terms of the GNU Affero General Public License as published by
@ -13,15 +13,27 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
export function humanJoin(arr: string[], sep: string = ", ", lastSep: string = " and "): string {
if (arr.length === 0) { package notification
return ""
import (
"gopkg.in/toast.v1"
)
func Send(title, text string, critical, sound bool) error {
notification := toast.Notification{
AppID: "gomuks",
Title: title,
Message: text,
Audio: toast.Silent,
Duration: toast.Short,
// Icon: ...,
} }
if (arr.length === 1) { if sound {
return arr[0] notification.Audio = toast.IM
} }
if (arr.length === 2) { if critical {
return arr.join(lastSep) notification.Duration = toast.Long
} }
return arr.slice(0, -1).join(sep) + lastSep + arr[arr.length - 1] return notification.Push()
} }

View file

@ -0,0 +1,84 @@
//go:build !windows && !darwin
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notification
import (
"os"
"os/exec"
)
var notifySendPath string
var audioCommand string
var tryAudioCommands = []string{"ogg123", "paplay"}
var soundNormal = "/usr/share/sounds/freedesktop/stereo/message-new-instant.oga"
var soundCritical = "/usr/share/sounds/freedesktop/stereo/complete.oga"
func getSoundPath(env, defaultPath string) string {
if path, ok := os.LookupEnv(env); ok {
// Sound file overriden by environment
return path
} else if _, err := os.Stat(defaultPath); os.IsNotExist(err) {
// Sound file doesn't exist, disable it
return ""
} else {
// Default sound file exists and wasn't overridden by environment
return defaultPath
}
}
func init() {
var err error
if notifySendPath, err = exec.LookPath("notify-send"); err != nil {
return
}
for _, cmd := range tryAudioCommands {
if audioCommand, err = exec.LookPath(cmd); err == nil {
break
}
}
soundNormal = getSoundPath("GOMUKS_SOUND_NORMAL", soundNormal)
soundCritical = getSoundPath("GOMUKS_SOUND_CRITICAL", soundCritical)
}
func Send(title, text string, critical, sound bool) error {
if len(notifySendPath) == 0 {
return nil
}
args := []string{"-a", "gomuks"}
if !critical {
args = append(args, "-u", "low")
}
//if iconPath {
// args = append(args, "-i", iconPath)
//}
args = append(args, title, text)
if sound && len(audioCommand) > 0 && len(soundNormal) > 0 {
audioFile := soundNormal
if critical && len(soundCritical) > 0 {
audioFile = soundCritical
}
go func() {
_ = exec.Command(audioCommand, audioFile).Run()
}()
}
return exec.Command(notifySendPath, args...).Run()
}

4
lib/open/doc.go Normal file
View file

@ -0,0 +1,4 @@
// Package open contains a simple cross-platform way to open files in the program the OS wants to use.
//
// Based on https://github.com/skratchdot/open-golang
package open

View file

@ -1,5 +1,5 @@
// gomuks - A Matrix client written in Go. // gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan // Copyright (C) 2020 Tulir Asokan
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by // it under the terms of the GNU Affero General Public License as published by
@ -13,13 +13,27 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { JSX } from "react"
import { getMediaURL } from "@/api/media.ts"
import { Emoji } from "@/util/emoji"
export default function renderEmoji(emoji: Emoji): JSX.Element | string { package open
if (emoji.u.startsWith("mxc://")) {
return <img loading="lazy" src={getMediaURL(emoji.u)} alt={`:${emoji.n}:`}/> import (
"os/exec"
"maunium.net/go/gomuks/debug"
)
func Open(input string) error {
cmd := exec.Command(Command, append(Args, input)...)
err := cmd.Start()
if err != nil {
debug.Printf("Failed to start %s: %v", Command, err)
} else {
go func() {
waitErr := cmd.Wait()
if waitErr != nil {
debug.Printf("Failed to run %s: %v", Command, err)
} }
return emoji.u }()
}
return err
} }

5
lib/open/open_darwin.go Normal file
View file

@ -0,0 +1,5 @@
package open
const Command = "open"
var Args []string

View file

@ -1,5 +1,5 @@
// gomuks - A Matrix client written in Go. // gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan // Copyright (C) 2020 Tulir Asokan
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by // it under the terms of the GNU Affero General Public License as published by
@ -14,13 +14,14 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
package web package open
import ( import (
"embed" "os"
"path/filepath"
) )
//go:generate npm install const FileProtocolHandler = "url.dll,FileProtocolHandler"
//go:generate npm run build
//go:embed dist var Command = filepath.Join(os.Getenv("SYSTEMROOT"), "System32", "rundll32.exe")
var Frontend embed.FS var Args = []string{FileProtocolHandler}

7
lib/open/open_xdg.go Normal file
View file

@ -0,0 +1,7 @@
//go:build !windows && !darwin
package open
const Command = "xdg-open"
var Args []string

2
lib/util/doc.go Normal file
View file

@ -0,0 +1,2 @@
// Package util contains miscellaneous utilities
package util

38
lib/util/lcp.go Normal file
View file

@ -0,0 +1,38 @@
// Licensed under the GNU Free Documentation License 1.2
// https://www.gnu.org/licenses/old-licenses/fdl-1.2.en.html
//
// Source: https://rosettacode.org/wiki/Longest_common_prefix#Go
package util
func LongestCommonPrefix(list []string) string {
// Special cases first
switch len(list) {
case 0:
return ""
case 1:
return list[0]
}
// LCP of min and max (lexigraphically)
// is the LCP of the whole set.
min, max := list[0], list[0]
for _, s := range list[1:] {
switch {
case s < min:
min = s
case s > max:
max = s
}
}
for i := 0; i < len(min) && i < len(max); i++ {
if min[i] != max[i] {
return min[:i]
}
}
// In the case where lengths are not equal but all bytes
// are equal, min is the answer ("foo" < "foobar").
return min
}

173
main.go Normal file
View file

@ -0,0 +1,173 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"maunium.net/go/gomuks/debug"
ifc "maunium.net/go/gomuks/interface"
"maunium.net/go/gomuks/ui"
)
var MainUIProvider ifc.UIProvider = ui.NewGomuksUI
func main() {
debugDir := os.Getenv("DEBUG_DIR")
if len(debugDir) > 0 {
debug.LogDirectory = debugDir
}
debugLevel := strings.ToLower(os.Getenv("DEBUG"))
if debugLevel != "0" && debugLevel != "f" && debugLevel != "false" {
debug.WriteLogs = true
debug.RecoverPrettyPanic = true
}
if debugLevel == "1" || debugLevel == "t" || debugLevel == "true" {
debug.RecoverPrettyPanic = false
debug.DeadlockDetection = true
}
debug.Initialize()
defer debug.Recover()
var configDir, dataDir, cacheDir, downloadDir string
var err error
configDir, err = UserConfigDir()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to get config directory:", err)
os.Exit(3)
}
dataDir, err = UserDataDir()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to get data directory:", err)
os.Exit(3)
}
cacheDir, err = UserCacheDir()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to get cache directory:", err)
os.Exit(3)
}
downloadDir, err = UserDownloadDir()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to get download directory:", err)
os.Exit(3)
}
debug.Print("Config directory:", configDir)
debug.Print("Data directory:", dataDir)
debug.Print("Cache directory:", cacheDir)
debug.Print("Download directory:", downloadDir)
gmx := NewGomuks(MainUIProvider, configDir, dataDir, cacheDir, downloadDir)
if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v") {
fmt.Println(VersionString)
os.Exit(0)
}
gmx.Start()
// We use os.Exit() everywhere, so exiting by returning from Start() shouldn't happen.
time.Sleep(5 * time.Second)
fmt.Println("Unexpected exit by return from gmx.Start().")
os.Exit(2)
}
func getRootDir(subdir string) string {
rootDir := os.Getenv("GOMUKS_ROOT")
if rootDir == "" {
return ""
}
return filepath.Join(rootDir, subdir)
}
func UserCacheDir() (dir string, err error) {
dir = os.Getenv("GOMUKS_CACHE_HOME")
if dir == "" {
dir = getRootDir("cache")
}
if dir == "" {
dir, err = os.UserCacheDir()
dir = filepath.Join(dir, "gomuks")
}
return
}
func UserDataDir() (dir string, err error) {
dir = os.Getenv("GOMUKS_DATA_HOME")
if dir != "" {
return
}
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
return UserConfigDir()
}
dir = getRootDir("data")
if dir == "" {
dir = os.Getenv("XDG_DATA_HOME")
}
if dir == "" {
dir = os.Getenv("HOME")
if dir == "" {
return "", errors.New("neither $XDG_DATA_HOME nor $HOME are defined")
}
dir = filepath.Join(dir, ".local", "share")
}
dir = filepath.Join(dir, "gomuks")
return
}
func getXDGUserDir(name string) (dir string, err error) {
cmd := exec.Command("xdg-user-dir", name)
var out strings.Builder
cmd.Stdout = &out
err = cmd.Run()
dir = strings.TrimSpace(out.String())
return
}
func UserDownloadDir() (dir string, err error) {
dir = os.Getenv("GOMUKS_DOWNLOAD_HOME")
if dir != "" {
return
}
dir, _ = getXDGUserDir("DOWNLOAD")
if dir != "" {
return
}
dir, err = os.UserHomeDir()
dir = filepath.Join(dir, "Downloads")
return
}
func UserConfigDir() (dir string, err error) {
dir = os.Getenv("GOMUKS_CONFIG_HOME")
if dir == "" {
dir = getRootDir("config")
}
if dir == "" {
dir, err = os.UserConfigDir()
dir = filepath.Join(dir, "gomuks")
}
return
}

100
matrix/crypto.go Normal file
View file

@ -0,0 +1,100 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//go:build cgo
package matrix
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/gomuks/debug"
)
type cryptoLogger struct {
prefix string
}
func (c cryptoLogger) Error(message string, args ...interface{}) {
debug.Printf(fmt.Sprintf("[%s/Error] %s", c.prefix, message), args...)
}
func (c cryptoLogger) Warn(message string, args ...interface{}) {
debug.Printf(fmt.Sprintf("[%s/Warn] %s", c.prefix, message), args...)
}
func (c cryptoLogger) Debug(message string, args ...interface{}) {
debug.Printf(fmt.Sprintf("[%s/Debug] %s", c.prefix, message), args...)
}
func (c cryptoLogger) Trace(message string, args ...interface{}) {
debug.Printf(fmt.Sprintf("[%s/Trace] %s", c.prefix, message), args...)
}
func isBadEncryptError(err error) bool {
return err != crypto.SessionExpired && err != crypto.SessionNotShared && err != crypto.NoGroupSession
}
func (c *Container) initCrypto() error {
var cryptoStore crypto.Store
var err error
legacyStorePath := filepath.Join(c.config.DataDir, "crypto.gob")
if _, err = os.Stat(legacyStorePath); err == nil {
debug.Printf("Using legacy crypto store as %s exists", legacyStorePath)
cryptoStore, err = crypto.NewGobStore(legacyStorePath)
if err != nil {
return fmt.Errorf("file open: %w", err)
}
} else {
debug.Printf("Using SQLite crypto store")
newStorePath := filepath.Join(c.config.DataDir, "crypto.db")
db, err := sql.Open("sqlite3", newStorePath)
if err != nil {
return fmt.Errorf("sql open: %w", err)
}
accID := fmt.Sprintf("%s/%s", c.config.UserID.String(), c.config.DeviceID)
sqlStore := crypto.NewSQLCryptoStore(db, "sqlite3", accID, c.config.DeviceID, []byte("fi.mau.gomuks"), cryptoLogger{"Crypto/DB"})
err = sqlStore.CreateTables()
if err != nil {
return fmt.Errorf("create table: %w", err)
}
cryptoStore = sqlStore
}
crypt := crypto.NewOlmMachine(c.client, cryptoLogger{"Crypto"}, cryptoStore, c.config.Rooms)
crypt.AllowUnverifiedDevices = !c.config.SendToVerifiedOnly
c.crypto = crypt
err = c.crypto.Load()
if err != nil {
return fmt.Errorf("failed to create olm machine: %w", err)
}
return nil
}
func (c *Container) cryptoOnLogin() {
sqlStore, ok := c.crypto.(*crypto.OlmMachine).CryptoStore.(*crypto.SQLCryptoStore)
if !ok {
return
}
sqlStore.DeviceID = c.config.DeviceID
sqlStore.AccountID = fmt.Sprintf("%s/%s", c.config.UserID.String(), c.config.DeviceID)
}

2
matrix/doc.go Normal file
View file

@ -0,0 +1,2 @@
// Package matrix contains wrappers for mautrix for use by the UI of gomuks.
package matrix

316
matrix/history.go Normal file
View file

@ -0,0 +1,316 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package matrix
import (
"bytes"
"compress/gzip"
"encoding/binary"
"encoding/gob"
"errors"
sync "github.com/sasha-s/go-deadlock"
bolt "go.etcd.io/bbolt"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/gomuks/matrix/muksevt"
"maunium.net/go/gomuks/matrix/rooms"
)
type HistoryManager struct {
sync.Mutex
db *bolt.DB
historyEndPtr map[*rooms.Room]uint64
}
var bucketRoomStreams = []byte("room_streams")
var bucketRoomEventIDs = []byte("room_event_ids")
var bucketStreamPointers = []byte("room_stream_pointers")
const halfUint64 = ^uint64(0) >> 1
func NewHistoryManager(dbPath string) (*HistoryManager, error) {
hm := &HistoryManager{
historyEndPtr: make(map[*rooms.Room]uint64),
}
db, err := bolt.Open(dbPath, 0600, &bolt.Options{
Timeout: 1,
NoGrowSync: false,
FreelistType: bolt.FreelistArrayType,
})
if err != nil {
return nil, err
}
err = db.Update(func(tx *bolt.Tx) error {
_, err = tx.CreateBucketIfNotExists(bucketRoomStreams)
if err != nil {
return err
}
_, err = tx.CreateBucketIfNotExists(bucketRoomEventIDs)
if err != nil {
return err
}
_, err = tx.CreateBucketIfNotExists(bucketStreamPointers)
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
hm.db = db
return hm, nil
}
func (hm *HistoryManager) Close() error {
return hm.db.Close()
}
var (
EventNotFoundError = errors.New("event not found")
RoomNotFoundError = errors.New("room not found")
)
func (hm *HistoryManager) getStreamIndex(tx *bolt.Tx, roomID []byte, eventID []byte) (*bolt.Bucket, []byte, error) {
eventIDs := tx.Bucket(bucketRoomEventIDs).Bucket(roomID)
if eventIDs == nil {
return nil, nil, RoomNotFoundError
}
index := eventIDs.Get(eventID)
if index == nil {
return nil, nil, EventNotFoundError
}
stream := tx.Bucket(bucketRoomStreams).Bucket(roomID)
return stream, index, nil
}
func (hm *HistoryManager) getEvent(tx *bolt.Tx, stream *bolt.Bucket, index []byte) (*muksevt.Event, error) {
eventData := stream.Get(index)
if eventData == nil || len(eventData) == 0 {
return nil, EventNotFoundError
}
return unmarshalEvent(eventData)
}
func (hm *HistoryManager) Get(room *rooms.Room, eventID id.EventID) (evt *muksevt.Event, err error) {
err = hm.db.View(func(tx *bolt.Tx) error {
if stream, index, err := hm.getStreamIndex(tx, []byte(room.ID), []byte(eventID)); err != nil {
return err
} else if evt, err = hm.getEvent(tx, stream, index); err != nil {
return err
}
return nil
})
return
}
func (hm *HistoryManager) Update(room *rooms.Room, eventID id.EventID, update func(evt *muksevt.Event) error) error {
return hm.db.Update(func(tx *bolt.Tx) error {
if stream, index, err := hm.getStreamIndex(tx, []byte(room.ID), []byte(eventID)); err != nil {
return err
} else if evt, err := hm.getEvent(tx, stream, index); err != nil {
return err
} else if err = update(evt); err != nil {
return err
} else if eventData, err := marshalEvent(evt); err != nil {
return err
} else if err := stream.Put(index, eventData); err != nil {
return err
}
return nil
})
}
func (hm *HistoryManager) Append(room *rooms.Room, events []*event.Event) ([]*muksevt.Event, error) {
muksEvts, _, err := hm.store(room, events, true)
return muksEvts, err
}
func (hm *HistoryManager) Prepend(room *rooms.Room, events []*event.Event) ([]*muksevt.Event, uint64, error) {
return hm.store(room, events, false)
}
func (hm *HistoryManager) store(room *rooms.Room, events []*event.Event, append bool) (newEvents []*muksevt.Event, newPtrStart uint64, err error) {
hm.Lock()
defer hm.Unlock()
newEvents = make([]*muksevt.Event, len(events))
err = hm.db.Update(func(tx *bolt.Tx) error {
streamPointers := tx.Bucket(bucketStreamPointers)
rid := []byte(room.ID)
stream, err := tx.Bucket(bucketRoomStreams).CreateBucketIfNotExists(rid)
if err != nil {
return err
}
eventIDs, err := tx.Bucket(bucketRoomEventIDs).CreateBucketIfNotExists(rid)
if err != nil {
return err
}
if stream.Sequence() < halfUint64 {
// The sequence counter (i.e. the future) the part after 2^63, i.e. the second half of uint64
// We set it to -1 because NextSequence will increment it by one.
err = stream.SetSequence(halfUint64 - 1)
if err != nil {
return err
}
}
if append {
ptrStart, err := stream.NextSequence()
if err != nil {
return err
}
for i, evt := range events {
newEvents[i] = muksevt.Wrap(evt)
if err := put(stream, eventIDs, newEvents[i], ptrStart+uint64(i)); err != nil {
return err
}
}
err = stream.SetSequence(ptrStart + uint64(len(events)) - 1)
if err != nil {
return err
}
} else {
ptrStart, ok := hm.historyEndPtr[room]
if !ok {
ptrStartRaw := streamPointers.Get(rid)
if ptrStartRaw != nil {
ptrStart = btoi(ptrStartRaw)
} else {
ptrStart = halfUint64 - 1
}
}
eventCount := uint64(len(events))
for i, evt := range events {
newEvents[i] = muksevt.Wrap(evt)
if err := put(stream, eventIDs, newEvents[i], -ptrStart-uint64(i)); err != nil {
return err
}
}
hm.historyEndPtr[room] = ptrStart + eventCount
// TODO this is not the correct value for newPtrStart, figure out what the f*ck is going on here
newPtrStart = ptrStart + eventCount
err := streamPointers.Put(rid, itob(ptrStart+eventCount))
if err != nil {
return err
}
}
return nil
})
return
}
func (hm *HistoryManager) Load(room *rooms.Room, num int, ptrStart uint64) (events []*muksevt.Event, newPtrStart uint64, err error) {
hm.Lock()
defer hm.Unlock()
err = hm.db.View(func(tx *bolt.Tx) error {
stream := tx.Bucket(bucketRoomStreams).Bucket([]byte(room.ID))
if stream == nil {
return nil
}
if ptrStart == 0 {
ptrStart = stream.Sequence() + 1
}
c := stream.Cursor()
k, v := c.Seek(itob(ptrStart - uint64(num)))
ptrStartFound := btoi(k)
if k == nil || ptrStartFound >= ptrStart {
return nil
}
newPtrStart = ptrStartFound
for ; k != nil && btoi(k) < ptrStart; k, v = c.Next() {
evt, parseError := unmarshalEvent(v)
if parseError != nil {
return parseError
}
events = append(events, evt)
}
return nil
})
// Reverse array because we read/append the history in reverse order.
i := 0
j := len(events) - 1
for i < j {
events[i], events[j] = events[j], events[i]
i++
j--
}
return
}
func itob(v uint64) []byte {
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, v)
return b
}
func btoi(b []byte) uint64 {
return binary.BigEndian.Uint64(b)
}
func stripRaw(evt *muksevt.Event) {
evtCopy := *evt.Event
evtCopy.Content = event.Content{
Parsed: evt.Content.Parsed,
}
evt.Event = &evtCopy
}
func marshalEvent(evt *muksevt.Event) ([]byte, error) {
stripRaw(evt)
var buf bytes.Buffer
enc, _ := gzip.NewWriterLevel(&buf, gzip.BestSpeed)
if err := gob.NewEncoder(enc).Encode(evt); err != nil {
_ = enc.Close()
return nil, err
} else if err := enc.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func unmarshalEvent(data []byte) (*muksevt.Event, error) {
evt := &muksevt.Event{}
if cmpReader, err := gzip.NewReader(bytes.NewReader(data)); err != nil {
return nil, err
} else if err := gob.NewDecoder(cmpReader).Decode(evt); err != nil {
_ = cmpReader.Close()
return nil, err
} else if err := cmpReader.Close(); err != nil {
return nil, err
}
return evt, nil
}
func put(streams, eventIDs *bolt.Bucket, evt *muksevt.Event, key uint64) error {
data, err := marshalEvent(evt)
if err != nil {
return err
}
keyBytes := itob(key)
if err = streams.Put(keyBytes, data); err != nil {
return err
}
if err = eventIDs.Put([]byte(evt.ID), keyBytes); err != nil {
return err
}
return nil
}

1310
matrix/matrix.go Normal file

File diff suppressed because it is too large Load diff

106
matrix/mediainfo.go Normal file
View file

@ -0,0 +1,106 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package matrix
import (
"context"
"fmt"
"image"
"os"
"strings"
"time"
"github.com/gabriel-vasile/mimetype"
"gopkg.in/vansante/go-ffprobe.v2"
"maunium.net/go/mautrix/event"
"maunium.net/go/gomuks/debug"
)
func getImageInfo(path string) (event.FileInfo, error) {
var info event.FileInfo
file, err := os.Open(path)
if err != nil {
return info, fmt.Errorf("failed to open image to get info: %w", err)
}
cfg, _, err := image.DecodeConfig(file)
if err != nil {
return info, fmt.Errorf("failed to get image info: %w", err)
}
info.Width = cfg.Width
info.Height = cfg.Height
return info, nil
}
func getFFProbeInfo(mimeClass, path string) (msgtype event.MessageType, info event.FileInfo, err error) {
ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelFn()
var probedInfo *ffprobe.ProbeData
probedInfo, err = ffprobe.ProbeURL(ctx, path)
if err != nil {
err = fmt.Errorf("failed to get %s info with ffprobe: %w", mimeClass, err)
return
}
if mimeClass == "audio" {
msgtype = event.MsgAudio
stream := probedInfo.FirstAudioStream()
if stream != nil {
info.Duration = int(stream.DurationTs)
}
} else {
msgtype = event.MsgVideo
stream := probedInfo.FirstVideoStream()
if stream != nil {
info.Duration = int(stream.DurationTs)
info.Width = stream.Width
info.Height = stream.Height
}
}
return
}
func getMediaInfo(path string) (msgtype event.MessageType, info event.FileInfo, err error) {
var mime *mimetype.MIME
mime, err = mimetype.DetectFile(path)
if err != nil {
err = fmt.Errorf("failed to get content type: %w", err)
return
}
mimeClass := strings.SplitN(mime.String(), "/", 2)[0]
switch mimeClass {
case "image":
msgtype = event.MsgImage
info, err = getImageInfo(path)
if err != nil {
debug.Printf("Failed to get image info for %s: %v", err)
err = nil
}
case "audio", "video":
msgtype, info, err = getFFProbeInfo(mimeClass, path)
if err != nil {
debug.Printf("Failed to get ffprobe info for %s: %v", err)
err = nil
}
default:
msgtype = event.MsgFile
}
info.MimeType = mime.String()
return
}

44
matrix/muksevt/content.go Normal file
View file

@ -0,0 +1,44 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package muksevt
import (
"encoding/gob"
"reflect"
"maunium.net/go/mautrix/event"
)
var EventBadEncrypted = event.Type{Type: "net.maunium.gomuks.bad_encrypted", Class: event.MessageEventType}
var EventEncryptionUnsupported = event.Type{Type: "net.maunium.gomuks.encryption_unsupported", Class: event.MessageEventType}
type BadEncryptedContent struct {
Original *event.EncryptedEventContent `json:"-"`
Reason string `json:"-"`
}
type EncryptionUnsupportedContent struct {
Original *event.EncryptedEventContent `json:"-"`
}
func init() {
gob.Register(&BadEncryptedContent{})
gob.Register(&EncryptionUnsupportedContent{})
event.TypeMap[EventBadEncrypted] = reflect.TypeOf(&BadEncryptedContent{})
event.TypeMap[EventEncryptionUnsupported] = reflect.TypeOf(&EncryptionUnsupportedContent{})
}

53
matrix/muksevt/event.go Normal file
View file

@ -0,0 +1,53 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package muksevt
import (
"maunium.net/go/mautrix/event"
)
type Event struct {
*event.Event
Gomuks GomuksContent `json:"-"`
}
func (evt *Event) SomewhatDangerousCopy() *Event {
base := *evt.Event
content := *base.Content.Parsed.(*event.MessageEventContent)
evt.Content.Parsed = &content
return &Event{
Event: &base,
Gomuks: evt.Gomuks,
}
}
func Wrap(event *event.Event) *Event {
return &Event{Event: event}
}
type OutgoingState int
const (
StateDefault OutgoingState = iota
StateLocalEcho
StateSendFail
)
type GomuksContent struct {
OutgoingState OutgoingState
Edits []*Event
}

15
matrix/nocrypto.go Normal file
View file

@ -0,0 +1,15 @@
// This contains no-op stubs of the methods in crypto.go for non-cgo builds with crypto disabled.
//go:build !cgo
package matrix
func isBadEncryptError(err error) bool {
return false
}
func (c *Container) initCrypto() error {
return nil
}
func (c *Container) cryptoOnLogin() {}

2
matrix/rooms/doc.go Normal file
View file

@ -0,0 +1,2 @@
// Package rooms contains a representation for Matrix rooms and utilities to parse state events.
package rooms

715
matrix/rooms/room.go Normal file
View file

@ -0,0 +1,715 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package rooms
import (
"compress/gzip"
"encoding/gob"
"encoding/json"
"fmt"
"os"
"time"
sync "github.com/sasha-s/go-deadlock"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/gomuks/debug"
)
func init() {
gob.Register(map[string]interface{}{})
gob.Register([]interface{}{})
}
type RoomNameSource int
const (
UnknownRoomName RoomNameSource = iota
MemberRoomName
CanonicalAliasRoomName
ExplicitRoomName
)
// RoomTag is a tag given to a specific room.
type RoomTag struct {
// The name of the tag.
Tag string
// The order of the tag.
Order json.Number
}
type UnreadMessage struct {
EventID id.EventID
Counted bool
Highlight bool
}
type Member struct {
event.MemberEventContent
// The user who sent the membership event
Sender id.UserID `json:"-"`
}
// Room represents a single Matrix room.
type Room struct {
// The room ID.
ID id.RoomID
// Whether or not the user has left the room.
HasLeft bool
// Whether or not the room is encrypted.
Encrypted bool
// The first batch of events that has been fetched for this room.
// Used for fetching additional history.
PrevBatch string
// The last_batch field from the most recent sync. Used for fetching member lists.
LastPrevBatch string
// The MXID of the user whose session this room was created for.
SessionUserID id.UserID
SessionMember *Member
// The number of unread messages that were notified about.
UnreadMessages []UnreadMessage
unreadCountCache *int
highlightCache *bool
lastMarkedRead id.EventID
// Whether or not this room is marked as a direct chat.
IsDirect bool
OtherUser id.UserID
// List of tags given to this room.
RawTags []RoomTag
// Timestamp of previously received actual message.
LastReceivedMessage time.Time
// The lazy loading summary for this room.
Summary mautrix.LazyLoadSummary
// Whether or not the members for this room have been fetched from the server.
MembersFetched bool
// Room state cache.
state map[event.Type]map[string]*event.Event
// MXID -> Member cache calculated from membership events.
memberCache map[id.UserID]*Member
exMemberCache map[id.UserID]*Member
// The first two non-SessionUserID members in the room. Calculated at
// the same time as memberCache.
firstMemberCache *Member
secondMemberCache *Member
// The name of the room. Calculated from the state event name,
// canonical_alias or alias or the member cache.
NameCache string
// The event type from which the name cache was calculated from.
nameCacheSource RoomNameSource
// The topic of the room. Directly fetched from the m.room.topic state event.
topicCache string
// The canonical alias of the room. Directly fetched from the m.room.canonical_alias state event.
CanonicalAliasCache id.RoomAlias
// Whether or not the room has been tombstoned.
replacedCache bool
// The room ID that replaced this room.
replacedByCache *id.RoomID
// Path for state store file.
path string
// Room cache object
cache *RoomCache
// Lock for state and other room stuff.
lock sync.RWMutex
// Pre/post un/load hooks
preUnload func() bool
preLoad func() bool
postUnload func()
postLoad func()
// Whether or not the room state has changed
changed bool
// Room state cache linked list.
prev *Room
next *Room
touch int64
}
func debugPrintError(fn func() error, message string) {
if err := fn(); err != nil {
debug.Printf("%s: %v", message, err)
}
}
func (room *Room) Loaded() bool {
return room.state != nil
}
func (room *Room) Load() {
room.cache.TouchNode(room)
if room.Loaded() {
return
}
if room.preLoad != nil && !room.preLoad() {
return
}
room.lock.Lock()
room.load()
room.lock.Unlock()
if room.postLoad != nil {
room.postLoad()
}
}
func (room *Room) load() {
if room.Loaded() {
return
}
debug.Print("Loading state for room", room.ID, "from disk")
room.state = make(map[event.Type]map[string]*event.Event)
file, err := os.OpenFile(room.path, os.O_RDONLY, 0600)
if err != nil {
if !os.IsNotExist(err) {
debug.Print("Failed to open room state file for reading:", err)
} else {
debug.Print("Room state file for", room.ID, "does not exist")
}
return
}
defer debugPrintError(file.Close, "Failed to close room state file after reading")
cmpReader, err := gzip.NewReader(file)
if err != nil {
debug.Print("Failed to open room state gzip reader:", err)
return
}
defer debugPrintError(cmpReader.Close, "Failed to close room state gzip reader")
dec := gob.NewDecoder(cmpReader)
if err = dec.Decode(&room.state); err != nil {
debug.Print("Failed to decode room state:", err)
}
room.changed = false
}
func (room *Room) Touch() {
room.cache.TouchNode(room)
}
func (room *Room) Unload() bool {
if room.preUnload != nil && !room.preUnload() {
return false
}
debug.Print("Unloading", room.ID)
room.Save()
room.state = nil
room.memberCache = nil
room.exMemberCache = nil
room.firstMemberCache = nil
room.secondMemberCache = nil
if room.postUnload != nil {
room.postUnload()
}
return true
}
func (room *Room) SetPreUnload(fn func() bool) {
room.preUnload = fn
}
func (room *Room) SetPreLoad(fn func() bool) {
room.preLoad = fn
}
func (room *Room) SetPostUnload(fn func()) {
room.postUnload = fn
}
func (room *Room) SetPostLoad(fn func()) {
room.postLoad = fn
}
func (room *Room) Save() {
if !room.Loaded() {
debug.Print("Failed to save room", room.ID, "state: room not loaded")
return
}
if !room.changed {
debug.Print("Not saving", room.ID, "as state hasn't changed")
return
}
debug.Print("Saving state for room", room.ID, "to disk")
file, err := os.OpenFile(room.path, os.O_WRONLY|os.O_CREATE, 0600)
if err != nil {
debug.Print("Failed to open room state file for writing:", err)
return
}
defer debugPrintError(file.Close, "Failed to close room state file after writing")
cmpWriter := gzip.NewWriter(file)
defer debugPrintError(cmpWriter.Close, "Failed to close room state gzip writer")
enc := gob.NewEncoder(cmpWriter)
room.lock.RLock()
defer room.lock.RUnlock()
if err := enc.Encode(&room.state); err != nil {
debug.Print("Failed to encode room state:", err)
}
}
// MarkRead clears the new message statuses on this room.
func (room *Room) MarkRead(eventID id.EventID) bool {
room.lock.Lock()
defer room.lock.Unlock()
if room.lastMarkedRead == eventID {
return false
}
room.lastMarkedRead = eventID
readToIndex := -1
for index, unreadMessage := range room.UnreadMessages {
if unreadMessage.EventID == eventID {
readToIndex = index
}
}
if readToIndex >= 0 {
room.UnreadMessages = room.UnreadMessages[readToIndex+1:]
room.highlightCache = nil
room.unreadCountCache = nil
}
return true
}
func (room *Room) UnreadCount() int {
room.lock.Lock()
defer room.lock.Unlock()
if room.unreadCountCache == nil {
room.unreadCountCache = new(int)
for _, unreadMessage := range room.UnreadMessages {
if unreadMessage.Counted {
*room.unreadCountCache++
}
}
}
return *room.unreadCountCache
}
func (room *Room) Highlighted() bool {
room.lock.Lock()
defer room.lock.Unlock()
if room.highlightCache == nil {
room.highlightCache = new(bool)
for _, unreadMessage := range room.UnreadMessages {
if unreadMessage.Highlight {
*room.highlightCache = true
break
}
}
}
return *room.highlightCache
}
func (room *Room) HasNewMessages() bool {
return len(room.UnreadMessages) > 0
}
func (room *Room) AddUnread(eventID id.EventID, counted, highlight bool) {
room.lock.Lock()
defer room.lock.Unlock()
room.UnreadMessages = append(room.UnreadMessages, UnreadMessage{
EventID: eventID,
Counted: counted,
Highlight: highlight,
})
if counted {
if room.unreadCountCache == nil {
room.unreadCountCache = new(int)
}
*room.unreadCountCache++
}
if highlight {
if room.highlightCache == nil {
room.highlightCache = new(bool)
}
*room.highlightCache = true
}
}
var (
tagDirect = RoomTag{"net.maunium.gomuks.fake.direct", "0.5"}
tagInvite = RoomTag{"net.maunium.gomuks.fake.invite", "0.5"}
tagDefault = RoomTag{"", "0.5"}
tagLeave = RoomTag{"net.maunium.gomuks.fake.leave", "0.5"}
)
func (room *Room) Tags() []RoomTag {
room.lock.RLock()
defer room.lock.RUnlock()
if len(room.RawTags) == 0 {
if room.IsDirect {
return []RoomTag{tagDirect}
} else if room.SessionMember != nil && room.SessionMember.Membership == event.MembershipInvite {
return []RoomTag{tagInvite}
} else if room.SessionMember != nil && room.SessionMember.Membership != event.MembershipJoin {
return []RoomTag{tagLeave}
}
return []RoomTag{tagDefault}
}
return room.RawTags
}
func (room *Room) UpdateSummary(summary mautrix.LazyLoadSummary) {
if summary.JoinedMemberCount != nil {
room.Summary.JoinedMemberCount = summary.JoinedMemberCount
}
if summary.InvitedMemberCount != nil {
room.Summary.InvitedMemberCount = summary.InvitedMemberCount
}
if summary.Heroes != nil {
room.Summary.Heroes = summary.Heroes
}
if room.nameCacheSource <= MemberRoomName {
room.NameCache = ""
}
}
// UpdateState updates the room's current state with the given Event. This will clobber events based
// on the type/state_key combination.
func (room *Room) UpdateState(evt *event.Event) {
if evt.StateKey == nil {
panic("Tried to UpdateState() event with no state key.")
}
room.Load()
room.lock.Lock()
defer room.lock.Unlock()
room.changed = true
_, exists := room.state[evt.Type]
if !exists {
room.state[evt.Type] = make(map[string]*event.Event)
}
switch content := evt.Content.Parsed.(type) {
case *event.RoomNameEventContent:
room.NameCache = content.Name
room.nameCacheSource = ExplicitRoomName
case *event.CanonicalAliasEventContent:
if room.nameCacheSource <= CanonicalAliasRoomName {
room.NameCache = string(content.Alias)
room.nameCacheSource = CanonicalAliasRoomName
}
room.CanonicalAliasCache = content.Alias
case *event.MemberEventContent:
if room.nameCacheSource <= MemberRoomName {
room.NameCache = ""
}
room.updateMemberState(id.UserID(evt.GetStateKey()), evt.Sender, content)
case *event.TopicEventContent:
room.topicCache = content.Topic
case *event.EncryptionEventContent:
if content.Algorithm == id.AlgorithmMegolmV1 {
room.Encrypted = true
}
}
if evt.Type != event.StateMember {
debug.Printf("Updating state %s#%s for %s", evt.Type.String(), evt.GetStateKey(), room.ID)
}
room.state[evt.Type][*evt.StateKey] = evt
}
func (room *Room) updateMemberState(userID, sender id.UserID, content *event.MemberEventContent) {
if userID == room.SessionUserID {
debug.Print("Updating session user state:", content)
room.SessionMember = room.eventToMember(userID, sender, content)
}
if room.memberCache != nil {
member := room.eventToMember(userID, sender, content)
if member.Membership.IsInviteOrJoin() {
existingMember, ok := room.memberCache[userID]
if ok {
*existingMember = *member
} else {
delete(room.exMemberCache, userID)
room.memberCache[userID] = member
room.updateNthMemberCache(userID, member)
}
} else {
existingExMember, ok := room.exMemberCache[userID]
if ok {
*existingExMember = *member
} else {
delete(room.memberCache, userID)
room.exMemberCache[userID] = member
}
}
}
}
// GetStateEvent returns the state event for the given type/state_key combo, or nil.
func (room *Room) GetStateEvent(eventType event.Type, stateKey string) *event.Event {
room.Load()
room.lock.RLock()
defer room.lock.RUnlock()
stateEventMap, _ := room.state[eventType]
evt, _ := stateEventMap[stateKey]
return evt
}
// getStateEvents returns the state events for the given type.
func (room *Room) getStateEvents(eventType event.Type) map[string]*event.Event {
stateEventMap, _ := room.state[eventType]
return stateEventMap
}
// GetTopic returns the topic of the room.
func (room *Room) GetTopic() string {
if len(room.topicCache) == 0 {
topicEvt := room.GetStateEvent(event.StateTopic, "")
if topicEvt != nil {
room.topicCache = topicEvt.Content.AsTopic().Topic
}
}
return room.topicCache
}
func (room *Room) GetCanonicalAlias() id.RoomAlias {
if len(room.CanonicalAliasCache) == 0 {
canonicalAliasEvt := room.GetStateEvent(event.StateCanonicalAlias, "")
if canonicalAliasEvt != nil {
room.CanonicalAliasCache = canonicalAliasEvt.Content.AsCanonicalAlias().Alias
} else {
room.CanonicalAliasCache = "-"
}
}
if room.CanonicalAliasCache == "-" {
return ""
}
return room.CanonicalAliasCache
}
// updateNameFromNameEvent updates the room display name to be the name set in the name event.
func (room *Room) updateNameFromNameEvent() {
nameEvt := room.GetStateEvent(event.StateRoomName, "")
if nameEvt != nil {
room.NameCache = nameEvt.Content.AsRoomName().Name
}
}
// updateNameFromMembers updates the room display name based on the members in this room.
//
// The room name depends on the number of users:
//
// Less than two users -> "Empty room"
// Exactly two users -> The display name of the other user.
// More than two users -> The display name of one of the other users, followed
// by "and X others", where X is the number of users
// excluding the local user and the named user.
func (room *Room) updateNameFromMembers() {
members := room.GetMembers()
if len(members) <= 1 {
room.NameCache = "Empty room"
} else if room.firstMemberCache == nil {
room.NameCache = "Room"
} else if len(members) == 2 {
room.NameCache = room.firstMemberCache.Displayname
} else if len(members) == 3 && room.secondMemberCache != nil {
room.NameCache = fmt.Sprintf("%s and %s", room.firstMemberCache.Displayname, room.secondMemberCache.Displayname)
} else {
members := room.firstMemberCache.Displayname
count := len(members) - 2
if room.secondMemberCache != nil {
members += ", " + room.secondMemberCache.Displayname
count--
}
room.NameCache = fmt.Sprintf("%s and %d others", members, count)
}
}
// updateNameCache updates the room display name based on the room state in the order
// specified in spec section 11.2.2.5.
func (room *Room) updateNameCache() {
if len(room.NameCache) == 0 {
room.updateNameFromNameEvent()
room.nameCacheSource = ExplicitRoomName
}
if len(room.NameCache) == 0 {
room.NameCache = string(room.GetCanonicalAlias())
room.nameCacheSource = CanonicalAliasRoomName
}
if len(room.NameCache) == 0 {
room.updateNameFromMembers()
room.nameCacheSource = MemberRoomName
}
}
// GetTitle returns the display name of the room.
//
// The display name is returned from the cache.
// If the cache is empty, it is updated first.
func (room *Room) GetTitle() string {
room.updateNameCache()
return room.NameCache
}
func (room *Room) IsReplaced() bool {
if room.replacedByCache == nil {
evt := room.GetStateEvent(event.StateTombstone, "")
var replacement id.RoomID
if evt != nil {
content, ok := evt.Content.Parsed.(*event.TombstoneEventContent)
if ok {
replacement = content.ReplacementRoom
}
}
room.replacedCache = evt != nil
room.replacedByCache = &replacement
}
return room.replacedCache
}
func (room *Room) ReplacedBy() id.RoomID {
if room.replacedByCache == nil {
room.IsReplaced()
}
return *room.replacedByCache
}
func (room *Room) eventToMember(userID, sender id.UserID, member *event.MemberEventContent) *Member {
if len(member.Displayname) == 0 {
member.Displayname = string(userID)
}
return &Member{
MemberEventContent: *member,
Sender: sender,
}
}
func (room *Room) updateNthMemberCache(userID id.UserID, member *Member) {
if userID != room.SessionUserID {
if room.firstMemberCache == nil {
room.firstMemberCache = member
} else if room.secondMemberCache == nil {
room.secondMemberCache = member
}
}
}
// createMemberCache caches all member events into a easily processable MXID -> *Member map.
func (room *Room) createMemberCache() map[id.UserID]*Member {
if len(room.memberCache) > 0 {
return room.memberCache
}
cache := make(map[id.UserID]*Member)
exCache := make(map[id.UserID]*Member)
room.lock.RLock()
memberEvents := room.getStateEvents(event.StateMember)
room.firstMemberCache = nil
room.secondMemberCache = nil
if memberEvents != nil {
for userIDStr, evt := range memberEvents {
userID := id.UserID(userIDStr)
member := room.eventToMember(userID, evt.Sender, evt.Content.AsMember())
if member.Membership.IsInviteOrJoin() {
cache[userID] = member
room.updateNthMemberCache(userID, member)
} else {
exCache[userID] = member
}
if userID == room.SessionUserID {
room.SessionMember = member
}
}
}
if len(room.Summary.Heroes) > 1 {
room.firstMemberCache, _ = cache[room.Summary.Heroes[0]]
}
if len(room.Summary.Heroes) > 2 {
room.secondMemberCache, _ = cache[room.Summary.Heroes[1]]
}
room.lock.RUnlock()
room.lock.Lock()
room.memberCache = cache
room.exMemberCache = exCache
room.lock.Unlock()
return cache
}
// GetMembers returns the members in this room.
//
// The members are returned from the cache.
// If the cache is empty, it is updated first.
func (room *Room) GetMembers() map[id.UserID]*Member {
room.Load()
room.createMemberCache()
return room.memberCache
}
func (room *Room) GetMemberList() []id.UserID {
members := room.GetMembers()
memberList := make([]id.UserID, len(members))
index := 0
for userID, _ := range members {
memberList[index] = userID
index++
}
return memberList
}
// GetMember returns the member with the given MXID.
// If the member doesn't exist, nil is returned.
func (room *Room) GetMember(userID id.UserID) *Member {
if userID == room.SessionUserID && room.SessionMember != nil {
return room.SessionMember
}
room.Load()
room.createMemberCache()
room.lock.RLock()
member, ok := room.memberCache[userID]
if ok {
room.lock.RUnlock()
return member
}
exMember, ok := room.exMemberCache[userID]
if ok {
room.lock.RUnlock()
return exMember
}
room.lock.RUnlock()
return nil
}
func (room *Room) GetMemberCount() int {
if room.memberCache == nil && room.Summary.JoinedMemberCount != nil {
return *room.Summary.JoinedMemberCount
}
return len(room.GetMembers())
}
// GetSessionOwner returns the ID of the user whose session this room was created for.
func (room *Room) GetOwnDisplayname() string {
member := room.GetMember(room.SessionUserID)
if member != nil {
return member.Displayname
}
return ""
}
// NewRoom creates a new Room with the given ID
func NewRoom(roomID id.RoomID, cache *RoomCache) *Room {
return &Room{
ID: roomID,
state: make(map[event.Type]map[string]*event.Event),
path: cache.roomPath(roomID),
cache: cache,
SessionUserID: cache.getOwner(),
}
}

376
matrix/rooms/roomcache.go Normal file
View file

@ -0,0 +1,376 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package rooms
import (
"compress/gzip"
"encoding/gob"
"fmt"
"os"
"path/filepath"
"strings"
"time"
sync "github.com/sasha-s/go-deadlock"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/gomuks/debug"
)
// RoomCache contains room state info in a hashmap and linked list.
type RoomCache struct {
sync.Mutex
listPath string
directory string
maxSize int
maxAge int64
getOwner func() id.UserID
noUnload bool
Map map[id.RoomID]*Room
head *Room
tail *Room
size int
}
func NewRoomCache(listPath, directory string, maxSize int, maxAge int64, getOwner func() id.UserID) *RoomCache {
return &RoomCache{
listPath: listPath,
directory: directory,
maxSize: maxSize,
maxAge: maxAge,
getOwner: getOwner,
Map: make(map[id.RoomID]*Room),
}
}
func (cache *RoomCache) DisableUnloading() {
cache.noUnload = true
}
func (cache *RoomCache) EnableUnloading() {
cache.noUnload = false
}
func (cache *RoomCache) IsEncrypted(roomID id.RoomID) bool {
room := cache.Get(roomID)
return room != nil && room.Encrypted
}
func (cache *RoomCache) GetEncryptionEvent(roomID id.RoomID) *event.EncryptionEventContent {
room := cache.Get(roomID)
evt := room.GetStateEvent(event.StateEncryption, "")
if evt == nil {
return nil
}
content, ok := evt.Content.Parsed.(*event.EncryptionEventContent)
if !ok {
return nil
}
return content
}
func (cache *RoomCache) FindSharedRooms(userID id.UserID) (shared []id.RoomID) {
// FIXME this disables unloading so TouchNode wouldn't try to double-lock
cache.DisableUnloading()
cache.Lock()
for _, room := range cache.Map {
if !room.Encrypted {
continue
}
member, ok := room.GetMembers()[userID]
if ok && member.Membership == event.MembershipJoin {
shared = append(shared, room.ID)
}
}
cache.Unlock()
cache.EnableUnloading()
return
}
func (cache *RoomCache) LoadList() error {
cache.Lock()
defer cache.Unlock()
// Open room list file
file, err := os.OpenFile(cache.listPath, os.O_RDONLY, 0600)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to open room list file for reading: %w", err)
}
defer debugPrintError(file.Close, "Failed to close room list file after reading")
// Open gzip reader for room list file
cmpReader, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("failed to read gzip room list: %w", err)
}
defer debugPrintError(cmpReader.Close, "Failed to close room list gzip reader")
// Open gob decoder for gzip reader
dec := gob.NewDecoder(cmpReader)
// Read number of items in list
var size int
err = dec.Decode(&size)
if err != nil {
return fmt.Errorf("failed to read size of room list: %w", err)
}
// Read list
cache.Map = make(map[id.RoomID]*Room, size)
for i := 0; i < size; i++ {
room := &Room{}
err = dec.Decode(room)
if err != nil {
debug.Printf("Failed to decode %dth room list entry: %v", i+1, err)
continue
}
room.path = cache.roomPath(room.ID)
room.cache = cache
cache.Map[room.ID] = room
}
return nil
}
func (cache *RoomCache) SaveLoadedRooms() {
cache.Lock()
cache.clean(false)
for node := cache.head; node != nil; node = node.prev {
node.Save()
}
cache.Unlock()
}
func (cache *RoomCache) SaveList() error {
cache.Lock()
defer cache.Unlock()
debug.Print("Saving room list...")
// Open room list file
file, err := os.OpenFile(cache.listPath, os.O_WRONLY|os.O_CREATE, 0600)
if err != nil {
return fmt.Errorf("failed to open room list file for writing: %w", err)
}
defer debugPrintError(file.Close, "Failed to close room list file after writing")
// Open gzip writer for room list file
cmpWriter := gzip.NewWriter(file)
defer debugPrintError(cmpWriter.Close, "Failed to close room list gzip writer")
// Open gob encoder for gzip writer
enc := gob.NewEncoder(cmpWriter)
// Write number of items in list
err = enc.Encode(len(cache.Map))
if err != nil {
return fmt.Errorf("failed to write size of room list: %w", err)
}
// Write list
for _, node := range cache.Map {
err = enc.Encode(node)
if err != nil {
debug.Printf("Failed to encode room list entry of %s: %v", node.ID, err)
}
}
debug.Print("Room list saved to", cache.listPath, len(cache.Map), cache.size)
return nil
}
func (cache *RoomCache) Touch(roomID id.RoomID) {
cache.Lock()
node, ok := cache.Map[roomID]
if !ok || node == nil {
cache.Unlock()
return
}
cache.touch(node)
cache.Unlock()
}
func (cache *RoomCache) TouchNode(node *Room) {
if cache.noUnload || node.touch+2 > time.Now().Unix() {
return
}
cache.Lock()
cache.touch(node)
cache.Unlock()
}
func (cache *RoomCache) touch(node *Room) {
if node == cache.head {
return
}
debug.Print("Touching", node.ID)
cache.llPop(node)
cache.llPush(node)
node.touch = time.Now().Unix()
}
func (cache *RoomCache) Get(roomID id.RoomID) *Room {
cache.Lock()
node := cache.get(roomID)
cache.Unlock()
return node
}
func (cache *RoomCache) GetOrCreate(roomID id.RoomID) *Room {
cache.Lock()
node := cache.get(roomID)
if node == nil {
node = cache.newRoom(roomID)
cache.llPush(node)
}
cache.Unlock()
return node
}
func (cache *RoomCache) get(roomID id.RoomID) *Room {
node, ok := cache.Map[roomID]
if ok && node != nil {
return node
}
return nil
}
func (cache *RoomCache) Put(room *Room) {
cache.Lock()
node := cache.get(room.ID)
if node != nil {
cache.touch(node)
} else {
cache.Map[room.ID] = room
if room.Loaded() {
cache.llPush(room)
}
node = room
}
cache.Unlock()
node.Save()
}
func (cache *RoomCache) roomPath(roomID id.RoomID) string {
escapedRoomID := strings.ReplaceAll(strings.ReplaceAll(string(roomID), "%", "%25"), "/", "%2F")
return filepath.Join(cache.directory, escapedRoomID+".gob.gz")
}
func (cache *RoomCache) Load(roomID id.RoomID) *Room {
cache.Lock()
defer cache.Unlock()
node, ok := cache.Map[roomID]
if ok {
return node
}
node = NewRoom(roomID, cache)
node.Load()
return node
}
func (cache *RoomCache) llPop(node *Room) {
if node.prev == nil && node.next == nil {
return
}
if node.prev != nil {
node.prev.next = node.next
}
if node.next != nil {
node.next.prev = node.prev
}
if node == cache.tail {
cache.tail = node.next
}
if node == cache.head {
cache.head = node.prev
}
node.next = nil
node.prev = nil
cache.size--
}
func (cache *RoomCache) llPush(node *Room) {
if node.next != nil || node.prev != nil {
debug.PrintStack()
debug.Print("Tried to llPush node that is already in stack")
return
}
if node == cache.head {
return
}
if cache.head != nil {
cache.head.next = node
}
node.prev = cache.head
node.next = nil
cache.head = node
if cache.tail == nil {
cache.tail = node
}
cache.size++
cache.clean(false)
}
func (cache *RoomCache) ForceClean() {
cache.Lock()
cache.clean(true)
cache.Unlock()
}
func (cache *RoomCache) clean(force bool) {
if cache.noUnload && !force {
return
}
origSize := cache.size
maxTS := time.Now().Unix() - cache.maxAge
for cache.size > cache.maxSize {
if cache.tail.touch > maxTS && !force {
break
}
ok := cache.tail.Unload()
node := cache.tail
cache.llPop(node)
if !ok {
debug.Print("Unload returned false, pushing node back")
cache.llPush(node)
}
}
if cleaned := origSize - cache.size; cleaned > 0 {
debug.Print("Cleaned", cleaned, "rooms")
}
}
func (cache *RoomCache) Unload(node *Room) {
cache.Lock()
defer cache.Unlock()
cache.llPop(node)
ok := node.Unload()
if !ok {
debug.Print("Unload returned false, pushing node back")
cache.llPush(node)
}
}
func (cache *RoomCache) newRoom(roomID id.RoomID) *Room {
node := NewRoom(roomID, cache)
cache.Map[node.ID] = node
return node
}

267
matrix/sync.go Normal file
View file

@ -0,0 +1,267 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Based on https://github.com/matrix-org/mautrix/blob/master/sync.go
package matrix
import (
"sync"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"maunium.net/go/gomuks/debug"
ifc "maunium.net/go/gomuks/interface"
"maunium.net/go/gomuks/matrix/rooms"
)
type GomuksSyncer struct {
rooms *rooms.RoomCache
globalListeners []mautrix.SyncHandler
listeners map[event.Type][]mautrix.EventHandler // event type to listeners array
FirstSyncDone bool
InitDoneCallback func()
FirstDoneCallback func()
Progress ifc.SyncingModal
}
// NewGomuksSyncer returns an instantiated GomuksSyncer
func NewGomuksSyncer(rooms *rooms.RoomCache) *GomuksSyncer {
return &GomuksSyncer{
rooms: rooms,
globalListeners: []mautrix.SyncHandler{},
listeners: make(map[event.Type][]mautrix.EventHandler),
FirstSyncDone: false,
Progress: StubSyncingModal{},
}
}
// ProcessResponse processes a Matrix sync response.
func (s *GomuksSyncer) ProcessResponse(res *mautrix.RespSync, since string) (err error) {
if since == "" {
s.rooms.DisableUnloading()
}
debug.Print("Received sync response")
s.Progress.SetMessage("Processing sync response")
steps := len(res.Rooms.Join) + len(res.Rooms.Invite) + len(res.Rooms.Leave)
s.Progress.SetSteps(steps + 2 + len(s.globalListeners))
wait := &sync.WaitGroup{}
callback := func() {
wait.Done()
s.Progress.Step()
}
wait.Add(len(s.globalListeners))
s.notifyGlobalListeners(res, since, callback)
wait.Wait()
s.processSyncEvents(nil, res.Presence.Events, mautrix.EventSourcePresence)
s.Progress.Step()
s.processSyncEvents(nil, res.AccountData.Events, mautrix.EventSourceAccountData)
s.Progress.Step()
wait.Add(steps)
for roomID, roomData := range res.Rooms.Join {
go s.processJoinedRoom(roomID, roomData, callback)
}
for roomID, roomData := range res.Rooms.Invite {
go s.processInvitedRoom(roomID, roomData, callback)
}
for roomID, roomData := range res.Rooms.Leave {
go s.processLeftRoom(roomID, roomData, callback)
}
wait.Wait()
s.Progress.SetMessage("Finishing sync")
if since == "" && s.InitDoneCallback != nil {
s.InitDoneCallback()
s.rooms.EnableUnloading()
}
if !s.FirstSyncDone && s.FirstDoneCallback != nil {
s.FirstDoneCallback()
}
s.FirstSyncDone = true
return
}
func (s *GomuksSyncer) notifyGlobalListeners(res *mautrix.RespSync, since string, callback func()) {
for _, listener := range s.globalListeners {
go func(listener mautrix.SyncHandler) {
listener(res, since)
callback()
}(listener)
}
}
func (s *GomuksSyncer) processJoinedRoom(roomID id.RoomID, roomData mautrix.SyncJoinedRoom, callback func()) {
defer debug.Recover()
room := s.rooms.GetOrCreate(roomID)
room.UpdateSummary(roomData.Summary)
s.processSyncEvents(room, roomData.State.Events, mautrix.EventSourceJoin|mautrix.EventSourceState)
s.processSyncEvents(room, roomData.Timeline.Events, mautrix.EventSourceJoin|mautrix.EventSourceTimeline)
s.processSyncEvents(room, roomData.Ephemeral.Events, mautrix.EventSourceJoin|mautrix.EventSourceEphemeral)
s.processSyncEvents(room, roomData.AccountData.Events, mautrix.EventSourceJoin|mautrix.EventSourceAccountData)
if len(room.PrevBatch) == 0 {
room.PrevBatch = roomData.Timeline.PrevBatch
}
room.LastPrevBatch = roomData.Timeline.PrevBatch
callback()
}
func (s *GomuksSyncer) processInvitedRoom(roomID id.RoomID, roomData mautrix.SyncInvitedRoom, callback func()) {
defer debug.Recover()
room := s.rooms.GetOrCreate(roomID)
room.UpdateSummary(roomData.Summary)
s.processSyncEvents(room, roomData.State.Events, mautrix.EventSourceInvite|mautrix.EventSourceState)
callback()
}
func (s *GomuksSyncer) processLeftRoom(roomID id.RoomID, roomData mautrix.SyncLeftRoom, callback func()) {
defer debug.Recover()
room := s.rooms.GetOrCreate(roomID)
room.HasLeft = true
room.UpdateSummary(roomData.Summary)
s.processSyncEvents(room, roomData.State.Events, mautrix.EventSourceLeave|mautrix.EventSourceState)
s.processSyncEvents(room, roomData.Timeline.Events, mautrix.EventSourceLeave|mautrix.EventSourceTimeline)
if len(room.PrevBatch) == 0 {
room.PrevBatch = roomData.Timeline.PrevBatch
}
room.LastPrevBatch = roomData.Timeline.PrevBatch
callback()
}
func (s *GomuksSyncer) processSyncEvents(room *rooms.Room, events []*event.Event, source mautrix.EventSource) {
for _, evt := range events {
s.processSyncEvent(room, evt, source)
}
}
func (s *GomuksSyncer) processSyncEvent(room *rooms.Room, evt *event.Event, source mautrix.EventSource) {
if room != nil {
evt.RoomID = room.ID
}
// Ensure the type class is correct. It's safe to mutate since it's not a pointer.
// Listeners are keyed by type structs, which means only the correct class will pass.
switch {
case evt.StateKey != nil:
evt.Type.Class = event.StateEventType
case source == mautrix.EventSourcePresence, source&mautrix.EventSourceEphemeral != 0:
evt.Type.Class = event.EphemeralEventType
case source&mautrix.EventSourceAccountData != 0:
evt.Type.Class = event.AccountDataEventType
case source == mautrix.EventSourceToDevice:
evt.Type.Class = event.ToDeviceEventType
default:
evt.Type.Class = event.MessageEventType
}
err := evt.Content.ParseRaw(evt.Type)
if err != nil {
debug.Printf("Failed to unmarshal content of event %s (type %s) by %s in %s: %v\n%s", evt.ID, evt.Type.Repr(), evt.Sender, evt.RoomID, err, string(evt.Content.VeryRaw))
// TODO might be good to let these pass to allow handling invalid events too
return
}
if room != nil && evt.Type.IsState() {
room.UpdateState(evt)
}
s.notifyListeners(source, evt)
}
// OnEventType allows callers to be notified when there are new events for the given event type.
// There are no duplicate checks.
func (s *GomuksSyncer) OnEventType(eventType event.Type, callback mautrix.EventHandler) {
_, exists := s.listeners[eventType]
if !exists {
s.listeners[eventType] = []mautrix.EventHandler{}
}
s.listeners[eventType] = append(s.listeners[eventType], callback)
}
func (s *GomuksSyncer) OnSync(callback mautrix.SyncHandler) {
s.globalListeners = append(s.globalListeners, callback)
}
func (s *GomuksSyncer) notifyListeners(source mautrix.EventSource, evt *event.Event) {
listeners, exists := s.listeners[evt.Type]
if !exists {
return
}
for _, fn := range listeners {
fn(source, evt)
}
}
// OnFailedSync always returns a 10 second wait period between failed /syncs, never a fatal error.
func (s *GomuksSyncer) OnFailedSync(res *mautrix.RespSync, err error) (time.Duration, error) {
debug.Printf("Sync failed: %v", err)
return 10 * time.Second, nil
}
// GetFilterJSON returns a filter with a timeline limit of 50.
func (s *GomuksSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter {
stateEvents := []event.Type{
event.StateMember,
event.StateRoomName,
event.StateTopic,
event.StateCanonicalAlias,
event.StatePowerLevels,
event.StateTombstone,
event.StateEncryption,
}
messageEvents := []event.Type{
event.EventMessage,
event.EventRedaction,
event.EventEncrypted,
event.EventSticker,
event.EventReaction,
}
return &mautrix.Filter{
Room: mautrix.RoomFilter{
IncludeLeave: false,
State: mautrix.FilterPart{
LazyLoadMembers: true,
Types: stateEvents,
},
Timeline: mautrix.FilterPart{
LazyLoadMembers: true,
Types: append(messageEvents, stateEvents...),
Limit: 50,
},
Ephemeral: mautrix.FilterPart{
Types: []event.Type{event.EphemeralEventTyping, event.EphemeralEventReceipt},
},
AccountData: mautrix.FilterPart{
Types: []event.Type{event.AccountDataRoomTags},
},
},
AccountData: mautrix.FilterPart{
Types: []event.Type{event.AccountDataPushRules, event.AccountDataDirectChats, AccountDataGomuksPreferences},
},
Presence: mautrix.FilterPart{
NotTypes: []event.Type{event.NewEventType("*")},
},
}
}

115
matrix/uia-fallback.go Normal file
View file

@ -0,0 +1,115 @@
// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package matrix
import (
"context"
"errors"
"net/http"
"net/url"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/gomuks/debug"
"maunium.net/go/gomuks/lib/open"
)
const uiaFallbackPage = `<!DOCTYPE html>
<html lang="en">
<head>
<title>gomuks user-interactive auth</title>
<meta charset="utf-8"/>
<style>
body {
text-align: center;
}
</style>
</head>
<body>
<h2>Please complete the login in the popup window</h2>
<p>Keep this page open while logging in, it will close automatically after the login finishes.</p>
<button onclick="openPopup()">Open popup</button>
<button onclick="finish(false)">Cancel</button>
<script>
const url = location.hash.substr(1)
let popupWindow
function finish(success) {
if (popupWindow) {
popupWindow.close()
}
fetch("", {method: success ? "POST" : "DELETE"}).then(() => window.close())
}
function openPopup() {
popupWindow = window.open(url)
}
window.addEventListener("message", evt => evt.data === "authDone" && finish(true))
</script>
</body>
</html>
`
func (c *Container) UIAFallback(loginType mautrix.AuthType, sessionID string) error {
errChan := make(chan error, 1)
server := &http.Server{Addr: ":29325"}
server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
w.Header().Add("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(uiaFallbackPage))
} else if r.Method == "POST" || r.Method == "DELETE" {
w.Header().Add("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := server.Shutdown(ctx)
if err != nil {
debug.Printf("Failed to shut down SSO server: %v\n", err)
}
if r.Method == "DELETE" {
errChan <- errors.New("login cancelled")
} else {
errChan <- nil
}
}()
} else {
w.WriteHeader(http.StatusMethodNotAllowed)
}
})
go server.ListenAndServe()
defer server.Close()
authURL := c.client.BuildURLWithQuery(mautrix.ClientURLPath{"v3", "auth", loginType, "fallback", "web"}, map[string]string{
"session": sessionID,
})
link := url.URL{
Scheme: "http",
Host: "localhost:29325",
Path: "/",
Fragment: authURL,
}
err := open.Open(link.String())
if err != nil {
return err
}
err = <-errChan
return err
}

View file

@ -1,157 +0,0 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"encoding/json"
"fmt"
"maps"
"slices"
"sync"
"github.com/coder/websocket"
"go.mau.fi/gomuks/pkg/hicli"
)
type WebsocketCloseFunc func(websocket.StatusCode, string)
type EventBuffer struct {
lock sync.RWMutex
buf []*hicli.JSONCommand
minID int64
maxID int64
MaxSize int
websocketClosers map[uint64]WebsocketCloseFunc
lastAckedID map[uint64]int64
eventListeners map[uint64]func(*hicli.JSONCommand)
nextListenerID uint64
}
func NewEventBuffer(maxSize int) *EventBuffer {
return &EventBuffer{
websocketClosers: make(map[uint64]WebsocketCloseFunc),
lastAckedID: make(map[uint64]int64),
eventListeners: make(map[uint64]func(*hicli.JSONCommand)),
buf: make([]*hicli.JSONCommand, 0, 32),
MaxSize: maxSize,
minID: -1,
}
}
func (eb *EventBuffer) Push(evt any) {
data, err := json.Marshal(evt)
if err != nil {
panic(fmt.Errorf("failed to marshal event %T: %w", evt, err))
}
allowCache := true
if syncComplete, ok := evt.(*hicli.SyncComplete); ok && syncComplete.Since != nil && *syncComplete.Since == "" {
// Don't cache initial sync responses
allowCache = false
} else if _, ok := evt.(*hicli.Typing); ok {
// Also don't cache typing events
allowCache = false
}
eb.lock.Lock()
defer eb.lock.Unlock()
jc := &hicli.JSONCommand{
Command: hicli.EventTypeName(evt),
Data: data,
}
if allowCache {
eb.addToBuffer(jc)
}
for _, listener := range eb.eventListeners {
listener(jc)
}
}
func (eb *EventBuffer) GetClosers() []WebsocketCloseFunc {
eb.lock.Lock()
defer eb.lock.Unlock()
return slices.Collect(maps.Values(eb.websocketClosers))
}
func (eb *EventBuffer) Unsubscribe(listenerID uint64) {
eb.lock.Lock()
defer eb.lock.Unlock()
delete(eb.eventListeners, listenerID)
delete(eb.websocketClosers, listenerID)
}
func (eb *EventBuffer) addToBuffer(evt *hicli.JSONCommand) {
eb.maxID--
evt.RequestID = eb.maxID
if len(eb.lastAckedID) > 0 {
eb.buf = append(eb.buf, evt)
} else {
eb.minID = eb.maxID - 1
}
if len(eb.buf) > eb.MaxSize {
eb.buf = eb.buf[len(eb.buf)-eb.MaxSize:]
eb.minID = eb.buf[0].RequestID
}
}
func (eb *EventBuffer) ClearListenerLastAckedID(listenerID uint64) {
eb.lock.Lock()
defer eb.lock.Unlock()
delete(eb.lastAckedID, listenerID)
eb.gc()
}
func (eb *EventBuffer) SetLastAckedID(listenerID uint64, ackedID int64) {
eb.lock.Lock()
defer eb.lock.Unlock()
eb.lastAckedID[listenerID] = ackedID
eb.gc()
}
func (eb *EventBuffer) gc() {
neededMinID := eb.maxID
for lid, evtID := range eb.lastAckedID {
if evtID > eb.minID {
delete(eb.lastAckedID, lid)
} else if evtID > neededMinID {
neededMinID = evtID
}
}
if neededMinID < eb.minID {
eb.buf = eb.buf[eb.minID-neededMinID:]
eb.minID = neededMinID
}
}
func (eb *EventBuffer) Subscribe(resumeFrom int64, closeForRestart WebsocketCloseFunc, cb func(*hicli.JSONCommand)) (uint64, []*hicli.JSONCommand) {
eb.lock.Lock()
defer eb.lock.Unlock()
eb.nextListenerID++
id := eb.nextListenerID
eb.eventListeners[id] = cb
if closeForRestart != nil {
eb.websocketClosers[id] = closeForRestart
}
var resumeData []*hicli.JSONCommand
if resumeFrom < eb.minID {
resumeData = eb.buf[eb.minID-resumeFrom+1:]
eb.lastAckedID[id] = resumeFrom
} else {
eb.lastAckedID[id] = eb.maxID
}
return id, resumeData
}

View file

@ -1,165 +0,0 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/chzyer/readline"
"github.com/rs/zerolog"
"go.mau.fi/util/ptr"
"go.mau.fi/util/random"
"go.mau.fi/zeroconfig"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
)
type Config struct {
Web WebConfig `yaml:"web"`
Matrix MatrixConfig `yaml:"matrix"`
Push PushConfig `yaml:"push"`
Media MediaConfig `yaml:"media"`
Logging zeroconfig.Config `yaml:"logging"`
}
type MatrixConfig struct {
DisableHTTP2 bool `yaml:"disable_http2"`
}
type PushConfig struct {
FCMGateway string `yaml:"fcm_gateway"`
}
type MediaConfig struct {
ThumbnailSize int `yaml:"thumbnail_size"`
}
type WebConfig struct {
ListenAddress string `yaml:"listen_address"`
Username string `yaml:"username"`
PasswordHash string `yaml:"password_hash"`
TokenKey string `yaml:"token_key"`
DebugEndpoints bool `yaml:"debug_endpoints"`
EventBufferSize int `yaml:"event_buffer_size"`
OriginPatterns []string `yaml:"origin_patterns"`
}
var defaultFileWriter = zeroconfig.WriterConfig{
Type: zeroconfig.WriterTypeFile,
Format: "json",
FileConfig: zeroconfig.FileConfig{
Filename: "",
MaxSize: 100 * 1024 * 1024,
MaxBackups: 10,
},
}
func makeDefaultConfig() Config {
return Config{
Web: WebConfig{
ListenAddress: "localhost:29325",
},
Matrix: MatrixConfig{
DisableHTTP2: false,
},
Media: MediaConfig{
ThumbnailSize: 120,
},
Logging: zeroconfig.Config{
MinLevel: ptr.Ptr(zerolog.DebugLevel),
Writers: []zeroconfig.WriterConfig{{
Type: zeroconfig.WriterTypeStdout,
Format: zeroconfig.LogFormatPrettyColored,
}, defaultFileWriter},
},
}
}
func (gmx *Gomuks) LoadConfig() error {
file, err := os.Open(filepath.Join(gmx.ConfigDir, "config.yaml"))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
gmx.Config = makeDefaultConfig()
changed := false
if file != nil {
err = yaml.NewDecoder(file).Decode(&gmx.Config)
if err != nil {
return err
}
} else {
changed = true
}
if gmx.Config.Web.TokenKey == "" {
gmx.Config.Web.TokenKey = random.String(64)
changed = true
}
if !gmx.DisableAuth && (gmx.Config.Web.Username == "" || gmx.Config.Web.PasswordHash == "") {
fmt.Println("Please create a username and password for authenticating the web app")
gmx.Config.Web.Username, err = readline.Line("Username: ")
if err != nil {
return fmt.Errorf("failed to read username: %w", err)
} else if len(gmx.Config.Web.Username) == 0 || len(gmx.Config.Web.Username) > 32 {
return fmt.Errorf("username must be 1-32 characters long")
}
passwd, err := readline.Password("Password: ")
if err != nil {
return fmt.Errorf("failed to read password: %w", err)
}
hash, err := bcrypt.GenerateFromPassword(passwd, 12)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
gmx.Config.Web.PasswordHash = string(hash)
changed = true
}
if gmx.Config.Web.EventBufferSize <= 0 {
gmx.Config.Web.EventBufferSize = 512
changed = true
}
if gmx.Config.Push.FCMGateway == "" {
gmx.Config.Push.FCMGateway = "https://push.gomuks.app"
changed = true
}
if gmx.Config.Media.ThumbnailSize == 0 {
gmx.Config.Media.ThumbnailSize = 120
changed = true
}
if len(gmx.Config.Web.OriginPatterns) == 0 {
gmx.Config.Web.OriginPatterns = []string{"localhost:*", "*.localhost:*"}
changed = true
}
if changed {
err = gmx.SaveConfig()
if err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
}
gmx.EventBuffer = NewEventBuffer(gmx.Config.Web.EventBufferSize)
return nil
}
func (gmx *Gomuks) SaveConfig() error {
file, err := os.OpenFile(filepath.Join(gmx.ConfigDir, "config.yaml"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}
return yaml.NewEncoder(file).Encode(&gmx.Config)
}

View file

@ -1,258 +0,0 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"context"
"embed"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"runtime"
"sync"
"syscall"
"time"
"github.com/coder/websocket"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/exzerolog"
"go.mau.fi/util/ptr"
"golang.org/x/net/http2"
"go.mau.fi/gomuks/pkg/hicli"
)
type Gomuks struct {
Log *zerolog.Logger
Server *http.Server
Client *hicli.HiClient
Version string
Commit string
LinkifiedVersion string
BuildTime time.Time
ConfigDir string
DataDir string
CacheDir string
TempDir string
LogDir string
FrontendFS embed.FS
indexWithETag []byte
frontendETag string
Config Config
DisableAuth bool
stopOnce sync.Once
stopChan chan struct{}
EventBuffer *EventBuffer
}
func NewGomuks() *Gomuks {
return &Gomuks{
stopChan: make(chan struct{}),
}
}
func (gmx *Gomuks) InitDirectories() {
// We need 4 directories: config, data, cache, logs
//
// 1. If GOMUKS_ROOT is set, all directories are created under that.
// 2. If GOMUKS_*_HOME is set, that value is used as the directory.
// 3. Use system-specific defaults as below
//
// *nix:
// - Config: $XDG_CONFIG_HOME/gomuks or $HOME/.config/gomuks
// - Data: $XDG_DATA_HOME/gomuks or $HOME/.local/share/gomuks
// - Cache: $XDG_CACHE_HOME/gomuks or $HOME/.cache/gomuks
// - Logs: $XDG_STATE_HOME/gomuks or $HOME/.local/state/gomuks
//
// Windows:
// - Config and Data: %AppData%\gomuks
// - Cache: %LocalAppData%\gomuks
// - Logs: %LocalAppData%\gomuks\logs
//
// macOS:
// - Config and Data: $HOME/Library/Application Support/gomuks
// - Cache: $HOME/Library/Caches/gomuks
// - Logs: $HOME/Library/Logs/gomuks
if gomuksRoot := os.Getenv("GOMUKS_ROOT"); gomuksRoot != "" {
exerrors.PanicIfNotNil(os.MkdirAll(gomuksRoot, 0700))
gmx.CacheDir = filepath.Join(gomuksRoot, "cache")
gmx.ConfigDir = filepath.Join(gomuksRoot, "config")
gmx.DataDir = filepath.Join(gomuksRoot, "data")
gmx.LogDir = filepath.Join(gomuksRoot, "logs")
} else {
homeDir := exerrors.Must(os.UserHomeDir())
if cacheDir := os.Getenv("GOMUKS_CACHE_HOME"); cacheDir != "" {
gmx.CacheDir = cacheDir
} else {
gmx.CacheDir = filepath.Join(exerrors.Must(os.UserCacheDir()), "gomuks")
}
if configDir := os.Getenv("GOMUKS_CONFIG_HOME"); configDir != "" {
gmx.ConfigDir = configDir
} else {
gmx.ConfigDir = filepath.Join(exerrors.Must(os.UserConfigDir()), "gomuks")
}
if dataDir := os.Getenv("GOMUKS_DATA_HOME"); dataDir != "" {
gmx.DataDir = dataDir
} else if dataDir = os.Getenv("XDG_DATA_HOME"); dataDir != "" {
gmx.DataDir = filepath.Join(dataDir, "gomuks")
} else if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
gmx.DataDir = gmx.ConfigDir
} else {
gmx.DataDir = filepath.Join(homeDir, ".local", "share", "gomuks")
}
if logDir := os.Getenv("GOMUKS_LOGS_HOME"); logDir != "" {
gmx.LogDir = logDir
} else if logDir = os.Getenv("XDG_STATE_HOME"); logDir != "" {
gmx.LogDir = filepath.Join(logDir, "gomuks")
} else if runtime.GOOS == "darwin" {
gmx.LogDir = filepath.Join(homeDir, "Library", "Logs", "gomuks")
} else if runtime.GOOS == "windows" {
gmx.LogDir = filepath.Join(gmx.CacheDir, "logs")
} else {
gmx.LogDir = filepath.Join(homeDir, ".local", "state", "gomuks")
}
}
if gmx.TempDir = os.Getenv("GOMUKS_TMPDIR"); gmx.TempDir == "" {
gmx.TempDir = filepath.Join(gmx.CacheDir, "tmp")
}
exerrors.PanicIfNotNil(os.MkdirAll(gmx.ConfigDir, 0700))
exerrors.PanicIfNotNil(os.MkdirAll(gmx.CacheDir, 0700))
exerrors.PanicIfNotNil(os.MkdirAll(gmx.TempDir, 0700))
exerrors.PanicIfNotNil(os.MkdirAll(gmx.DataDir, 0700))
exerrors.PanicIfNotNil(os.MkdirAll(gmx.LogDir, 0700))
defaultFileWriter.FileConfig.Filename = filepath.Join(gmx.LogDir, "gomuks.log")
}
func (gmx *Gomuks) SetupLog() {
gmx.Log = exerrors.Must(gmx.Config.Logging.Compile())
exzerolog.SetupDefaults(gmx.Log)
}
func (gmx *Gomuks) StartClient() {
hicli.HTMLSanitizerImgSrcTemplate = "_gomuks/media/%s/%s?encrypted=false"
rawDB, err := dbutil.NewFromConfig("gomuks", dbutil.Config{
PoolConfig: dbutil.PoolConfig{
Type: "sqlite3-fk-wal",
URI: fmt.Sprintf("file:%s/gomuks.db?_txlock=immediate", gmx.DataDir),
MaxOpenConns: 5,
MaxIdleConns: 1,
},
}, dbutil.ZeroLogger(gmx.Log.With().Str("component", "hicli").Str("db_section", "main").Logger()))
if err != nil {
gmx.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to open database")
os.Exit(10)
}
ctx := gmx.Log.WithContext(context.Background())
gmx.Client = hicli.New(
rawDB,
nil,
gmx.Log.With().Str("component", "hicli").Logger(),
[]byte("meow"),
gmx.HandleEvent,
)
gmx.Client.LogoutFunc = gmx.Logout
httpClient := gmx.Client.Client.Client
httpClient.Transport.(*http.Transport).ForceAttemptHTTP2 = false
if !gmx.Config.Matrix.DisableHTTP2 {
h2, err := http2.ConfigureTransports(httpClient.Transport.(*http.Transport))
if err != nil {
gmx.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to configure HTTP/2")
os.Exit(13)
}
h2.ReadIdleTimeout = 30 * time.Second
}
userID, err := gmx.Client.DB.Account.GetFirstUserID(ctx)
if err != nil {
gmx.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to get first user ID")
os.Exit(11)
}
err = gmx.Client.Start(ctx, userID, nil)
if err != nil {
gmx.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to start client")
os.Exit(12)
}
gmx.Log.Info().Stringer("user_id", userID).Msg("Client started")
}
func (gmx *Gomuks) HandleEvent(evt any) {
gmx.EventBuffer.Push(evt)
syncComplete, ok := evt.(*hicli.SyncComplete)
if ok && ptr.Val(syncComplete.Since) != "" {
go gmx.SendPushNotifications(syncComplete)
}
}
func (gmx *Gomuks) Stop() {
gmx.stopOnce.Do(func() {
close(gmx.stopChan)
})
}
func (gmx *Gomuks) WaitForInterrupt() {
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
select {
case <-c:
case <-gmx.stopChan:
}
}
func (gmx *Gomuks) DirectStop() {
for _, closer := range gmx.EventBuffer.GetClosers() {
closer(websocket.StatusServiceRestart, "Server shutting down")
}
gmx.Client.Stop()
if gmx.Server != nil {
err := gmx.Server.Close()
if err != nil {
gmx.Log.Error().Err(err).Msg("Failed to close server")
}
}
}
func (gmx *Gomuks) Run() {
gmx.InitDirectories()
err := gmx.LoadConfig()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to load config:", err)
os.Exit(9)
}
gmx.SetupLog()
gmx.Log.Info().
Str("version", gmx.Version).
Str("go_version", runtime.Version()).
Time("built_at", gmx.BuildTime).
Msg("Initializing gomuks")
gmx.StartServer()
gmx.StartClient()
gmx.Log.Info().Msg("Initialization complete")
gmx.WaitForInterrupt()
gmx.Log.Info().Msg("Shutting down...")
gmx.DirectStop()
gmx.Log.Info().Msg("Shutdown complete")
os.Exit(0)
}

View file

@ -1,110 +0,0 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2025 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"errors"
"fmt"
"io"
"mime"
"net/http"
"strconv"
"github.com/rs/zerolog/hlog"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exhttp"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/id"
)
func (gmx *Gomuks) ExportKeys(w http.ResponseWriter, r *http.Request) {
found, correct := gmx.doBasicAuth(r)
if !found || !correct {
hlog.FromRequest(r).Debug().Msg("Requesting credentials for key export request")
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Cache-Control", "no-store")
err := r.ParseForm()
if err != nil {
hlog.FromRequest(r).Err(err).Msg("Failed to parse form")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("Failed to parse form data\n"))
return
}
roomID := id.RoomID(r.PathValue("room_id"))
var sessions dbutil.RowIter[*crypto.InboundGroupSession]
filename := "gomuks-keys.txt"
if roomID == "" {
sessions = gmx.Client.CryptoStore.GetAllGroupSessions(r.Context())
} else {
filename = fmt.Sprintf("gomuks-keys-%s.txt", roomID)
sessions = gmx.Client.CryptoStore.GetGroupSessionsForRoom(r.Context(), roomID)
}
export, err := crypto.ExportKeysIter(r.FormValue("passphrase"), sessions)
if errors.Is(err, crypto.ErrNoSessionsForExport) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("No keys found\n"))
return
} else if err != nil {
hlog.FromRequest(r).Err(err).Msg("Failed to export keys")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("Failed to export keys (see logs for more details)\n"))
return
}
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))
w.Header().Set("Content-Length", strconv.Itoa(len(export)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(export)
}
var badMultipartForm = mautrix.RespError{ErrCode: "FI.MAU.GOMUKS.BAD_FORM_DATA", Err: "Failed to parse form data", StatusCode: http.StatusBadRequest}
func (gmx *Gomuks) ImportKeys(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(5 * 1024 * 1024)
if err != nil {
badMultipartForm.Write(w)
return
}
export, _, err := r.FormFile("export")
if err != nil {
badMultipartForm.WithMessage("Failed to get export file from form: %w", err).Write(w)
return
}
exportData, err := io.ReadAll(export)
if err != nil {
badMultipartForm.WithMessage("Failed to read export file: %w", err).Write(w)
return
}
importedCount, totalCount, err := gmx.Client.Crypto.ImportKeys(r.Context(), r.FormValue("passphrase"), exportData)
if err != nil {
hlog.FromRequest(r).Err(err).Msg("Failed to import keys")
mautrix.MUnknown.WithMessage("Failed to import keys: %w", err).Write(w)
return
}
hlog.FromRequest(r).Info().
Int("imported_count", importedCount).
Int("total_count", totalCount).
Msg("Successfully imported keys")
exhttp.WriteJSONResponse(w, http.StatusOK, map[string]int{
"imported": importedCount,
"total": totalCount,
})
}

View file

@ -1,64 +0,0 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"context"
"errors"
"os"
"path/filepath"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
)
func (gmx *Gomuks) Logout(ctx context.Context) error {
log := zerolog.Ctx(ctx)
log.Info().Msg("Stopping client and logging out")
gmx.Client.Stop()
_, err := gmx.Client.Client.Logout(ctx)
if err != nil && !errors.Is(err, mautrix.MUnknownToken) {
log.Warn().Err(err).Msg("Failed to log out")
return err
}
log.Info().Msg("Logout complete, removing data")
err = os.RemoveAll(gmx.CacheDir)
if err != nil {
log.Err(err).Str("cache_dir", gmx.CacheDir).Msg("Failed to remove cache dir")
}
if gmx.DataDir == gmx.ConfigDir {
err = os.Remove(filepath.Join(gmx.DataDir, "gomuks.db"))
if err != nil && !errors.Is(err, os.ErrNotExist) {
log.Err(err).Str("data_dir", gmx.DataDir).Msg("Failed to remove database")
}
_ = os.Remove(filepath.Join(gmx.DataDir, "gomuks.db-shm"))
_ = os.Remove(filepath.Join(gmx.DataDir, "gomuks.db-wal"))
} else {
err = os.RemoveAll(gmx.DataDir)
if err != nil {
log.Err(err).Str("data_dir", gmx.DataDir).Msg("Failed to remove data dir")
}
}
log.Info().Msg("Re-initializing directories")
gmx.InitDirectories()
log.Info().Msg("Restarting client")
gmx.StartClient()
gmx.Client.EventHandler(gmx.Client.State())
gmx.Client.EventHandler(gmx.Client.SyncStatus.Load())
log.Info().Msg("Client restarted")
return nil
}

View file

@ -1,728 +0,0 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"html"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/buckket/go-blurhash"
"github.com/disintegration/imaging"
"github.com/gabriel-vasile/mimetype"
"github.com/rs/zerolog"
"github.com/rs/zerolog/hlog"
"go.mau.fi/util/exhttp"
"go.mau.fi/util/ffmpeg"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr"
"go.mau.fi/util/random"
cwebp "go.mau.fi/webp"
_ "golang.org/x/image/webp"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/attachment"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/gomuks/pkg/hicli/database"
)
var ErrBadGateway = mautrix.RespError{
ErrCode: "FI.MAU.GOMUKS.BAD_GATEWAY",
StatusCode: http.StatusBadGateway,
}
func (gmx *Gomuks) downloadMediaFromCache(ctx context.Context, w http.ResponseWriter, r *http.Request, entry *database.Media, force, useThumbnail bool) bool {
if !entry.UseCache() {
if force {
mautrix.MNotFound.WithMessage("Media not found in cache").Write(w)
return true
}
return false
}
etag := entry.ETag(useThumbnail)
if entry.Error != nil {
w.Header().Set("Mau-Cached-Error", "true")
entry.Error.Write(w)
return true
} else if etag != "" && r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return true
} else if entry.MimeType != "" && r.URL.Query().Has("fallback") && !isAllowedAvatarMime(entry.MimeType) {
w.WriteHeader(http.StatusUnsupportedMediaType)
return true
}
log := zerolog.Ctx(ctx)
hash := entry.Hash
if useThumbnail {
if entry.ThumbnailError != "" {
log.Debug().Str(zerolog.ErrorFieldName, entry.ThumbnailError).Msg("Returning cached thumbnail error")
w.WriteHeader(http.StatusInternalServerError)
return true
}
if entry.ThumbnailHash == nil {
err := gmx.generateAvatarThumbnail(entry, gmx.Config.Media.ThumbnailSize)
if errors.Is(err, os.ErrNotExist) && !force {
return false
} else if err != nil {
log.Err(err).Msg("Failed to generate avatar thumbnail")
gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, err)
w.WriteHeader(http.StatusInternalServerError)
return true
} else {
gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, nil)
}
}
hash = entry.ThumbnailHash
}
cacheFile, err := os.Open(gmx.cacheEntryToPath(hash[:]))
if useThumbnail && errors.Is(err, os.ErrNotExist) {
err = gmx.generateAvatarThumbnail(entry, gmx.Config.Media.ThumbnailSize)
if errors.Is(err, os.ErrNotExist) && !force {
return false
} else if err != nil {
log.Err(err).Msg("Failed to generate avatar thumbnail")
gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, err)
w.WriteHeader(http.StatusInternalServerError)
return true
} else {
gmx.saveMediaCacheEntryWithThumbnail(ctx, entry, nil)
cacheFile, err = os.Open(gmx.cacheEntryToPath(hash[:]))
}
}
if err != nil {
if errors.Is(err, os.ErrNotExist) && !force {
return false
}
log.Err(err).Msg("Failed to open cache file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to open cache file: %v", err)).Write(w)
return true
}
defer func() {
_ = cacheFile.Close()
}()
cacheEntryToHeaders(w, entry, useThumbnail)
w.WriteHeader(http.StatusOK)
_, err = io.Copy(w, cacheFile)
if err != nil {
log.Err(err).Msg("Failed to copy cache file to response")
}
return true
}
func (gmx *Gomuks) cacheEntryToPath(hash []byte) string {
hashPath := hex.EncodeToString(hash[:])
return filepath.Join(gmx.CacheDir, "media", hashPath[0:2], hashPath[2:4], hashPath[4:])
}
func cacheEntryToHeaders(w http.ResponseWriter, entry *database.Media, thumbnail bool) {
if thumbnail {
w.Header().Set("Content-Type", "image/webp")
w.Header().Set("Content-Length", strconv.FormatInt(entry.ThumbnailSize, 10))
w.Header().Set("Content-Disposition", "inline; filename=thumbnail.webp")
} else {
w.Header().Set("Content-Type", entry.MimeType)
w.Header().Set("Content-Length", strconv.FormatInt(entry.Size, 10))
w.Header().Set("Content-Disposition", mime.FormatMediaType(entry.ContentDisposition(), map[string]string{"filename": entry.FileName}))
}
w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; script-src 'none'; media-src 'self';")
w.Header().Set("Cache-Control", "max-age=2592000, immutable")
w.Header().Set("ETag", entry.ETag(thumbnail))
}
func (gmx *Gomuks) saveMediaCacheEntryWithThumbnail(ctx context.Context, entry *database.Media, err error) {
if errors.Is(err, os.ErrNotExist) {
return
}
if err != nil {
entry.ThumbnailError = err.Error()
}
err = gmx.Client.DB.Media.Put(ctx, entry)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to save cache entry after generating thumbnail")
}
}
func (gmx *Gomuks) generateAvatarThumbnail(entry *database.Media, size int) error {
cacheFile, err := os.Open(gmx.cacheEntryToPath(entry.Hash[:]))
if err != nil {
return fmt.Errorf("failed to open full file: %w", err)
}
img, _, err := image.Decode(cacheFile)
if err != nil {
return fmt.Errorf("failed to decode image: %w", err)
}
tempFile, err := os.CreateTemp(gmx.TempDir, "thumbnail-*")
if err != nil {
return fmt.Errorf("failed to create temporary file: %w", err)
}
defer func() {
_ = tempFile.Close()
_ = os.Remove(tempFile.Name())
}()
thumbnailImage := imaging.Thumbnail(img, size, size, imaging.Lanczos)
fileHasher := sha256.New()
wrappedWriter := io.MultiWriter(fileHasher, tempFile)
err = cwebp.Encode(wrappedWriter, thumbnailImage, &cwebp.Options{Quality: 80})
if err != nil {
return fmt.Errorf("failed to encode thumbnail: %w", err)
}
fileInfo, err := tempFile.Stat()
if err != nil {
return fmt.Errorf("failed to stat temporary file: %w", err)
}
entry.ThumbnailHash = (*[32]byte)(fileHasher.Sum(nil))
entry.ThumbnailError = ""
entry.ThumbnailSize = fileInfo.Size()
cachePath := gmx.cacheEntryToPath(entry.ThumbnailHash[:])
err = os.MkdirAll(filepath.Dir(cachePath), 0700)
if err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
err = os.Rename(tempFile.Name(), cachePath)
if err != nil {
return fmt.Errorf("failed to rename temporary file: %w", err)
}
return nil
}
type noErrorWriter struct {
io.Writer
}
func (new *noErrorWriter) Write(p []byte) (n int, err error) {
n, _ = new.Writer.Write(p)
return
}
// note: this should stay in sync with makeAvatarFallback in web/src/api/media.ts
const fallbackAvatarTemplate = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
<rect x="0" y="0" width="1000" height="1000" fill="%s"/>
<text x="500" y="750" text-anchor="middle" fill="#fff" font-weight="bold" font-size="666"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
>%s</text>
</svg>`
type avatarResponseWriter struct {
http.ResponseWriter
bgColor string
character string
errored bool
}
func isAllowedAvatarMime(mime string) bool {
switch mime {
case "image/png", "image/jpeg", "image/gif", "image/webp":
return true
default:
return false
}
}
func (w *avatarResponseWriter) WriteHeader(statusCode int) {
if statusCode != http.StatusOK && statusCode != http.StatusNotModified {
data := []byte(fmt.Sprintf(fallbackAvatarTemplate, w.bgColor, html.EscapeString(w.character)))
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.Header().Del("Content-Disposition")
w.ResponseWriter.WriteHeader(http.StatusOK)
_, _ = w.ResponseWriter.Write(data)
w.errored = true
return
}
w.ResponseWriter.WriteHeader(statusCode)
}
func (w *avatarResponseWriter) Write(p []byte) (n int, err error) {
if w.errored {
return len(p), nil
}
return w.ResponseWriter.Write(p)
}
func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) {
mxc := id.ContentURI{
Homeserver: r.PathValue("server"),
FileID: r.PathValue("media_id"),
}
if !mxc.IsValid() {
mautrix.MInvalidParam.WithMessage("Invalid mxc URI").Write(w)
return
}
query := r.URL.Query()
fallback := query.Get("fallback")
if fallback != "" {
fallbackParts := strings.Split(fallback, ":")
if len(fallbackParts) == 2 {
w = &avatarResponseWriter{
ResponseWriter: w,
bgColor: fallbackParts[0],
character: fallbackParts[1],
}
}
}
encrypted, _ := strconv.ParseBool(query.Get("encrypted"))
useThumbnail := query.Get("thumbnail") == "avatar"
logVal := zerolog.Ctx(r.Context()).With().
Stringer("mxc_uri", mxc).
Bool("encrypted", encrypted).
Logger()
log := &logVal
ctx := log.WithContext(r.Context())
cacheEntry, err := gmx.Client.DB.Media.Get(ctx, mxc)
if err != nil {
log.Err(err).Msg("Failed to get cached media entry")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to get cached media entry: %v", err)).Write(w)
return
} else if (cacheEntry == nil || cacheEntry.EncFile == nil) && encrypted {
mautrix.MNotFound.WithMessage("Media encryption keys not found in cache").Write(w)
return
} else if cacheEntry != nil && cacheEntry.EncFile != nil && !encrypted {
mautrix.MNotFound.WithMessage("Tried to download encrypted media without encrypted flag").Write(w)
return
}
if gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, false, useThumbnail) {
return
}
tempFile, err := os.CreateTemp(gmx.TempDir, "download-*")
if err != nil {
log.Err(err).Msg("Failed to create temporary file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to create temp file: %v", err)).Write(w)
return
}
defer func() {
_ = tempFile.Close()
_ = os.Remove(tempFile.Name())
}()
resp, err := gmx.Client.Client.Download(ctx, mxc)
if err != nil {
if ctx.Err() != nil {
w.WriteHeader(499)
return
}
log.Err(err).Msg("Failed to download media")
var httpErr mautrix.HTTPError
if cacheEntry == nil {
cacheEntry = &database.Media{
MXC: mxc,
}
}
if cacheEntry.Error == nil {
cacheEntry.Error = &database.MediaError{
ReceivedAt: jsontime.UnixMilliNow(),
Attempts: 1,
}
} else {
cacheEntry.Error.Attempts++
cacheEntry.Error.ReceivedAt = jsontime.UnixMilliNow()
}
if errors.As(err, &httpErr) {
if httpErr.WrappedError != nil {
cacheEntry.Error.Matrix = ptr.Ptr(ErrBadGateway.WithMessage(httpErr.WrappedError.Error()))
cacheEntry.Error.StatusCode = http.StatusBadGateway
} else if httpErr.RespError != nil {
cacheEntry.Error.Matrix = httpErr.RespError
cacheEntry.Error.StatusCode = httpErr.Response.StatusCode
} else {
cacheEntry.Error.Matrix = ptr.Ptr(mautrix.MUnknown.WithMessage("Server returned non-JSON error with status %d", httpErr.Response.StatusCode))
cacheEntry.Error.StatusCode = httpErr.Response.StatusCode
}
} else {
cacheEntry.Error.Matrix = ptr.Ptr(ErrBadGateway.WithMessage(err.Error()))
cacheEntry.Error.StatusCode = http.StatusBadGateway
}
err = gmx.Client.DB.Media.Put(ctx, cacheEntry)
if err != nil {
log.Err(err).Msg("Failed to save errored cache entry")
}
cacheEntry.Error.Write(w)
return
}
defer func() {
_ = resp.Body.Close()
}()
if cacheEntry == nil {
cacheEntry = &database.Media{
MXC: mxc,
MimeType: resp.Header.Get("Content-Type"),
Size: resp.ContentLength,
}
}
reader := resp.Body
if cacheEntry.EncFile != nil {
err = cacheEntry.EncFile.PrepareForDecryption()
if err != nil {
log.Err(err).Msg("Failed to prepare media for decryption")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to prepare media for decryption: %v", err)).Write(w)
return
}
reader = cacheEntry.EncFile.DecryptStream(reader)
}
if cacheEntry.FileName == "" {
_, params, _ := mime.ParseMediaType(resp.Header.Get("Content-Disposition"))
cacheEntry.FileName = params["filename"]
}
if cacheEntry.MimeType == "" {
cacheEntry.MimeType = resp.Header.Get("Content-Type")
}
cacheEntry.Size = resp.ContentLength
fileHasher := sha256.New()
wrappedReader := io.TeeReader(reader, fileHasher)
if cacheEntry.Size > 0 && cacheEntry.EncFile == nil && !useThumbnail {
cacheEntryToHeaders(w, cacheEntry, useThumbnail)
w.WriteHeader(http.StatusOK)
wrappedReader = io.TeeReader(wrappedReader, &noErrorWriter{w})
w = nil
}
cacheEntry.Size, err = io.Copy(tempFile, wrappedReader)
if err != nil {
log.Err(err).Msg("Failed to copy media to temporary file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to copy media to temp file: %v", err)).Write(w)
return
}
err = reader.Close()
if err != nil {
log.Err(err).Msg("Failed to close media reader")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to finish reading media: %v", err)).Write(w)
return
}
_ = tempFile.Close()
cacheEntry.Hash = (*[32]byte)(fileHasher.Sum(nil))
cacheEntry.Error = nil
err = gmx.Client.DB.Media.Put(ctx, cacheEntry)
if err != nil {
log.Err(err).Msg("Failed to save cache entry")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to save cache entry: %v", err)).Write(w)
return
}
cachePath := gmx.cacheEntryToPath(cacheEntry.Hash[:])
err = os.MkdirAll(filepath.Dir(cachePath), 0700)
if err != nil {
log.Err(err).Msg("Failed to create cache directory")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to create cache directory: %v", err)).Write(w)
return
}
err = os.Rename(tempFile.Name(), cachePath)
if err != nil {
log.Err(err).Msg("Failed to rename temporary file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to rename temp file: %v", err)).Write(w)
return
}
if w != nil {
gmx.downloadMediaFromCache(ctx, w, r, cacheEntry, true, useThumbnail)
}
}
func (gmx *Gomuks) UploadMedia(w http.ResponseWriter, r *http.Request) {
log := hlog.FromRequest(r)
tempFile, err := os.CreateTemp(gmx.TempDir, "upload-*")
if err != nil {
log.Err(err).Msg("Failed to create temporary file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to create temp file: %v", err)).Write(w)
return
}
defer func() {
_ = tempFile.Close()
_ = os.Remove(tempFile.Name())
}()
hasher := sha256.New()
_, err = io.Copy(tempFile, io.TeeReader(r.Body, hasher))
if err != nil {
log.Err(err).Msg("Failed to copy upload media to temporary file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to copy media to temp file: %v", err)).Write(w)
return
}
_ = tempFile.Close()
checksum := hasher.Sum(nil)
cachePath := gmx.cacheEntryToPath(checksum)
if _, err = os.Stat(cachePath); err == nil {
log.Debug().Str("path", cachePath).Msg("Media already exists in cache, removing temp file")
} else {
err = os.MkdirAll(filepath.Dir(cachePath), 0700)
if err != nil {
log.Err(err).Msg("Failed to create cache directory")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to create cache directory: %v", err)).Write(w)
return
}
err = os.Rename(tempFile.Name(), cachePath)
if err != nil {
log.Err(err).Msg("Failed to rename temporary file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to rename temp file: %v", err)).Write(w)
return
}
}
cacheFile, err := os.Open(cachePath)
if err != nil {
log.Err(err).Msg("Failed to open cache file")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to open cache file: %v", err)).Write(w)
return
}
msgType, info, defaultFileName, err := gmx.generateFileInfo(r.Context(), cacheFile)
if err != nil {
log.Err(err).Msg("Failed to generate file info")
mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to generate file info: %v", err)).Write(w)
return
}
encrypt, _ := strconv.ParseBool(r.URL.Query().Get("encrypt"))
if msgType == event.MsgVideo {
err = gmx.generateVideoThumbnail(r.Context(), cacheFile.Name(), encrypt, info)
if err != nil {
log.Warn().Err(err).Msg("Failed to generate video thumbnail")
}
}
fileName := r.URL.Query().Get("filename")
if fileName == "" {
fileName = defaultFileName
}
content := &event.MessageEventContent{
MsgType: msgType,
Body: fileName,
Info: info,
FileName: fileName,
}
content.File, content.URL, err = gmx.uploadFile(r.Context(), checksum, cacheFile, encrypt, int64(info.Size), info.MimeType, fileName)
if err != nil {
log.Err(err).Msg("Failed to upload media")
writeMaybeRespError(err, w)
return
}
exhttp.WriteJSONResponse(w, http.StatusOK, content)
}
func (gmx *Gomuks) uploadFile(ctx context.Context, checksum []byte, cacheFile *os.File, encrypt bool, fileSize int64, mimeType, fileName string) (*event.EncryptedFileInfo, id.ContentURIString, error) {
cm := &database.Media{
FileName: fileName,
MimeType: mimeType,
Size: fileSize,
Hash: (*[32]byte)(checksum),
}
var cacheReader io.ReadSeekCloser = cacheFile
if encrypt {
cm.EncFile = attachment.NewEncryptedFile()
cacheReader = cm.EncFile.EncryptStream(cacheReader)
mimeType = "application/octet-stream"
fileName = ""
}
resp, err := gmx.Client.Client.UploadMedia(ctx, mautrix.ReqUploadMedia{
Content: cacheReader,
ContentLength: fileSize,
ContentType: mimeType,
FileName: fileName,
})
err2 := cacheReader.Close()
if err != nil {
return nil, "", err
} else if err2 != nil {
return nil, "", fmt.Errorf("failed to close cache reader: %w", err)
}
cm.MXC = resp.ContentURI
err = gmx.Client.DB.Media.Put(ctx, cm)
if err != nil {
zerolog.Ctx(ctx).Err(err).
Stringer("mxc", cm.MXC).
Hex("checksum", checksum).
Msg("Failed to save cache entry")
}
if cm.EncFile != nil {
return &event.EncryptedFileInfo{
EncryptedFile: *cm.EncFile,
URL: resp.ContentURI.CUString(),
}, "", nil
} else {
return nil, resp.ContentURI.CUString(), nil
}
}
func (gmx *Gomuks) generateFileInfo(ctx context.Context, file *os.File) (event.MessageType, *event.FileInfo, string, error) {
fileInfo, err := file.Stat()
if err != nil {
return "", nil, "", fmt.Errorf("failed to stat cache file: %w", err)
}
mimeType, err := mimetype.DetectReader(file)
if err != nil {
return "", nil, "", fmt.Errorf("failed to detect mime type: %w", err)
}
_, err = file.Seek(0, io.SeekStart)
if err != nil {
return "", nil, "", fmt.Errorf("failed to seek to start of file: %w", err)
}
info := &event.FileInfo{
MimeType: mimeType.String(),
Size: int(fileInfo.Size()),
}
var msgType event.MessageType
var defaultFileName string
switch strings.Split(mimeType.String(), "/")[0] {
case "image":
msgType = event.MsgImage
defaultFileName = "image" + mimeType.Extension()
img, _, err := image.Decode(file)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to decode image config")
} else {
bounds := img.Bounds()
info.Width = bounds.Dx()
info.Height = bounds.Dy()
hash, err := blurhash.Encode(4, 3, img)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to generate image blurhash")
}
info.AnoaBlurhash = hash
}
case "video":
msgType = event.MsgVideo
defaultFileName = "video" + mimeType.Extension()
case "audio":
msgType = event.MsgAudio
defaultFileName = "audio" + mimeType.Extension()
default:
msgType = event.MsgFile
defaultFileName = "file" + mimeType.Extension()
}
if msgType == event.MsgVideo || msgType == event.MsgAudio {
probe, err := ffmpeg.Probe(ctx, file.Name())
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to probe video")
} else if probe != nil && probe.Format != nil {
info.Duration = int(probe.Format.Duration * 1000)
for _, stream := range probe.Streams {
if stream.Width != 0 {
info.Width = stream.Width
info.Height = stream.Height
break
}
}
}
}
_, err = file.Seek(0, io.SeekStart)
if err != nil {
return "", nil, "", fmt.Errorf("failed to seek to start of file: %w", err)
}
return msgType, info, defaultFileName, nil
}
func (gmx *Gomuks) generateVideoThumbnail(ctx context.Context, filePath string, encrypt bool, saveInto *event.FileInfo) error {
tempPath := filepath.Join(gmx.TempDir, "thumbnail-"+random.String(12)+".jpeg")
defer os.Remove(tempPath)
err := ffmpeg.ConvertPathWithDestination(
ctx, filePath, tempPath, nil,
[]string{"-frames:v", "1", "-update", "1", "-f", "image2"},
false,
)
if err != nil {
return err
}
tempFile, err := os.Open(tempPath)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer tempFile.Close()
fileInfo, err := tempFile.Stat()
if err != nil {
return fmt.Errorf("failed to stat file: %w", err)
}
hasher := sha256.New()
_, err = io.Copy(hasher, tempFile)
if err != nil {
return fmt.Errorf("failed to hash file: %w", err)
}
thumbnailInfo := &event.FileInfo{
MimeType: "image/jpeg",
Size: int(fileInfo.Size()),
}
_, err = tempFile.Seek(0, io.SeekStart)
if err != nil {
return fmt.Errorf("failed to seek to start of file: %w", err)
}
img, _, err := image.Decode(tempFile)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to decode thumbnail image config")
} else {
bounds := img.Bounds()
thumbnailInfo.Width = bounds.Dx()
thumbnailInfo.Height = bounds.Dy()
hash, err := blurhash.Encode(4, 3, img)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to generate image blurhash")
}
thumbnailInfo.AnoaBlurhash = hash
}
_ = tempFile.Close()
checksum := hasher.Sum(nil)
cachePath := gmx.cacheEntryToPath(checksum)
if _, err = os.Stat(cachePath); err == nil {
zerolog.Ctx(ctx).Debug().Str("path", cachePath).Msg("Media already exists in cache, removing temp file")
} else {
err = os.MkdirAll(filepath.Dir(cachePath), 0700)
if err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
err = os.Rename(tempPath, cachePath)
if err != nil {
return fmt.Errorf("failed to rename file: %w", err)
}
}
tempFile, err = os.Open(cachePath)
if err != nil {
return fmt.Errorf("failed to open renamed file: %w", err)
}
saveInto.ThumbnailFile, saveInto.ThumbnailURL, err = gmx.uploadFile(ctx, checksum, tempFile, encrypt, fileInfo.Size(), "image/jpeg", "thumbnail.jpeg")
if err != nil {
return fmt.Errorf("failed to upload: %w", err)
}
saveInto.ThumbnailInfo = thumbnailInfo
return nil
}
func writeMaybeRespError(err error, w http.ResponseWriter) {
var httpErr mautrix.HTTPError
if errors.As(err, &httpErr) {
if httpErr.WrappedError != nil {
ErrBadGateway.WithMessage(httpErr.WrappedError.Error()).Write(w)
} else if httpErr.RespError != nil {
httpErr.RespError.Write(w)
} else {
mautrix.MUnknown.WithMessage("Server returned non-JSON error").Write(w)
}
} else {
mautrix.MUnknown.WithMessage(err.Error()).Write(w)
}
}

View file

@ -1,258 +0,0 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2025 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"net/http"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr"
"go.mau.fi/util/random"
"maunium.net/go/mautrix/id"
"go.mau.fi/gomuks/pkg/hicli"
"go.mau.fi/gomuks/pkg/hicli/database"
)
type PushNotification struct {
Dismiss []PushDismiss `json:"dismiss,omitempty"`
OrigMessages []*PushNewMessage `json:"-"`
RawMessages []json.RawMessage `json:"messages,omitempty"`
ImageAuth string `json:"image_auth,omitempty"`
ImageAuthExpiry *jsontime.UnixMilli `json:"image_auth_expiry,omitempty"`
HasImportant bool `json:"-"`
}
type PushDismiss struct {
RoomID id.RoomID `json:"room_id"`
}
var pushClient = &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
ResponseHeaderTimeout: 10 * time.Second,
Proxy: http.ProxyFromEnvironment,
ForceAttemptHTTP2: true,
MaxIdleConns: 5,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
Timeout: 60 * time.Second,
}
func (gmx *Gomuks) SendPushNotifications(sync *hicli.SyncComplete) {
var ctx context.Context
var push PushNotification
for _, room := range sync.Rooms {
if room.DismissNotifications && len(push.Dismiss) < 10 {
push.Dismiss = append(push.Dismiss, PushDismiss{RoomID: room.Meta.ID})
}
for _, notif := range room.Notifications {
if ctx == nil {
ctx = gmx.Log.With().
Str("action", "send push notification").
Logger().WithContext(context.Background())
}
msg := gmx.formatPushNotificationMessage(ctx, notif)
if msg == nil {
continue
}
msgJSON, err := json.Marshal(msg)
if err != nil {
zerolog.Ctx(ctx).Err(err).
Int64("event_rowid", int64(notif.RowID)).
Stringer("event_id", notif.Event.ID).
Msg("Failed to marshal push notification")
continue
} else if len(msgJSON) > 1500 {
// This should not happen as long as formatPushNotificationMessage doesn't return too long messages
zerolog.Ctx(ctx).Error().
Int64("event_rowid", int64(notif.RowID)).
Stringer("event_id", notif.Event.ID).
Msg("Push notification too long")
continue
}
push.RawMessages = append(push.RawMessages, msgJSON)
push.OrigMessages = append(push.OrigMessages, msg)
}
}
if len(push.Dismiss) == 0 && len(push.RawMessages) == 0 {
return
}
if ctx == nil {
ctx = gmx.Log.With().
Str("action", "send push notification").
Logger().WithContext(context.Background())
}
pushRegs, err := gmx.Client.DB.PushRegistration.GetAll(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to get push registrations")
return
}
if len(push.RawMessages) > 0 {
exp := time.Now().Add(24 * time.Hour)
push.ImageAuth = gmx.generateImageToken(24 * time.Hour)
push.ImageAuthExpiry = ptr.Ptr(jsontime.UM(exp))
}
for notif := range push.Split {
gmx.SendPushNotification(ctx, pushRegs, notif)
}
}
func (pn *PushNotification) Split(yield func(*PushNotification) bool) {
const maxSize = 2000
currentSize := 0
offset := 0
hasSound := false
for i, msg := range pn.RawMessages {
if len(msg) >= maxSize {
// This is already checked in SendPushNotifications, so this should never happen
panic("push notification message too long")
}
if currentSize+len(msg) > maxSize {
yield(&PushNotification{
Dismiss: pn.Dismiss,
RawMessages: pn.RawMessages[offset:i],
ImageAuth: pn.ImageAuth,
HasImportant: hasSound,
})
offset = i
currentSize = 0
hasSound = false
}
currentSize += len(msg)
hasSound = hasSound || pn.OrigMessages[i].Sound
}
yield(&PushNotification{
Dismiss: pn.Dismiss,
RawMessages: pn.RawMessages[offset:],
ImageAuth: pn.ImageAuth,
HasImportant: hasSound,
})
}
func (gmx *Gomuks) SendPushNotification(ctx context.Context, pushRegs []*database.PushRegistration, notif *PushNotification) {
log := zerolog.Ctx(ctx).With().
Bool("important", notif.HasImportant).
Int("message_count", len(notif.RawMessages)).
Int("dismiss_count", len(notif.Dismiss)).
Logger()
ctx = log.WithContext(ctx)
rawPayload, err := json.Marshal(notif)
if err != nil {
log.Err(err).Msg("Failed to marshal push notification")
return
} else if base64.StdEncoding.EncodedLen(len(rawPayload)) >= 4000 {
log.Error().Msg("Generated push payload too long")
return
}
for _, reg := range pushRegs {
devicePayload := rawPayload
encrypted := false
if reg.Encryption.Key != nil {
var err error
devicePayload, err = encryptPush(rawPayload, reg.Encryption.Key)
if err != nil {
log.Err(err).Str("device_id", reg.DeviceID).Msg("Failed to encrypt push payload")
continue
}
encrypted = true
}
switch reg.Type {
case database.PushTypeFCM:
if !encrypted {
log.Warn().
Str("device_id", reg.DeviceID).
Msg("FCM push registration doesn't have encryption key")
continue
}
var token string
err = json.Unmarshal(reg.Data, &token)
if err != nil {
log.Err(err).Str("device_id", reg.DeviceID).Msg("Failed to unmarshal FCM token")
continue
}
gmx.SendFCMPush(ctx, token, devicePayload, notif.HasImportant)
}
}
}
func encryptPush(payload, key []byte) ([]byte, error) {
if len(key) != 32 {
return nil, fmt.Errorf("encryption key must be 32 bytes long")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM cipher: %w", err)
}
iv := random.Bytes(12)
encrypted := make([]byte, 12, 12+len(payload))
copy(encrypted, iv)
return gcm.Seal(encrypted, iv, payload, nil), nil
}
type PushRequest struct {
Token string `json:"token"`
Payload []byte `json:"payload"`
HighPriority bool `json:"high_priority"`
}
func (gmx *Gomuks) SendFCMPush(ctx context.Context, token string, payload []byte, highPriority bool) {
wrappedPayload, _ := json.Marshal(&PushRequest{
Token: token,
Payload: payload,
HighPriority: highPriority,
})
url := fmt.Sprintf("%s/_gomuks/push/fcm", gmx.Config.Push.FCMGateway)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(wrappedPayload))
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to create push request")
return
}
resp, err := pushClient.Do(req)
if err != nil {
zerolog.Ctx(ctx).Err(err).Str("push_token", token).Msg("Failed to send push request")
} else if resp.StatusCode != http.StatusOK {
zerolog.Ctx(ctx).Error().
Int("status", resp.StatusCode).
Str("push_token", token).
Msg("Non-200 status while sending push request")
} else {
zerolog.Ctx(ctx).Trace().
Int("status", resp.StatusCode).
Str("push_token", token).
Msg("Sent push request")
}
if resp != nil {
_ = resp.Body.Close()
}
}

View file

@ -1,171 +0,0 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2025 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"context"
"encoding/json"
"fmt"
"net/url"
"unicode/utf8"
"github.com/rs/zerolog"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/ptr"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/gomuks/pkg/hicli"
"go.mau.fi/gomuks/pkg/hicli/database"
)
type PushNewMessage struct {
Timestamp jsontime.UnixMilli `json:"timestamp"`
EventID id.EventID `json:"event_id"`
EventRowID database.EventRowID `json:"event_rowid"`
RoomID id.RoomID `json:"room_id"`
RoomName string `json:"room_name"`
RoomAvatar string `json:"room_avatar,omitempty"`
Sender NotificationUser `json:"sender"`
Self NotificationUser `json:"self"`
Text string `json:"text"`
Image string `json:"image,omitempty"`
Mention bool `json:"mention,omitempty"`
Reply bool `json:"reply,omitempty"`
Sound bool `json:"sound,omitempty"`
}
type NotificationUser struct {
ID id.UserID `json:"id"`
Name string `json:"name"`
Avatar string `json:"avatar,omitempty"`
}
func getAvatarLinkForNotification(name, ident string, uri id.ContentURIString) string {
parsed := uri.ParseOrIgnore()
if !parsed.IsValid() {
return ""
}
var fallbackChar rune
if name == "" {
fallbackChar, _ = utf8.DecodeRuneInString(ident[1:])
} else {
fallbackChar, _ = utf8.DecodeRuneInString(name)
}
return fmt.Sprintf("_gomuks/media/%s/%s?encrypted=false&fallback=%s", parsed.Homeserver, parsed.FileID, url.QueryEscape(string(fallbackChar)))
}
func (gmx *Gomuks) getNotificationUser(ctx context.Context, roomID id.RoomID, userID id.UserID) (user NotificationUser) {
user = NotificationUser{ID: userID, Name: userID.Localpart()}
memberEvt, err := gmx.Client.DB.CurrentState.Get(ctx, roomID, event.StateMember, userID.String())
if err != nil {
zerolog.Ctx(ctx).Err(err).Stringer("of_user_id", userID).Msg("Failed to get member event")
return
} else if memberEvt == nil {
return
}
var memberContent event.MemberEventContent
_ = json.Unmarshal(memberEvt.Content, &memberContent)
if memberContent.Displayname != "" {
user.Name = memberContent.Displayname
}
if len(user.Name) > 50 {
user.Name = user.Name[:50] + "…"
}
if memberContent.AvatarURL != "" {
user.Avatar = getAvatarLinkForNotification(memberContent.Displayname, userID.String(), memberContent.AvatarURL)
}
return
}
func (gmx *Gomuks) formatPushNotificationMessage(ctx context.Context, notif hicli.SyncNotification) *PushNewMessage {
evtType := notif.Event.Type
rawContent := notif.Event.Content
if evtType == event.EventEncrypted.Type {
evtType = notif.Event.DecryptedType
rawContent = notif.Event.Decrypted
}
if evtType != event.EventMessage.Type && evtType != event.EventSticker.Type {
return nil
}
var content event.MessageEventContent
err := json.Unmarshal(rawContent, &content)
if err != nil {
zerolog.Ctx(ctx).Warn().Err(err).
Stringer("event_id", notif.Event.ID).
Msg("Failed to unmarshal message content to format push notification")
return nil
}
var roomAvatar, image string
if notif.Room.Avatar != nil {
avatarIdent := notif.Room.ID.String()
if ptr.Val(notif.Room.DMUserID) != "" {
avatarIdent = notif.Room.DMUserID.String()
}
roomAvatar = getAvatarLinkForNotification(ptr.Val(notif.Room.Name), avatarIdent, notif.Room.Avatar.CUString())
}
roomName := ptr.Val(notif.Room.Name)
if roomName == "" {
roomName = "Unnamed room"
}
if len(roomName) > 50 {
roomName = roomName[:50] + "…"
}
text := content.Body
if len(text) > 400 {
text = text[:350] + "[…]"
}
if content.MsgType == event.MsgImage || evtType == event.EventSticker.Type {
if content.File != nil && content.File.URL != "" {
parsed := content.File.URL.ParseOrIgnore()
if len(content.File.URL) < 255 && parsed.IsValid() {
image = fmt.Sprintf("_gomuks/media/%s/%s?encrypted=true", parsed.Homeserver, parsed.FileID)
}
} else if content.URL != "" {
parsed := content.URL.ParseOrIgnore()
if len(content.URL) < 255 && parsed.IsValid() {
image = fmt.Sprintf("_gomuks/media/%s/%s?encrypted=false", parsed.Homeserver, parsed.FileID)
}
}
if content.FileName == "" || content.FileName == content.Body {
text = "Sent a photo"
}
} else if content.MsgType.IsMedia() {
if content.FileName == "" || content.FileName == content.Body {
text = "Sent a file: " + text
}
}
return &PushNewMessage{
Timestamp: notif.Event.Timestamp,
EventID: notif.Event.ID,
EventRowID: notif.Event.RowID,
RoomID: notif.Room.ID,
RoomName: roomName,
RoomAvatar: roomAvatar,
Sender: gmx.getNotificationUser(ctx, notif.Room.ID, notif.Event.Sender),
Self: gmx.getNotificationUser(ctx, notif.Room.ID, gmx.Client.Account.UserID),
Text: text,
Image: image,
Mention: content.Mentions.Has(gmx.Client.Account.UserID),
Reply: content.RelatesTo.GetNonFallbackReplyTo() != "",
Sound: notif.Sound,
}
}

View file

@ -1,321 +0,0 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"io/fs"
"net/http"
_ "net/http/pprof"
"strconv"
"strings"
"time"
"github.com/alecthomas/chroma/v2/styles"
"github.com/rs/zerolog/hlog"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/exhttp"
"go.mau.fi/util/jsontime"
"go.mau.fi/util/requestlog"
"golang.org/x/crypto/bcrypt"
"maunium.net/go/mautrix"
"go.mau.fi/gomuks/pkg/hicli"
)
func (gmx *Gomuks) CreateAPIRouter() http.Handler {
api := http.NewServeMux()
api.HandleFunc("GET /websocket", gmx.HandleWebsocket)
api.HandleFunc("POST /auth", gmx.Authenticate)
api.HandleFunc("POST /upload", gmx.UploadMedia)
api.HandleFunc("GET /sso", gmx.HandleSSOComplete)
api.HandleFunc("POST /sso", gmx.PrepareSSO)
api.HandleFunc("GET /media/{server}/{media_id}", gmx.DownloadMedia)
api.HandleFunc("POST /keys/export", gmx.ExportKeys)
api.HandleFunc("POST /keys/export/{room_id}", gmx.ExportKeys)
api.HandleFunc("POST /keys/import", gmx.ImportKeys)
api.HandleFunc("GET /codeblock/{style}", gmx.GetCodeblockCSS)
return exhttp.ApplyMiddleware(
api,
hlog.NewHandler(*gmx.Log),
hlog.RequestIDHandler("request_id", "Request-ID"),
requestlog.AccessLogger(false),
)
}
func (gmx *Gomuks) StartServer() {
api := gmx.CreateAPIRouter()
router := http.NewServeMux()
if gmx.Config.Web.DebugEndpoints {
router.Handle("/debug/", http.DefaultServeMux)
}
router.Handle("/_gomuks/", exhttp.ApplyMiddleware(
api,
exhttp.StripPrefix("/_gomuks"),
gmx.AuthMiddleware,
))
if frontend, err := fs.Sub(gmx.FrontendFS, "dist"); err != nil {
gmx.Log.Warn().Msg("Frontend not found")
} else {
router.Handle("/", gmx.FrontendCacheMiddleware(http.FileServerFS(frontend)))
if gmx.Commit != "unknown" && !gmx.BuildTime.IsZero() {
gmx.frontendETag = fmt.Sprintf(`"%s-%s"`, gmx.Commit, gmx.BuildTime.Format(time.RFC3339))
indexFile, err := frontend.Open("index.html")
if err != nil {
gmx.Log.Err(err).Msg("Failed to open index.html")
} else {
data, err := io.ReadAll(indexFile)
_ = indexFile.Close()
if err == nil {
gmx.indexWithETag = bytes.Replace(
data,
[]byte("<!-- etag placeholder -->"),
[]byte(fmt.Sprintf(`<meta name="gomuks-frontend-etag" content="%s">`, html.EscapeString(gmx.frontendETag))),
1,
)
}
}
}
}
gmx.Server = &http.Server{
Addr: gmx.Config.Web.ListenAddress,
Handler: router,
}
go func() {
err := gmx.Server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
panic(err)
}
}()
gmx.Log.Info().Str("address", gmx.Config.Web.ListenAddress).Msg("Server started")
}
func (gmx *Gomuks) FrontendCacheMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if gmx.frontendETag != "" && r.Header.Get("If-None-Match") == gmx.frontendETag {
w.WriteHeader(http.StatusNotModified)
return
}
if strings.HasPrefix(r.URL.Path, "/assets/") {
w.Header().Set("Cache-Control", "max-age=604800, immutable")
}
if gmx.frontendETag != "" {
w.Header().Set("ETag", gmx.frontendETag)
if r.URL.Path == "/" && gmx.indexWithETag != nil {
w.Header().Set("Content-Type", "text/html")
w.Header().Set("Content-Length", strconv.Itoa(len(gmx.indexWithETag)))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(gmx.indexWithETag)
return
}
}
next.ServeHTTP(w, r)
})
}
var (
ErrInvalidHeader = mautrix.RespError{ErrCode: "FI.MAU.GOMUKS.INVALID_HEADER", StatusCode: http.StatusForbidden}
ErrMissingCookie = mautrix.RespError{ErrCode: "FI.MAU.GOMUKS.MISSING_COOKIE", Err: "Missing gomuks_auth cookie", StatusCode: http.StatusUnauthorized}
ErrInvalidCookie = mautrix.RespError{ErrCode: "FI.MAU.GOMUKS.INVALID_COOKIE", Err: "Invalid gomuks_auth cookie", StatusCode: http.StatusUnauthorized}
)
type tokenData struct {
Username string `json:"username"`
Expiry jsontime.Unix `json:"expiry"`
ImageOnly bool `json:"image_only,omitempty"`
}
func (gmx *Gomuks) validateToken(token string, output any) bool {
if len(token) > 4096 {
return false
}
parts := strings.Split(token, ".")
if len(parts) != 2 {
return false
}
rawJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return false
}
checksum, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return false
}
hasher := hmac.New(sha256.New, []byte(gmx.Config.Web.TokenKey))
hasher.Write(rawJSON)
if !hmac.Equal(hasher.Sum(nil), checksum) {
return false
}
err = json.Unmarshal(rawJSON, output)
return err == nil
}
func (gmx *Gomuks) validateAuth(token string, imageOnly bool) bool {
if len(token) > 500 {
return false
}
var td tokenData
return gmx.validateToken(token, &td) &&
td.Username == gmx.Config.Web.Username &&
td.Expiry.After(time.Now()) &&
td.ImageOnly == imageOnly
}
func (gmx *Gomuks) generateToken() (string, time.Time) {
expiry := time.Now().Add(7 * 24 * time.Hour)
return gmx.signToken(tokenData{
Username: gmx.Config.Web.Username,
Expiry: jsontime.U(expiry),
}), expiry
}
func (gmx *Gomuks) generateImageToken(expiry time.Duration) string {
return gmx.signToken(tokenData{
Username: gmx.Config.Web.Username,
Expiry: jsontime.U(time.Now().Add(expiry)),
ImageOnly: true,
})
}
func (gmx *Gomuks) signToken(td any) string {
data := exerrors.Must(json.Marshal(td))
hasher := hmac.New(sha256.New, []byte(gmx.Config.Web.TokenKey))
hasher.Write(data)
checksum := hasher.Sum(nil)
return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(checksum)
}
func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter, created, jsonOutput bool) {
token, expiry := gmx.generateToken()
if !jsonOutput {
http.SetCookie(w, &http.Cookie{
Name: "gomuks_auth",
Value: token,
Expires: expiry,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
if created {
w.WriteHeader(http.StatusCreated)
} else {
w.WriteHeader(http.StatusOK)
}
if jsonOutput {
_ = json.NewEncoder(w).Encode(map[string]string{"token": token})
}
}
func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) {
if gmx.DisableAuth {
w.WriteHeader(http.StatusOK)
return
}
jsonOutput := r.URL.Query().Get("output") == "json"
allowPrompt := r.URL.Query().Get("no_prompt") != "true"
authCookie, err := r.Cookie("gomuks_auth")
if err == nil && gmx.validateAuth(authCookie.Value, false) {
hlog.FromRequest(r).Debug().Msg("Authentication successful with existing cookie")
gmx.writeTokenCookie(w, false, jsonOutput)
} else if found, correct := gmx.doBasicAuth(r); found && correct {
hlog.FromRequest(r).Debug().Msg("Authentication successful with username and password")
gmx.writeTokenCookie(w, true, jsonOutput)
} else {
if !found {
hlog.FromRequest(r).Debug().Msg("Requesting credentials for auth request")
} else {
hlog.FromRequest(r).Debug().Msg("Authentication failed with username and password, re-requesting credentials")
}
if allowPrompt {
w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`)
}
w.WriteHeader(http.StatusUnauthorized)
}
}
func (gmx *Gomuks) doBasicAuth(r *http.Request) (found, correct bool) {
var username, password string
username, password, found = r.BasicAuth()
if !found {
return
}
usernameHash := sha256.Sum256([]byte(username))
expectedUsernameHash := sha256.Sum256([]byte(gmx.Config.Web.Username))
usernameCorrect := hmac.Equal(usernameHash[:], expectedUsernameHash[:])
passwordCorrect := bcrypt.CompareHashAndPassword([]byte(gmx.Config.Web.PasswordHash), []byte(password)) == nil
correct = passwordCorrect && usernameCorrect
return
}
func isImageFetch(header http.Header) bool {
return header.Get("Sec-Fetch-Site") == "cross-site" &&
header.Get("Sec-Fetch-Mode") == "no-cors" &&
header.Get("Sec-Fetch-Dest") == "image"
}
func (gmx *Gomuks) AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/media") &&
isImageFetch(r.Header) &&
gmx.validateAuth(r.URL.Query().Get("image_auth"), true) &&
r.URL.Query().Get("encrypted") == "false" {
next.ServeHTTP(w, r)
return
}
if r.URL.Path != "/auth" {
authCookie, err := r.Cookie("gomuks_auth")
if err != nil {
ErrMissingCookie.Write(w)
return
} else if !gmx.validateAuth(authCookie.Value, false) {
http.SetCookie(w, &http.Cookie{
Name: "gomuks_auth",
MaxAge: -1,
})
ErrInvalidCookie.Write(w)
return
}
}
next.ServeHTTP(w, r)
})
}
func (gmx *Gomuks) GetCodeblockCSS(w http.ResponseWriter, r *http.Request) {
styleName := r.PathValue("style")
if !strings.HasSuffix(styleName, ".css") {
w.WriteHeader(http.StatusNotFound)
return
}
style := styles.Get(strings.TrimSuffix(styleName, ".css"))
if style == nil {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/css")
_ = hicli.CodeBlockFormatter.WriteCSS(w, style)
}

View file

@ -1,128 +0,0 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"encoding/json"
"fmt"
"html"
"net/http"
"net/url"
"time"
"go.mau.fi/util/random"
"maunium.net/go/mautrix"
)
const ssoErrorPage = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>gomuks web</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
}
</style>
</head>
<body>
<h1>Failed to log in</h1>
<p><code>%s</code></p>
</body>
</html>`
func (gmx *Gomuks) parseSSOServerURL(r *http.Request) error {
cookie, _ := r.Cookie("gomuks_sso_session")
if cookie == nil {
return fmt.Errorf("no SSO session cookie")
}
var cookieData SSOCookieData
if !gmx.validateToken(cookie.Value, &cookieData) {
return fmt.Errorf("invalid SSO session cookie")
} else if cookieData.SessionID != r.URL.Query().Get("gomuksSession") {
return fmt.Errorf("session ID mismatch in query param and cookie")
} else if time.Until(cookieData.Expiry) < 0 {
return fmt.Errorf("SSO session cookie expired")
}
var err error
gmx.Client.Client.HomeserverURL, err = url.Parse(cookieData.HomeserverURL)
if err != nil {
return fmt.Errorf("failed to parse server URL: %w", err)
}
return nil
}
func (gmx *Gomuks) HandleSSOComplete(w http.ResponseWriter, r *http.Request) {
err := gmx.parseSSOServerURL(r)
if err != nil {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprintf(w, ssoErrorPage, html.EscapeString(err.Error()))
return
}
err = gmx.Client.Login(r.Context(), &mautrix.ReqLogin{
Type: mautrix.AuthTypeToken,
Token: r.URL.Query().Get("loginToken"),
})
if err != nil {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprintf(w, ssoErrorPage, html.EscapeString(err.Error()))
} else {
w.Header().Set("Location", "..")
w.WriteHeader(http.StatusFound)
}
}
type SSOCookieData struct {
SessionID string `json:"session_id"`
HomeserverURL string `json:"homeserver_url"`
Expiry time.Time `json:"expiry"`
}
func (gmx *Gomuks) PrepareSSO(w http.ResponseWriter, r *http.Request) {
var data SSOCookieData
err := json.NewDecoder(r.Body).Decode(&data)
if err != nil {
mautrix.MBadJSON.WithMessage("Failed to decode request JSON").Write(w)
return
}
data.SessionID = random.String(16)
data.Expiry = time.Now().Add(30 * time.Minute)
cookieData, err := json.Marshal(&data)
if err != nil {
mautrix.MUnknown.WithMessage("Failed to encode cookie data").Write(w)
return
}
http.SetCookie(w, &http.Cookie{
Name: "gomuks_sso_session",
Value: gmx.signToken(json.RawMessage(cookieData)),
Expires: data.Expiry,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(cookieData)
}

View file

@ -1,338 +0,0 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package gomuks
import (
"context"
"encoding/json"
"errors"
"net/http"
"runtime/debug"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
"github.com/rs/zerolog"
"go.mau.fi/util/exerrors"
"go.mau.fi/gomuks/pkg/hicli"
)
func writeCmd[T any](ctx context.Context, conn *websocket.Conn, cmd *hicli.JSONCommandCustom[T]) error {
writer, err := conn.Writer(ctx, websocket.MessageText)
if err != nil {
return err
}
err = json.NewEncoder(writer).Encode(&cmd)
if err != nil {
return err
}
return writer.Close()
}
const (
StatusEventsStuck = 4001
StatusPingTimeout = 4002
)
var emptyObject = json.RawMessage("{}")
type PingRequestData struct {
LastReceivedID int64 `json:"last_received_id"`
}
var runID = time.Now().UnixNano()
type RunData struct {
RunID string `json:"run_id"`
ETag string `json:"etag"`
}
func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
var conn *websocket.Conn
log := zerolog.Ctx(r.Context())
recoverPanic := func(context string) bool {
err := recover()
if err != nil {
logEvt := log.Error().
Bytes(zerolog.ErrorStackFieldName, debug.Stack()).
Str("goroutine", context)
if realErr, ok := err.(error); ok {
logEvt = logEvt.Err(realErr)
} else {
logEvt = logEvt.Any(zerolog.ErrorFieldName, err)
}
logEvt.Msg("Panic in websocket handler")
return true
}
return false
}
defer recoverPanic("read loop")
conn, acceptErr := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: gmx.Config.Web.OriginPatterns,
})
if acceptErr != nil {
log.Warn().Err(acceptErr).Msg("Failed to accept websocket connection")
return
}
resumeFrom, _ := strconv.ParseInt(r.URL.Query().Get("last_received_event"), 10, 64)
resumeRunID, _ := strconv.ParseInt(r.URL.Query().Get("run_id"), 10, 64)
log.Info().
Int64("resume_from", resumeFrom).
Int64("resume_run_id", resumeRunID).
Int64("current_run_id", runID).
Msg("Accepted new websocket connection")
conn.SetReadLimit(128 * 1024)
ctx, cancel := context.WithCancel(context.Background())
ctx = log.WithContext(ctx)
var listenerID uint64
evts := make(chan *hicli.JSONCommand, 32)
forceClose := func() {
cancel()
if listenerID != 0 {
gmx.EventBuffer.Unsubscribe(listenerID)
}
_ = conn.CloseNow()
close(evts)
}
var closeOnce sync.Once
defer closeOnce.Do(forceClose)
closeManually := func(statusCode websocket.StatusCode, reason string) {
log.Debug().Stringer("status_code", statusCode).Str("reason", reason).Msg("Closing connection manually")
_ = conn.Close(statusCode, reason)
closeOnce.Do(forceClose)
}
if resumeRunID != runID {
resumeFrom = 0
}
var resumeData []*hicli.JSONCommand
listenerID, resumeData = gmx.EventBuffer.Subscribe(resumeFrom, closeManually, func(evt *hicli.JSONCommand) {
if ctx.Err() != nil {
return
}
select {
case evts <- evt:
default:
log.Warn().Msg("Event queue full, closing connection")
cancel()
go func() {
defer recoverPanic("closing connection after error in event handler")
_ = conn.Close(StatusEventsStuck, "Event queue full")
closeOnce.Do(forceClose)
}()
}
})
didResume := resumeData != nil
lastDataReceived := &atomic.Int64{}
lastDataReceived.Store(time.Now().UnixMilli())
const RecvTimeout = 60 * time.Second
lastImageAuthTokenSent := time.Now()
sendImageAuthToken := func() {
err := writeCmd(ctx, conn, &hicli.JSONCommand{
Command: "image_auth_token",
Data: exerrors.Must(json.Marshal(gmx.generateImageToken(1 * time.Hour))),
})
if err != nil {
log.Err(err).Msg("Failed to write image auth token message")
return
}
}
go func() {
defer recoverPanic("event loop")
defer closeOnce.Do(forceClose)
for _, cmd := range resumeData {
err := writeCmd(ctx, conn, cmd)
if err != nil {
log.Err(err).Int64("req_id", cmd.RequestID).Msg("Failed to write outgoing event from resume data")
return
} else {
log.Trace().Int64("req_id", cmd.RequestID).Msg("Sent outgoing event from resume data")
}
}
resumeData = nil
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
ctxDone := ctx.Done()
for {
select {
case cmd := <-evts:
err := writeCmd(ctx, conn, cmd)
if err != nil {
log.Err(err).Int64("req_id", cmd.RequestID).Msg("Failed to write outgoing event")
return
} else {
log.Trace().Int64("req_id", cmd.RequestID).Msg("Sent outgoing event")
}
case <-ticker.C:
if time.Since(lastImageAuthTokenSent) > 30*time.Minute {
sendImageAuthToken()
lastImageAuthTokenSent = time.Now()
}
if time.Now().UnixMilli()-lastDataReceived.Load() > RecvTimeout.Milliseconds() {
log.Warn().Msg("No data received in a minute, closing connection")
_ = conn.Close(StatusPingTimeout, "Ping timeout")
return
}
case <-ctxDone:
return
}
}
}()
submitCmd := func(cmd *hicli.JSONCommand) {
defer func() {
if recoverPanic("command handler") {
_ = conn.Close(websocket.StatusInternalError, "Command handler panicked")
closeOnce.Do(forceClose)
}
}()
if cmd.Data == nil {
cmd.Data = emptyObject
}
log.Trace().
Int64("req_id", cmd.RequestID).
Str("command", cmd.Command).
RawJSON("data", cmd.Data).
Msg("Received command")
var resp *hicli.JSONCommand
if cmd.Command == "ping" {
resp = &hicli.JSONCommand{
Command: "pong",
RequestID: cmd.RequestID,
}
var pingData PingRequestData
err := json.Unmarshal(cmd.Data, &pingData)
if err != nil {
log.Err(err).Msg("Failed to parse ping data")
} else if pingData.LastReceivedID != 0 {
gmx.EventBuffer.SetLastAckedID(listenerID, pingData.LastReceivedID)
}
} else {
resp = gmx.Client.SubmitJSONCommand(ctx, cmd)
}
if ctx.Err() != nil {
return
}
err := writeCmd(ctx, conn, resp)
if err != nil && ctx.Err() == nil {
log.Err(err).Int64("req_id", cmd.RequestID).Msg("Failed to write response")
closeOnce.Do(forceClose)
} else {
log.Trace().Int64("req_id", cmd.RequestID).Msg("Sent response to command")
}
}
initErr := writeCmd(ctx, conn, &hicli.JSONCommandCustom[*RunData]{
Command: "run_id",
Data: &RunData{
RunID: strconv.FormatInt(runID, 10),
ETag: gmx.frontendETag,
},
})
if initErr != nil {
log.Err(initErr).Msg("Failed to write init client state message")
return
}
initErr = writeCmd(ctx, conn, &hicli.JSONCommandCustom[*hicli.ClientState]{
Command: "client_state",
Data: gmx.Client.State(),
})
if initErr != nil {
log.Err(initErr).Msg("Failed to write init client state message")
return
}
initErr = writeCmd(ctx, conn, &hicli.JSONCommandCustom[*hicli.SyncStatus]{
Command: "sync_status",
Data: gmx.Client.SyncStatus.Load(),
})
if initErr != nil {
log.Err(initErr).Msg("Failed to write init sync status message")
return
}
go sendImageAuthToken()
if gmx.Client.IsLoggedIn() && !didResume {
go gmx.sendInitialData(ctx, conn)
}
log.Debug().Bool("did_resume", didResume).Msg("Connection initialization complete")
var closeErr websocket.CloseError
for {
msgType, reader, err := conn.Reader(ctx)
if err != nil {
if errors.As(err, &closeErr) {
log.Debug().
Stringer("status_code", closeErr.Code).
Str("reason", closeErr.Reason).
Msg("Connection closed")
if closeErr.Code == websocket.StatusGoingAway {
gmx.EventBuffer.ClearListenerLastAckedID(listenerID)
}
} else {
log.Err(err).Msg("Failed to read message")
}
return
} else if msgType != websocket.MessageText {
log.Error().Stringer("message_type", msgType).Msg("Unexpected message type")
_ = conn.Close(websocket.StatusUnsupportedData, "Non-text message")
return
}
lastDataReceived.Store(time.Now().UnixMilli())
var cmd hicli.JSONCommand
err = json.NewDecoder(reader).Decode(&cmd)
if err != nil {
log.Err(err).Msg("Failed to parse message")
_ = conn.Close(websocket.StatusUnsupportedData, "Invalid JSON")
return
}
go submitCmd(&cmd)
}
}
func (gmx *Gomuks) sendInitialData(ctx context.Context, conn *websocket.Conn) {
log := zerolog.Ctx(ctx)
var roomCount int
for payload := range gmx.Client.GetInitialSync(ctx, 100) {
roomCount += len(payload.Rooms)
marshaledPayload, err := json.Marshal(&payload)
if err != nil {
log.Err(err).Msg("Failed to marshal initial rooms to send to client")
return
}
err = writeCmd(ctx, conn, &hicli.JSONCommand{
Command: "sync_complete",
RequestID: 0,
Data: marshaledPayload,
})
if err != nil {
log.Err(err).Msg("Failed to send initial rooms to client")
return
}
}
if ctx.Err() != nil {
return
}
err := writeCmd(ctx, conn, &hicli.JSONCommand{
Command: "init_complete",
RequestID: 0,
})
if err != nil {
log.Err(err).Msg("Failed to send initial rooms done event to client")
return
}
log.Info().Int("room_count", roomCount).Msg("Sent initial rooms to client")
}

View file

@ -1,113 +0,0 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package hicli
import (
"context"
"encoding/json"
"fmt"
"slices"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto"
"maunium.net/go/mautrix/crypto/backup"
"maunium.net/go/mautrix/id"
)
func (c *HiClient) uploadKeysToBackup(ctx context.Context) {
log := zerolog.Ctx(ctx)
version := c.KeyBackupVersion
key := c.KeyBackupKey
if version == "" || key == nil {
return
}
sessions, err := c.CryptoStore.GetGroupSessionsWithoutKeyBackupVersion(ctx, version).AsList()
if err != nil {
log.Err(err).Msg("Failed to get megolm sessions that aren't backed up")
return
} else if len(sessions) == 0 {
return
}
log.Debug().Int("session_count", len(sessions)).Msg("Backing up megolm sessions")
for chunk := range slices.Chunk(sessions, 100) {
err = c.uploadKeyBackupBatch(ctx, version, key, chunk)
if err != nil {
log.Err(err).Msg("Failed to upload key backup batch")
return
}
err = c.CryptoStore.DB.DoTxn(ctx, nil, func(ctx context.Context) error {
for _, sess := range chunk {
sess.KeyBackupVersion = version
err := c.CryptoStore.PutGroupSession(ctx, sess)
if err != nil {
return err
}
}
return nil
})
if err != nil {
log.Err(err).Msg("Failed to update key backup version of uploaded megolm sessions in database")
return
}
}
log.Info().Int("session_count", len(sessions)).Msg("Successfully uploaded megolm sessions to key backup")
}
func (c *HiClient) uploadKeyBackupBatch(ctx context.Context, version id.KeyBackupVersion, megolmBackupKey *backup.MegolmBackupKey, sessions []*crypto.InboundGroupSession) error {
if len(sessions) == 0 {
return nil
}
req := mautrix.ReqKeyBackup{
Rooms: map[id.RoomID]mautrix.ReqRoomKeyBackup{},
}
for _, session := range sessions {
sessionKey, err := session.Internal.Export(session.Internal.FirstKnownIndex())
if err != nil {
return fmt.Errorf("failed to export session data: %w", err)
}
sessionData, err := backup.EncryptSessionData(megolmBackupKey, &backup.MegolmSessionData{
Algorithm: id.AlgorithmMegolmV1,
ForwardingKeyChain: session.ForwardingChains,
SenderClaimedKeys: backup.SenderClaimedKeys{
Ed25519: session.SigningKey,
},
SenderKey: session.SenderKey,
SessionKey: string(sessionKey),
})
if err != nil {
return fmt.Errorf("failed to encrypt session data: %w", err)
}
jsonSessionData, err := json.Marshal(sessionData)
if err != nil {
return fmt.Errorf("failed to marshal session data: %w", err)
}
roomData, ok := req.Rooms[session.RoomID]
if !ok {
roomData = mautrix.ReqRoomKeyBackup{
Sessions: map[id.SessionID]mautrix.ReqKeyBackupData{},
}
req.Rooms[session.RoomID] = roomData
}
roomData.Sessions[session.ID()] = mautrix.ReqKeyBackupData{
FirstMessageIndex: int(session.Internal.FirstKnownIndex()),
ForwardedCount: len(session.ForwardingChains),
IsVerified: session.Internal.IsVerified(),
SessionData: jsonSessionData,
}
}
_, err := c.Client.PutKeysInBackup(ctx, version, &req)
return err
}

View file

@ -1,64 +0,0 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package hicli
import (
"context"
"fmt"
"time"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
type hiCryptoHelper HiClient
var _ mautrix.CryptoHelper = (*hiCryptoHelper)(nil)
func (h *hiCryptoHelper) Encrypt(ctx context.Context, roomID id.RoomID, evtType event.Type, content any) (*event.EncryptedEventContent, error) {
roomMeta, err := h.DB.Room.Get(ctx, roomID)
if err != nil {
return nil, fmt.Errorf("failed to get room metadata: %w", err)
} else if roomMeta == nil {
return nil, fmt.Errorf("unknown room")
}
return (*HiClient)(h).Encrypt(ctx, roomMeta, evtType, content)
}
func (h *hiCryptoHelper) Decrypt(ctx context.Context, evt *event.Event) (*event.Event, error) {
return h.Crypto.DecryptMegolmEvent(ctx, evt)
}
func (h *hiCryptoHelper) WaitForSession(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, timeout time.Duration) bool {
return h.Crypto.WaitForSession(ctx, roomID, senderKey, sessionID, timeout)
}
func (h *hiCryptoHelper) RequestSession(ctx context.Context, roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, userID id.UserID, deviceID id.DeviceID) {
err := h.Crypto.SendRoomKeyRequest(ctx, roomID, senderKey, sessionID, "", map[id.UserID][]id.DeviceID{
userID: {deviceID},
h.Account.UserID: {"*"},
})
if err != nil {
zerolog.Ctx(ctx).Err(err).
Stringer("room_id", roomID).
Stringer("session_id", sessionID).
Stringer("user_id", userID).
Msg("Failed to send room key request")
} else {
zerolog.Ctx(ctx).Debug().
Stringer("room_id", roomID).
Stringer("session_id", sessionID).
Stringer("user_id", userID).
Msg("Sent room key request")
}
}
func (h *hiCryptoHelper) Init(ctx context.Context) error {
return nil
}

View file

@ -1,73 +0,0 @@
// Copyright (c) 2024 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package database
import (
"context"
"database/sql"
"errors"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
)
const (
getAccountQuery = `SELECT user_id, device_id, access_token, homeserver_url, next_batch FROM account WHERE user_id = $1`
putNextBatchQuery = `UPDATE account SET next_batch = $1 WHERE user_id = $2`
upsertAccountQuery = `
INSERT INTO account (user_id, device_id, access_token, homeserver_url, next_batch)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id)
DO UPDATE SET device_id = excluded.device_id,
access_token = excluded.access_token,
homeserver_url = excluded.homeserver_url,
next_batch = excluded.next_batch
`
)
type AccountQuery struct {
*dbutil.QueryHelper[*Account]
}
func (aq *AccountQuery) GetFirstUserID(ctx context.Context) (userID id.UserID, err error) {
var exists bool
if exists, err = aq.GetDB().TableExists(ctx, "account"); err != nil || !exists {
return
}
err = aq.GetDB().QueryRow(ctx, `SELECT user_id FROM account LIMIT 1`).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
return
}
func (aq *AccountQuery) Get(ctx context.Context, userID id.UserID) (*Account, error) {
return aq.QueryOne(ctx, getAccountQuery, userID)
}
func (aq *AccountQuery) PutNextBatch(ctx context.Context, userID id.UserID, nextBatch string) error {
return aq.Exec(ctx, putNextBatchQuery, nextBatch, userID)
}
func (aq *AccountQuery) Put(ctx context.Context, account *Account) error {
return aq.Exec(ctx, upsertAccountQuery, account.sqlVariables()...)
}
type Account struct {
UserID id.UserID
DeviceID id.DeviceID
AccessToken string
HomeserverURL string
NextBatch string
}
func (a *Account) Scan(row dbutil.Scannable) (*Account, error) {
return dbutil.ValueOrErr(a, row.Scan(&a.UserID, &a.DeviceID, &a.AccessToken, &a.HomeserverURL, &a.NextBatch))
}
func (a *Account) sqlVariables() []any {
return []any{a.UserID, a.DeviceID, a.AccessToken, a.HomeserverURL, a.NextBatch}
}

Some files were not shown because too many files have changed in this diff Show more