forked from Mirrors/gomuks
Compare commits
No commits in common. "main" and "master" have entirely different histories.
440 changed files with 15637 additions and 56722 deletions
17
.codeclimate.yml
Normal file
17
.codeclimate.yml
Normal 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
|
|
@ -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
1
.envrc
|
@ -1 +0,0 @@
|
||||||
use flake
|
|
|
@ -1,11 +0,0 @@
|
||||||
on: [push]
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: docker
|
|
||||||
steps:
|
|
||||||
- uses: actions/cascading-pr@v1.0.1
|
|
||||||
with:
|
|
||||||
GOPATH="$CI_PROJECT_DIR/.cache"
|
|
||||||
GOCACHE="$CI_PROJECT_DIR/.cache/build"
|
|
||||||
MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
|
|
||||||
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'"
|
|
45
.github/workflows/go.yml
vendored
45
.github/workflows/go.yml
vendored
|
@ -2,41 +2,48 @@ 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:
|
|
||||||
matrix:
|
|
||||||
go-version: ["1.23", "1.24"]
|
|
||||||
name: Lint Go ${{ matrix.go-version == '1.24' && '(latest)' || '(old)' }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: ${{ matrix.go-version }}
|
go-version: "1.22"
|
||||||
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.21", "1.22"]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go ${{ matrix.go-version }}
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
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 ./...
|
||||||
|
|
20
.github/workflows/js.yml
vendored
20
.github/workflows/js.yml
vendored
|
@ -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
|
|
29
.github/workflows/stale.yml
vendored
29
.github/workflows/stale.yml
vendored
|
@ -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
6
.gitignore
vendored
|
@ -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
|
|
||||||
|
|
226
.gitlab-ci.yml
226
.gitlab-ci.yml
|
@ -1,8 +1,6 @@
|
||||||
stages:
|
stages:
|
||||||
- frontend
|
|
||||||
- build
|
- build
|
||||||
- build desktop
|
- package
|
||||||
- docker
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
before_script:
|
before_script:
|
||||||
|
@ -13,70 +11,23 @@ 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
|
||||||
|
@ -86,14 +37,13 @@ linux/arm64:
|
||||||
- arm64
|
- 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
|
||||||
tags:
|
|
||||||
- linux
|
|
||||||
- amd64
|
|
||||||
|
|
||||||
macos/arm64:
|
macos/arm64:
|
||||||
stage: build
|
stage: build
|
||||||
|
@ -101,156 +51,34 @@ 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:
|
debian:
|
||||||
<<: *build-docker
|
image: debian
|
||||||
tags:
|
stage: package
|
||||||
- linux
|
|
||||||
- amd64
|
|
||||||
dependencies:
|
dependencies:
|
||||||
- linux/amd64
|
- linux/amd64
|
||||||
needs:
|
only:
|
||||||
- linux/amd64
|
- tags
|
||||||
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:
|
script:
|
||||||
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64
|
- mkdir -p deb/usr/bin
|
||||||
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
|
- cp gomuks deb/usr/bin/gomuks
|
||||||
- |
|
- chmod -R -s deb/DEBIAN && chmod -R 0755 deb/DEBIAN
|
||||||
if [[ "$CI_COMMIT_BRANCH" == "main" ]]; then
|
- dpkg-deb --build deb gomuks.deb
|
||||||
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:
|
artifacts:
|
||||||
paths:
|
paths:
|
||||||
- desktop/bin/*
|
- gomuks.deb
|
||||||
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:
|
|
||||||
- macos
|
|
||||||
- arm64
|
|
||||||
|
|
|
@ -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]
|
|
||||||
|
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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"]
|
|
18
README.md
18
README.md
|
@ -1,7 +1,17 @@
|
||||||
# nyxmuks
|
# gomuks
|
||||||
|

|
||||||
|
[](LICENSE)
|
||||||
|
[](https://github.com/tulir/gomuks/releases)
|
||||||
|
[](https://mau.dev/tulir/gomuks/pipelines)
|
||||||
|
[](https://codeclimate.com/github/tulir/gomuks)
|
||||||
|
[](https://repology.org/project/gomuks/versions)
|
||||||
|
|
||||||
Soft fork of Tulir's Gomuks.
|

|
||||||
|
|
||||||
# why?
|
A terminal Matrix client written in Go using [mautrix](https://github.com/tulir/mautrix-go) and [mauview](https://github.com/tulir/mauview).
|
||||||
|
|
||||||
Gomuks is adding unneccesary features, and the developer is acting maliciously. This fork aims to remove a few features that seem to be made with a malicious intent, like the "un-redact" button.
|
## Docs
|
||||||
|
For installation and usage instructions, see [docs.mau.fi](https://docs.mau.fi/gomuks/).
|
||||||
|
|
||||||
|
## Discussion
|
||||||
|
Matrix room: [#gomuks:maunium.net](https://matrix.to/#/#gomuks:maunium.net)
|
||||||
|
|
6
build.sh
6
build.sh
|
@ -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
BIN
chat-preview.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 163 KiB |
|
@ -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()
|
|
||||||
}
|
|
401
config/config.go
Normal file
401
config/config.go
Normal file
|
@ -0,0 +1,401 @@
|
||||||
|
// 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"`
|
||||||
|
|
||||||
|
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
2
config/doc.go
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
// Package config contains the wrappers for gomuks configurations and sessions.
|
||||||
|
package config
|
42
config/keybindings.yaml
Normal file
42
config/keybindings.yaml
Normal 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
7
deb/DEBIAN/control
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
Package: gomuks
|
||||||
|
Version: 0.3.1-1
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Architecture: amd64
|
||||||
|
Maintainer: Tulir Asokan <tulir@maunium.net>
|
||||||
|
Description: A terminal based Matrix client written in Go.
|
184
debug/debug.go
Normal file
184
debug/debug.go
Normal file
|
@ -0,0 +1,184 @@
|
||||||
|
// 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"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"runtime/debug"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sasha-s/go-deadlock"
|
||||||
|
)
|
||||||
|
|
||||||
|
var writer io.Writer
|
||||||
|
var RecoverPrettyPanic bool = true
|
||||||
|
var DeadlockDetection bool
|
||||||
|
var WriteLogs bool
|
||||||
|
var OnRecover func()
|
||||||
|
var LogDirectory = GetUserDebugDir()
|
||||||
|
|
||||||
|
func GetUserDebugDir() string {
|
||||||
|
if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
|
||||||
|
return filepath.Join(os.TempDir(), "gomuks-"+getUname())
|
||||||
|
}
|
||||||
|
// See https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
|
||||||
|
if xdgStateHome := os.Getenv("XDG_STATE_HOME"); xdgStateHome != "" {
|
||||||
|
return filepath.Join(xdgStateHome, "gomuks")
|
||||||
|
}
|
||||||
|
home := os.Getenv("HOME")
|
||||||
|
if home == "" {
|
||||||
|
fmt.Println("XDG_STATE_HOME and HOME are both unset")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".local", "state", "gomuks")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUname() string {
|
||||||
|
currUser, err := user.Current()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return currUser.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
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
2
debug/doc.go
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
// Package debug contains utilities to log debug messages and display panics nicely.
|
||||||
|
package debug
|
3
desktop/.gitignore
vendored
3
desktop/.gitignore
vendored
|
@ -1,3 +0,0 @@
|
||||||
.task
|
|
||||||
bin
|
|
||||||
build/appimage
|
|
|
@ -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
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -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 .
|
|
|
@ -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}}'
|
|
|
@ -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}}'
|
|
|
@ -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'
|
|
|
@ -1 +0,0 @@
|
||||||
../../web/public/gomuks.png
|
|
|
@ -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"
|
|
|
@ -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.
|
@ -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": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
|
|
@ -1 +0,0 @@
|
||||||
#!/bin/bash
|
|
|
@ -1 +0,0 @@
|
||||||
#!/bin/bash
|
|
|
@ -1 +0,0 @@
|
||||||
#!/bin/bash
|
|
|
@ -1 +0,0 @@
|
||||||
#!/bin/bash
|
|
|
@ -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
|
|
|
@ -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
|
|
|
@ -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>
|
|
|
@ -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-20250303134427-723919f7f203 // 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.33.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.37.0 // indirect
|
|
||||||
golang.org/x/sync v0.12.0 // indirect
|
|
||||||
golang.org/x/sys v0.31.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.2 // indirect
|
|
||||||
mvdan.cc/xurls/v2 v2.6.0 // indirect
|
|
||||||
)
|
|
||||||
|
|
||||||
replace go.mau.fi/gomuks => ../
|
|
267
desktop/go.sum
267
desktop/go.sum
|
@ -1,267 +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-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs=
|
|
||||||
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203/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.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
|
||||||
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.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
|
||||||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
|
||||||
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.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
|
||||||
golang.org/x/net v0.37.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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
|
||||||
golang.org/x/sys v0.31.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.2 h1:Bo3tPrQJwkxyL7aMmy/T+d2tqIrypZjHqeHe8fyeAOg=
|
|
||||||
maunium.net/go/mautrix v0.23.2/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=
|
|
168
desktop/main.go
168
desktop/main.go
|
@ -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
61
flake.lock
generated
|
@ -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
|
|
||||||
}
|
|
40
flake.nix
40
flake.nix
|
@ -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
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
}
|
|
70
go.mod
70
go.mod
|
@ -1,47 +1,51 @@
|
||||||
module go.mau.fi/gomuks
|
module maunium.net/go/gomuks
|
||||||
|
|
||||||
go 1.23.0
|
go 1.21
|
||||||
|
|
||||||
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.4
|
||||||
|
github.com/kyokomi/emoji/v2 v2.2.13
|
||||||
|
github.com/lithammer/fuzzysearch v1.1.8
|
||||||
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.15
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22
|
||||||
github.com/rivo/uniseg v0.4.7
|
github.com/rivo/uniseg v0.4.7
|
||||||
github.com/rs/zerolog v1.33.0
|
github.com/sasha-s/go-deadlock v0.3.1
|
||||||
github.com/tidwall/gjson v1.18.0
|
github.com/yuin/goldmark v1.7.4
|
||||||
github.com/tidwall/sjson v1.2.5
|
github.com/zyedidia/clipboard v1.0.4
|
||||||
github.com/yuin/goldmark v1.7.8
|
go.etcd.io/bbolt v1.3.10
|
||||||
go.mau.fi/util v0.8.6
|
go.mau.fi/cbind v0.0.0-20220415094356-e1d579b7925e
|
||||||
go.mau.fi/webp v0.2.0
|
go.mau.fi/mauview v0.2.1
|
||||||
go.mau.fi/zeroconfig v0.1.3
|
go.mau.fi/tcell v0.4.0
|
||||||
golang.org/x/crypto v0.36.0
|
golang.org/x/image v0.18.0
|
||||||
golang.org/x/image v0.25.0
|
golang.org/x/net v0.27.0
|
||||||
golang.org/x/net v0.37.0
|
gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2
|
||||||
golang.org/x/text v0.23.0
|
gopkg.in/vansante/go-ffprobe.v2 v2.2.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
maunium.net/go/mauflag v1.0.0
|
maunium.net/go/mauflag v1.0.0
|
||||||
maunium.net/go/mautrix v0.23.2
|
maunium.net/go/mautrix v0.11.2-0.20240620211416-fa19263891f5
|
||||||
mvdan.cc/xurls/v2 v2.6.0
|
mvdan.cc/xurls/v2 v2.5.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-20250303134427-723919f7f203 // indirect
|
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
|
||||||
github.com/rs/xid v1.6.0 // indirect
|
github.com/tidwall/gjson v1.17.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.1 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
golang.org/x/sys v0.31.0 // indirect
|
golang.org/x/crypto v0.25.0 // indirect
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
golang.org/x/sys v0.22.0 // indirect
|
||||||
|
golang.org/x/term v0.22.0 // indirect
|
||||||
|
golang.org/x/text v0.16.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
|
||||||
|
|
183
go.sum
183
go.sum
|
@ -1,64 +1,46 @@
|
||||||
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.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
|
||||||
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/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
|
||||||
|
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
|
||||||
|
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
|
||||||
|
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
|
||||||
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.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
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/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-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs=
|
|
||||||
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203/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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0=
|
||||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM=
|
||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
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.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
|
||||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
github.com/tidwall/gjson v1.17.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/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
@ -66,41 +48,82 @@ 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/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 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
github.com/tulir/go-runewidth v0.0.14-0.20221113132156-dc2fc6d28246 h1:WjkNcgoEaoL7i9mJuH+ff/hZHkSBR1KDdvoOoLpG6vs=
|
||||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
github.com/tulir/go-runewidth v0.0.14-0.20221113132156-dc2fc6d28246/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
go.mau.fi/util v0.8.6 h1:AEK13rfgtiZJL2YsNK+W4ihhYCuukcRom8WPP/w/L54=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.mau.fi/util v0.8.6/go.mod h1:uNB3UTXFbkpp7xL1M/WvQks90B/L4gvbLpbS0603KOE=
|
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
|
||||||
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
|
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
|
github.com/zyedidia/clipboard v1.0.4 h1:r6GUQOyPtIaApRLeD56/U+2uJbXis6ANGbKWCljULEo=
|
||||||
go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM=
|
github.com/zyedidia/clipboard v1.0.4/go.mod h1:zykFnZUXX0ErxqvYLUFEq7QDJKId8rmh2FgD0/Y8cjA=
|
||||||
go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70=
|
go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0=
|
||||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ=
|
||||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
go.mau.fi/cbind v0.0.0-20220415094356-e1d579b7925e h1:zY4TZmHAaUhrMFJQfh02dqxDYSfnnXlw/qRoFanxZTw=
|
||||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
go.mau.fi/cbind v0.0.0-20220415094356-e1d579b7925e/go.mod h1:9nnzlslhUo/xO+8tsQgkFqG/W+SgD+r0iTYAuglzlmA=
|
||||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
go.mau.fi/mauview v0.2.1 h1:Sv+L3MQoo0VWuqgO/SIzhTzDcd7iqPGZgxH3au2kUGw=
|
||||||
|
go.mau.fi/mauview v0.2.1/go.mod h1:aTb1VjsjFmZ5YsdMQTIHrma9Ki2O0WwkS2Er7bIgoUs=
|
||||||
|
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.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||||
|
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||||
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.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||||
|
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.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||||
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||||
|
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
|
||||||
|
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
|
||||||
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=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||||
|
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||||
|
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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
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/toast.v1 v1.0.0-20180812000517-0a84660828b2 h1:MZF6J7CV6s/h0HBkfqebrYfKCVEo5iN+wzE4QhV3Evo=
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2/go.mod h1:s1Sn2yZos05Qfs7NKt867Xe18emOmtsO3eAKbDaon0o=
|
||||||
|
gopkg.in/vansante/go-ffprobe.v2 v2.2.0 h1:iuOqTsbfYuqIz4tAU9NWh22CmBGxlGHdgj4iqP+NUmY=
|
||||||
|
gopkg.in/vansante/go-ffprobe.v2 v2.2.0/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/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
|
||||||
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
|
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
|
||||||
maunium.net/go/mautrix v0.23.2 h1:Bo3tPrQJwkxyL7aMmy/T+d2tqIrypZjHqeHe8fyeAOg=
|
maunium.net/go/maulogger/v2 v2.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0=
|
||||||
maunium.net/go/mautrix v0.23.2/go.mod h1:pCYLHmo02Jauak/9VlTkbGPrBMvLXsGqTGMNOx+L2PE=
|
maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A=
|
||||||
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
maunium.net/go/mautrix v0.11.2-0.20240620211416-fa19263891f5 h1:zAELWR3594qziixinqE+CgKZzgQwpiubArNZXXTmfIs=
|
||||||
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
maunium.net/go/mautrix v0.11.2-0.20240620211416-fa19263891f5/go.mod h1:K29EcHwsNg6r7fMfwvi0GHQ9o5wSjqB9+Q8RjCIQEjA=
|
||||||
|
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
|
||||||
|
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=
|
||||||
|
|
192
gomuks.go
Normal file
192
gomuks.go
Normal file
|
@ -0,0 +1,192 @@
|
||||||
|
// 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/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"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.1"
|
||||||
|
// 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 with %s)", Version, BuildTime, runtime.Version())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
err := gmx.matrix.InitClient(true)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, matrix.ErrServerOutdated) {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, strings.Replace(err.Error(), "homeserver", gmx.config.HS, 1))
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr)
|
||||||
|
_, _ = fmt.Fprintf(os.Stderr, "See `%s --help` if you want to skip this check or clear all data.\n", os.Args[0])
|
||||||
|
os.Exit(4)
|
||||||
|
} else if strings.HasPrefix(err.Error(), "failed to check server versions") {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "Failed to check versions supported by server:", errors.Unwrap(err))
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr)
|
||||||
|
_, _ = fmt.Fprintf(os.Stderr, "Modify %s if the server has moved.\n", filepath.Join(gmx.config.Dir, "config.yaml"))
|
||||||
|
_, _ = fmt.Fprintf(os.Stderr, "See `%s --help` if you want to skip this check or clear all data.\n", os.Args[0])
|
||||||
|
os.Exit(5)
|
||||||
|
} else {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
2
interface/doc.go
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
// Package ifc contains interfaces to allow circular function calls without circular imports.
|
||||||
|
package ifc
|
|
@ -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
92
interface/matrix.go
Normal 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(isStartup bool) 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, file *attachment.EncryptedFile) 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
89
interface/ui.go
Normal 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
297
lib/ansimage/ansimage.go
Normal 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
11
lib/ansimage/doc.go
Normal 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
|
|
@ -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,
|
|
||||||
}
|
|
||||||
return <input
|
|
||||||
{...props}
|
|
||||||
type="checkbox"
|
|
||||||
className={props.className ? `toggle ${props.className}` : "toggle"}
|
|
||||||
style={{ ...(props.style ?? {}), ...extraStyle }}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Toggle
|
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 "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(output.String()), nil
|
||||||
|
}
|
2
lib/notification/doc.go
Normal file
2
lib/notification/doc.go
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
// Package notification contains a simple cross-platform desktop notification sending function.
|
||||||
|
package notification
|
65
lib/notification/notify_darwin.go
Normal file
65
lib/notification/notify_darwin.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
||||||
}
|
}
|
84
lib/notification/notify_xdg.go
Normal file
84
lib/notification/notify_xdg.go
Normal 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
4
lib/open/doc.go
Normal 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
|
|
@ -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
5
lib/open/open_darwin.go
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
package open
|
||||||
|
|
||||||
|
const Command = "open"
|
||||||
|
|
||||||
|
var Args []string
|
|
@ -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
7
lib/open/open_xdg.go
Normal 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
2
lib/util/doc.go
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
// Package util contains miscellaneous utilities
|
||||||
|
package util
|
38
lib/util/lcp.go
Normal file
38
lib/util/lcp.go
Normal 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
|
||||||
|
}
|
203
main.go
Normal file
203
main.go
Normal file
|
@ -0,0 +1,203 @@
|
||||||
|
// 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"
|
||||||
|
|
||||||
|
flag "maunium.net/go/mauflag"
|
||||||
|
|
||||||
|
"maunium.net/go/gomuks/debug"
|
||||||
|
ifc "maunium.net/go/gomuks/interface"
|
||||||
|
"maunium.net/go/gomuks/matrix"
|
||||||
|
"maunium.net/go/gomuks/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
var MainUIProvider ifc.UIProvider = ui.NewGomuksUI
|
||||||
|
|
||||||
|
var wantVersion = flag.MakeFull("v", "version", "Show the version of gomuks", "false").Bool()
|
||||||
|
var clearCache = flag.MakeFull("c", "clear-cache", "Clear the cache directory instead of starting", "false").Bool()
|
||||||
|
var clearData = flag.Make().LongKey("clear-all-data").Usage("Clear all data instead of starting").Default("false").Bool()
|
||||||
|
var skipVersionCheck = flag.MakeFull("s", "skip-version-check", "Skip the homeserver version checks at startup and login", "false").Bool()
|
||||||
|
var wantHelp, _ = flag.MakeHelpFlag()
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.SetHelpTitles(
|
||||||
|
"gomuks - A terminal Matrix client written in Go.",
|
||||||
|
"gomuks [-vch] [--clear-all-data]",
|
||||||
|
)
|
||||||
|
err := flag.Parse()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
} else if *wantHelp {
|
||||||
|
flag.PrintHelp()
|
||||||
|
return
|
||||||
|
} else if *wantVersion {
|
||||||
|
fmt.Println(VersionString)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
debugDir := os.Getenv("DEBUG_DIR")
|
||||||
|
if len(debugDir) > 0 {
|
||||||
|
debug.LogDirectory = debugDir
|
||||||
|
}
|
||||||
|
debugLevel := strings.ToLower(os.Getenv("DEBUG"))
|
||||||
|
if debugLevel == "1" || debugLevel == "t" || debugLevel == "true" {
|
||||||
|
debug.RecoverPrettyPanic = false
|
||||||
|
debug.DeadlockDetection = true
|
||||||
|
debug.WriteLogs = true
|
||||||
|
}
|
||||||
|
debug.Initialize()
|
||||||
|
defer debug.Recover()
|
||||||
|
|
||||||
|
var configDir, dataDir, cacheDir, downloadDir string
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
matrix.SkipVersionCheck = *skipVersionCheck
|
||||||
|
gmx := NewGomuks(MainUIProvider, configDir, dataDir, cacheDir, downloadDir)
|
||||||
|
|
||||||
|
if *clearCache {
|
||||||
|
debug.Print("Clearing cache as requested by CLI flag")
|
||||||
|
gmx.config.Clear()
|
||||||
|
fmt.Printf("Cleared cache at %s\n", gmx.config.CacheDir)
|
||||||
|
return
|
||||||
|
} else if *clearData {
|
||||||
|
debug.Print("Clearing all data as requested by CLI flag")
|
||||||
|
gmx.config.Clear()
|
||||||
|
gmx.config.ClearData()
|
||||||
|
_ = os.RemoveAll(gmx.config.Dir)
|
||||||
|
fmt.Printf("Cleared cache at %s, data at %s and config at %s\n", gmx.config.CacheDir, gmx.config.DataDir, gmx.config.Dir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
100
matrix/crypto.go
Normal 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
2
matrix/doc.go
Normal 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
316
matrix/history.go
Normal 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
|
||||||
|
}
|
1418
matrix/matrix.go
Normal file
1418
matrix/matrix.go
Normal file
File diff suppressed because it is too large
Load diff
106
matrix/mediainfo.go
Normal file
106
matrix/mediainfo.go
Normal 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
44
matrix/muksevt/content.go
Normal 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
53
matrix/muksevt/event.go
Normal 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
15
matrix/nocrypto.go
Normal 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
2
matrix/rooms/doc.go
Normal 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
715
matrix/rooms/room.go
Normal 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
376
matrix/rooms/roomcache.go
Normal 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
267
matrix/sync.go
Normal 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
115
matrix/uia-fallback.go
Normal 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
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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")
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
Loading…
Add table
Reference in a new issue