Merge remote-tracking branch 'github.com/develop' into develop

# Conflicts:
#	.github/workflows/build_and_deploy.yaml
#	.github/workflows/build_and_test.yaml
#	.github/workflows/build_linux.yaml
#	.github/workflows/build_macos.yaml
#	.github/workflows/build_prepare.yaml
#	.github/workflows/dockerbuild.yaml
#	.github/workflows/static_analysis.yaml
#	.github/workflows/triage-stale.yml
#	package.json
This commit is contained in:
2025-06-01 20:25:31 +02:00
51 changed files with 3587 additions and 1663 deletions

298
.github/workflows/build_and_deploy.yaml vendored Normal file
View File

@@ -0,0 +1,298 @@
name: Build and Deploy
on:
# Nightly build
schedule:
- cron: "0 9 * * *"
# Release build
release:
types: [published]
# Manual nightly & release
workflow_dispatch:
inputs:
mode:
description: What type of build to trigger. Release builds MUST be ran from the `master` branch.
required: true
default: nightly
type: choice
options:
- nightly
- release
macos:
description: Build macOS
required: true
type: boolean
default: true
windows:
description: Build Windows
required: true
type: boolean
default: true
linux:
description: Build Linux
required: true
type: boolean
default: true
deploy:
description: Deploy artifacts
required: true
type: boolean
default: true
run-name: Element ${{ inputs.mode != 'release' && github.event_name != 'release' && 'Nightly' || 'Desktop' }}
concurrency: ${{ github.workflow }}
env:
R2_BUCKET: ${{ vars.R2_BUCKET }}
permissions: {} # Uses ELEMENT_BOT_TOKEN
jobs:
prepare:
uses: ./.github/workflows/build_prepare.yaml
permissions:
contents: read
with:
config: element.io/${{ inputs.mode || (github.event_name == 'release' && 'release') || 'nightly' }}
version: ${{ (inputs.mode != 'release' && github.event_name != 'release') && 'develop' || '' }}
nightly: ${{ inputs.mode != 'release' && github.event_name != 'release' }}
deploy: ${{ inputs.deploy || (github.event_name != 'workflow_dispatch' && github.event.release.prerelease != true) }}
secrets:
CF_R2_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
CF_R2_TOKEN: ${{ secrets.CF_R2_TOKEN }}
windows:
if: github.event_name != 'workflow_dispatch' || inputs.windows
needs: prepare
name: Windows ${{ matrix.arch }}
strategy:
matrix:
arch: [x64, arm64]
uses: ./.github/workflows/build_windows.yaml
secrets: inherit
with:
sign: true
arch: ${{ matrix.arch }}
version: ${{ needs.prepare.outputs.nightly-version }}
macos:
if: github.event_name != 'workflow_dispatch' || inputs.macos
needs: prepare
name: macOS
uses: ./.github/workflows/build_macos.yaml
secrets: inherit
with:
sign: true
base-url: https://packages.element.io/${{ needs.prepare.outputs.packages-dir }}
version: ${{ needs.prepare.outputs.nightly-version }}
linux:
if: github.event_name != 'workflow_dispatch' || inputs.linux
needs: prepare
name: Linux ${{ matrix.arch }} (sqlcipher ${{ matrix.sqlcipher }})
strategy:
matrix:
arch: [amd64, arm64]
sqlcipher: [static]
uses: ./.github/workflows/build_linux.yaml
with:
arch: ${{ matrix.arch }}
sqlcipher: ${{ matrix.sqlcipher }}
version: ${{ needs.prepare.outputs.nightly-version }}
deploy:
needs:
- prepare
- macos
- linux
- windows
runs-on: ubuntu-24.04
name: ${{ needs.prepare.outputs.deploy == 'true' && 'Deploy' || 'Deploy (dry-run)' }}
if: always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled')
environment: ${{ needs.prepare.outputs.deploy == 'true' && 'packages.element.io' || '' }}
steps:
- name: Download artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
- name: Prepare artifacts for deployment
run: |
set -x
# Windows
for arch in x64 arm64
do
if [ -d "win-$arch" ]; then
mkdir -p packages.element.io/{install,update}/win32/$arch
mv win-$arch/squirrel-windows*/*.exe "packages.element.io/install/win32/$arch/"
mv win-$arch/squirrel-windows*/*.nupkg "packages.element.io/update/win32/$arch/"
mv win-$arch/squirrel-windows*/RELEASES "packages.element.io/update/win32/$arch/"
fi
done
# macOS
if [ -d macos ]; then
mkdir -p packages.element.io/{install,update}/macos
mv macos/*.dmg packages.element.io/install/macos/
mv macos/*-mac.zip packages.element.io/update/macos/
mv macos/*.json packages.element.io/update/macos/
fi
# Linux
if [ -d linux-amd64-sqlcipher-static ]; then
mkdir -p packages.element.io/install/linux/glibc-x86-64
mv linux-amd64-sqlcipher-static/*.tar.gz packages.element.io/install/linux/glibc-x86-64
fi
if [ -d linux-arm64-sqlcipher-static ]; then
mkdir -p packages.element.io/install/linux/glibc-aarch64
mv linux-arm64-sqlcipher-static/*.tar.gz packages.element.io/install/linux/glibc-aarch64
fi
# We don't wish to store the installer for every nightly ever, so we only keep the latest
- name: "[Nightly] Strip version from installer file"
if: needs.prepare.outputs.nightly-version != ''
run: |
set -x
# Windows
for arch in x64 arm64
do
[ -d "win-$arch" ] && mv packages.element.io/install/win32/$arch/{*,"Element Nightly Setup"}.exe
done
# macOS
[ -d macos ] && mv packages.element.io/install/macos/{*,"Element Nightly"}.dmg
# Linux
[ -d linux-amd64-sqlcipher-static ] && mv packages.element.io/install/linux/glibc-x86-64/{*,element-desktop-nightly}.tar.gz
[ -d linux-arm64-sqlcipher-static ] && mv packages.element.io/install/linux/glibc-aarch64/{*,element-desktop-nightly}.tar.gz
- name: "[Release] Prepare release latest symlink"
if: needs.prepare.outputs.nightly-version == ''
run: |
set -x
# Windows
for arch in x64 arm64
do
if [ -d "win-$arch" ]; then
pushd packages.element.io/install/win32/$arch
ln -s "$(find . -type f -iname "*.exe" | xargs -0 -n1 -- basename)" "Element Setup.exe"
popd
fi
done
# macOS
if [ -d macos ]; then
pushd packages.element.io/install/macos
ln -s "$(find . -type f -iname "*.dmg" | xargs -0 -n1 -- basename)" "Element.dmg"
popd
fi
# Linux
if [ -d linux-amd64-sqlcipher-static ]; then
pushd packages.element.io/install/linux/glibc-x86-64
ln -s "$(find . -type f -iname "*.tar.gz" | xargs -0 -n1 -- basename)" "element-desktop.tar.gz"
popd
fi
if [ -d linux-arm64-sqlcipher-static ]; then
pushd packages.element.io/install/linux/glibc-aarch64
ln -s "$(find . -type f -iname "*.tar.gz" | xargs -0 -n1 -- basename)" "element-desktop.tar.gz"
popd
fi
- name: Stash packages.element.io
if: needs.prepare.outputs.deploy == 'false'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: packages.element.io
path: packages.element.io
# Checksum algorithm specified as per https://developers.cloudflare.com/r2/examples/aws/aws-cli/
- name: Deploy artifacts
if: needs.prepare.outputs.deploy == 'true'
run: |
set -x
aws s3 cp --recursive packages.element.io/ s3://$R2_BUCKET/$DEPLOYMENT_DIR --endpoint-url $R2_URL --region auto --checksum-algorithm CRC32
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_TOKEN }}
R2_URL: ${{ vars.CF_R2_S3_API }}
DEPLOYMENT_DIR: ${{ needs.prepare.outputs.packages-dir }}
- name: Notify packages.element.io of new files
if: needs.prepare.outputs.deploy == 'true'
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
repository: element-hq/packages.element.io
event-type: packages-index
- name: Find debs
id: deb
if: needs.linux.result == 'success'
run: |
set -x
for arch in amd64 arm64
do
echo "$arch=$(ls linux-$arch-sqlcipher-static/*.deb | tail -n1)" >> $GITHUB_OUTPUT
done
- name: Stash debs
if: needs.prepare.outputs.deploy == 'false' && needs.linux.result == 'success'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: debs
path: |
${{ steps.deb.outputs.amd64 }}
${{ steps.deb.outputs.arm64 }}
- name: Publish amd64 deb to packages.element.io
uses: element-hq/packages.element.io@master
if: needs.prepare.outputs.deploy == 'true' && needs.linux.result == 'success'
with:
file: ${{ steps.deb.outputs.amd64 }}
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
bucket-api: ${{ vars.CF_R2_S3_API }}
bucket-key-id: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
bucket-access-key: ${{ secrets.CF_R2_TOKEN }}
- name: Publish arm64 deb to packages.element.io
uses: element-hq/packages.element.io@master
if: needs.prepare.outputs.deploy == 'true' && needs.linux.result == 'success'
with:
file: ${{ steps.deb.outputs.arm64 }}
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
bucket-api: ${{ vars.CF_R2_S3_API }}
bucket-key-id: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
bucket-access-key: ${{ secrets.CF_R2_TOKEN }}
deploy-ess:
needs: deploy
runs-on: ubuntu-24.04
name: Deploy builds to ESS
if: needs.prepare.outputs.deploy == 'true' && github.event_name == 'release'
env:
BUCKET_NAME: "element-desktop-msi.onprem.element.io"
AWS_REGION: "eu-central-1"
permissions:
id-token: write # This is required for requesting the JWT
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4
with:
role-to-assume: arn:aws:iam::264135176173:role/Push-ElementDesktop-MSI
role-session-name: githubaction-run-${{ github.run_id }}
aws-region: ${{ env.AWS_REGION }}
- name: Download artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: win-*
- name: Copy files to S3
run: |
set -x
PREFIX="${VERSION%.*}"
for file in win-*/*.msi; do
filename=$(basename "$file")
aws s3 cp "$file" "s3://${{ env.BUCKET_NAME }}/$PREFIX/$filename"
done
env:
VERSION: ${{ github.event.release.tag_name }}

View File

@@ -1,42 +1,85 @@
name: Build Windows Package name: Build and Test
on: on:
push: pull_request: {}
branches: [develop, master, pipeline-rework] push:
pull_request: {} branches: [develop, staging, master]
workflow_dispatch: {} concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {} # No permissions required
jobs: jobs:
fetch: fetch:
uses: ./.github/workflows/build_prepare.yaml uses: ./.github/workflows/build_prepare.yaml
permissions: permissions:
contents: read contents: read
with:
config: ""
version: custom
setup:
runs-on: windows-gp
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: with:
node-version-file: .node-version config: ${{ (github.event.pull_request.base.ref || github.ref_name) == 'develop' && 'element.io/nightly' || 'element.io/release' }}
version: ${{ (github.event.pull_request.base.ref || github.ref_name) == 'develop' && 'develop' || '' }}
- name: Install Yarn branch-matching: true
run: npm install -g yarn
- name: Cache Yarn dependencies
uses: actions/cache@v4
with:
path: ~/.yarn/cache
key: yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
yarn-
windows: windows:
needs: [fetch, setup] needs: fetch
name: Windows Build name: Windows
uses: ./.github/workflows/build_windows.yaml uses: ./.github/workflows/build_windows.yaml
with: strategy:
arch: x64 matrix:
arch: [x64, ia32, arm64]
with:
arch: ${{ matrix.arch }}
blob_report: true
linux:
needs: fetch
name: "Linux (${{ matrix.arch }}) (sqlcipher: ${{ matrix.sqlcipher }})"
uses: ./.github/workflows/build_linux.yaml
strategy:
matrix:
sqlcipher: [system, static]
arch: [amd64, arm64]
with:
sqlcipher: ${{ matrix.sqlcipher }}
arch: ${{ matrix.arch }}
blob_report: true
macos:
needs: fetch
name: macOS
uses: ./.github/workflows/build_macos.yaml
with:
blob_report: true
tests-done:
needs: [windows, linux, macos]
runs-on: ubuntu-24.04
if: always()
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version: "lts/*"
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Download blob reports from GitHub Actions Artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: blob-report-*
path: all-blob-reports
merge-multiple: true
- name: Merge into HTML Report
run: yarn playwright merge-reports -c ./playwright.config.ts --reporter=html ./all-blob-reports
- name: Upload HTML report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: html-report
path: playwright-report
retention-days: 14
- if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1

201
.github/workflows/build_linux.yaml vendored Normal file
View File

@@ -0,0 +1,201 @@
# This workflow relies on actions/cache to store the hak dependency artifacts as they take a long time to build
# Due to this extra care must be taken to only ever run all build_* scripts against the same branch to ensure
# the correct cache scoping, and additional care must be taken to not run untrusted actions on the develop branch.
on:
workflow_call:
inputs:
arch:
type: string
required: true
description: "The architecture to build for, one of 'amd64' | 'arm64'"
version:
type: string
required: false
description: "Version string to override the one in package.json, used for non-release builds"
sqlcipher:
type: string
required: true
description: "How to link sqlcipher, one of 'system' | 'static'"
blob_report:
type: boolean
required: false
description: "Whether to run the blob report"
env:
SQLCIPHER_BUNDLED: ${{ inputs.sqlcipher == 'static' && '1' || '' }}
MAX_GLIBC: 2.31 # bullseye-era glibc, used by glibc-check.sh
permissions: {} # No permissions required
jobs:
build:
# We build on native infrastructure as matrix-seshat fails to cross-compile properly
# https://github.com/matrix-org/seshat/issues/135
runs-on: ${{ inputs.arch == 'arm64' && 'ubuntu-22.04-arm' || 'ubuntu-22.04' }}
env:
HAK_DOCKER_IMAGE: ghcr.io/element-hq/element-desktop-dockerbuild
steps:
- name: Resolve docker image tag for push
if: github.event_name == 'push'
run: echo "HAK_DOCKER_IMAGE=$HAK_DOCKER_IMAGE:$GITHUB_REF_NAME" >> $GITHUB_ENV
- name: Resolve docker image tag for release
if: github.event_name == 'release'
run: echo "HAK_DOCKER_IMAGE=$HAK_DOCKER_IMAGE:staging" >> $GITHUB_ENV
- name: Resolve docker image tag for other triggers
if: github.event_name != 'push' && github.event_name != 'release'
run: echo "HAK_DOCKER_IMAGE=$HAK_DOCKER_IMAGE:develop" >> $GITHUB_ENV
- uses: nbucic/variable-mapper@0673f6891a0619ba7c002ecfed0f9f4f39017b6f
id: config
with:
key: "${{ inputs.arch }}"
export_to: output
map: |
{
"amd64": {
"target": "x86_64-unknown-linux-gnu",
"arch": "x86-64"
},
"arm64": {
"target": "aarch64-unknown-linux-gnu",
"arch": "aarch64",
"build-args": "--arm64"
}
}
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: webapp
- name: Cache .hak
id: cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
key: ${{ runner.os }}-${{ github.ref_name }}-${{ inputs.sqlcipher }}-${{ inputs.arch }}-${{ hashFiles('hakHash', 'electronVersion', 'dockerbuild/*') }}
path: |
./.hak
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version-file: .node-version
cache: "yarn"
env:
# Workaround for https://github.com/actions/setup-node/issues/317
FORCE_COLOR: 0
- name: Install Deps
run: "yarn install --frozen-lockfile"
- name: "Get modified files"
id: changed_files
if: steps.cache.outputs.cache-hit != 'true' && github.event_name == 'pull_request'
uses: tj-actions/changed-files@823fcebdb31bb35fdf2229d9f769b400309430d0 # v46
with:
files: |
dockerbuild/**
# This allows contributors to test changes to the dockerbuild image within a pull request
- name: Build docker image
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6
if: steps.changed_files.outputs.any_modified == 'true'
with:
file: dockerbuild/Dockerfile
load: true
platforms: linux/${{ inputs.arch }}
tags: ${{ env.HAK_DOCKER_IMAGE }}
- name: Build Natives
if: steps.cache.outputs.cache-hit != 'true'
run: |
docker run \
-v ${{ github.workspace }}:/work -w /work \
-e SQLCIPHER_BUNDLED \
$HAK_DOCKER_IMAGE \
yarn build:native
- name: Fix permissions on .hak
run: sudo chown -R $USER:$USER .hak
- name: Check native libraries in hak dependencies
run: |
shopt -s globstar
for filename in ./.hak/hakModules/**/*.node; do
./scripts/glibc-check.sh $filename
done
- name: Generate debian files and arguments
run: |
if [ -f changelog.Debian ]; then
echo "ED_DEBIAN_CHANGELOG=changelog.Debian" >> $GITHUB_ENV
fi
# Workaround for https://github.com/electron-userland/electron-builder/issues/6116
- name: Install fpm
if: inputs.arch == 'arm64'
run: |
sudo apt-get install ruby-dev build-essential
sudo gem install fpm
echo "USE_SYSTEM_FPM=true" >> $GITHUB_ENV
- name: Build App
run: yarn build --publish never -l ${{ steps.config.outputs.build-args }}
env:
# Only set for Nightly builds
ED_NIGHTLY: ${{ inputs.version }}
- name: Check native libraries
run: |
set -x
shopt -s globstar
FILES=$(file dist/**/*.node)
echo "$FILES"
if [ grep -v "$ARCH" ]; then
exit 1
fi
LIBS=$(readelf -d dist/**/*.node | grep NEEDED)
echo "$LIBS"
set +x
assert_contains_string() { [[ "$1" == *"$2"* ]]; }
! assert_contains_string "$LIBS" "libcrypto.so.1.1"
if [ "$SQLCIPHER_BUNDLED" == "1" ]; then
! assert_contains_string "$LIBS" "libsqlcipher.so.0"
else
assert_contains_string "$LIBS" "libsqlcipher.so.0"
fi
./scripts/glibc-check.sh dist/linux-*unpacked/element-desktop*
env:
ARCH: ${{ steps.config.outputs.arch }}
# We exclude *-unpacked as it loses permissions and the tarball contains it with correct permissions
- name: Upload Artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: linux-${{ inputs.arch }}-sqlcipher-${{ inputs.sqlcipher }}
path: |
dist
!dist/*-unpacked/**
retention-days: 1
- name: Assert all required files are present
run: |
test -f ./dist/element-desktop*$ARCH.deb
test -f ./dist/element-desktop*.tar.gz
env:
ARCH: ${{ inputs.arch }}
test:
needs: build
uses: ./.github/workflows/build_test.yaml
with:
artifact: linux-${{ inputs.arch }}-sqlcipher-${{ inputs.sqlcipher }}
runs-on: ${{ inputs.arch == 'arm64' && 'ubuntu-22.04-arm' || 'ubuntu-22.04' }}
executable: /opt/Element*/element-desktop*
prepare_cmd: |
sudo apt-get -qq update
sudo apt install ./dist/*.deb
blob_report: ${{ inputs.blob_report }}

166
.github/workflows/build_macos.yaml vendored Normal file
View File

@@ -0,0 +1,166 @@
# This workflow relies on actions/cache to store the hak dependency artifacts as they take a long time to build
# Due to this extra care must be taken to only ever run all build_* scripts against the same branch to ensure
# the correct cache scoping, and additional care must be taken to not run untrusted actions on the develop branch.
on:
workflow_call:
secrets:
APPLE_ID:
required: false
APPLE_ID_PASSWORD:
required: false
APPLE_TEAM_ID:
required: false
APPLE_CSC_KEY_PASSWORD:
required: false
APPLE_CSC_LINK:
required: false
inputs:
version:
type: string
required: false
description: "Version string to override the one in package.json, used for non-release builds"
sign:
type: string
required: false
description: "Whether to sign & notarise the build, requires 'packages.element.io' environment"
base-url:
type: string
required: false
description: "The URL to which the output will be deployed."
blob_report:
type: boolean
required: false
description: "Whether to run the blob report"
permissions: {} # No permissions required
jobs:
build:
runs-on: macos-14 # M1
environment: ${{ inputs.sign && 'packages.element.io' || '' }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: webapp
- name: Cache .hak
id: cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
key: ${{ runner.os }}-${{ hashFiles('hakHash', 'electronVersion') }}
path: |
./.hak
- name: Install Rust
if: steps.cache.outputs.cache-hit != 'true'
run: |
rustup toolchain install stable --profile minimal --no-self-update
rustup default stable
rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
# M1 macos-14 comes without Python preinstalled
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version-file: .node-version
cache: "yarn"
- name: Install Deps
run: "yarn install --frozen-lockfile"
# Python 3.12 drops distutils which keytar relies on
- name: Install setuptools
run: pip3 install setuptools
- name: Build Natives
if: steps.cache.outputs.cache-hit != 'true'
run: yarn build:native:universal
# We split these because electron-builder gets upset if we set CSC_LINK even to an empty string
- name: "[Signed] Build App"
if: inputs.sign != ''
run: |
yarn build:universal --publish never
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.APPLE_CSC_LINK }}
# Only set for Nightly builds
ED_NIGHTLY: ${{ inputs.version }}
- name: Check app was signed & notarised successfully
if: inputs.sign != ''
run: |
hdiutil attach dist/*.dmg -mountpoint /Volumes/Element
codesign -dv --verbose=4 /Volumes/Element/*.app
spctl -a -vvv -t install /Volumes/Element/*.app
hdiutil detach /Volumes/Element
- name: "[Unsigned] Build App"
if: inputs.sign == ''
run: |
yarn build:universal --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: false
- name: Generate releases.json
if: inputs.base-url
run: |
PKG_JSON_VERSION=$(cat package.json | jq -r .version)
LATEST=$(find dist -type f -iname "*-mac.zip" | xargs -0 -n1 -- basename)
# Encode spaces in the URL as Squirrel.Mac complains about bad JSON otherwise
URL="${{ inputs.base-url }}/update/macos/${LATEST// /%20}"
jq -n --arg version "${VERSION:-$PKG_JSON_VERSION}" --arg url "$URL" '
{
currentRelease: $version,
releases: [{
version: $version,
updateTo: {
version: $version,
url: $url,
},
}],
}
' > dist/releases.json
jq -n --arg url "$URL" '
{ url: $url }
' > dist/releases-legacy.json
env:
VERSION: ${{ inputs.version }}
# We exclude mac-universal as the unpacked app takes forever to upload and zip and dmg already contains it
- name: Upload Artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: macos
path: |
dist
!dist/mac-universal/**
retention-days: 1
- name: Assert all required files are present
run: |
test -f ./dist/Element*.dmg
test -f ./dist/Element*-mac.zip
test:
needs: build
uses: ./.github/workflows/build_test.yaml
with:
artifact: macos
runs-on: macos-14
executable: /Users/runner/Applications/Element*.app/Contents/MacOS/Element*
# We need to mount the DMG and copy the app to the Applications folder as a mounted DMG is
# read-only and thus would not allow us to override the fuses as is required for Playwright.
prepare_cmd: |
hdiutil attach ./dist/*.dmg -mountpoint /Volumes/Element &&
rsync -a /Volumes/Element/Element*.app ~/Applications/ &&
hdiutil detach /Volumes/Element
blob_report: ${{ inputs.blob_report }}

View File

@@ -1,4 +1,4 @@
name: Prepare Build # This action helps perform common actions before the build_* actions are started in parallel.
on: on:
workflow_call: workflow_call:
inputs: inputs:
@@ -10,90 +10,163 @@ on:
type: string type: string
required: false required: false
description: "The version tag to fetch, or 'develop', will pick automatically if not passed" description: "The version tag to fetch, or 'develop', will pick automatically if not passed"
nightly:
type: boolean
required: false
default: false
description: "Whether the build is a Nightly and to calculate the version strings new builds should use"
deploy:
type: boolean
required: false
default: false
description: "Whether the build should be deployed to production"
branch-matching:
type: boolean
required: false
default: false
description: "Whether the branch name should be matched to find the element-web commit"
secrets:
# Required if `nightly` is set
CF_R2_ACCESS_KEY_ID:
required: false
# Required if `nightly` is set
CF_R2_TOKEN:
required: false
outputs: outputs:
nightly-version:
description: "The version string the next Nightly should use, only output for nightly"
value: ${{ jobs.prepare.outputs.nightly-version }}
packages-dir: packages-dir:
description: "The directory non-deb packages for this run should live in" description: "The directory non-deb packages for this run should live in within packages.element.io"
value: "desktop" value: ${{ inputs.nightly && 'nightly' || 'desktop' }}
# This is just a simple pass-through of the input to simplify reuse of complex inline conditions
deploy: deploy:
description: "Whether the build should be deployed to production" description: "Whether the build should be deployed to production"
value: false value: ${{ inputs.deploy }}
permissions: {} permissions: {}
jobs: jobs:
prepare: prepare:
name: Prepare name: Prepare
runs-on: ubuntu-latest environment: ${{ inputs.nightly && 'packages.element.io' || '' }}
runs-on: ubuntu-24.04
permissions: permissions:
contents: read contents: read
secrets: read outputs:
nightly-version: ${{ steps.versions.outputs.nightly }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: .node-version node-version-file: .node-version
cache: "yarn"
- name: Install Dependencies
run: |
sudo apt-get update
sudo apt-get install -y libnss3-dev
- name: Install Yarn
run: npm install -g yarn
- name: Install Deps - name: Install Deps
run: "yarn install --frozen-lockfile" run: "yarn install --frozen-lockfile"
- name: Clone Element Web - name: Fetch Element Web (matching branch)
id: branch-matching
if: inputs.branch-matching
continue-on-error: true
run: | run: |
git clone https://git.piskot.si/nikrozman/element-web.git element-web scripts/branch-match.sh
cd element-web cp "$CONFIG_DIR/config.json" element-web/
yarn install --frozen-lockfile yarn --cwd element-web install --frozen-lockfile
yarn build yarn --cwd element-web run build
cd .. mv element-web/webapp .
yarn run asar pack element-web/webapp webapp.asar yarn asar-webapp
env:
# These must be set for branch-match.sh to get the right branch
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
CONFIG_DIR: ${{ inputs.config }}
- name: Fetch Element Web (${{ inputs.version }})
if: steps.branch-matching.outcome == 'failure' || steps.branch-matching.outcome == 'skipped'
run: yarn run fetch --noverify -d ${{ inputs.config }} ${{ inputs.version }}
# We split this out to save the build_* scripts having to do it to make use of `hashFiles` in the cache action
- name: Generate cache hash files - name: Generate cache hash files
run: | run: |
# Save electron version # Add --no-sandbox as otherwise it fails because the helper isn't setuid root. It's only getting the version.
NODE_ENV=production node -e "console.log(require('./package.json').devDependencies.electron)" > electronVersion yarn run --silent electron --no-sandbox --version > electronVersion
cat package.json | jq -c .hakDependencies | sha1sum > hakHash
# Generate hak dependencies hash find hak -type f -print0 | xargs -0 sha1sum >> hakHash
jq -c .hakDependencies package.json | sha1sum > hakHash find scripts/hak -type f -print0 | xargs -0 sha1sum >> hakHash
if [ -d "hak" ]; then
find hak -type f -print0 | sort -z | xargs -0 sha1sum >> hakHash
fi
if [ -d "scripts/hak" ]; then
find scripts/hak -type f -print0 | sort -z | xargs -0 sha1sum >> hakHash
fi
- name: "[Nightly] Calculate version"
- name: Setup MinIO client id: versions
uses: yakubique/setup-minio-cli@v1 if: inputs.nightly
- name: Test credential
run: | run: |
echo "${S3_AK:0:4}" "${S3_SK:0:4}" # Find all latest Nightly versions
aws s3 cp s3://$R2_BUCKET/nightly/update/macos/releases.json - --endpoint-url $R2_URL --region auto | jq -r .currentRelease >> VERSIONS
aws s3 cp s3://$R2_BUCKET/debian/dists/default/main/binary-amd64/Packages - --endpoint-url $R2_URL --region auto | grep "Package: element-nightly" -A 50 | grep Version -m1 | sed -n 's/Version: //p' >> VERSIONS
aws s3 cp s3://$R2_BUCKET/debian/dists/default/main/binary-arm64/Packages - --endpoint-url $R2_URL --region auto | grep "Package: element-nightly" -A 50 | grep Version -m1 | sed -n 's/Version: //p' >> VERSIONS
aws s3 cp s3://$R2_BUCKET/nightly/update/win32/x64/RELEASES - --endpoint-url $R2_URL --region auto | awk '{print $2}' | cut -d "-" -f 5 | cut -c 8- >> VERSIONS
aws s3 cp s3://$R2_BUCKET/nightly/update/win32/arm64/RELEASES - --endpoint-url $R2_URL --region auto | awk '{print $2}' | cut -d "-" -f 5 | cut -c 8- >> VERSIONS
# Pick the greatest one
VERSION=$(cat VERSIONS | sort -uf | tail -n1)
echo "Found latest nightly version $VERSION"
# Increment it
echo "nightly=$(scripts/generate-nightly-version.ts --latest $VERSION)" >> $GITHUB_OUTPUT
env: env:
S3_AK: ${{ secrets.S3_AK }} AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
S3_SK: ${{ secrets.S3_SK }} AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_TOKEN }}
R2_BUCKET: ${{ vars.R2_BUCKET }}
R2_URL: ${{ vars.CF_R2_S3_API }}
- name: Configure MinIO client - name: Check version
id: package
run: | run: |
mc alias set s3piskot https://s3.piskot.si "${S3_AK}" "${S3_SK}" echo "version=$(cat package.json | jq -r .version)" >> $GITHUB_OUTPUT
if ! mc alias list | grep -q "s3piskot"; then
echo "Error: Failed to set S3 alias" - name: "[Release] Fetch release"
exit 1 id: release
fi if: ${{ !inputs.nightly && inputs.version != 'develop' }}
uses: cardinalby/git-get-release-action@cedef2faf69cb7c55b285bad07688d04430b7ada # v1
echo "Successfully configured MinIO client"
env: env:
S3_AK: ${{ secrets.S3_AK }} GITHUB_TOKEN: ${{ github.token }}
S3_SK: ${{ secrets.S3_SK }} with:
tag: v${{ steps.package.outputs.version }}
- name: Upload cache files - name: "[Release] Write changelog"
if: ${{ !inputs.nightly && inputs.version != 'develop' }}
run: | run: |
# Upload cache files to MinIO TIME=$(date -d "$PUBLISHED_AT" -R)
mc cp webapp.asar s3piskot/element-desktop/staging/ echo "element-desktop ($VERSION) default; urgency=medium" >> changelog.Debian
mc cp package.json s3piskot/element-desktop/staging/ echo "$BODY" | sed 's/^##/\n */g;s/^\*/ */g' | perl -pe 's/\[.+?]\((.+?)\)/\1/g' >> changelog.Debian
mc cp electronVersion s3piskot/element-desktop/staging/ echo "" >> changelog.Debian
mc cp hakHash s3piskot/element-desktop/staging/ echo " -- $ACTOR <support@element.io> $TIME" >> changelog.Debian
env:
ACTOR: ${{ github.actor }}
VERSION: v${{ steps.package.outputs.version }}
BODY: ${{ steps.release.outputs.body }}
PUBLISHED_AT: ${{ steps.release.outputs.published_at }}
- name: "[Nightly] Write summary"
if: inputs.nightly
run: |
BUNDLE_HASH=$(npx asar l webapp.asar | grep /bundles/ | head -n 1 | sed 's|.*/||')
WEBAPP_VERSION=$(./scripts/get-version.ts)
WEB_VERSION=${WEBAPP_VERSION:0:12}
JS_VERSION=${WEBAPP_VERSION:16:12}
echo "### Nightly build ${{ steps.versions.outputs.nightly }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Component | Version |" >> $GITHUB_STEP_SUMMARY
echo "| ----------- | ------- |" >> $GITHUB_STEP_SUMMARY
echo "| Bundle Hash | $BUNDLE_HASH |" >> $GITHUB_STEP_SUMMARY
echo "| Element Web | [$WEB_VERSION](https://github.com/element-hq/element-web/commit/$WEB_VERSION) |" >> $GITHUB_STEP_SUMMARY
echo "| JS SDK | [$JS_VERSION](https://github.com/matrix-org/matrix-js-sdk/commit/$JS_VERSION) |" >> $GITHUB_STEP_SUMMARY
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: webapp
retention-days: 1
path: |
webapp.asar
package.json
electronVersion
hakHash
changelog.Debian

View File

@@ -27,9 +27,9 @@ jobs:
test: test:
runs-on: ${{ inputs.runs-on }} runs-on: ${{ inputs.runs-on }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: .node-version node-version-file: .node-version
cache: "yarn" cache: "yarn"
@@ -37,7 +37,7 @@ jobs:
- name: Install Deps - name: Install Deps
run: "yarn install --frozen-lockfile" run: "yarn install --frozen-lockfile"
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with: with:
name: ${{ inputs.artifact }} name: ${{ inputs.artifact }}
path: dist path: dist
@@ -67,7 +67,7 @@ jobs:
- name: Run tests - name: Run tests
uses: coactions/setup-xvfb@6b00cf1889f4e1d5a48635647013c0508128ee1a uses: coactions/setup-xvfb@6b00cf1889f4e1d5a48635647013c0508128ee1a
timeout-minutes: 5 timeout-minutes: 20
with: with:
run: yarn test --project=${{ inputs.artifact }} ${{ runner.os != 'Linux' && '--ignore-snapshots' || '' }} ${{ inputs.blob_report == false && '--reporter=html' || '' }} run: yarn test --project=${{ inputs.artifact }} ${{ runner.os != 'Linux' && '--ignore-snapshots' || '' }} ${{ inputs.blob_report == false && '--reporter=html' || '' }}
env: env:
@@ -75,7 +75,7 @@ jobs:
- name: Upload blob report - name: Upload blob report
if: always() && inputs.blob_report if: always() && inputs.blob_report
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: blob-report-${{ inputs.artifact }} name: blob-report-${{ inputs.artifact }}
path: blob-report path: blob-report
@@ -83,7 +83,7 @@ jobs:
- name: Upload HTML report - name: Upload HTML report
if: always() && inputs.blob_report == false if: always() && inputs.blob_report == false
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: ${{ inputs.artifact }}-test name: ${{ inputs.artifact }}-test
path: playwright-report path: playwright-report

View File

@@ -65,15 +65,15 @@ jobs:
} }
} }
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with: with:
name: webapp name: webapp
- name: Cache .hak - name: Cache .hak
id: cache id: cache
uses: actions/cache@v4 uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with: with:
key: ${{ runner.os }}-${{ inputs.arch }}-${{ hashFiles('hakHash', 'electronVersion') }} key: ${{ runner.os }}-${{ inputs.arch }}-${{ hashFiles('hakHash', 'electronVersion') }}
path: | path: |
@@ -102,7 +102,7 @@ jobs:
rustup default stable rustup default stable
rustup target add ${{ steps.config.outputs.target }} rustup target add ${{ steps.config.outputs.target }}
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version-file: .node-version node-version-file: .node-version
cache: "yarn" cache: "yarn"
@@ -206,7 +206,7 @@ jobs:
| ForEach-Object -Process {. $env:SIGNTOOL_PATH verify /pa $_.FullName; if(!$?) { throw }} | ForEach-Object -Process {. $env:SIGNTOOL_PATH verify /pa $_.FullName; if(!$?) { throw }}
- name: Upload Artifacts - name: Upload Artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: win-${{ inputs.arch }} name: win-${{ inputs.arch }}
path: | path: |

70
.github/workflows/dockerbuild.yaml vendored Normal file
View File

@@ -0,0 +1,70 @@
name: Dockerbuild
on:
workflow_dispatch: {}
push:
branches: [master, staging, develop]
paths:
- "dockerbuild/**"
pull_request:
concurrency: ${{ github.workflow }}-${{ github.ref_name }}
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}-dockerbuild
permissions: {}
jobs:
build:
name: Docker Build
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
with:
install: true
- name: Build test image
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6
with:
file: dockerbuild/Dockerfile
push: false
load: true
tags: element-desktop-dockerbuild
platforms: linux/amd64
- name: Test image
run: docker run -v $PWD:/project element-desktop-dockerbuild yarn install
- name: Log in to the Container registry
uses: docker/login-action@6d4b68b490aef8836e8fb5e50ee7b3bdfa5894f0
if: github.event_name != 'pull_request'
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
if: github.event_name != 'pull_request'
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
- name: Build and push Docker image
if: github.event_name != 'pull_request'
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6
with:
file: dockerbuild/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64

85
.github/workflows/static_analysis.yaml vendored Normal file
View File

@@ -0,0 +1,85 @@
name: Static Analysis
on:
pull_request: {}
push:
branches: [develop, master]
permissions: {} # No permissions needed
jobs:
ts_lint:
name: "Typescript Syntax Check"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version-file: package.json
cache: "yarn"
# Does not need branch matching as only analyses this layer
- name: Install Deps
run: "yarn install --frozen-lockfile"
- name: Typecheck
run: "yarn run lint:types"
i18n_lint:
name: "i18n Check"
uses: matrix-org/matrix-web-i18n/.github/workflows/i18n_check.yml@main
permissions:
pull-requests: read
with:
hardcoded-words: "Element"
js_lint:
name: "ESLint"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version-file: package.json
cache: "yarn"
# Does not need branch matching as only analyses this layer
- name: Install Deps
run: "yarn install --frozen-lockfile"
- name: Run Linter
run: "yarn run lint:js"
workflow_lint:
name: "Workflow Lint"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version-file: package.json
cache: "yarn"
# Does not need branch matching as only analyses this layer
- name: Install Deps
run: "yarn install --frozen-lockfile"
- name: Run Linter
run: "yarn lint:workflows"
analyse_dead_code:
name: "Analyse Dead Code"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version-file: package.json
cache: "yarn"
- name: Install Deps
run: "yarn install --frozen-lockfile"
- name: Run linter
run: "yarn run lint:knip"

22
.github/workflows/triage-stale.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Close stale PRs
on:
workflow_dispatch: {}
schedule:
- cron: "30 1 * * *"
permissions: {}
jobs:
close:
runs-on: ubuntu-24.04
permissions:
actions: write
issues: write
pull-requests: write
steps:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
with:
operations-per-run: 250
days-before-issue-stale: -1
days-before-issue-close: -1
days-before-pr-stale: 180
days-before-pr-close: 0
close-pr-message: "This PR has been automatically closed because it has been stale for 180 days. If you wish to continue working on this PR, please ping a maintainer to reopen it."

View File

@@ -1 +1 @@
v22.14.0 v22.16.0

View File

@@ -1,3 +1,57 @@
Changes in [1.11.101](https://github.com/element-hq/element-desktop/releases/tag/v1.11.101) (2025-05-20)
========================================================================================================
## ✨ Features
* Migrate from keytar to safeStorage ([#2227](https://github.com/element-hq/element-desktop/pull/2227)). Contributed by @t3chguy.
* New room list: add keyboard navigation support ([#29805](https://github.com/element-hq/element-web/pull/29805)). Contributed by @florianduros.
* Use the JoinRuleSettings component for the guest link access prompt. ([#28614](https://github.com/element-hq/element-web/pull/28614)). Contributed by @toger5.
* Add loading state to the new room list view ([#29725](https://github.com/element-hq/element-web/pull/29725)). Contributed by @langleyd.
* Make OIDC identity reset consistent with EX ([#29854](https://github.com/element-hq/element-web/pull/29854)). Contributed by @andybalaam.
* Support error code for email / phone adding unsupported (MSC4178) ([#29855](https://github.com/element-hq/element-web/pull/29855)). Contributed by @dbkr.
* Update identity reset UI (Make consistent with EX) ([#29701](https://github.com/element-hq/element-web/pull/29701)). Contributed by @andybalaam.
* Add secondary filters to the new room list ([#29818](https://github.com/element-hq/element-web/pull/29818)). Contributed by @dbkr.
* Fix battery drain from Web Audio ([#29203](https://github.com/element-hq/element-web/pull/29203)). Contributed by @mbachry.
## 🐛 Bug Fixes
* Fix go home shortcut on macos and change toggle action events shortcut ([#29929](https://github.com/element-hq/element-web/pull/29929)). Contributed by @florianduros.
* New room list: fix outdated message preview when space or filter change ([#29925](https://github.com/element-hq/element-web/pull/29925)). Contributed by @florianduros.
* Stop migrating to MSC4278 if the config exists. ([#29924](https://github.com/element-hq/element-web/pull/29924)). Contributed by @Half-Shot.
* Ensure consistent download file name on download from ImageView ([#29913](https://github.com/element-hq/element-web/pull/29913)). Contributed by @t3chguy.
* Add error toast when service worker registration fails ([#29895](https://github.com/element-hq/element-web/pull/29895)). Contributed by @t3chguy.
* New Room List: Prevent old tombstoned rooms from appearing in the list ([#29881](https://github.com/element-hq/element-web/pull/29881)). Contributed by @MidhunSureshR.
* Remove lag in search field ([#29885](https://github.com/element-hq/element-web/pull/29885)). Contributed by @florianduros.
* Respect UIFeature.Voip ([#29873](https://github.com/element-hq/element-web/pull/29873)). Contributed by @langleyd.
* Allow jumping to message search from spotlight ([#29850](https://github.com/element-hq/element-web/pull/29850)). Contributed by @t3chguy.
Changes in [1.11.100](https://github.com/element-hq/element-desktop/releases/tag/v1.11.100) (2025-05-06)
========================================================================================================
## ✨ Features
* Move rich topics out of labs / stabilise MSC3765 ([#29817](https://github.com/element-hq/element-web/pull/29817)). Contributed by @Johennes.
* Spell out that Element Web does \*not\* work on mobile. ([#29211](https://github.com/element-hq/element-web/pull/29211)). Contributed by @ara4n.
* Add message preview support to the new room list ([#29784](https://github.com/element-hq/element-web/pull/29784)). Contributed by @dbkr.
* Global configuration flag for media previews ([#29582](https://github.com/element-hq/element-web/pull/29582)). Contributed by @Half-Shot.
* New room list: add partial keyboard shortcuts support ([#29783](https://github.com/element-hq/element-web/pull/29783)). Contributed by @florianduros.
* MVVM RoomSummaryCard Topic ([#29710](https://github.com/element-hq/element-web/pull/29710)). Contributed by @MarcWadai.
* Warn on self change from settings > roles ([#28926](https://github.com/element-hq/element-web/pull/28926)). Contributed by @MarcWadai.
* New room list: new visual for invitation ([#29773](https://github.com/element-hq/element-web/pull/29773)). Contributed by @florianduros.
## 🐛 Bug Fixes
* Apply workaround to fix app launching on Linux ([#2308](https://github.com/element-hq/element-desktop/pull/2308)). Contributed by @dbkr.
* Notification fixes for Windows - AppID name was messing up handler ([#2275](https://github.com/element-hq/element-desktop/pull/2275)). Contributed by @Fusseldieb.
* Fix incorrect display of the user info display name ([#29826](https://github.com/element-hq/element-web/pull/29826)). Contributed by @langleyd.
* RoomListStore: Remove invite rooms on decline ([#29804](https://github.com/element-hq/element-web/pull/29804)). Contributed by @MidhunSureshR.
* Fix the buttons not being displayed with long preview text ([#29811](https://github.com/element-hq/element-web/pull/29811)). Contributed by @dbkr.
* New room list: fix missing/incorrect notification decoration ([#29796](https://github.com/element-hq/element-web/pull/29796)). Contributed by @florianduros.
* New Room List: Prevent potential scroll jump/flicker when switching spaces ([#29781](https://github.com/element-hq/element-web/pull/29781)). Contributed by @MidhunSureshR.
* New room list: fix incorrect decoration ([#29770](https://github.com/element-hq/element-web/pull/29770)). Contributed by @florianduros.
Changes in [1.11.99](https://github.com/element-hq/element-desktop/releases/tag/v1.11.99) (2025-04-23) Changes in [1.11.99](https://github.com/element-hq/element-desktop/releases/tag/v1.11.99) (2025-04-23)
====================================================================================================== ======================================================================================================
## 🐛 Bug Fixes ## 🐛 Bug Fixes

View File

@@ -1,6 +1,6 @@
# Docker image to facilitate building Element Desktop's native bits using a glibc version (2.31) # Docker image to facilitate building Element Desktop's native bits using a glibc version (2.31)
# with broader compatibility, down to Debian bullseye & Ubuntu focal. # with broader compatibility, down to Debian bullseye & Ubuntu focal.
FROM rust:bullseye FROM rust:bullseye@sha256:eb809362961259a30f540857c3cac8423c466d558bea0f55f32e3a6354654353
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive

View File

@@ -2,10 +2,12 @@
- [Introduction](../README.md) - [Introduction](../README.md)
# Build # Build/Debug
- [Native Node modules](native-node-modules.md) - [Native Node modules](native-node-modules.md)
- [Windows requirements](windows-requirements.md) - [Windows requirements](windows-requirements.md)
- [Debugging](debugging.md)
- [Using gdb](gdb.md)
# Distribution # Distribution

28
docs/debugging.md Normal file
View File

@@ -0,0 +1,28 @@
# Debugging Element-Desktop
There are two parts of the desktop app that you might want to debug.
## The renderer process
This is the regular element-web codeand can be debugged by just selecting 'toggle developer tools'
from the menu, even on ppackaged builds. This then works the same as chrome dev tools for element web.
## The main process
This is debugged as a node app, so:
1. Open any chrome dev tools window
1. Start element with the `--inspect-brk` flag
1. Notice that you now have a little green icon in the top left of your chrome devtools window, click it.
You are now debugging the code of the desktop app itself.
## The main process of a package app
When the app is shipped, electron's "fuses" are flipped, editing the electron binary itself to prevent certain features from being usable, one of which is debugging using `--inspect-brk` as above. You can flip the fuse back on Linux as follows:
```
sudo npx @electron/fuses write --app /opt/Element/element-desktop EnableNodeCliInspectArguments=on
```
A similar command will work, in theory, on mac and windows, except that this will break code signing (which is the point of fuses) so you would have to re-sign the app or somesuch.

46
docs/gdb.md Normal file
View File

@@ -0,0 +1,46 @@
# Using gdb against Element-Desktop
Occasionally it is useful to be able to connect to a running Element-Desktop
with [`gdb`](https://sourceware.org/gdb/), or to analayze a coredump. For this,
you will need debug symbols.
1. If you don't already have the right version of Element-Desktop (eg because
you are analyzing someone else's coredump), download and unpack the tarball
from https://packages.element.io/desktop/install/linux/. If it was a
nightly, your best bet may be to download the deb from
https://packages.element.io/debian/pool/main/e/element-nightly/ and unpack
it.
2. Figure out which version of Electron your Element-Desktop is based on. The
best way to do this is to figure out the version of Element-Desktop, then
look at
[`yarn.lock`](https://github.com/element-hq/element-desktop/blob/develop/yarn.lock)
for the corresponding version. There should be an entry starting
`electron@`, and under it a `version` line: this will tell you the version
of Electron that was used for that version of Element-Desktop.
3. Go to [Electron's releases page](https://github.com/electron/electron/releases/)
and find the version you just identified. Under "Assets", download
`electron-v<version>-linux-x64-debug.zip` (or, the -debug zip corresponding to your
architecture).
4. The debug zip has a structure like:
```
.
├── debug
│   ├── chrome_crashpad_handler.debug
│   ├── electron.debug
│   ├── libEGL.so.debug
│   ├── libffmpeg.so.debug
│   ├── libGLESv2.so.debug
│   └── libvk_swiftshader.so.debug
├── LICENSE
├── LICENSES.chromium.html
└── version
```
Take all the contents of `debug`, and copy them into the Element-Desktop directory,
so that `electron.debug` is alongside the `element-desktop-nightly` executable.
5. You now have a thing you can gdb as normal, either as `gdb --args element-desktop-nightly`, or
`gdb element-desktop-nightly core`.

View File

@@ -1,10 +1,6 @@
import * as os from "node:os"; import * as os from "node:os";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import { type Configuration as BaseConfiguration, type Protocol } from "electron-builder";
import * as plist from "plist";
import { AfterPackContext, Arch, Configuration as BaseConfiguration, Platform } from "electron-builder";
import { computeData } from "app-builder-lib/out/asar/integrity";
import { readFile, writeFile } from "node:fs/promises";
/** /**
* This script has different outputs depending on your os platform. * This script has different outputs depending on your os platform.
@@ -20,9 +16,13 @@ import { readFile, writeFile } from "node:fs/promises";
* Passes $ED_DEBIAN_CHANGELOG to build.deb.fpm if specified * Passes $ED_DEBIAN_CHANGELOG to build.deb.fpm if specified
*/ */
const DEFAULT_APP_ID = "im.riot.app";
const NIGHTLY_APP_ID = "im.riot.nightly"; const NIGHTLY_APP_ID = "im.riot.nightly";
const NIGHTLY_DEB_NAME = "element-nightly"; const NIGHTLY_DEB_NAME = "element-nightly";
const DEFAULT_PROTOCOL_SCHEME = "io.element.desktop";
const NIGHTLY_PROTOCOL_SCHEME = "io.element.nightly";
interface Pkg { interface Pkg {
name: string; name: string;
productName: string; productName: string;
@@ -37,7 +37,11 @@ type Writable<T> = NonNullable<
const pkg: Pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); const pkg: Pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
interface Configuration extends BaseConfiguration { interface Configuration extends BaseConfiguration {
extraMetadata: Partial<Pick<Pkg, "version">> & Omit<Pkg, "version">; extraMetadata: Partial<Pick<Pkg, "version">> &
Omit<Pkg, "version"> & {
electron_appId: string;
electron_protocol: string;
};
linux: BaseConfiguration["linux"]; linux: BaseConfiguration["linux"];
win: BaseConfiguration["win"]; win: BaseConfiguration["win"];
mac: BaseConfiguration["mac"]; mac: BaseConfiguration["mac"];
@@ -46,26 +50,6 @@ interface Configuration extends BaseConfiguration {
} & BaseConfiguration["deb"]; } & BaseConfiguration["deb"];
} }
async function injectAsarIntegrity(context: AfterPackContext) {
const packager = context.packager;
// We only need to re-generate asar on universal Mac builds, due to https://github.com/electron/universal/issues/116
if (packager.platform !== Platform.MAC || context.arch !== Arch.universal) return;
const resourcesPath = packager.getResourcesDir(context.appOutDir);
const asarIntegrity = await computeData({
resourcesPath,
resourcesRelativePath: "Resources",
resourcesDestinationPath: resourcesPath,
extraResourceMatchers: [],
});
const plistPath = path.join(resourcesPath, "..", "Info.plist");
const data = plist.parse(await readFile(plistPath, "utf8")) as unknown as Writable<plist.PlistObject>;
data["ElectronAsarIntegrity"] = asarIntegrity as unknown as Writable<plist.PlistValue>;
await writeFile(plistPath, plist.build(data));
}
/** /**
* @type {import('electron-builder').Configuration} * @type {import('electron-builder').Configuration}
* @see https://www.electron.build/configuration/configuration * @see https://www.electron.build/configuration/configuration
@@ -74,7 +58,7 @@ const config: Omit<Writable<Configuration>, "electronFuses"> & {
// Make all fuses required to ensure they are all explicitly specified // Make all fuses required to ensure they are all explicitly specified
electronFuses: Required<Configuration["electronFuses"]>; electronFuses: Required<Configuration["electronFuses"]>;
} = { } = {
appId: "im.riot.app", appId: DEFAULT_APP_ID,
asarUnpack: "**/*.node", asarUnpack: "**/*.node",
electronFuses: { electronFuses: {
enableCookieEncryption: true, enableCookieEncryption: true,
@@ -90,9 +74,6 @@ const config: Omit<Writable<Configuration>, "electronFuses"> & {
loadBrowserProcessSpecificV8Snapshot: false, loadBrowserProcessSpecificV8Snapshot: false,
enableEmbeddedAsarIntegrityValidation: true, enableEmbeddedAsarIntegrityValidation: true,
}, },
afterPack: async (context: AfterPackContext) => {
await injectAsarIntegrity(context);
},
files: [ files: [
"package.json", "package.json",
{ {
@@ -112,6 +93,8 @@ const config: Omit<Writable<Configuration>, "electronFuses"> & {
name: pkg.name, name: pkg.name,
productName: pkg.productName, productName: pkg.productName,
description: pkg.description, description: pkg.description,
electron_appId: DEFAULT_APP_ID,
electron_protocol: DEFAULT_PROTOCOL_SCHEME,
}, },
linux: { linux: {
target: ["tar.gz", "deb"], target: ["tar.gz", "deb"],
@@ -154,6 +137,7 @@ const config: Omit<Writable<Configuration>, "electronFuses"> & {
entitlements: "./build/entitlements.mac.plist", entitlements: "./build/entitlements.mac.plist",
icon: "build/icons/icon.icns", icon: "build/icons/icon.icns",
mergeASARs: true, mergeASARs: true,
x64ArchFiles: "**/matrix-seshat/*.node", // hak already runs lipo
}, },
win: { win: {
target: ["squirrel", "msi"], target: ["squirrel", "msi"],
@@ -168,12 +152,10 @@ const config: Omit<Writable<Configuration>, "electronFuses"> & {
directories: { directories: {
output: "dist", output: "dist",
}, },
protocols: [ protocols: {
{ name: "element",
name: "element", schemes: [DEFAULT_PROTOCOL_SCHEME, "element"],
schemes: ["io.element.desktop", "element"], },
},
],
nativeRebuilder: "sequential", nativeRebuilder: "sequential",
nodeGypRebuild: false, nodeGypRebuild: false,
npmRebuild: true, npmRebuild: true,
@@ -196,11 +178,12 @@ if (process.env.ED_SIGNTOOL_SUBJECT_NAME && process.env.ED_SIGNTOOL_THUMBPRINT)
if (process.env.ED_NIGHTLY) { if (process.env.ED_NIGHTLY) {
config.deb.fpm = []; // Clear the fpm as the breaks deb fields don't apply to nightly config.deb.fpm = []; // Clear the fpm as the breaks deb fields don't apply to nightly
config.appId = NIGHTLY_APP_ID; config.appId = config.extraMetadata.electron_appId = NIGHTLY_APP_ID;
config.extraMetadata.productName += " Nightly"; config.extraMetadata.productName += " Nightly";
config.extraMetadata.name += "-nightly"; config.extraMetadata.name += "-nightly";
config.extraMetadata.description += " (nightly unstable build)"; config.extraMetadata.description += " (nightly unstable build)";
config.deb.fpm.push("--name", NIGHTLY_DEB_NAME); config.deb.fpm.push("--name", NIGHTLY_DEB_NAME);
(config.protocols as Protocol).schemes[0] = config.extraMetadata.electron_protocol = NIGHTLY_PROTOCOL_SCHEME;
let version = process.env.ED_NIGHTLY; let version = process.env.ED_NIGHTLY;
if (os.platform() === "win32") { if (os.platform() === "win32") {

View File

@@ -3,7 +3,7 @@
"productName": "Element Piskot", "productName": "Element Piskot",
"main": "lib/electron-main.js", "main": "lib/electron-main.js",
"exports": "./lib/electron-main.js", "exports": "./lib/electron-main.js",
"version": "1.11.97-piskot", "version": "1.11.101-piskot",
"description": "Element: the future of secure communication", "description": "Element: the future of secure communication",
"author": "Element", "author": "Element",
"homepage": "https://element.io", "homepage": "https://element.io",
@@ -74,22 +74,22 @@
"@babel/core": "^7.18.10", "@babel/core": "^7.18.10",
"@babel/preset-env": "^7.18.10", "@babel/preset-env": "^7.18.10",
"@babel/preset-typescript": "^7.18.6", "@babel/preset-typescript": "^7.18.6",
"@electron/asar": "3.4.1", "@electron/asar": "4.0.0",
"@playwright/test": "1.51.1", "@playwright/test": "1.52.0",
"@stylistic/eslint-plugin": "^4.0.0", "@stylistic/eslint-plugin": "^4.0.0",
"@types/auto-launch": "^5.0.1", "@types/auto-launch": "^5.0.1",
"@types/counterpart": "^0.18.1", "@types/counterpart": "^0.18.1",
"@types/minimist": "^1.2.1", "@types/minimist": "^1.2.1",
"@types/node": "18.19.86", "@types/node": "18.19.105",
"@types/pacote": "^11.1.1", "@types/pacote": "^11.1.1",
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0", "@typescript-eslint/parser": "^8.0.0",
"app-builder-lib": "26.0.12", "app-builder-lib": "26.0.15",
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"detect-libc": "^2.0.0", "detect-libc": "^2.0.0",
"electron": "35.1.5", "electron": "36.3.2",
"electron-builder": "26.0.12", "electron-builder": "26.0.15",
"electron-builder-squirrel-windows": "26.0.12", "electron-builder-squirrel-windows": "26.0.15",
"electron-devtools-installer": "^4.0.0", "electron-devtools-installer": "^4.0.0",
"eslint": "^8.26.0", "eslint": "^8.26.0",
"eslint-config-google": "^0.14.0", "eslint-config-google": "^0.14.0",
@@ -101,11 +101,10 @@
"glob": "^11.0.0", "glob": "^11.0.0",
"husky": "^9.1.6", "husky": "^9.1.6",
"knip": "^5.0.0", "knip": "^5.0.0",
"lint-staged": "^15.2.10", "lint-staged": "^16.0.0",
"matrix-web-i18n": "^3.2.1", "matrix-web-i18n": "^3.2.1",
"mkdirp": "^3.0.0", "mkdirp": "^3.0.0",
"pacote": "^21.0.0", "pacote": "^21.0.0",
"plist": "^3.1.0",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"rimraf": "^6.0.0", "rimraf": "^6.0.0",
"tar": "^7.0.0", "tar": "^7.0.0",
@@ -116,7 +115,8 @@
"matrix-seshat": "^4.0.1" "matrix-seshat": "^4.0.1"
}, },
"resolutions": { "resolutions": {
"@types/node": "18.19.86", "@types/node": "18.19.105",
"config-file-ts": "0.2.8-rc1" "config-file-ts": "0.2.8-rc1",
"node-abi": "4.9.0"
} }
} }

View File

@@ -1,4 +1,4 @@
FROM mcr.microsoft.com/playwright:v1.51.1-jammy FROM mcr.microsoft.com/playwright:v1.52.0-jammy@sha256:ff2946177f0756c87482c0ef958b7cfbf389b92525ace78a1c9890281d0d60f4
WORKDIR /work/element-desktop WORKDIR /work/element-desktop

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import { platform } from "node:os"; import keytar from "keytar-forked";
import { test, expect } from "../../element-desktop-test.js"; import { test, expect } from "../../element-desktop-test.js";
@@ -17,6 +17,7 @@ declare global {
supportsEventIndexing(): Promise<boolean>; supportsEventIndexing(): Promise<boolean>;
} }
| undefined; | undefined;
getPickleKey(userId: string, deviceId: string): Promise<string | null>;
createPickleKey(userId: string, deviceId: string): Promise<string | null>; createPickleKey(userId: string, deviceId: string): Promise<string | null>;
} }
@@ -48,14 +49,46 @@ test.describe("App launch", () => {
).resolves.toBeTruthy(); ).resolves.toBeTruthy();
}); });
test("should launch and render the welcome view successfully and support keytar", async ({ page }) => { test.describe("safeStorage", () => {
test.skip(platform() === "linux", "This test does not yet support Linux"); const userId = "@user:server";
const deviceId = "ABCDEF";
await expect( test("should be supported", async ({ page }) => {
page.evaluate<string | null>(async () => { await expect(
return await window.mxPlatformPeg.get().createPickleKey("@user:server", "ABCDEF"); page.evaluate(
}), ([userId, deviceId]) => window.mxPlatformPeg.get().createPickleKey(userId, deviceId),
).resolves.not.toBeNull(); [userId, deviceId],
),
).resolves.not.toBeNull();
});
test.describe("migrate from keytar", () => {
test.skip(
process.env.GITHUB_ACTIONS && ["linux", "darwin"].includes(process.platform),
"GitHub Actions hosted runner are not a compatible environment for this test",
);
const pickleKey = "DEADBEEF1234";
const keytarService = "element.io";
const keytarKey = `${userId}|${deviceId}`;
test.beforeAll(async () => {
await keytar.setPassword(keytarService, keytarKey, pickleKey);
await expect(keytar.getPassword(keytarService, keytarKey)).resolves.toBe(pickleKey);
});
test.afterAll(async () => {
await keytar.deletePassword(keytarService, keytarKey);
});
test("should migrate successfully", async ({ page }) => {
await expect(
page.evaluate(
([userId, deviceId]) => window.mxPlatformPeg.get().getPickleKey(userId, deviceId),
[userId, deviceId],
),
).resolves.toBe(pickleKey);
});
});
}); });
test.describe("--no-update", () => { test.describe("--no-update", () => {

View File

@@ -31,6 +31,6 @@ test.describe("OIDC Native", () => {
page.evaluate<string>(() => { page.evaluate<string>(() => {
return window.mxPlatformPeg.get().getOidcCallbackUrl().toString(); return window.mxPlatformPeg.get().getOidcCallbackUrl().toString();
}), }),
).resolves.toBe("io.element.desktop:/vector/webapp/"); ).resolves.toMatch(/io\.element\.(desktop|nightly):\/vector\/webapp\//);
}); });
}); });

View File

@@ -62,12 +62,21 @@ export const test = base.extend<Fixtures>({
// eslint-disable-next-line no-empty-pattern // eslint-disable-next-line no-empty-pattern
tmpDir: async ({}, use) => { tmpDir: async ({}, use) => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "element-desktop-tests-")); const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "element-desktop-tests-"));
console.log("Using temp profile directory: ", tmpDir);
await use(tmpDir); await use(tmpDir);
await fs.rm(tmpDir, { recursive: true }); await fs.rm(tmpDir, { recursive: true });
}, },
app: async ({ tmpDir, extraEnv, extraArgs, stdout, stderr }, use) => { app: async ({ tmpDir, extraEnv, extraArgs, stdout, stderr }, use) => {
const args = ["--profile-dir", tmpDir]; const args = ["--profile-dir", tmpDir, ...extraArgs];
if (process.env.GITHUB_ACTIONS) {
if (process.platform === "linux") {
// GitHub Actions hosted runner lacks dbus and a compatible keyring, so we need to force plaintext storage
args.push("--storage-mode", "force-plaintext");
} else if (process.platform === "darwin") {
// GitHub Actions hosted runner has no working default keychain, so allow plaintext storage
args.push("--storage-mode", "allow-plaintext");
}
}
const executablePath = process.env["ELEMENT_DESKTOP_EXECUTABLE"]; const executablePath = process.env["ELEMENT_DESKTOP_EXECUTABLE"];
if (!executablePath) { if (!executablePath) {
@@ -75,13 +84,15 @@ export const test = base.extend<Fixtures>({
args.unshift(path.join(__dirname, "..", "lib", "electron-main.js")); args.unshift(path.join(__dirname, "..", "lib", "electron-main.js"));
} }
console.log(`Launching '${executablePath}' with args ${args.join(" ")}`);
const app = await electron.launch({ const app = await electron.launch({
env: { env: {
...process.env, ...process.env,
...extraEnv, ...extraEnv,
}, },
executablePath, executablePath,
args: [...args, ...extraArgs], args,
}); });
app.process().stdout.pipe(stdout).pipe(process.stdout); app.process().stdout.pipe(stdout).pipe(process.stdout);

48
scripts/branch-match.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/bin/bash
# Script for downloading a branch of element-web matching the branch a PR is contributed from
set -x
deforg="element-hq"
defrepo="element-web"
# The PR_NUMBER variable must be set explicitly.
default_org_repo=${GITHUB_REPOSITORY:-"$deforg/$defrepo"}
PR_ORG=${PR_ORG:-${default_org_repo%%/*}}
PR_REPO=${PR_REPO:-${default_org_repo##*/}}
# A function that clones a branch of a repo based on the org, repo and branch
clone() {
org=$1
repo=$2
branch=$3
if [ -n "$branch" ]
then
echo "Trying to use $org/$repo#$branch"
# Disable auth prompts: https://serverfault.com/a/665959
GIT_TERMINAL_PROMPT=0 git clone https://github.com/$org/$repo.git $repo --branch "$branch" --depth 1 && exit 0
fi
}
echo "Getting info about a PR with number $PR_NUMBER"
apiEndpoint="https://api.github.com/repos/$PR_ORG/$PR_REPO/pulls/$PR_NUMBER"
head=$(curl "$apiEndpoint" | jq -r '.head.label')
# for forks, $head will be in the format "fork:branch", so we split it by ":"
# into an array. On non-forks, this has the effect of splitting into a single
# element array given ":" shouldn't appear in the head - it'll just be the
# branch name. Based on the results, we clone.
BRANCH_ARRAY=(${head//:/ })
TRY_ORG=$deforg
TRY_BRANCH=${BRANCH_ARRAY[0]}
if [[ "$head" == *":"* ]]; then
# ... but only match that fork if it's a real fork
if [ "${BRANCH_ARRAY[0]}" != "$PR_ORG" ]; then
TRY_ORG=${BRANCH_ARRAY[0]}
fi
TRY_BRANCH=${BRANCH_ARRAY[1]}
fi
clone "$TRY_ORG" "$defrepo" "$TRY_BRANCH"
exit 1

View File

@@ -1,41 +0,0 @@
#!/bin/bash
set -x
deforg="$1"
defrepo="$2"
defbranch="$3"
[ -z "$defbranch" ] && defbranch="develop"
rm -r "$defrepo" || true
clone() {
org=$1
repo=$2
branch=$3
if [ -n "$branch" ]
then
echo "Trying to use $org/$repo#$branch"
# Disable auth prompts: https://serverfault.com/a/665959
GIT_TERMINAL_PROMPT=0 git clone https://github.com/$org/$repo.git $repo --branch "$branch" --depth 1 && exit 0
fi
}
# Try the PR author's branch in case it exists on the deps as well.
# If BUILDKITE_BRANCH is set, it will contain either:
# * "branch" when the author's branch and target branch are in the same repo
# * "author:branch" when the author's branch is in their fork
# We can split on `:` into an array to check.
BUILDKITE_BRANCH_ARRAY=(${BUILDKITE_BRANCH//:/ })
if [[ "${#BUILDKITE_BRANCH_ARRAY[@]}" == "1" ]]; then
clone $deforg $defrepo $BUILDKITE_BRANCH
elif [[ "${#BUILDKITE_BRANCH_ARRAY[@]}" == "2" ]]; then
clone ${BUILDKITE_BRANCH_ARRAY[0]} $defrepo ${BUILDKITE_BRANCH_ARRAY[1]}
fi
# Try the target branch of the push or PR.
clone $deforg $defrepo $BUILDKITE_PULL_REQUEST_BASE_BRANCH
# Try the current branch from Jenkins.
clone $deforg $defrepo `"echo $GIT_BRANCH" | sed -e 's/^origin\///'`
# Use the default branch as the last resort.
clone $deforg $defrepo $defbranch

View File

@@ -8,7 +8,7 @@
"module": "node16", "module": "node16",
"sourceMap": false, "sourceMap": false,
"strict": true, "strict": true,
"lib": ["es2020"] "lib": ["es2021"]
}, },
"include": ["../src/@types", "./**/*.ts"] "include": ["../src/@types", "./**/*.ts"]
} }

View File

@@ -7,30 +7,23 @@ Please see LICENSE files in the repository root for full details.
import { type BrowserWindow } from "electron"; import { type BrowserWindow } from "electron";
import type Store from "electron-store";
import type AutoLaunch from "auto-launch"; import type AutoLaunch from "auto-launch";
import { type AppLocalization } from "../language-helper.js"; import { type AppLocalization } from "../language-helper.js";
// global type extensions need to use var for whatever reason // global type extensions need to use var for whatever reason
/* eslint-disable no-var */ /* eslint-disable no-var */
declare global { declare global {
type IConfigOptions = Record<string, any>;
var mainWindow: BrowserWindow | null; var mainWindow: BrowserWindow | null;
var appQuitting: boolean; var appQuitting: boolean;
var appLocalization: AppLocalization; var appLocalization: AppLocalization;
var launcher: AutoLaunch; var launcher: AutoLaunch;
var vectorConfig: Record<string, any>; var vectorConfig: IConfigOptions;
var trayConfig: { var trayConfig: {
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
icon_path: string; icon_path: string;
brand: string; brand: string;
}; };
var store: Store<{
warnBeforeExit?: boolean;
minimizeToTray?: boolean;
spellCheckerEnabled?: boolean;
autoHideMenuBar?: boolean;
locale?: string | string[];
disableHardwareAcceleration?: boolean;
}>;
} }
/* eslint-enable no-var */ /* eslint-enable no-var */

26
src/build-config.ts Normal file
View File

@@ -0,0 +1,26 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import path, { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { type JsonObject, loadJsonFile } from "./utils.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
interface BuildConfig {
appId: string;
protocol: string;
}
export function readBuildConfig(): BuildConfig {
const packageJson = loadJsonFile(path.join(__dirname, "..", "package.json")) as JsonObject;
return {
appId: (packageJson["electron_appId"] as string) || "im.riot.app",
protocol: (packageJson["electron_protocol"] as string) || "io.element.desktop",
};
}

View File

@@ -10,13 +10,12 @@ Please see LICENSE files in the repository root for full details.
// Squirrel on windows starts the app with various flags as hooks to tell us when we've been installed/uninstalled etc. // Squirrel on windows starts the app with various flags as hooks to tell us when we've been installed/uninstalled etc.
import "./squirrelhooks.js"; import "./squirrelhooks.js";
import { app, BrowserWindow, Menu, autoUpdater, protocol, dialog, type Input, type Event, session } from "electron"; import { app, BrowserWindow, Menu, autoUpdater, dialog, type Input, type Event, session, protocol } from "electron";
// eslint-disable-next-line n/file-extension-in-import // eslint-disable-next-line n/file-extension-in-import
import * as Sentry from "@sentry/electron/main"; import * as Sentry from "@sentry/electron/main";
import AutoLaunch from "auto-launch"; import AutoLaunch from "auto-launch";
import path, { dirname } from "node:path"; import path, { dirname } from "node:path";
import windowStateKeeper from "electron-window-state"; import windowStateKeeper from "electron-window-state";
import Store from "electron-store";
import fs, { promises as afs } from "node:fs"; import fs, { promises as afs } from "node:fs";
import { URL, fileURLToPath } from "node:url"; import { URL, fileURLToPath } from "node:url";
import minimist from "minimist"; import minimist from "minimist";
@@ -25,15 +24,17 @@ import "./ipc.js";
import "./seshat.js"; import "./seshat.js";
import "./settings.js"; import "./settings.js";
import * as tray from "./tray.js"; import * as tray from "./tray.js";
import Store from "./store.js";
import { buildMenuTemplate } from "./vectormenu.js"; import { buildMenuTemplate } from "./vectormenu.js";
import webContentsHandler from "./webcontents-handler.js"; import webContentsHandler from "./webcontents-handler.js";
import * as updater from "./updater.js"; import * as updater from "./updater.js";
import { getProfileFromDeeplink, protocolInit } from "./protocol.js"; import ProtocolHandler from "./protocol.js";
import { _t, AppLocalization } from "./language-helper.js"; import { _t, AppLocalization } from "./language-helper.js";
import { setDisplayMediaCallback } from "./displayMediaCallback.js"; import { setDisplayMediaCallback } from "./displayMediaCallback.js";
import { setupMacosTitleBar } from "./macos-titlebar.js"; import { setupMacosTitleBar } from "./macos-titlebar.js";
import { type Json, loadJsonFile } from "./utils.js"; import { type Json, loadJsonFile } from "./utils.js";
import { setupMediaAuth } from "./media-auth.js"; import { setupMediaAuth } from "./media-auth.js";
import { readBuildConfig } from "./build-config.js";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -72,10 +73,13 @@ function isRealUserDataDir(d: string): boolean {
return fs.existsSync(path.join(d, "IndexedDB")); return fs.existsSync(path.join(d, "IndexedDB"));
} }
const buildConfig = readBuildConfig();
const protocolHandler = new ProtocolHandler(buildConfig.protocol);
// check if we are passed a profile in the SSO callback url // check if we are passed a profile in the SSO callback url
let userDataPath: string; let userDataPath: string;
const userDataPathInProtocol = getProfileFromDeeplink(argv["_"]); const userDataPathInProtocol = protocolHandler.getProfileFromDeeplink(argv["_"]);
if (userDataPathInProtocol) { if (userDataPathInProtocol) {
userDataPath = userDataPathInProtocol; userDataPath = userDataPathInProtocol;
} else if (argv["profile-dir"]) { } else if (argv["profile-dir"]) {
@@ -273,8 +277,6 @@ async function moveAutoLauncher(): Promise<void> {
} }
} }
global.store = new Store({ name: "electron-config" });
global.appQuitting = false; global.appQuitting = false;
const exitShortcuts: Array<(input: Input, platform: string) => boolean> = [ const exitShortcuts: Array<(input: Input, platform: string) => boolean> = [
@@ -284,32 +286,6 @@ const exitShortcuts: Array<(input: Input, platform: string) => boolean> = [
platform === "darwin" && input.meta && !input.control && input.key.toUpperCase() === "Q", platform === "darwin" && input.meta && !input.control && input.key.toUpperCase() === "Q",
]; ];
const warnBeforeExit = (event: Event, input: Input): void => {
const shouldWarnBeforeExit = global.store.get("warnBeforeExit", true);
const exitShortcutPressed =
input.type === "keyDown" && exitShortcuts.some((shortcutFn) => shortcutFn(input, process.platform));
if (shouldWarnBeforeExit && exitShortcutPressed && global.mainWindow) {
const shouldCancelCloseRequest =
dialog.showMessageBoxSync(global.mainWindow, {
type: "question",
buttons: [
_t("action|cancel"),
_t("action|close_brand", {
brand: global.vectorConfig.brand || "Element",
}),
],
message: _t("confirm_quit"),
defaultId: 1,
cancelId: 0,
}) === 0;
if (shouldCancelCloseRequest) {
event.preventDefault();
}
}
};
void configureSentry(); void configureSentry();
// handle uncaught errors otherwise it displays // handle uncaught errors otherwise it displays
@@ -326,6 +302,11 @@ app.commandLine.appendSwitch("--enable-usermedia-screen-capturing");
if (!app.commandLine.hasSwitch("enable-features")) { if (!app.commandLine.hasSwitch("enable-features")) {
app.commandLine.appendSwitch("enable-features", "WebRTCPipeWireCapturer"); app.commandLine.appendSwitch("enable-features", "WebRTCPipeWireCapturer");
} }
// Workaround bug in electron 36:https://github.com/electron/electron/issues/46538
// Hopefully this will no longer be needed soon and can be removed
if (process.platform === "linux") {
app.commandLine.appendSwitch("gtk-version", "3");
}
const gotLock = app.requestSingleInstanceLock(); const gotLock = app.requestSingleInstanceLock();
if (!gotLock) { if (!gotLock) {
@@ -334,7 +315,7 @@ if (!gotLock) {
} }
// do this after we know we are the primary instance of the app // do this after we know we are the primary instance of the app
protocolInit(); protocolHandler.initialise(userDataPath);
// Register the scheme the app is served from as 'standard' // Register the scheme the app is served from as 'standard'
// which allows things like relative URLs and IndexedDB to // which allows things like relative URLs and IndexedDB to
@@ -366,13 +347,17 @@ app.enableSandbox();
// We disable media controls here. We do this because calls use audio and video elements and they sometimes capture the media keys. See https://github.com/vector-im/element-web/issues/15704 // We disable media controls here. We do this because calls use audio and video elements and they sometimes capture the media keys. See https://github.com/vector-im/element-web/issues/15704
app.commandLine.appendSwitch("disable-features", "HardwareMediaKeyHandling,MediaSessionService"); app.commandLine.appendSwitch("disable-features", "HardwareMediaKeyHandling,MediaSessionService");
const store = Store.initialize(argv["storage-mode"]); // must be called before any async actions
// Disable hardware acceleration if the setting has been set. // Disable hardware acceleration if the setting has been set.
if (global.store.get("disableHardwareAcceleration", false) === true) { if (store.get("disableHardwareAcceleration")) {
console.log("Disabling hardware acceleration."); console.log("Disabling hardware acceleration.");
app.disableHardwareAcceleration(); app.disableHardwareAcceleration();
} }
app.on("ready", async () => { app.on("ready", async () => {
console.debug("Reached Electron ready state");
let asarPath: string; let asarPath: string;
try { try {
@@ -462,12 +447,27 @@ app.on("ready", async () => {
console.log("No update_base_url is defined: auto update is disabled"); console.log("No update_base_url is defined: auto update is disabled");
} }
// Set up i18n before loading storage as we need translations for dialogs
global.appLocalization = new AppLocalization({
components: [(): void => tray.initApplicationMenu(), (): void => Menu.setApplicationMenu(buildMenuTemplate())],
store,
});
try {
console.debug("Ensuring storage is ready");
await store.safeStorageReady();
} catch (e) {
console.error(e);
app.exit(1);
}
// Load the previous window state with fallback to defaults // Load the previous window state with fallback to defaults
const mainWindowState = windowStateKeeper({ const mainWindowState = windowStateKeeper({
defaultWidth: 1024, defaultWidth: 1024,
defaultHeight: 768, defaultHeight: 768,
}); });
console.debug("Opening main window");
const preloadScript = path.normalize(`${__dirname}/preload.cjs`); const preloadScript = path.normalize(`${__dirname}/preload.cjs`);
global.mainWindow = new BrowserWindow({ global.mainWindow = new BrowserWindow({
// https://www.electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do // https://www.electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do
@@ -478,7 +478,7 @@ app.on("ready", async () => {
icon: global.trayConfig.icon_path, icon: global.trayConfig.icon_path,
show: false, show: false,
autoHideMenuBar: global.store.get("autoHideMenuBar", true), autoHideMenuBar: store.get("autoHideMenuBar"),
x: mainWindowState.x, x: mainWindowState.x,
y: mainWindowState.y, y: mainWindowState.y,
@@ -500,10 +500,10 @@ app.on("ready", async () => {
// Handle spellchecker // Handle spellchecker
// For some reason spellCheckerEnabled isn't persisted, so we have to use the store here // For some reason spellCheckerEnabled isn't persisted, so we have to use the store here
global.mainWindow.webContents.session.setSpellCheckerEnabled(global.store.get("spellCheckerEnabled", true)); global.mainWindow.webContents.session.setSpellCheckerEnabled(store.get("spellCheckerEnabled", true));
// Create trayIcon icon // Create trayIcon icon
if (global.store.get("minimizeToTray", true)) tray.create(global.trayConfig); if (store.get("minimizeToTray")) tray.create(global.trayConfig);
global.mainWindow.once("ready-to-show", () => { global.mainWindow.once("ready-to-show", () => {
if (!global.mainWindow) return; if (!global.mainWindow) return;
@@ -517,7 +517,31 @@ app.on("ready", async () => {
} }
}); });
global.mainWindow.webContents.on("before-input-event", warnBeforeExit); global.mainWindow.webContents.on("before-input-event", (event: Event, input: Input): void => {
const shouldWarnBeforeExit = store.get("warnBeforeExit", true);
const exitShortcutPressed =
input.type === "keyDown" && exitShortcuts.some((shortcutFn) => shortcutFn(input, process.platform));
if (shouldWarnBeforeExit && exitShortcutPressed && global.mainWindow) {
const shouldCancelCloseRequest =
dialog.showMessageBoxSync(global.mainWindow, {
type: "question",
buttons: [
_t("action|cancel"),
_t("action|close_brand", {
brand: global.vectorConfig.brand || "Element",
}),
],
message: _t("confirm_quit"),
defaultId: 1,
cancelId: 0,
}) === 0;
if (shouldCancelCloseRequest) {
event.preventDefault();
}
}
});
global.mainWindow.on("closed", () => { global.mainWindow.on("closed", () => {
global.mainWindow = null; global.mainWindow = null;
@@ -555,11 +579,6 @@ app.on("ready", async () => {
webContentsHandler(global.mainWindow.webContents); webContentsHandler(global.mainWindow.webContents);
global.appLocalization = new AppLocalization({
store: global.store,
components: [(): void => tray.initApplicationMenu(), (): void => Menu.setApplicationMenu(buildMenuTemplate())],
});
session.defaultSession.setDisplayMediaRequestHandler((_, callback) => { session.defaultSession.setDisplayMediaRequestHandler((_, callback) => {
global.mainWindow?.webContents.send("openDesktopCapturerSourcePicker"); global.mainWindow?.webContents.send("openDesktopCapturerSourcePicker");
setDisplayMediaCallback(callback); setDisplayMediaCallback(callback);
@@ -596,8 +615,9 @@ app.on("second-instance", (ev, commandLine, workingDirectory) => {
} }
}); });
// Set the App User Model ID to match what the squirrel // This is required to make notification handlers work
// installer uses for the shortcut icon. // on Windows 8.1/10/11 (and is a noop on other platforms);
// This makes notifications work on windows 8.1 (and is // It must also match the ID found in 'electron-builder'
// a noop on other platforms). // in order to get the title and icon to show up correctly.
app.setAppUserModelId("com.squirrel.element-desktop.Element"); // Ref: https://stackoverflow.com/a/77314604/3525780
app.setAppUserModelId(buildConfig.appId);

View File

@@ -22,7 +22,9 @@
"about": "O", "about": "O",
"brand_help": "%(brand)s nápověda", "brand_help": "%(brand)s nápověda",
"help": "Nápověda", "help": "Nápověda",
"preferences": "Předvolby" "no": "Ne",
"preferences": "Předvolby",
"yes": "Ano"
}, },
"confirm_quit": "Opravdu chcete ukončit aplikaci?", "confirm_quit": "Opravdu chcete ukončit aplikaci?",
"edit_menu": { "edit_menu": {
@@ -49,6 +51,21 @@
"save_image_as_error_description": "Obrázek se nepodařilo uložit", "save_image_as_error_description": "Obrázek se nepodařilo uložit",
"save_image_as_error_title": "Chyba při ukládání obrázku" "save_image_as_error_title": "Chyba při ukládání obrázku"
}, },
"store": {
"error": {
"backend_changed": "Vymazat data a znovu načíst?",
"backend_changed_detail": "Nelze získat přístup k tajnému klíči ze systémové klíčenky, zdá se, že se změnil.",
"backend_changed_title": "Nepodařilo se načíst databázi",
"backend_no_encryption": "Váš systém má podporovanou klíčenku, ale šifrování není k dispozici.",
"backend_no_encryption_detail": "Electron zjistil, že pro vaši klíčenku %(backend)s není k dispozici šifrování. Ujistěte se, že máte nainstalovanou klíčenku. Pokud ji máte, restartujte počítač a zkuste to znovu. Volitelně můžete povolit %(brand)s použít slabší formu šifrování.",
"backend_no_encryption_title": "Bez podpory šifrování",
"unsupported_keyring": "Váš systém má nepodporovanou klíčenku, což znamená, že databázi nelze otevřít.",
"unsupported_keyring_detail": "Detekce klíčenky Electronu nenalezla podporovaný backend. Můžete se pokusit ručně nakonfigurovat backend spuštěním %(brand)s s argumentem příkazového řádku, jednorázovou operací. Viz %(link)s.",
"unsupported_keyring_title": "Systém není podporován",
"unsupported_keyring_use_basic_text": "Používat slabší šifrování",
"unsupported_keyring_use_plaintext": "Nepoužívat žádné šifrování"
}
},
"view_menu": { "view_menu": {
"actual_size": "Aktuální velikost", "actual_size": "Aktuální velikost",
"toggle_developer_tools": "Přepnout zobrazení nástrojů pro vývojáře", "toggle_developer_tools": "Přepnout zobrazení nástrojů pro vývojáře",

View File

@@ -22,7 +22,9 @@
"about": "Ynghylch", "about": "Ynghylch",
"brand_help": "Cymorth %(brand)s", "brand_help": "Cymorth %(brand)s",
"help": "Cymorth", "help": "Cymorth",
"preferences": "Dewisiadau" "no": "Na",
"preferences": "Dewisiadau",
"yes": "Iawn"
}, },
"confirm_quit": "Ydych chi'n siŵr eich bod am roi'r gorau iddi?", "confirm_quit": "Ydych chi'n siŵr eich bod am roi'r gorau iddi?",
"edit_menu": { "edit_menu": {
@@ -49,6 +51,19 @@
"save_image_as_error_description": "Methodd cadw'r ddelwedd", "save_image_as_error_description": "Methodd cadw'r ddelwedd",
"save_image_as_error_title": "Methodd cadw'r ddelwedd" "save_image_as_error_title": "Methodd cadw'r ddelwedd"
}, },
"store": {
"error": {
"backend_changed": "Clirio data ac ail-lwytho?",
"backend_changed_detail": "Methu cael mynediad at y gyfrinach o allweddi'r system, mae'n ymddangos ei fod wedi newid.",
"backend_changed_title": "Methwyd llwytho'r gronfa ddata",
"backend_no_encryption": "Mae gan eich system cylch allwedd sy'n cael ei gefnogi ond nid yw amgryptio ar gael.",
"backend_no_encryption_detail": "Mae Electron wedi canfod nad yw amgryptio ar gael ar eich cylch allweddi %(backend)s. Gwnewch yn siŵr bod y cylch allweddi wedi'i osod. Os oes y cylch allweddi wedi'i osod, ail gychwynnwch a cheisiwch eto. Yn ddewisol, gallwch ganiatáu i %(brand)s ddefnyddio ffurf wannach o amgryptio.",
"backend_no_encryption_title": "Dim cefnogaeth amgryptio",
"unsupported_keyring": "Mae gan eich system allweddell nad yw'n cael ei chefnogi sy'n golygu nad oes modd agor y gronfa ddata.",
"unsupported_keyring_detail": "Heb ganfod allweddell Electron gefn. Gallwch geisio ffurfweddu'r gefn â llaw trwy gychwyn %(brand)s gyda dadl llinell orchymyn, gweithrediad untro. Gweler %(link)s.",
"unsupported_keyring_title": "System heb ei chefnogi"
}
},
"view_menu": { "view_menu": {
"actual_size": "Maint Gwirioneddol", "actual_size": "Maint Gwirioneddol",
"toggle_developer_tools": "Toglo Offer Datblygwyr", "toggle_developer_tools": "Toglo Offer Datblygwyr",

View File

@@ -22,7 +22,9 @@
"about": "Über", "about": "Über",
"brand_help": "%(brand)s Hilfe", "brand_help": "%(brand)s Hilfe",
"help": "Hilfe", "help": "Hilfe",
"preferences": "Präferenzen" "no": "Nein",
"preferences": "Präferenzen",
"yes": "Ja"
}, },
"confirm_quit": "Wirklich beenden?", "confirm_quit": "Wirklich beenden?",
"edit_menu": { "edit_menu": {
@@ -49,6 +51,19 @@
"save_image_as_error_description": "Das Bild konnte nicht gespeichert werden", "save_image_as_error_description": "Das Bild konnte nicht gespeichert werden",
"save_image_as_error_title": "Bild kann nicht gespeichert werden" "save_image_as_error_title": "Bild kann nicht gespeichert werden"
}, },
"store": {
"error": {
"backend_changed": "Daten löschen und neu laden?",
"backend_changed_detail": "Zugriff auf Schlüssel im Systemschlüsselbund nicht möglich, er scheint sich geändert zu haben.",
"backend_changed_title": "Datenbank konnte nicht geladen werden",
"backend_no_encryption": "Ihr System verfügt über einen unterstützten Keyring, aber die Verschlüsselung ist nicht verfügbar.",
"backend_no_encryption_detail": "Electron hat festgestellt, dass Verschlüsselung in Ihrem Keyring %(backend)s nicht verfügbar ist. Bitte stellen Sie sicher, dass Sie den Keyringinstalliert haben. Wenn Sie den Keyring installiert haben, starten Sie ihn bitte neu und versuchen Sie es erneut. Optional können Sie die Verwendung einer schwächeren Form der Verschlüsselung zulassen %(brand)s.",
"backend_no_encryption_title": "Keine Verschlüsselungsunterstützung",
"unsupported_keyring": "Der Schlüsselbund ihres Systems wird nicht unterstützt, wodurch die Datenbank nicht geöffnet werden kann.",
"unsupported_keyring_detail": "Die Schlüsselbunderkennung von Electron hat kein unterstütztes Backend gefunden. Möglicherweise können sie dennoch den ihres Systemes verwenden. Infos unter %(link)s.",
"unsupported_keyring_title": "System nicht unterstützt"
}
},
"view_menu": { "view_menu": {
"actual_size": "Tatsächliche Größe", "actual_size": "Tatsächliche Größe",
"toggle_developer_tools": "Developer-Tools an/aus", "toggle_developer_tools": "Developer-Tools an/aus",

View File

@@ -22,7 +22,9 @@
"about": "About", "about": "About",
"brand_help": "%(brand)s Help", "brand_help": "%(brand)s Help",
"help": "Help", "help": "Help",
"preferences": "Preferences" "no": "No",
"preferences": "Preferences",
"yes": "Yes"
}, },
"confirm_quit": "Are you sure you want to quit?", "confirm_quit": "Are you sure you want to quit?",
"edit_menu": { "edit_menu": {
@@ -49,6 +51,21 @@
"save_image_as_error_description": "The image failed to save", "save_image_as_error_description": "The image failed to save",
"save_image_as_error_title": "Failed to save image" "save_image_as_error_title": "Failed to save image"
}, },
"store": {
"error": {
"backend_changed": "Clear data and reload?",
"backend_changed_detail": "Unable to access secret from system keyring, it appears to have changed.",
"backend_changed_title": "Failed to load database",
"backend_no_encryption": "Your system has a supported keyring but encryption is not available.",
"backend_no_encryption_detail": "Electron has detected that encryption is not available on your keyring %(backend)s. Please ensure that you have the keyring installed. If you do have the keyring installed, please reboot and try again. Optionally, you can allow %(brand)s to use a weaker form of encryption.",
"backend_no_encryption_title": "No encryption support",
"unsupported_keyring": "Your system has an unsupported keyring meaning the database cannot be opened.",
"unsupported_keyring_detail": "Electron's keyring detection did not find a supported backend. You can attempt to manually configure the backend by starting %(brand)s with a command-line argument, a one-time operation. See %(link)s.",
"unsupported_keyring_title": "System unsupported",
"unsupported_keyring_use_basic_text": "Use weaker encryption",
"unsupported_keyring_use_plaintext": "Use no encryption"
}
},
"view_menu": { "view_menu": {
"actual_size": "Actual Size", "actual_size": "Actual Size",
"toggle_developer_tools": "Toggle Developer Tools", "toggle_developer_tools": "Toggle Developer Tools",

View File

@@ -22,7 +22,9 @@
"about": "Rakenduse teave", "about": "Rakenduse teave",
"brand_help": "%(brand)s abiteave", "brand_help": "%(brand)s abiteave",
"help": "Abiteave", "help": "Abiteave",
"preferences": "Eelistused" "no": "Ei",
"preferences": "Eelistused",
"yes": "Jah"
}, },
"confirm_quit": "Kas sa kindlasti soovid rakendusest väljuda?", "confirm_quit": "Kas sa kindlasti soovid rakendusest väljuda?",
"edit_menu": { "edit_menu": {
@@ -49,6 +51,21 @@
"save_image_as_error_description": "Seda pilti ei õnnestunud salvestada", "save_image_as_error_description": "Seda pilti ei õnnestunud salvestada",
"save_image_as_error_title": "Pildi salvestamine ei õnnestunud" "save_image_as_error_title": "Pildi salvestamine ei õnnestunud"
}, },
"store": {
"error": {
"backend_changed": "Kas kustutame andmed ja laadime uuesti?",
"backend_changed_detail": "Süsteemsest võtmerõngast ei õnnestu laadida vajalikku saladust, tundub et ta on muutunud.",
"backend_changed_title": "Andmebaasi ei õnnestunud laadida",
"backend_no_encryption": "Sinu süsteem kasutab toetatud võtmerõngast, kuid krüptimist pole saadaval.",
"backend_no_encryption_detail": "Electron on tuvastanud, et krüptimine pole sinu %(backend)s võtmerõnga jaoks saadaval. Palun kontrolli, et võtmerõngas oleks korrektselt paigaldatud. Kui sul on võtmerõngas paigaldatud, siis palun taaskäivita ta ja proovi uuesti. Lisavõimalusena saad lubada, et %(brand)s kasutab nõrgemat krüptimislahendust.",
"backend_no_encryption_title": "Krüptimise tugi puudub",
"unsupported_keyring": "Sinu süsteemis on kasutusel mittetoetatud võtmerõnga versioon ning see tähendab, et andmebaasi ei saa avada.",
"unsupported_keyring_detail": "Electroni võtmerõnga tuvastamine ei leidnud toetatud taustateenust. Kui käivitad rakenduse %(brand)s käsurealt õigete argumentidega, siis võib taustateenuse käsitsi seadistamine õnnestuda ning seda tegevust peaksid vaid üks kord tegema. Lisateave: %(link)s.",
"unsupported_keyring_title": "Süsteem pole toetatud",
"unsupported_keyring_use_basic_text": "Kasuta nõrgemat krüptimist",
"unsupported_keyring_use_plaintext": "Ära üldse kasuta krüptimist"
}
},
"view_menu": { "view_menu": {
"actual_size": "Näita tavasuuruses", "actual_size": "Näita tavasuuruses",
"toggle_developer_tools": "Arendaja töövahendid sisse/välja", "toggle_developer_tools": "Arendaja töövahendid sisse/välja",

View File

@@ -22,7 +22,9 @@
"about": "Tietoa", "about": "Tietoa",
"brand_help": "%(brand)s-tuki", "brand_help": "%(brand)s-tuki",
"help": "Ohje", "help": "Ohje",
"preferences": "Valinnat" "no": "Ei",
"preferences": "Valinnat",
"yes": "Kyllä"
}, },
"confirm_quit": "Haluatko varmasti poistua?", "confirm_quit": "Haluatko varmasti poistua?",
"edit_menu": { "edit_menu": {
@@ -49,6 +51,12 @@
"save_image_as_error_description": "Kuvan tallennus epäonnistui", "save_image_as_error_description": "Kuvan tallennus epäonnistui",
"save_image_as_error_title": "Kuvan tallennus epäonnistui" "save_image_as_error_title": "Kuvan tallennus epäonnistui"
}, },
"store": {
"error": {
"backend_changed_title": "Tietokannan lataaminen epäonnistui",
"unsupported_keyring_title": "Järjestelmä ei ole tuettu"
}
},
"view_menu": { "view_menu": {
"actual_size": "Alkuperäinen koko", "actual_size": "Alkuperäinen koko",
"toggle_developer_tools": "Näytä tai piilota kehittäjätyökalut", "toggle_developer_tools": "Näytä tai piilota kehittäjätyökalut",

View File

@@ -22,7 +22,9 @@
"about": "À propos", "about": "À propos",
"brand_help": "Aide de %(brand)s", "brand_help": "Aide de %(brand)s",
"help": "Aide", "help": "Aide",
"preferences": "Préférences" "no": "Non",
"preferences": "Préférences",
"yes": "Oui"
}, },
"confirm_quit": "Êtes-vous sûr de vouloir quitter ?", "confirm_quit": "Êtes-vous sûr de vouloir quitter ?",
"edit_menu": { "edit_menu": {
@@ -49,6 +51,16 @@
"save_image_as_error_description": "Limage na pas pu être sauvegardée", "save_image_as_error_description": "Limage na pas pu être sauvegardée",
"save_image_as_error_title": "Échec de la sauvegarde de limage" "save_image_as_error_title": "Échec de la sauvegarde de limage"
}, },
"store": {
"error": {
"backend_changed": "Effacer les données et recharger ?",
"backend_changed_detail": "Impossible d'accéder aux secrets depuis le trousseau de clés du système, il semble avoir changé.",
"backend_changed_title": "Impossible de charger la base de données",
"unsupported_keyring": "Votre système possède un trousseau de clés non pris en charge, la base de données ne peut pas être ouverte.",
"unsupported_keyring_detail": "La détection du porte-clés par Electron n'a pas permis de trouver de backend compatible. Vous pouvez essayer de configurer manuellement le backend en utilisant %(brand)s avec un argument de ligne de commande. Cette opération doit être effectuer une seule fois. Voir%(link)s.",
"unsupported_keyring_title": "Système non pris en charge"
}
},
"view_menu": { "view_menu": {
"actual_size": "Taille réelle", "actual_size": "Taille réelle",
"toggle_developer_tools": "Basculer les outils de développement", "toggle_developer_tools": "Basculer les outils de développement",

View File

@@ -22,7 +22,9 @@
"about": "Névjegy", "about": "Névjegy",
"brand_help": "%(brand)s Súgó", "brand_help": "%(brand)s Súgó",
"help": "Súgó", "help": "Súgó",
"preferences": "Beállítások" "no": "Nem",
"preferences": "Beállítások",
"yes": "Igen"
}, },
"confirm_quit": "Biztos, hogy kilép?", "confirm_quit": "Biztos, hogy kilép?",
"edit_menu": { "edit_menu": {
@@ -49,6 +51,16 @@
"save_image_as_error_description": "A kép mentése sikertelen", "save_image_as_error_description": "A kép mentése sikertelen",
"save_image_as_error_title": "Kép mentése sikertelen" "save_image_as_error_title": "Kép mentése sikertelen"
}, },
"store": {
"error": {
"backend_changed": "Adatok törlése és újratöltés?",
"backend_changed_detail": "Nem sikerült hozzáférni a rendszerkulcstartó titkos kódjához, úgy tűnik, megváltozott.",
"backend_changed_title": "Nem sikerült betölteni az adatbázist",
"unsupported_keyring": "A rendszer nem támogatott kulcstartóval rendelkezik, ami azt jelenti, hogy az adatbázis nem nyitható meg.",
"unsupported_keyring_detail": "Az Electron kulcstartóészlelése nem talált támogatott háttérrendszert. Megpróbálhatja kézileg beállítani a háttérrendszert az %(brand)s egyszeri, parancssori argumentummal való indításával. Lásd: %(link)s.",
"unsupported_keyring_title": "A rendszer nem támogatott"
}
},
"view_menu": { "view_menu": {
"actual_size": "Jelenlegi méret", "actual_size": "Jelenlegi méret",
"toggle_developer_tools": "Fejlesztői eszközök", "toggle_developer_tools": "Fejlesztői eszközök",

View File

@@ -22,7 +22,9 @@
"about": "Tentang", "about": "Tentang",
"brand_help": "Bantuan %(brand)s", "brand_help": "Bantuan %(brand)s",
"help": "Bantuan", "help": "Bantuan",
"preferences": "Preferensi" "no": "Tidak",
"preferences": "Preferensi",
"yes": "Ya"
}, },
"confirm_quit": "Apakah Anda yakin ingin keluar?", "confirm_quit": "Apakah Anda yakin ingin keluar?",
"edit_menu": { "edit_menu": {
@@ -49,6 +51,21 @@
"save_image_as_error_description": "Gambar gagal disimpan", "save_image_as_error_description": "Gambar gagal disimpan",
"save_image_as_error_title": "Gagal menyimpan gambar" "save_image_as_error_title": "Gagal menyimpan gambar"
}, },
"store": {
"error": {
"backend_changed": "Hapus data dan muat ulang?",
"backend_changed_detail": "Tidak dapat mengakses rahasia dari keyring sistem, tampaknya telah berubah.",
"backend_changed_title": "Gagal memuat basis data",
"backend_no_encryption": "Sistem Anda memiliki keyring yang didukung tetapi enkripsi tidak tersedia.",
"backend_no_encryption_detail": "Electron telah mendeteksi bahwa enkripsi tidak tersedia pada keyring %(backend)s Anda. Harap pastikan bahwa Anda telah memasang keyring. Jika Anda telah memasang keyring, silakan mulai ulang dan coba lagi. Secara opsional, Anda dapat mengizinkan %(brand)s untuk menggunakan bentuk enkripsi yang lebih lemah.",
"backend_no_encryption_title": "Tidak ada dukungan enkripsi",
"unsupported_keyring": "Sistem Anda memiliki keyring yang tidak didukung yang berarti basis data tidak dapat dibuka.",
"unsupported_keyring_detail": "Deteksi keyring Electron tidak menemukan backend yang didukung. Anda dapat mencoba mengonfigurasi backend secara manual dengan memulai %(brand)s dengan argumen baris perintah, operasi satu kali. Lihat %(link)s.",
"unsupported_keyring_title": "Sistem tidak didukung",
"unsupported_keyring_use_basic_text": "Gunakan enkripsi yang lebih lemah",
"unsupported_keyring_use_plaintext": "Jangan gunakan enkripsi"
}
},
"view_menu": { "view_menu": {
"actual_size": "Ukuran Sebenarnya", "actual_size": "Ukuran Sebenarnya",
"toggle_developer_tools": "Beralih Alat Pengembang", "toggle_developer_tools": "Beralih Alat Pengembang",

View File

@@ -22,7 +22,9 @@
"about": "Om", "about": "Om",
"brand_help": "%(brand)s Hjelp", "brand_help": "%(brand)s Hjelp",
"help": "Hjelp", "help": "Hjelp",
"preferences": "Brukervalg" "no": "Nei",
"preferences": "Brukervalg",
"yes": "Ja"
}, },
"confirm_quit": "Er du sikker på at du vil slutte?", "confirm_quit": "Er du sikker på at du vil slutte?",
"edit_menu": { "edit_menu": {
@@ -49,6 +51,21 @@
"save_image_as_error_description": "Bildet kunne ikke lagres", "save_image_as_error_description": "Bildet kunne ikke lagres",
"save_image_as_error_title": "Kunne ikke lagre bildet" "save_image_as_error_title": "Kunne ikke lagre bildet"
}, },
"store": {
"error": {
"backend_changed": "Tøm data og last inn på nytt?",
"backend_changed_detail": "Kan ikke få tilgang til hemmeligheten fra systemnøkkelringen, den ser ut til å ha blitt endret.",
"backend_changed_title": "Kunne ikke laste inn databasen",
"backend_no_encryption": "Systemet ditt har en støttet nøkkelring, men kryptering er ikke tilgjengelig.",
"backend_no_encryption_detail": "Electron har oppdaget at kryptering ikke er tilgjengelig på nøkkelringen %(backend)s din. Forsikre deg om at du har nøkkelringen installert. Hvis du har nøkkelringen installert, vennligst start på nytt og prøv igjen. Eventuelt kan du tillate %(brand)s å bruke en svakere form for kryptering.",
"backend_no_encryption_title": "Ingen støtte for kryptering",
"unsupported_keyring": "Systemet ditt har en nøkkelring som ikke støttes, noe som betyr at databasen ikke kan åpnes.",
"unsupported_keyring_detail": "Electrons nøkkelringdeteksjon fant ikke en støttet backend. Du kan prøve å konfigurere backend manuelt ved å starte %(brand)s med et kommandolinjeargument, en engangsoperasjon. Se%(link)s.",
"unsupported_keyring_title": "Systemet støttes ikke",
"unsupported_keyring_use_basic_text": "Bruk svakere kryptering",
"unsupported_keyring_use_plaintext": "Ikke bruk kryptering"
}
},
"view_menu": { "view_menu": {
"actual_size": "Faktisk størrelse", "actual_size": "Faktisk størrelse",
"toggle_developer_tools": "Veksle Utvikleralternativer", "toggle_developer_tools": "Veksle Utvikleralternativer",

View File

@@ -22,7 +22,9 @@
"about": "Informacje", "about": "Informacje",
"brand_help": "Pomoc %(brand)s", "brand_help": "Pomoc %(brand)s",
"help": "Pomoc", "help": "Pomoc",
"preferences": "Preferencje" "no": "Nie",
"preferences": "Preferencje",
"yes": "Tak"
}, },
"confirm_quit": "Czy na pewno chcesz zamknąć?", "confirm_quit": "Czy na pewno chcesz zamknąć?",
"edit_menu": { "edit_menu": {
@@ -49,6 +51,16 @@
"save_image_as_error_description": "Obraz nie został zapisany", "save_image_as_error_description": "Obraz nie został zapisany",
"save_image_as_error_title": "Nie udało się zapisać obrazu" "save_image_as_error_title": "Nie udało się zapisać obrazu"
}, },
"store": {
"error": {
"backend_changed": "Wyczyścić dane i przeładować?",
"backend_changed_detail": "Nie można uzyskać dostępu do sekretnego magazynu, wygląda na to, że uległ zmianie.",
"backend_changed_title": "Nie udało się załadować bazy danych",
"unsupported_keyring": "System zawiera niewspierany keyring, nie można otworzyć bazy danych.",
"unsupported_keyring_detail": "Wykrywanie keyringu Electron nie znalazł wspieranego backendu. Możesz spróbować ręcznie ustawić backed, uruchamiając %(brand)s za pomocą wiesza poleceń. Zobacz %(link)s.",
"unsupported_keyring_title": "System niewspierany"
}
},
"view_menu": { "view_menu": {
"actual_size": "Rozmiar rzeczywisty", "actual_size": "Rozmiar rzeczywisty",
"toggle_developer_tools": "Przełącz narzędzia deweloperskie", "toggle_developer_tools": "Przełącz narzędzia deweloperskie",

View File

@@ -22,7 +22,9 @@
"about": "Sobre", "about": "Sobre",
"brand_help": "%(brand)s Ajuda", "brand_help": "%(brand)s Ajuda",
"help": "Ajuda", "help": "Ajuda",
"preferences": "Preferências" "no": "Não",
"preferences": "Preferências",
"yes": "Sim"
}, },
"confirm_quit": "Você tem certeza que você quer sair?", "confirm_quit": "Você tem certeza que você quer sair?",
"edit_menu": { "edit_menu": {
@@ -49,6 +51,21 @@
"save_image_as_error_description": "A imagem falhou para salvar", "save_image_as_error_description": "A imagem falhou para salvar",
"save_image_as_error_title": "Falha para salvar imagem" "save_image_as_error_title": "Falha para salvar imagem"
}, },
"store": {
"error": {
"backend_changed": "Limpar dados e recarregar?",
"backend_changed_detail": "Não foi possível acessar o segredo no cofre do sistema, parece que ele foi alterado.",
"backend_changed_title": "Falha ao carregar o banco de dados",
"backend_no_encryption": "Seu sistema tem um cofre compatível, mas a criptografia não está disponível.",
"backend_no_encryption_detail": "O Electron detetou que a encriptação não está disponível no seu cofre %(backend)s. Certifique-se de que tem o cofre instalado. Se tiver o cofre instalado, reinicie e tente novamente. Opcionalmente, você pode permitir que %(brand)s use uma forma mais fraca de criptografia.",
"backend_no_encryption_title": "Sem suporte para criptografia",
"unsupported_keyring": "Seu sistema possui um cofre não compatível, o que impede a abertura do banco de dados.",
"unsupported_keyring_detail": "A detecção de cofre do Electron não encontrou um backend compatível. Você pode tentar configurar manualmente o backend iniciando %(brand)s com um argumento de linha de comando, uma operação única. Consulte %(link)s.",
"unsupported_keyring_title": "Sistema não suportado",
"unsupported_keyring_use_basic_text": "Use criptografia mais fraca",
"unsupported_keyring_use_plaintext": "Não usar criptografia"
}
},
"view_menu": { "view_menu": {
"actual_size": "Tamanho de Verdade", "actual_size": "Tamanho de Verdade",
"toggle_developer_tools": "Ativar/Desativar Ferramentas de Desenvolvimento", "toggle_developer_tools": "Ativar/Desativar Ferramentas de Desenvolvimento",

View File

@@ -22,7 +22,9 @@
"about": "Informácie", "about": "Informácie",
"brand_help": "%(brand)s Pomoc", "brand_help": "%(brand)s Pomoc",
"help": "Pomocník", "help": "Pomocník",
"preferences": "Predvoľby" "no": "Nie",
"preferences": "Predvoľby",
"yes": "Áno"
}, },
"confirm_quit": "Naozaj chcete zavrieť aplikáciu?", "confirm_quit": "Naozaj chcete zavrieť aplikáciu?",
"edit_menu": { "edit_menu": {
@@ -49,6 +51,21 @@
"save_image_as_error_description": "Obrázok sa nepodarilo uložiť", "save_image_as_error_description": "Obrázok sa nepodarilo uložiť",
"save_image_as_error_title": "Chyba pri ukladaní obrázka" "save_image_as_error_title": "Chyba pri ukladaní obrázka"
}, },
"store": {
"error": {
"backend_changed": "Vymazať údaje a znova načítať?",
"backend_changed_detail": "Nepodarilo sa získať prístup k tajnému kľúču zo systémového zväzku kľúčov, zdá sa, že sa zmenil.",
"backend_changed_title": "Nepodarilo sa načítať databázu",
"backend_no_encryption": "Váš systém má podporovaný zväzok kľúčov, ale šifrovanie nie je k dispozícii.",
"backend_no_encryption_detail": "Electron zistil, že šifrovanie nie je k dispozícii na vašom zväzku kľúčov %(backend)s. Uistite sa, že máte nainštalovaný zväzok kľúčov. Ak máte zväzok kľúčov nainštalovaný, reštartujte počítač a skúste to znova. Voliteľne môžete povoliť aplikácii %(brand)s používať slabšiu formu šifrovania.",
"backend_no_encryption_title": "Žiadna podpora šifrovania",
"unsupported_keyring": "Váš systém má nepodporovaný zväzok kľúčov, čo znamená, že databázu nemožno otvoriť.",
"unsupported_keyring_detail": "Detekcia zväzku kľúčov aplikácie Electron nenašla podporovaný backend. Môžete sa pokúsiť manuálne nastaviť backend spustením aplikácie %(brand)s s argumentom príkazového riadka, je to jednorazová operácia. Pozrite si %(link)s .",
"unsupported_keyring_title": "Systém nie je podporovaný",
"unsupported_keyring_use_basic_text": "Použiť slabšie šifrovanie",
"unsupported_keyring_use_plaintext": "Nepoužiť žiadne šifrovanie"
}
},
"view_menu": { "view_menu": {
"actual_size": "Aktuálna veľkosť", "actual_size": "Aktuálna veľkosť",
"toggle_developer_tools": "Nástroje pre vývojárov", "toggle_developer_tools": "Nástroje pre vývojárov",

View File

@@ -22,7 +22,9 @@
"about": "Про застосунок", "about": "Про застосунок",
"brand_help": "Довідка %(brand)s", "brand_help": "Довідка %(brand)s",
"help": "Довідка", "help": "Довідка",
"preferences": "Параметри" "no": "Ні",
"preferences": "Параметри",
"yes": "Так"
}, },
"confirm_quit": "Ви впевнені, що хочете вийти?", "confirm_quit": "Ви впевнені, що хочете вийти?",
"edit_menu": { "edit_menu": {
@@ -49,6 +51,16 @@
"save_image_as_error_description": "Не вдалося зберегти зображення", "save_image_as_error_description": "Не вдалося зберегти зображення",
"save_image_as_error_title": "Не вдалося зберегти зображення" "save_image_as_error_title": "Не вдалося зберегти зображення"
}, },
"store": {
"error": {
"backend_changed": "Очистити дані та перезавантажити?",
"backend_changed_detail": "Не вдається отримати доступ до таємного ключа з системного набору ключів, видається, він змінився.",
"backend_changed_title": "Не вдалося завантажити базу даних",
"unsupported_keyring": "Ваша система має непідтримуваний набір ключів. Це означає, що базу даних неможливо відкрити.",
"unsupported_keyring_detail": "Electron не виявив підтримуваного бекенда для роботи зі сховищем паролів. Ви можете вручну налаштувати його, запустивши %(brand)s з відповідним аргументом у командному рядку. Цю дію потрібно виконати лише один раз. Докладніше %(link)s.",
"unsupported_keyring_title": "Система не підтримується"
}
},
"view_menu": { "view_menu": {
"actual_size": "Фактичний розмір", "actual_size": "Фактичний розмір",
"toggle_developer_tools": "Перемкнути інструменти розробника", "toggle_developer_tools": "Перемкнути інструменти розробника",

View File

@@ -6,14 +6,12 @@ Please see LICENSE files in the repository root for full details.
*/ */
import { app, autoUpdater, desktopCapturer, ipcMain, powerSaveBlocker, TouchBar, nativeImage } from "electron"; import { app, autoUpdater, desktopCapturer, ipcMain, powerSaveBlocker, TouchBar, nativeImage } from "electron";
import { relaunchApp } from "@standardnotes/electron-clear-data";
import keytar from "keytar-forked";
import IpcMainEvent = Electron.IpcMainEvent; import IpcMainEvent = Electron.IpcMainEvent;
import { recordSSOSession } from "./protocol.js";
import { randomArray } from "./utils.js"; import { randomArray } from "./utils.js";
import { Settings } from "./settings.js"; import { Settings } from "./settings.js";
import { getDisplayMediaCallback, setDisplayMediaCallback } from "./displayMediaCallback.js"; import { getDisplayMediaCallback, setDisplayMediaCallback } from "./displayMediaCallback.js";
import Store, { clearDataAndRelaunch } from "./store.js";
ipcMain.on("setBadgeCount", function (_ev: IpcMainEvent, count: number): void { ipcMain.on("setBadgeCount", function (_ev: IpcMainEvent, count: number): void {
if (process.platform !== "win32") { if (process.platform !== "win32") {
@@ -61,7 +59,8 @@ ipcMain.on("app_onAction", function (_ev: IpcMainEvent, payload) {
}); });
ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
if (!global.mainWindow) return; const store = Store.instance;
if (!global.mainWindow || !store) return;
const args = payload.args || []; const args = payload.args || [];
let ret: any; let ret: any;
@@ -96,9 +95,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
global.mainWindow.focus(); global.mainWindow.focus();
} }
break; break;
case "getConfig":
ret = global.vectorConfig;
break;
case "navigateBack": case "navigateBack":
if (global.mainWindow.webContents.canGoBack()) { if (global.mainWindow.webContents.canGoBack()) {
global.mainWindow.webContents.goBack(); global.mainWindow.webContents.goBack();
@@ -113,11 +110,11 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
if (typeof args[0] !== "boolean") return; if (typeof args[0] !== "boolean") return;
global.mainWindow.webContents.session.setSpellCheckerEnabled(args[0]); global.mainWindow.webContents.session.setSpellCheckerEnabled(args[0]);
global.store.set("spellCheckerEnabled", args[0]); store.set("spellCheckerEnabled", args[0]);
break; break;
case "getSpellCheckEnabled": case "getSpellCheckEnabled":
ret = global.store.get("spellCheckerEnabled", true); ret = store.get("spellCheckerEnabled");
break; break;
case "setSpellCheckLanguages": case "setSpellCheckLanguages":
@@ -135,18 +132,9 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
ret = global.mainWindow.webContents.session.availableSpellCheckerLanguages; ret = global.mainWindow.webContents.session.availableSpellCheckerLanguages;
break; break;
case "startSSOFlow":
recordSSOSession(args[0]);
break;
case "getPickleKey": case "getPickleKey":
try { try {
ret = await keytar.getPassword("element.io", `${args[0]}|${args[1]}`); ret = await store.getSecret(`${args[0]}|${args[1]}`);
// migrate from riot.im (remove once we think there will no longer be
// logins from the time of riot.im)
if (ret === null) {
ret = await keytar.getPassword("riot.im", `${args[0]}|${args[1]}`);
}
} catch { } catch {
// if an error is thrown (e.g. keytar can't connect to the keychain), // if an error is thrown (e.g. keytar can't connect to the keychain),
// then return null, which means the default pickle key will be used // then return null, which means the default pickle key will be used
@@ -157,9 +145,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
case "createPickleKey": case "createPickleKey":
try { try {
const pickleKey = await randomArray(32); const pickleKey = await randomArray(32);
// We purposefully throw if keytar is not available so the caller can handle it await store.setSecret(`${args[0]}|${args[1]}`, pickleKey);
// rather than sending them a pickle key we did not store on their behalf.
await keytar!.setPassword("element.io", `${args[0]}|${args[1]}`, pickleKey);
ret = pickleKey; ret = pickleKey;
} catch (e) { } catch (e) {
console.error("Failed to create pickle key", e); console.error("Failed to create pickle key", e);
@@ -169,11 +155,10 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
case "destroyPickleKey": case "destroyPickleKey":
try { try {
await keytar.deletePassword("element.io", `${args[0]}|${args[1]}`); await store.deleteSecret(`${args[0]}|${args[1]}`);
// migrate from riot.im (remove once we think there will no longer be } catch (e) {
// logins from the time of riot.im) console.error("Failed to destroy pickle key", e);
await keytar.deletePassword("riot.im", `${args[0]}|${args[1]}`); }
} catch {}
break; break;
case "getDesktopCapturerSources": case "getDesktopCapturerSources":
ret = (await desktopCapturer.getSources(args[0])).map((source) => ({ ret = (await desktopCapturer.getSources(args[0])).map((source) => ({
@@ -189,10 +174,7 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
break; break;
case "clearStorage": case "clearStorage":
global.store.clear(); await clearDataAndRelaunch();
global.mainWindow.webContents.session.flushStorageData();
await global.mainWindow.webContents.session.clearStorageData();
relaunchApp();
return; // the app is about to stop, we don't need to reply to the IPC return; // the app is about to stop, we don't need to reply to the IPC
case "breadcrumbs": { case "breadcrumbs": {
@@ -259,3 +241,5 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
reply: ret, reply: ret,
}); });
}); });
ipcMain.handle("getConfig", () => global.vectorConfig);

View File

@@ -10,9 +10,9 @@ import { type TranslationKey as TKey } from "matrix-web-i18n";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import type Store from "electron-store";
import type EN from "./i18n/strings/en_EN.json"; import type EN from "./i18n/strings/en_EN.json";
import { loadJsonFile } from "./utils.js"; import { loadJsonFile } from "./utils.js";
import type Store from "./store.js";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -59,26 +59,24 @@ export function _t(text: TranslationKey, variables: Variables = {}): string {
type Component = () => void; type Component = () => void;
type TypedStore = Store<{ locale?: string | string[] }>;
export class AppLocalization { export class AppLocalization {
private static readonly STORE_KEY = "locale"; private static readonly STORE_KEY = "locale";
private readonly store: TypedStore;
private readonly localizedComponents?: Set<Component>; private readonly localizedComponents?: Set<Component>;
private readonly store: Store;
public constructor({ store, components = [] }: { store: TypedStore; components: Component[] }) { public constructor({ components = [], store }: { components: Component[]; store: Store }) {
counterpart.registerTranslations(FALLBACK_LOCALE, this.fetchTranslationJson("en_EN")); counterpart.registerTranslations(FALLBACK_LOCALE, this.fetchTranslationJson("en_EN"));
counterpart.setFallbackLocale(FALLBACK_LOCALE); counterpart.setFallbackLocale(FALLBACK_LOCALE);
counterpart.setSeparator("|"); counterpart.setSeparator("|");
this.store = store;
if (Array.isArray(components)) { if (Array.isArray(components)) {
this.localizedComponents = new Set(components); this.localizedComponents = new Set(components);
} }
this.store = store; if (store.has(AppLocalization.STORE_KEY)) {
if (this.store.has(AppLocalization.STORE_KEY)) { const locales = store.get(AppLocalization.STORE_KEY);
const locales = this.store.get(AppLocalization.STORE_KEY);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.setAppLocale(locales!); this.setAppLocale(locales!);
} }

View File

@@ -49,4 +49,16 @@ contextBridge.exposeInMainWorld("electron", {
} }
ipcRenderer.send(channel, ...args); ipcRenderer.send(channel, ...args);
}, },
async initialise(): Promise<{
protocol: string;
sessionId: string;
config: IConfigOptions;
}> {
const [{ protocol, sessionId }, config] = await Promise.all([
ipcRenderer.invoke("getProtocol"),
ipcRenderer.invoke("getConfig"),
]);
return { protocol, sessionId, config };
},
}); });

View File

@@ -6,119 +6,136 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import { app } from "electron"; import { app, ipcMain } from "electron";
import { URL } from "node:url"; import { URL } from "node:url";
import path from "node:path"; import path from "node:path";
import fs from "node:fs"; import fs from "node:fs";
import { randomUUID } from "node:crypto";
const LEGACY_PROTOCOL = "element"; const LEGACY_PROTOCOL = "element";
const PROTOCOL = "io.element.desktop";
const SEARCH_PARAM = "element-desktop-ssoid"; const SEARCH_PARAM = "element-desktop-ssoid";
const STORE_FILE_NAME = "sso-sessions.json"; const STORE_FILE_NAME = "sso-sessions.json";
// we getPath userData before electron-main changes it, so this is the default value // we getPath userData before electron-main changes it, so this is the default value
const storePath = path.join(app.getPath("userData"), STORE_FILE_NAME); const storePath = path.join(app.getPath("userData"), STORE_FILE_NAME);
function processUrl(url: string): void { export default class ProtocolHandler {
if (!global.mainWindow) return; private readonly store: Record<string, string> = {};
private readonly sessionId: string;
const parsed = new URL(url); public constructor(private readonly protocol: string) {
// sanity check: we only register for the one protocol, so we shouldn't // get all args except `hidden` as it'd mean the app would not get focused
// be getting anything else unless the user is forcing a URL to open // XXX: passing args to protocol handlers only works on Windows, so unpackaged deep-linking
// with the Element app. // --profile/--profile-dir are passed via the SEARCH_PARAM var in the callback url
if (parsed.protocol !== `${PROTOCOL}:` && parsed.protocol !== `${LEGACY_PROTOCOL}:`) { const args = process.argv.slice(1).filter((arg) => arg !== "--hidden" && arg !== "-hidden");
console.log("Ignoring unexpected protocol: ", parsed.protocol); if (app.isPackaged) {
return; app.setAsDefaultProtocolClient(this.protocol, process.execPath, args);
app.setAsDefaultProtocolClient(LEGACY_PROTOCOL, process.execPath, args);
} else if (process.platform === "win32") {
// on Mac/Linux this would just cause the electron binary to open
// special handler for running without being packaged, e.g `electron .` by passing our app path to electron
app.setAsDefaultProtocolClient(this.protocol, process.execPath, [app.getAppPath(), ...args]);
app.setAsDefaultProtocolClient(LEGACY_PROTOCOL, process.execPath, [app.getAppPath(), ...args]);
}
if (process.platform === "darwin") {
// Protocol handler for macos
app.on("open-url", (ev, url) => {
ev.preventDefault();
this.processUrl(url);
});
} else {
// Protocol handler for win32/Linux
app.on("second-instance", (ev, commandLine) => {
const url = commandLine[commandLine.length - 1];
if (!url.startsWith(`${this.protocol}:/`) && !url.startsWith(`${LEGACY_PROTOCOL}://`)) return;
this.processUrl(url);
});
}
this.store = this.readStore();
this.sessionId = randomUUID();
ipcMain.handle("getProtocol", this.onGetProtocol);
} }
const urlToLoad = new URL("vector://vector/webapp/"); private readonly onGetProtocol = (): { protocol: string; sessionId: string } => {
// ignore anything other than the search (used for SSO login redirect) return {
// and the hash (for general element deep links) protocol: this.protocol,
// There's no reason to allow anything else, particularly other paths, sessionId: this.sessionId,
// since this would allow things like the internal jitsi wrapper to };
// be loaded, which would get the app stuck on that page and generally };
// be a bit strange and confusing.
urlToLoad.search = parsed.search;
urlToLoad.hash = parsed.hash;
console.log("Opening URL: ", urlToLoad.href); private processUrl(url: string): void {
void global.mainWindow.loadURL(urlToLoad.href); if (!global.mainWindow) return;
}
function readStore(): Record<string, string> { const parsed = new URL(url);
try { // sanity check: we only register for the one protocol, so we shouldn't
const s = fs.readFileSync(storePath, { encoding: "utf8" }); // be getting anything else unless the user is forcing a URL to open
const o = JSON.parse(s); // with the Element app.
return typeof o === "object" ? o : {}; if (parsed.protocol !== `${this.protocol}:` && parsed.protocol !== `${LEGACY_PROTOCOL}:`) {
} catch { console.log("Ignoring unexpected protocol: ", parsed.protocol);
return {}; return;
}
const urlToLoad = new URL("vector://vector/webapp/");
// ignore anything other than the search (used for SSO login redirect)
// and the hash (for general element deep links)
// There's no reason to allow anything else, particularly other paths,
// since this would allow things like the internal jitsi wrapper to
// be loaded, which would get the app stuck on that page and generally
// be a bit strange and confusing.
urlToLoad.search = parsed.search;
urlToLoad.hash = parsed.hash;
console.log("Opening URL: ", urlToLoad.href);
void global.mainWindow.loadURL(urlToLoad.href);
} }
}
function writeStore(data: Record<string, string>): void { private readStore(): Record<string, string> {
fs.writeFileSync(storePath, JSON.stringify(data)); try {
} const s = fs.readFileSync(storePath, { encoding: "utf8" });
const o = JSON.parse(s);
export function recordSSOSession(sessionID: string): void { return typeof o === "object" ? o : {};
const userDataPath = app.getPath("userData"); } catch {
const store = readStore(); return {};
for (const key in store) {
// ensure each instance only has one (the latest) session ID to prevent the file growing unbounded
if (store[key] === userDataPath) {
delete store[key];
break;
} }
} }
store[sessionID] = userDataPath;
writeStore(store);
}
export function getProfileFromDeeplink(args: string[]): string | undefined { private writeStore(): void {
// check if we are passed a profile in the SSO callback url fs.writeFileSync(storePath, JSON.stringify(this.store));
const deeplinkUrl = args.find((arg) => arg.startsWith(`${PROTOCOL}://`) || arg.startsWith(`${LEGACY_PROTOCOL}://`)); }
if (deeplinkUrl?.includes(SEARCH_PARAM)) {
const parsedUrl = new URL(deeplinkUrl); public initialise(userDataPath: string): void {
if (parsedUrl.protocol === `${PROTOCOL}:` || parsedUrl.protocol === `${LEGACY_PROTOCOL}:`) { for (const key in this.store) {
const store = readStore(); // ensure each instance only has one (the latest) session ID to prevent the file growing unbounded
let ssoID = parsedUrl.searchParams.get(SEARCH_PARAM); if (this.store[key] === userDataPath) {
if (!ssoID) { delete this.store[key];
// In OIDC, we must shuttle the value in the `state` param rather than `element-desktop-ssoid` break;
// We encode it as a suffix like `:element-desktop-ssoid:XXYYZZ` }
ssoID = parsedUrl.searchParams.get("state")!.split(`:${SEARCH_PARAM}:`)[1]; }
this.store[this.sessionId] = userDataPath;
this.writeStore();
}
public getProfileFromDeeplink(args: string[]): string | undefined {
// check if we are passed a profile in the SSO callback url
const deeplinkUrl = args.find(
(arg) => arg.startsWith(`${this.protocol}://`) || arg.startsWith(`${LEGACY_PROTOCOL}://`),
);
if (deeplinkUrl?.includes(SEARCH_PARAM)) {
const parsedUrl = new URL(deeplinkUrl);
if (parsedUrl.protocol === `${this.protocol}:` || parsedUrl.protocol === `${LEGACY_PROTOCOL}:`) {
const store = this.readStore();
let sessionId = parsedUrl.searchParams.get(SEARCH_PARAM);
if (!sessionId) {
// In OIDC, we must shuttle the value in the `state` param rather than `element-desktop-ssoid`
// We encode it as a suffix like `:element-desktop-ssoid:XXYYZZ`
sessionId = parsedUrl.searchParams.get("state")!.split(`:${SEARCH_PARAM}:`)[1];
}
console.log("Forwarding to profile: ", store[sessionId]);
return store[sessionId];
} }
console.log("Forwarding to profile: ", store[ssoID]);
return store[ssoID];
} }
} }
} }
export function protocolInit(): void {
// get all args except `hidden` as it'd mean the app would not get focused
// XXX: passing args to protocol handlers only works on Windows, so unpackaged deep-linking
// --profile/--profile-dir are passed via the SEARCH_PARAM var in the callback url
const args = process.argv.slice(1).filter((arg) => arg !== "--hidden" && arg !== "-hidden");
if (app.isPackaged) {
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, args);
app.setAsDefaultProtocolClient(LEGACY_PROTOCOL, process.execPath, args);
} else if (process.platform === "win32") {
// on Mac/Linux this would just cause the electron binary to open
// special handler for running without being packaged, e.g `electron .` by passing our app path to electron
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [app.getAppPath(), ...args]);
app.setAsDefaultProtocolClient(LEGACY_PROTOCOL, process.execPath, [app.getAppPath(), ...args]);
}
if (process.platform === "darwin") {
// Protocol handler for macos
app.on("open-url", function (ev, url) {
ev.preventDefault();
processUrl(url);
});
} else {
// Protocol handler for win32/Linux
app.on("second-instance", (ev, commandLine) => {
const url = commandLine[commandLine.length - 1];
if (!url.startsWith(`${PROTOCOL}:/`) && !url.startsWith(`${LEGACY_PROTOCOL}://`)) return;
processUrl(url);
});
}
}

View File

@@ -8,7 +8,6 @@ Please see LICENSE files in the repository root for full details.
import { app, ipcMain } from "electron"; import { app, ipcMain } from "electron";
import { promises as afs } from "node:fs"; import { promises as afs } from "node:fs";
import path from "node:path"; import path from "node:path";
import keytar from "keytar-forked";
import type { import type {
Seshat as SeshatType, Seshat as SeshatType,
@@ -17,6 +16,7 @@ import type {
} from "matrix-seshat"; // Hak dependency type } from "matrix-seshat"; // Hak dependency type
import IpcMainEvent = Electron.IpcMainEvent; import IpcMainEvent = Electron.IpcMainEvent;
import { randomArray } from "./utils.js"; import { randomArray } from "./utils.js";
import Store from "./store.js";
let seshatSupported = false; let seshatSupported = false;
let Seshat: typeof SeshatType; let Seshat: typeof SeshatType;
@@ -40,21 +40,24 @@ try {
let eventIndex: SeshatType | null = null; let eventIndex: SeshatType | null = null;
const seshatDefaultPassphrase = "DEFAULT_PASSPHRASE"; const seshatDefaultPassphrase = "DEFAULT_PASSPHRASE";
async function getOrCreatePassphrase(key: string): Promise<string> { async function getOrCreatePassphrase(store: Store, key: string): Promise<string> {
if (keytar) { try {
try { const storedPassphrase = await store.getSecret(key);
const storedPassphrase = await keytar.getPassword("element.io", key); if (storedPassphrase !== null) {
if (storedPassphrase !== null) { return storedPassphrase;
return storedPassphrase;
} else {
const newPassphrase = await randomArray(32);
await keytar.setPassword("element.io", key, newPassphrase);
return newPassphrase;
}
} catch (e) {
console.log("Error getting the event index passphrase out of the secret store", e);
} }
} catch (e) {
console.error("Error getting the event index passphrase out of the secret store", e);
} }
try {
const newPassphrase = await randomArray(32);
await store.setSecret(key, newPassphrase);
return newPassphrase;
} catch (e) {
console.error("Error creating new event index passphrase, using default", e);
}
return seshatDefaultPassphrase; return seshatDefaultPassphrase;
} }
@@ -74,7 +77,8 @@ const deleteContents = async (p: string): Promise<void> => {
}; };
ipcMain.on("seshat", async function (_ev: IpcMainEvent, payload): Promise<void> { ipcMain.on("seshat", async function (_ev: IpcMainEvent, payload): Promise<void> {
if (!global.mainWindow) return; const store = Store.instance;
if (!global.mainWindow || !store) return;
// We do this here to ensure we get the path after --profile has been resolved // We do this here to ensure we get the path after --profile has been resolved
const eventStorePath = path.join(app.getPath("userData"), "EventStore"); const eventStorePath = path.join(app.getPath("userData"), "EventStore");
@@ -101,7 +105,7 @@ ipcMain.on("seshat", async function (_ev: IpcMainEvent, payload): Promise<void>
const deviceId = args[1]; const deviceId = args[1];
const passphraseKey = `seshat|${userId}|${deviceId}`; const passphraseKey = `seshat|${userId}|${deviceId}`;
const passphrase = await getOrCreatePassphrase(passphraseKey); const passphrase = await getOrCreatePassphrase(store, passphraseKey);
try { try {
await afs.mkdir(eventStorePath, { recursive: true }); await afs.mkdir(eventStorePath, { recursive: true });

View File

@@ -6,6 +6,7 @@ Please see LICENSE files in the repository root for full details.
*/ */
import * as tray from "./tray.js"; import * as tray from "./tray.js";
import Store from "./store.js";
interface Setting { interface Setting {
read(): Promise<any>; read(): Promise<any>;
@@ -27,10 +28,10 @@ export const Settings: Record<string, Setting> = {
}, },
"Electron.warnBeforeExit": { "Electron.warnBeforeExit": {
async read(): Promise<any> { async read(): Promise<any> {
return global.store.get("warnBeforeExit", true); return Store.instance?.get("warnBeforeExit");
}, },
async write(value: any): Promise<void> { async write(value: any): Promise<void> {
global.store.set("warnBeforeExit", value); Store.instance?.set("warnBeforeExit", value);
}, },
}, },
"Electron.alwaysShowMenuBar": { "Electron.alwaysShowMenuBar": {
@@ -39,7 +40,7 @@ export const Settings: Record<string, Setting> = {
return !global.mainWindow!.autoHideMenuBar; return !global.mainWindow!.autoHideMenuBar;
}, },
async write(value: any): Promise<void> { async write(value: any): Promise<void> {
global.store.set("autoHideMenuBar", !value); Store.instance?.set("autoHideMenuBar", !value);
global.mainWindow!.autoHideMenuBar = !value; global.mainWindow!.autoHideMenuBar = !value;
global.mainWindow!.setMenuBarVisibility(value); global.mainWindow!.setMenuBarVisibility(value);
}, },
@@ -56,15 +57,15 @@ export const Settings: Record<string, Setting> = {
} else { } else {
tray.destroy(); tray.destroy();
} }
global.store.set("minimizeToTray", value); Store.instance?.set("minimizeToTray", value);
}, },
}, },
"Electron.enableHardwareAcceleration": { "Electron.enableHardwareAcceleration": {
async read(): Promise<any> { async read(): Promise<any> {
return !global.store.get("disableHardwareAcceleration", false); return !Store.instance?.get("disableHardwareAcceleration");
}, },
async write(value: any): Promise<void> { async write(value: any): Promise<void> {
global.store.set("disableHardwareAcceleration", !value); Store.instance?.set("disableHardwareAcceleration", !value);
}, },
}, },
}; };

505
src/store.ts Normal file
View File

@@ -0,0 +1,505 @@
/*
Copyright 2022-2025 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import ElectronStore from "electron-store";
import keytar from "keytar-forked";
import { app, safeStorage, dialog, type SafeStorage } from "electron";
import { clearAllUserData, relaunchApp } from "@standardnotes/electron-clear-data";
import { _t } from "./language-helper.js";
/**
* Legacy keytar service name for storing secrets.
* @deprecated
*/
const KEYTAR_SERVICE = "element.io";
/**
* Super legacy keytar service name for reading secrets.
* @deprecated
*/
const LEGACY_KEYTAR_SERVICE = "riot.im";
/**
* String union type representing all the safeStorage backends.
* + The "unknown" backend shouldn't exist in practice once the app is ready
* + The "plaintext" is the temporarily-unencrypted backend for migration, data is wholly unencrypted - uses PlaintextStorageWriter
* + The "basic_text" backend is the 'plaintext' backend on Linux, data is encrypted but not using the keychain
* + The "system" backend is the encrypted backend on Windows & macOS, data is encrypted using system keychain
* + All other backends are linux-specific and are encrypted using the keychain
*/
type SafeStorageBackend = ReturnType<SafeStorage["getSelectedStorageBackend"]> | "system" | "plaintext";
/**
* The "unknown" backend is not a valid backend, so we exclude it from the type.
*/
type SaneSafeStorageBackend = Exclude<SafeStorageBackend, "unknown">;
/**
* Map of safeStorage backends to their command line arguments.
* kwallet6 cannot be specified via command line
* https://www.electronjs.org/docs/latest/api/safe-storage#safestoragegetselectedstoragebackend-linux
*/
const safeStorageBackendMap: Omit<
Record<SafeStorageBackend, string>,
"unknown" | "kwallet6" | "system" | "plaintext"
> = {
basic_text: "basic",
gnome_libsecret: "gnome-libsecret",
kwallet: "kwallet",
kwallet5: "kwallet5",
};
/**
* Clear all data and relaunch the app.
*/
export async function clearDataAndRelaunch(): Promise<void> {
Store.instance?.clear();
clearAllUserData();
relaunchApp();
}
interface StoreData {
warnBeforeExit: boolean;
minimizeToTray: boolean;
spellCheckerEnabled: boolean;
autoHideMenuBar: boolean;
locale?: string | string[];
disableHardwareAcceleration: boolean;
safeStorage?: Record<string, string>;
/** the safeStorage backend used for the safeStorage data as written */
safeStorageBackend?: SafeStorageBackend;
/** whether to explicitly override the safeStorage backend, used for migration */
safeStorageBackendOverride?: boolean;
/** whether to perform a migration of the safeStorage data */
safeStorageBackendMigrate?: boolean;
}
/**
* Fallback storage writer for secrets, mainly used for automated tests and systems without any safeStorage support.
*/
class StorageWriter {
public constructor(protected readonly store: ElectronStore<StoreData>) {}
public getKey(key: string): `safeStorage.${string}` {
return `safeStorage.${key.replaceAll(".", "-")}`;
}
public set(key: string, secret: string): void {
this.store.set(this.getKey(key), secret);
}
public get(key: string): string | null {
return this.store.get(this.getKey(key));
}
public delete(key: string): void {
this.store.delete(this.getKey(key));
}
}
/**
* Storage writer for secrets using safeStorage.
*/
class SafeStorageWriter extends StorageWriter {
public set(key: string, secret: string): void {
this.store.set(this.getKey(key), safeStorage.encryptString(secret).toString("base64"));
}
public get(key: string): string | null {
const ciphertext = this.store.get<string, string | undefined>(this.getKey(key));
if (ciphertext) {
try {
return safeStorage.decryptString(Buffer.from(ciphertext, "base64"));
} catch (e) {
console.error("Failed to decrypt secret", e);
console.error("...ciphertext:", JSON.stringify(ciphertext));
}
}
return null;
}
}
const enum Mode {
Encrypted = "encrypted", // default
AllowPlaintext = "allow-plaintext",
ForcePlaintext = "force-plaintext",
}
/**
* JSON-backed store for settings which need to be accessible by the main process.
* Secrets are stored within the `safeStorage` object, encrypted with safeStorage.
* Any secrets operations are blocked on Electron app ready emit, and keytar migration if still needed.
*/
class Store extends ElectronStore<StoreData> {
private static internalInstance?: Store;
public static get instance(): Store | undefined {
return Store.internalInstance;
}
/**
* Prepare the store, does not prepare safeStorage, which needs to be done after the app is ready.
* Must be executed in the first tick of the event loop so that it can call Electron APIs before ready state.
*/
public static initialize(mode: Mode | undefined): Store {
if (Store.internalInstance) {
throw new Error("Store already initialized");
}
const store = new Store(mode ?? Mode.Encrypted);
Store.internalInstance = store;
if (
process.platform === "linux" &&
(store.get("safeStorageBackendOverride") || store.get("safeStorageBackendMigrate"))
) {
const backend = store.get("safeStorageBackend")!;
if (backend in safeStorageBackendMap) {
// If the safeStorage backend which was used to write the data is one we can specify via the commandLine
// then do so to ensure we use the same backend for reading the data.
app.commandLine.appendSwitch(
"password-store",
safeStorageBackendMap[backend as keyof typeof safeStorageBackendMap],
);
}
}
return store;
}
// Provides "raw" access to the underlying secrets storage,
// should be avoided in favour of the getSecret/setSecret/deleteSecret methods.
private secrets?: StorageWriter;
private constructor(private mode: Mode) {
super({
name: "electron-config",
clearInvalidConfig: false,
schema: {
warnBeforeExit: {
type: "boolean",
default: true,
},
minimizeToTray: {
type: "boolean",
default: true,
},
spellCheckerEnabled: {
type: "boolean",
default: true,
},
autoHideMenuBar: {
type: "boolean",
default: true,
},
locale: {
anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
},
disableHardwareAcceleration: {
type: "boolean",
default: false,
},
safeStorage: {
type: "object",
},
safeStorageBackend: {
type: "string",
},
safeStorageBackendOverride: {
type: "boolean",
},
safeStorageBackendMigrate: {
type: "boolean",
},
},
});
}
private safeStorageReadyPromise?: Promise<unknown>;
public async safeStorageReady(): Promise<void> {
if (!this.safeStorageReadyPromise) {
this.safeStorageReadyPromise = this.prepareSafeStorage();
}
await this.safeStorageReadyPromise;
}
/**
* Normalise the backend to a sane value (exclude `unknown`), respect forcePlaintext mode,
* and ensure that if an encrypted backend is picked that encryption is available, falling back to plaintext if not.
* @param forcePlaintext - whether to force plaintext mode
* @private
*/
private chooseBackend(forcePlaintext: boolean): SaneSafeStorageBackend {
if (forcePlaintext) {
return "plaintext";
}
if (process.platform === "linux") {
// The following enables plain text encryption if the backend used is basic_text.
// It has no significance for any other backend.
// We do this early so that in case we end up using the basic_text backend (either because that's the only one available
// or as a fallback when the configured backend lacks encryption support), encryption is already turned on.
safeStorage.setUsePlainTextEncryption(true);
// Linux safeStorage support is hellish, the support varies on the Desktop Environment used rather than the store itself.
// https://github.com/electron/electron/issues/39789 https://github.com/microsoft/vscode/issues/185212
const selectedBackend = safeStorage.getSelectedStorageBackend();
if (selectedBackend === "unknown" || !safeStorage.isEncryptionAvailable()) {
return "plaintext";
}
return selectedBackend;
}
return safeStorage.isEncryptionAvailable() ? "system" : "plaintext";
}
/**
* Prepare the safeStorage backend for use.
* We don't eagerly import from keytar as that would bring in data for all Element profiles and not just the current one,
* so we import lazily in getSecret.
*/
private async prepareSafeStorage(): Promise<void> {
await app.whenReady();
// The backend the existing data is written with if any
let existingSafeStorageBackend = this.get("safeStorageBackend");
// The backend and encryption status of the currently loaded backend
const backend = this.chooseBackend(this.mode === Mode.ForcePlaintext);
// Handle migrations
if (existingSafeStorageBackend) {
if (existingSafeStorageBackend === "basic_text" && backend !== "plaintext" && backend !== "basic_text") {
return this.prepareMigrateBasicTextToPlaintext();
}
if (this.get("safeStorageBackendMigrate") && backend === "basic_text") {
return this.migrateBasicTextToPlaintext();
}
if (existingSafeStorageBackend === "plaintext" && backend !== "plaintext") {
this.migratePlaintextToEncrypted();
// Ensure we update existingSafeStorageBackend so we don't fall into the "backend changed" clause below
existingSafeStorageBackend = this.get("safeStorageBackend");
}
}
if (!existingSafeStorageBackend) {
// First launch of the app or first launch since the update
if (this.mode === Mode.Encrypted && (backend === "plaintext" || backend === "basic_text")) {
// Ask the user for consent to use a degraded mode
await this.consultUserConsentDegradedMode(backend);
}
// Store the backend used for the safeStorage data so we can detect if it changes, and we know how the data is encoded
this.recordSafeStorageBackend(backend);
} else if (existingSafeStorageBackend !== backend) {
console.warn(`safeStorage backend changed from ${existingSafeStorageBackend} to ${backend}`);
if (existingSafeStorageBackend in safeStorageBackendMap) {
this.set("safeStorageBackendOverride", true);
return relaunchApp();
} else {
await this.consultUserBackendChangedUnableToMigrate();
}
}
console.info(`Using storage mode '${this.mode}' with backend '${backend}'`);
if (backend !== "plaintext") {
this.secrets = new SafeStorageWriter(this);
} else {
this.secrets = new StorageWriter(this);
}
}
private async consultUserBackendChangedUnableToMigrate(): Promise<void> {
const { response } = await dialog.showMessageBox({
title: _t("store|error|backend_changed_title"),
message: _t("store|error|backend_changed"),
detail: _t("store|error|backend_changed_detail"),
type: "question",
buttons: [_t("common|no"), _t("common|yes")],
defaultId: 0,
cancelId: 0,
});
if (response === 0) {
throw new Error("safeStorage backend changed and cannot migrate");
}
return clearDataAndRelaunch();
}
private async consultUserConsentDegradedMode(backend: "plaintext" | "basic_text"): Promise<void> {
if (backend === "plaintext") {
// Sometimes we may have a working backend that for some reason does not support encryption at the moment.
// This may be because electron reported an incorrect backend or because of some known issues with the keyring itself.
// Or the environment specified `--storage-mode=force-plaintext`.
// In any case, when this happens, we give the user an option to use a weaker form of encryption.
const { response } = await dialog.showMessageBox({
title: _t("store|error|backend_no_encryption_title"),
message: _t("store|error|backend_no_encryption"),
detail: _t("store|error|backend_no_encryption_detail", {
backend: safeStorage.getSelectedStorageBackend(),
brand: global.vectorConfig.brand || "Element",
}),
type: "error",
buttons: [_t("action|cancel"), _t("store|error|unsupported_keyring_use_plaintext")],
defaultId: 0,
cancelId: 0,
});
if (response === 0) {
throw new Error("isEncryptionAvailable=false and user rejected plaintext");
}
} else {
// Electron did not identify a compatible encrypted backend, ask user for consent to degraded mode
const { response } = await dialog.showMessageBox({
title: _t("store|error|unsupported_keyring_title"),
message: _t("store|error|unsupported_keyring"),
detail: _t("store|error|unsupported_keyring_detail", {
brand: global.vectorConfig.brand || "Element",
link: "https://www.electronjs.org/docs/latest/api/safe-storage#safestoragegetselectedstoragebackend-linux",
}),
type: "error",
buttons: [_t("action|cancel"), _t("store|error|unsupported_keyring_use_basic_text")],
defaultId: 0,
cancelId: 0,
});
if (response === 0) {
throw new Error("safeStorage backend basic_text and user rejected it");
}
}
}
private recordSafeStorageBackend(backend: SafeStorageBackend): void {
this.set("safeStorageBackend", backend);
}
/**
* Linux support for upgrading the backend from basic_text to one of the encrypted backends,
* this is quite a tricky process as the backend is not known until the app is ready & cannot be changed once it is.
* 1. We restart the app in safeStorageBackendMigrate mode
* 2. Now that we are in the mode which our data is written in we decrypt the data, write it back in plaintext
* & restart back in default backend mode,
* 3. Finally, we load the plaintext data & encrypt it.
*/
private prepareMigrateBasicTextToPlaintext(): void {
console.info(`Starting safeStorage migration to ${safeStorage.getSelectedStorageBackend()}`);
this.set("safeStorageBackendMigrate", true);
relaunchApp();
}
private migrateBasicTextToPlaintext(): void {
const secrets = new SafeStorageWriter(this);
console.info("Performing safeStorage migration");
const data = this.get("safeStorage");
if (data) {
for (const key in data) {
this.set(secrets.getKey(key), secrets.get(key));
}
this.recordSafeStorageBackend("plaintext");
}
this.delete("safeStorageBackendMigrate");
relaunchApp();
}
private migratePlaintextToEncrypted(): void {
const secrets = new SafeStorageWriter(this);
const selectedSafeStorageBackend = safeStorage.getSelectedStorageBackend();
console.info(`Finishing safeStorage migration to ${selectedSafeStorageBackend}`);
const data = this.get("safeStorage");
if (data) {
for (const key in data) {
secrets.set(key, data[key]);
}
}
this.recordSafeStorageBackend(selectedSafeStorageBackend);
}
/**
* Get the stored secret for the key.
* Lazily migrates keys from keytar if they are not yet in the store.
*
* @param key The string key name.
*
* @returns A promise for the secret string.
*/
public async getSecret(key: string): Promise<string | null> {
await this.safeStorageReady();
let secret = this.secrets!.get(key);
if (secret) return secret;
try {
secret = await this.getSecretKeytar(key);
} catch (e) {
console.warn(`Failed to read data from keytar with key='${key}'`, e);
}
if (secret) {
console.debug("Migrating secret from keytar", key);
this.secrets!.set(key, secret);
}
return secret;
}
/**
* Add the secret for the key to the keychain.
* We write to both safeStorage & keytar to support downgrading the application.
*
* @param key The string key name.
* @param secret The string password.
*
* @returns A promise for the set password completion.
*/
public async setSecret(key: string, secret: string): Promise<void> {
await this.safeStorageReady();
this.secrets!.set(key, secret);
try {
await keytar.setPassword(KEYTAR_SERVICE, key, secret);
} catch (e) {
console.warn(`Failed to write safeStorage backwards-compatibility key='${key}' data to keytar`, e);
}
}
/**
* Delete the stored password for the key.
* Removes from safeStorage, keytar & keytar legacy.
*
* @param key The string key name.
*/
public async deleteSecret(key: string): Promise<void> {
await this.safeStorageReady();
this.secrets!.delete(key);
try {
await this.deleteSecretKeytar(key);
} catch (e) {
console.warn(`Failed to delete secret with key='${key}' from keytar`, e);
}
}
/**
* @deprecated will be removed in the near future
*/
private async getSecretKeytar(key: string): Promise<string | null> {
return (
(await keytar.getPassword(KEYTAR_SERVICE, key)) ?? (await keytar.getPassword(LEGACY_KEYTAR_SERVICE, key))
);
}
/**
* @deprecated will be removed in the near future
*/
private async deleteSecretKeytar(key: string): Promise<void> {
await keytar.deletePassword(LEGACY_KEYTAR_SERVICE, key);
await keytar.deletePassword(KEYTAR_SERVICE, key);
}
}
export default Store;

View File

@@ -23,7 +23,7 @@ export async function randomArray(size: number): Promise<string> {
type JsonValue = null | string | number; type JsonValue = null | string | number;
type JsonArray = Array<JsonValue | JsonObject | JsonArray>; type JsonArray = Array<JsonValue | JsonObject | JsonArray>;
interface JsonObject { export interface JsonObject {
[key: string]: JsonObject | JsonArray | JsonValue; [key: string]: JsonObject | JsonArray | JsonValue;
} }
export type Json = JsonArray | JsonObject; export type Json = JsonArray | JsonObject;

2485
yarn.lock

File diff suppressed because it is too large Load Diff