Merge branch 'main' into HEAD

This commit is contained in:
Scott Nonnenberg 2024-07-30 16:46:34 -07:00
commit d57d0cea19
1135 changed files with 264116 additions and 302492 deletions

View file

@ -20,6 +20,7 @@ build/ICUMessageParams.d.ts
# Third-party files # Third-party files
js/Mp3LameEncoder.min.js js/Mp3LameEncoder.min.js
js/WebAudioRecorderMp3.js js/WebAudioRecorderMp3.js
js/calling-tools/**
# TypeScript generated files # TypeScript generated files
app/**/*.js app/**/*.js

View file

@ -17,7 +17,7 @@ Remember, you can preview this before saving it.
- [ ] My contribution is **not** related to translations. - [ ] My contribution is **not** related to translations.
- [ ] My commits are in nice logical chunks with [good commit messages](http://chris.beams.io/posts/git-commit/) - [ ] My commits are in nice logical chunks with [good commit messages](http://chris.beams.io/posts/git-commit/)
- [ ] My changes are [rebased](https://medium.com/free-code-camp/git-rebase-and-the-golden-rule-explained-70715eccc372) on the latest [`main`](https://github.com/signalapp/Signal-Desktop/tree/main) branch - [ ] My changes are [rebased](https://medium.com/free-code-camp/git-rebase-and-the-golden-rule-explained-70715eccc372) on the latest [`main`](https://github.com/signalapp/Signal-Desktop/tree/main) branch
- [ ] A `yarn ready` run passes successfully ([more about tests here](https://github.com/signalapp/Signal-Desktop/blob/main/CONTRIBUTING.md#tests)) - [ ] A `npm run ready` run passes successfully ([more about tests here](https://github.com/signalapp/Signal-Desktop/blob/main/CONTRIBUTING.md#tests))
- [ ] My changes are ready to be shipped to users - [ ] My changes are ready to be shipped to users
### Description ### Description

View file

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
token: ${{ secrets.AUTOMATED_GITHUB_PAT }} token: ${{ secrets.AUTOMATED_GITHUB_PAT }}
repository: signalapp/Signal-Backport-Action-Private repository: signalapp/Signal-Backport-Action-Private

View file

@ -9,11 +9,13 @@ on:
- main - main
- '[0-9]+.[0-9]+.x' - '[0-9]+.[0-9]+.x'
pull_request: pull_request:
schedule:
- cron: '0 */12 * * *'
jobs: jobs:
linux: linux:
runs-on: ubuntu-latest-8-cores runs-on: ubuntu-latest-8-cores
if: ${{ github.repository == 'signalapp/Signal-Desktop-Private' }} if: ${{ github.repository == 'signalapp/Signal-Desktop-Private' && (!github.event.schedule || github.ref == 'refs/heads/main') }}
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
@ -23,35 +25,35 @@ jobs:
run: uname -a run: uname -a
- name: Clone Desktop repo - name: Clone Desktop repo
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Setup node.js - name: Setup node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: '20.9.0' node-version-file: '.nvmrc'
- name: Install global dependencies - name: Install global dependencies
run: npm install -g yarn@1.22.10 npm@10.2.5 run: npm install -g npm@10.2.5
- name: Install xvfb - name: Install xvfb
run: sudo apt-get install xvfb run: sudo apt-get install xvfb
- name: Cache Desktop node_modules - name: Cache Desktop node_modules
id: cache-desktop-modules id: cache-desktop-modules
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: node_modules path: node_modules
key: ${{ runner.os }}-${{ hashFiles('package.json', 'yarn.lock', 'patches/**') }} key: ${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'patches/**') }}
- name: Install Desktop node_modules - name: Install Desktop node_modules
if: steps.cache-desktop-modules.outputs.cache-hit != 'true' if: steps.cache-desktop-modules.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile run: npm ci
env: env:
NPM_CONFIG_LOGLEVEL: verbose NPM_CONFIG_LOGLEVEL: verbose
- name: Build typescript - name: Build typescript
run: yarn generate run: npm run generate
- name: Bundle - name: Bundle
run: yarn build:esbuild:prod run: npm run build:esbuild:prod
- name: Run startup benchmarks - name: Run startup benchmarks
run: | run: |
@ -128,13 +130,13 @@ jobs:
- name: Upload benchmark logs on failure - name: Upload benchmark logs on failure
if: failure() if: failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: logs name: logs
path: artifacts path: artifacts
- name: Clone benchmark repo - name: Clone benchmark repo
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
repository: 'signalapp/Signal-Desktop-Benchmarks-Private' repository: 'signalapp/Signal-Desktop-Benchmarks-Private'
path: 'benchmark-results' path: 'benchmark-results'

View file

@ -18,48 +18,49 @@ jobs:
steps: steps:
- run: lsb_release -a - run: lsb_release -a
- run: uname -a - run: uname -a
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: actions/setup-node@v3 - name: Setup node.js
uses: actions/setup-node@v4
with: with:
node-version: '20.9.0' node-version-file: '.nvmrc'
- run: npm install -g yarn@1.22.10 npm@10.2.5 - run: npm install -g npm@10.2.5
- name: Cache Desktop node_modules
id: cache-desktop-modules
uses: actions/cache@v3
with:
path: node_modules
key: ${{ runner.os }}-${{ hashFiles('package.json', 'yarn.lock', 'patches/**') }}
- name: Restore cached .eslintcache and tsconfig.tsbuildinfo - name: Restore cached .eslintcache and tsconfig.tsbuildinfo
uses: actions/cache/restore@v3 uses: actions/cache/restore@v4
id: cache-lint id: cache-lint
with: with:
path: | path: |
.eslintcache .eslintcache
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
key: lint-${{ runner.os }}-${{ hashFiles('package.json', 'yarn.lock', 'patches/**', '.eslintrc.js', '.eslint/**', 'tsconfig.json') }} key: lint-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'patches/**', '.eslintrc.js', '.eslint/**', 'tsconfig.json') }}
- name: Cache Desktop node_modules
id: cache-desktop-modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'patches/**') }}
- name: Install Desktop node_modules - name: Install Desktop node_modules
if: steps.cache-desktop-modules.outputs.cache-hit != 'true' if: steps.cache-desktop-modules.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile run: npm ci
env: env:
NPM_CONFIG_LOGLEVEL: verbose NPM_CONFIG_LOGLEVEL: verbose
- run: yarn generate - run: npm run generate
- run: yarn lint - run: npm run lint
- run: yarn lint-deps - run: npm run lint-deps
- run: yarn lint-license-comments - run: npm run lint-license-comments
- name: Check acknowledgments file is up to date - name: Check acknowledgments file is up to date
run: yarn build:acknowledgments run: npm run build:acknowledgments
env: env:
REQUIRE_SIGNAL_LIB_FILES: 1 REQUIRE_SIGNAL_LIB_FILES: 1
- run: git diff --exit-code - run: git diff --exit-code
- name: Update cached .eslintcache and tsconfig.tsbuildinfo - name: Update cached .eslintcache and tsconfig.tsbuildinfo
uses: actions/cache/save@v3 uses: actions/cache/save@v4
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
with: with:
path: | path: |
@ -75,109 +76,113 @@ jobs:
steps: steps:
- run: uname -a - run: uname -a
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: actions/setup-node@v3 - name: Setup node.js
uses: actions/setup-node@v4
with: with:
node-version: '20.9.0' node-version-file: '.nvmrc'
- run: npm install -g yarn@1.22.10 npm@10.2.5 - run: npm install -g npm@10.2.5
- name: Cache Desktop node_modules - name: Cache Desktop node_modules
id: cache-desktop-modules id: cache-desktop-modules
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: node_modules path: node_modules
key: ${{ runner.os }}-${{ hashFiles('package.json', 'yarn.lock', 'patches/**') }} key: ${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'patches/**') }}
- name: Install Desktop node_modules - name: Install Desktop node_modules
if: steps.cache-desktop-modules.outputs.cache-hit != 'true' if: steps.cache-desktop-modules.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile run: npm ci
env: env:
NPM_CONFIG_LOGLEVEL: verbose NPM_CONFIG_LOGLEVEL: verbose
- run: yarn generate - run: npm run generate
- run: yarn prepare-beta-build - run: npm run prepare-beta-build
- run: yarn test-node - run: npm run test-node
- run: yarn test-electron - run: npm run test-electron
env: env:
ARTIFACTS_DIR: artifacts/macos ARTIFACTS_DIR: artifacts/macos
timeout-minutes: 5 timeout-minutes: 5
- run: touch noop.sh && chmod +x noop.sh - run: touch noop.sh && chmod +x noop.sh
- run: yarn build - run: npm run build
env: env:
DISABLE_INSPECT_FUSE: on DISABLE_INSPECT_FUSE: on
SIGN_MACOS_SCRIPT: noop.sh SIGN_MACOS_SCRIPT: noop.sh
- name: Rebuild native modules for x64 - name: Rebuild native modules for x64
run: yarn electron:install-app-deps run: npm run electron:install-app-deps
- run: yarn test-release - run: npm run test-release
env: env:
NODE_ENV: production NODE_ENV: production
- run: yarn test-eslint - run: npm run test-eslint
- name: Upload artifacts on failure - name: Upload artifacts on failure
if: failure() if: failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
path: artifacts path: artifacts
linux: linux:
needs: lint needs: lint
runs-on: ubuntu-latest runs-on: ubuntu-latest-8-cores
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- run: lsb_release -a - run: lsb_release -a
- run: uname -a - run: uname -a
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: actions/setup-node@v3 - name: Setup node.js
uses: actions/setup-node@v4
with: with:
node-version: '20.9.0' node-version-file: '.nvmrc'
- run: sudo apt-get install xvfb - run: sudo apt-get install xvfb
- run: npm install -g yarn@1.22.10 npm@10.2.5 - run: npm install -g npm@10.2.5
- name: Cache Desktop node_modules - name: Cache Desktop node_modules
id: cache-desktop-modules id: cache-desktop-modules
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: node_modules path: node_modules
key: ${{ runner.os }}-${{ hashFiles('package.json', 'yarn.lock', 'patches/**') }} key: ${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'patches/**') }}
- name: Install Desktop node_modules - name: Install Desktop node_modules
if: steps.cache-desktop-modules.outputs.cache-hit != 'true' if: steps.cache-desktop-modules.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile run: npm ci
env: env:
NPM_CONFIG_LOGLEVEL: verbose NPM_CONFIG_LOGLEVEL: verbose
- run: yarn generate - run: npm run generate
- run: yarn prepare-beta-build - run: npm run prepare-beta-build
- name: Create bundle - name: Create bundle
run: yarn build:esbuild:prod run: npm run build:esbuild:prod
- name: Build with packaging .deb file - name: Build with packaging .deb file
run: yarn build:release -- --publish=never run: npm run build:release -- --publish=never
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
env: env:
DISABLE_INSPECT_FUSE: on DISABLE_INSPECT_FUSE: on
- name: Build without packaging .deb file - name: Build without packaging .deb file
run: yarn build:release --linux dir run: npm run build:release -- --linux dir
if: github.ref != 'refs/heads/main' if: github.ref != 'refs/heads/main'
env: env:
DISABLE_INSPECT_FUSE: on DISABLE_INSPECT_FUSE: on
- run: xvfb-run --auto-servernum yarn test-node - run: xvfb-run --auto-servernum npm run test-node
- run: xvfb-run --auto-servernum yarn test-electron - run: xvfb-run --auto-servernum npm run test-electron
timeout-minutes: 5 timeout-minutes: 5
env: env:
ARTIFACTS_DIR: artifacts/linux ARTIFACTS_DIR: artifacts/linux
LANG: en_US LANG: en_US
LANGUAGE: en_US LANGUAGE: en_US
- run: xvfb-run --auto-servernum yarn test-release - run: xvfb-run --auto-servernum npm run test-release
env: env:
NODE_ENV: production NODE_ENV: production
- name: Upload artifacts on failure - name: Upload artifacts on failure
if: failure() if: failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
path: artifacts path: artifacts
@ -194,11 +199,12 @@ jobs:
- run: systeminfo - run: systeminfo
- run: git config --global core.autocrlf false - run: git config --global core.autocrlf false
- run: git config --global core.eol lf - run: git config --global core.eol lf
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: actions/setup-node@v3 - name: Setup node.js
uses: actions/setup-node@v4
with: with:
node-version: '20.9.0' node-version-file: '.nvmrc'
- run: npm install -g yarn@1.22.10 npm@10.2.5 node-gyp@10.0.1 - run: npm install -g npm@10.2.5 node-gyp@10.0.1
# Set things up so @nodert-win10-rs4 dependencies build properly # Set things up so @nodert-win10-rs4 dependencies build properly
- run: dir "$env:BUILD_LOCATION" - run: dir "$env:BUILD_LOCATION"
@ -208,49 +214,50 @@ jobs:
- name: Cache Desktop node_modules - name: Cache Desktop node_modules
id: cache-desktop-modules id: cache-desktop-modules
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: node_modules path: node_modules
key: ${{ runner.os }}-${{ hashFiles('package.json', 'yarn.lock', 'patches/**') }} key: ${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'patches/**') }}
- name: Install Desktop node_modules - name: Install Desktop node_modules
if: steps.cache-desktop-modules.outputs.cache-hit != 'true' if: steps.cache-desktop-modules.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile run: npm ci
env: env:
CHILD_CONCURRENCY: 1 CHILD_CONCURRENCY: 1
NPM_CONFIG_LOGLEVEL: verbose NPM_CONFIG_LOGLEVEL: verbose
- run: yarn generate - run: npm run generate
- run: yarn test-node - run: npm run test-node
- run: copy package.json temp.json - run: copy package.json temp.json
- run: del package.json - run: del package.json
- run: type temp.json | findstr /v certificateSubjectName | findstr /v certificateSha1 > package.json - run: type temp.json | findstr /v certificateSubjectName | findstr /v certificateSha1 > package.json
- run: yarn prepare-beta-build - run: npm run prepare-beta-build
- name: Create bundle - name: Create bundle
run: yarn build:esbuild:prod run: npm run build:esbuild:prod
- name: Build with NSIS - name: Build with NSIS
run: yarn build:release run: npm run build:release
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
env: env:
DISABLE_INSPECT_FUSE: on DISABLE_INSPECT_FUSE: on
- name: Build without NSIS - name: Build without NSIS
run: yarn build:release --win dir run: npm run build:release -- --win dir
if: github.ref != 'refs/heads/main' if: github.ref != 'refs/heads/main'
env: env:
DISABLE_INSPECT_FUSE: on DISABLE_INSPECT_FUSE: on
- run: yarn test-electron - run: npm run test-electron
env: env:
ARTIFACTS_DIR: artifacts/windows ARTIFACTS_DIR: artifacts/windows
timeout-minutes: 5 timeout-minutes: 5
- run: yarn test-release - run: npm run test-release
env: env:
SIGNAL_ENV: production SIGNAL_ENV: production
- name: Upload artifacts on failure - name: Upload artifacts on failure
if: failure() if: failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
path: artifacts path: artifacts
@ -264,29 +271,31 @@ jobs:
working-directory: sticker-creator working-directory: sticker-creator
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: actions/setup-node@v3 - name: Setup node.js
uses: actions/setup-node@v4
with: with:
node-version: '18.17.1' node-version-file: '.nvmrc'
- run: npm install -g yarn@1.22.10 cache: 'npm'
cache-dependency-path: 'package-lock.json'
- name: Install Sticker Creator node_modules - name: Install Sticker Creator node_modules
run: yarn install run: npm ci
- name: Build Sticker Creator - name: Build Sticker Creator
run: yarn build run: npm run build
- name: Check Sticker Creator types - name: Check Sticker Creator types
run: yarn check:types run: npm run check:types
- name: Check Sticker Creator formatting - name: Check Sticker Creator formatting
run: yarn prettier:check run: npm run prettier:check
- name: Check Sticker Creator linting - name: Check Sticker Creator linting
run: yarn lint run: npm run lint
- name: Run tests - name: Run tests
run: yarn test --run run: npm test -- --run
mock-tests: mock-tests:
needs: lint needs: lint
@ -301,40 +310,40 @@ jobs:
run: uname -a run: uname -a
- name: Clone Desktop repo - name: Clone Desktop repo
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Setup node.js - name: Setup node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: '20.9.0' node-version-file: '.nvmrc'
- name: Install global dependencies - name: Install global dependencies
run: npm install -g yarn@1.22.10 npm@10.2.5 run: npm install -g npm@10.2.5
- name: Install xvfb - name: Install xvfb
run: sudo apt-get install xvfb run: sudo apt-get install xvfb
- name: Cache Desktop node_modules - name: Cache Desktop node_modules
id: cache-desktop-modules id: cache-desktop-modules
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: node_modules path: node_modules
key: ${{ runner.os }}-${{ hashFiles('package.json', 'yarn.lock', 'patches/**') }} key: ${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'patches/**') }}
- name: Install Desktop node_modules - name: Install Desktop node_modules
if: steps.cache-desktop-modules.outputs.cache-hit != 'true' if: steps.cache-desktop-modules.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile run: npm ci
env: env:
NPM_CONFIG_LOGLEVEL: verbose NPM_CONFIG_LOGLEVEL: verbose
- name: Build typescript - name: Build typescript
run: yarn generate run: npm run generate
- name: Bundle - name: Bundle
run: yarn build:esbuild:prod run: npm run build:esbuild:prod
- name: Run mock server tests - name: Run mock server tests
run: | run: |
set -o pipefail set -o pipefail
xvfb-run --auto-servernum yarn test-mock xvfb-run --auto-servernum npm run test-mock
timeout-minutes: 10 timeout-minutes: 10
env: env:
NODE_ENV: production NODE_ENV: production
@ -343,7 +352,7 @@ jobs:
- name: Upload mock server test logs on failure - name: Upload mock server test logs on failure
if: failure() if: failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: logs name: logs
path: artifacts path: artifacts

View file

@ -10,23 +10,26 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 # fetch all history fetch-depth: 0 # fetch all history
- uses: actions/setup-node@v3 - name: Setup node.js
uses: actions/setup-node@v4
with: with:
node-version: '20.9.0' node-version-file: '.nvmrc'
- run: npm install -g yarn@1.22.10 npm@10.2.5 cache: 'npm'
cache-dependency-path: 'package-lock.json'
- run: npm install -g npm@10.2.5
- name: Cache danger node_modules - name: Cache danger node_modules
id: cache-desktop-modules id: cache-desktop-modules
uses: actions/cache@v3 uses: actions/cache@v3
with: with:
path: danger/node_modules path: danger/node_modules
key: danger-${{ runner.os }}-${{ hashFiles('danger/package.json', 'danger/yarn.lock') }} key: danger-${{ runner.os }}-${{ hashFiles('danger/package.json', 'danger/package-lock.json') }}
- name: Install danger node_modules - name: Install danger node_modules
if: steps.cache-desktop-modules.outputs.cache-hit != 'true' if: steps.cache-desktop-modules.outputs.cache-hit != 'true'
run: cd danger && yarn install --frozen-lockfile run: cd danger && npm ci
- name: Run DangerJS - name: Run DangerJS
run: yarn danger:ci run: npm run danger:ci
env: env:
DANGER_GITHUB_API_TOKEN: ${{ secrets.AUTOMATED_GITHUB_PAT }} DANGER_GITHUB_API_TOKEN: ${{ secrets.AUTOMATED_GITHUB_PAT }}

View file

@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
token: ${{ secrets.AUTOMATED_GITHUB_PAT }} token: ${{ secrets.AUTOMATED_GITHUB_PAT }}
repository: signalapp/Signal-Notes-Action-Private repository: signalapp/Signal-Notes-Action-Private

28
.github/workflows/release-notes.yml vendored Normal file
View file

@ -0,0 +1,28 @@
# Copyright 2024 Signal Messenger, LLC
# SPDX-License-Identifier: AGPL-3.0-only
name: Release Notes
on:
issue_comment:
types: [created]
issues:
types: [opened]
pull_request:
types: [opened, labeled, unlabeled, closed]
jobs:
backport:
name: Update release notes issue
if: ${{ github.repository == 'signalapp/Signal-Desktop-Private' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.AUTOMATED_GITHUB_PAT }}
repository: signalapp/Signal-Release-Notes-Action-Private
path: ./.github/actions/release-notes
- name: Run action
uses: ./.github/actions/release-notes
with:
token: ${{ secrets.AUTOMATED_GITHUB_PAT }}

View file

@ -13,25 +13,20 @@ jobs:
runs-on: ubuntu-latest-8-cores runs-on: ubuntu-latest-8-cores
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: actions/setup-node@v3 - name: Setup node.js
uses: actions/setup-node@v4
with: with:
node-version: '20.9.0' node-version-file: '.nvmrc'
cache: 'yarn' cache: 'npm'
cache-dependency-path: 'package-lock.json'
- name: Install global dependencies - name: Install global dependencies
run: npm install -g yarn@1.22.10 npm@10.2.5 run: npm install -g npm@10.2.5
- name: Cache Desktop node_modules
id: cache-desktop-modules
uses: actions/cache@v3
with:
path: node_modules
key: ${{ runner.os }}-${{ hashFiles('package.json', 'yarn.lock', 'patches/**') }}
- name: Install Desktop node_modules - name: Install Desktop node_modules
if: steps.cache-desktop-modules.outputs.cache-hit != 'true' run: npm ci
run: yarn install --frozen-lockfile --prefer-offline
env: env:
CHILD_CONCURRENCY: 1 CHILD_CONCURRENCY: 1
NPM_CONFIG_LOGLEVEL: verbose NPM_CONFIG_LOGLEVEL: verbose
- run: yarn build:storybook - run: npm run build:storybook
- run: npx playwright install chromium - run: npx playwright install chromium
- run: yarn run-p --race test:storybook:serve test:storybook:test - run: ./node_modules/.bin/run-p --race test:storybook:serve test:storybook:test

4
.gitignore vendored
View file

@ -4,11 +4,12 @@ node_modules_bkp
coverage/* coverage/*
build/curve25519_compiled.js build/curve25519_compiled.js
build/dns-fallback.json build/dns-fallback.json
build/compact-locales
stylesheets/*.css.map stylesheets/*.css.map
/dist /dist
.DS_Store .DS_Store
config/local.json config/local.json
config/local-*.json config/local-*
*.provisionprofile *.provisionprofile
release/ release/
/dev-app-update.yml /dev-app-update.yml
@ -26,6 +27,7 @@ js/components.js
js/util_worker.js js/util_worker.js
libtextsecure/components.js libtextsecure/components.js
stylesheets/*.css stylesheets/*.css
!stylesheets/webrtc_internals.css
/storybook-static/ /storybook-static/
preload.bundle.* preload.bundle.*
bundles/ bundles/

1
.npmrc Normal file
View file

@ -0,0 +1 @@
legacy-peer-deps=true

2
.nvmrc
View file

@ -1 +1 @@
20.9.0 20.15.1

View file

@ -21,13 +21,18 @@ ts/util/lint/exceptions.json
storybook-static storybook-static
build/locale-display-names.json build/locale-display-names.json
build/country-display-names.json build/country-display-names.json
build/compact-locales/**/*.json
release/**
# Third-party files # Third-party files
node_modules/** node_modules/**
danger/node_modules/**
sticker-creator/node_modules/**
components/** components/**
js/curve/** js/curve/**
js/Mp3LameEncoder.min.js js/Mp3LameEncoder.min.js
js/WebAudioRecorderMp3.js js/WebAudioRecorderMp3.js
js/calling-tools/**
# Assets # Assets
/images/ /images/

View file

@ -8,7 +8,9 @@ const config: StorybookConfig = {
typescript: { typescript: {
reactDocgen: false, reactDocgen: false,
}, },
stories: ['../ts/components/**/*.stories.tsx'], stories: ['../ts/components/**/*.stories.tsx'],
addons: [ addons: [
'@storybook/addon-a11y', '@storybook/addon-a11y',
'@storybook/addon-actions', '@storybook/addon-actions',
@ -17,15 +19,19 @@ const config: StorybookConfig = {
'@storybook/addon-toolbars', '@storybook/addon-toolbars',
'@storybook/addon-viewport', '@storybook/addon-viewport',
'@storybook/addon-jest', '@storybook/addon-jest',
// This must be imported last. // This must be imported last.
'@storybook/addon-interactions', '@storybook/addon-interactions',
'@storybook/addon-webpack5-compiler-swc',
], ],
framework: '@storybook/react-webpack5', framework: '@storybook/react-webpack5',
core: {},
features: { core: {
storyStoreV7: true, disableTelemetry: true,
}, },
features: {},
staticDirs: [ staticDirs: [
{ from: '../fonts', to: 'fonts' }, { from: '../fonts', to: 'fonts' },
{ from: '../images', to: 'images' }, { from: '../images', to: 'images' },
@ -35,6 +41,7 @@ const config: StorybookConfig = {
to: 'node_modules/emoji-datasource-apple/img', to: 'node_modules/emoji-datasource-apple/img',
}, },
], ],
webpackFinal(config) { webpackFinal(config) {
config.cache = { config.cache = {
type: 'filesystem', type: 'filesystem',
@ -97,6 +104,8 @@ const config: StorybookConfig = {
return config; return config;
}, },
docs: {},
}; };
export default config; export default config;

View file

@ -21,6 +21,9 @@ import {
ScrollerLockContext, ScrollerLockContext,
createScrollerLock, createScrollerLock,
} from '../ts/hooks/useScrollLock'; } from '../ts/hooks/useScrollLock';
import { Environment, setEnvironment } from '../ts/environment.ts';
setEnvironment(Environment.Development, true);
const i18n = setupI18n('en', messages); const i18n = setupI18n('en', messages);
@ -80,6 +83,7 @@ const noop = () => {};
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
window.Whisper.events = { window.Whisper.events = {
on: noop, on: noop,
off: noop,
}; };
window.SignalContext = { window.SignalContext = {
@ -93,7 +97,6 @@ window.SignalContext = {
unregisterForChange: noop, unregisterForChange: noop,
}, },
isTestOrMockEnvironment: () => false,
nativeThemeListener: { nativeThemeListener: {
getSystemTheme: () => 'light', getSystemTheme: () => 'light',
subscribe: noop, subscribe: noop,
@ -116,7 +119,6 @@ window.SignalContext = {
getHourCyclePreference: () => HourCyclePreference.UnknownPreference, getHourCyclePreference: () => HourCyclePreference.UnknownPreference,
getPreferredSystemLocales: () => ['en'], getPreferredSystemLocales: () => ['en'],
getResolvedMessagesLocaleDirection: () => 'ltr',
getLocaleOverride: () => null, getLocaleOverride: () => null,
getLocaleDisplayNames: () => ({ en: { en: 'English' } }), getLocaleDisplayNames: () => ({ en: { en: 'English' } }),
}; };
@ -133,6 +135,9 @@ const withGlobalTypesProvider = (Story, context) => {
const mode = context.globals.mode; const mode = context.globals.mode;
const direction = context.globals.direction ?? 'auto'; const direction = context.globals.direction ?? 'auto';
window.SignalContext.getResolvedMessagesLocaleDirection = () =>
direction === 'auto' ? 'ltr' : direction;
// Adding it to the body as well so that we can cover modals and other // Adding it to the body as well so that we can cover modals and other
// components that are rendered outside of this decorator container // components that are rendered outside of this decorator container
if (theme === 'light') { if (theme === 'light') {
@ -193,3 +198,4 @@ export const parameters = {
disabledRules: ['html-has-lang'], disabledRules: ['html-has-lang'],
}, },
}; };
export const tags = [];

File diff suppressed because it is too large Load diff

View file

@ -52,13 +52,12 @@ Install the [Xcode Command-Line Tools](http://osxdaily.com/2014/02/12/install-co
Now, run these commands in your preferred terminal in a good directory for development: Now, run these commands in your preferred terminal in a good directory for development:
``` ```
npm install --global yarn # Make sure you have have `yarn`
git clone https://github.com/signalapp/Signal-Desktop.git git clone https://github.com/signalapp/Signal-Desktop.git
cd Signal-Desktop cd Signal-Desktop
yarn install --frozen-lockfile # Install and build dependencies (this will take a while) npm install # Install and build dependencies (this will take a while)
yarn generate # Generate final JS and CSS assets npm run generate # Generate final JS and CSS assets
yarn test # A good idea to make sure tests run first npm test # A good idea to make sure tests run first
yarn start # Start Signal! npm start # Start Signal!
``` ```
You'll need to restart the application regularly to see your changes, as there You'll need to restart the application regularly to see your changes, as there
@ -68,14 +67,53 @@ is no automatic restart mechanism. Alternatively, keep the developer tools open
(Windows & Linux). (Windows & Linux).
Also, note that the assets loaded by the application are not necessarily the same files Also, note that the assets loaded by the application are not necessarily the same files
youre touching. You may not see your changes until you run `yarn generate` on the youre touching. You may not see your changes until you run `npm run generate` on the
command-line like you did during setup. You can make it easier on yourself by generating command-line like you did during setup. You can make it easier on yourself by generating
the latest built assets when you change a file. Run each of these in their own terminal the latest built assets when you change a file. Run each of these in their own terminal
instance while you make changes - they'll run until you stop them: instance while you make changes - they'll run until you stop them:
``` ```
yarn dev:transpile # recompiles when you change .ts files npm run dev:transpile # recompiles when you change .ts files
yarn dev:sass # recompiles when you change .scss files npm run dev:sass # recompiles when you change .scss files
```
#### Known issues
##### `yarn install` prints error 'Could not detect abi for version 30.0.6 and runtime electron'
`yarn install` may print an error like the following, but it can be ignored because the overall operation succeeds.
```
$ ./node_modules/.bin/electron-builder install-app-deps
• electron-builder version=24.6.3
• loaded configuration file=package.json ("build" field)
• rebuilding native dependencies dependencies=@nodert-win10-rs4/windows.data.xml.dom@0.4.4, @nodert-win10-rs4/windows.ui.notifications@0.4.4, @signalapp/better-sqlite3@8.7.1, @signalapp/windows-dummy-keystroke@1.0.0, bufferutil@4.0.7, fs-xattr@0.3.0, mac-screen-capture-permissions@2.0.0, utf-8-validate@5.0.10
platform=linux
arch=x64
• install prebuilt binary name=mac-screen-capture-permissions version=2.0.0 platform=linux arch=x64 napi=
• build native dependency from sources name=mac-screen-capture-permissions
version=2.0.0
platform=linux
arch=x64
napi=
reason=prebuild-install failed with error (run with env DEBUG=electron-builder to get more information)
error=/home/ben/sauce/Signal-Desktop/node_modules/node-abi/index.js:30
throw new Error('Could not detect abi for version ' + target + ' and runtime ' + runtime + '. Updating "node-abi" might help solve this issue if it is a new release of ' + runtime)
^
Error: Could not detect abi for version 30.0.6 and runtime electron. Updating "node-abi" might help solve this issue if it is a new release of electron
at getAbi (/home/ben/sauce/Signal-Desktop/node_modules/node-abi/index.js:30:9)
at module.exports (/home/ben/sauce/Signal-Desktop/node_modules/prebuild-install/rc.js:53:57)
at Object.<anonymous> (/home/ben/sauce/Signal-Desktop/node_modules/prebuild-install/bin.js:8:25)
at Module._compile (node:internal/modules/cjs/loader:1376:14)
at Module._extensions..js (node:internal/modules/cjs/loader:1435:10)
at Module.load (node:internal/modules/cjs/loader:1207:32)
at Module._load (node:internal/modules/cjs/loader:1023:12)
at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:135:12)
at node:internal/main/run_main_module:28:49
Node.js v20.11.1
``` ```
### webpack ### webpack
@ -85,7 +123,7 @@ You can run a development server for these parts of the app with the
following command: following command:
``` ```
yarn dev npm run dev
``` ```
In order for the app to make requests to the development server you must set In order for the app to make requests to the development server you must set
@ -93,7 +131,7 @@ the `SIGNAL_ENABLE_HTTP` environment variable to a truthy value. On Linux and
macOS, that simply looks like this: macOS, that simply looks like this:
``` ```
SIGNAL_ENABLE_HTTP=1 yarn start SIGNAL_ENABLE_HTTP=1 npm start
``` ```
## Setting up standalone ## Setting up standalone
@ -158,7 +196,7 @@ For example, to create an 'alice' profile, put a file called `local-alice.json`
Then you can start up the application a little differently to load the profile: Then you can start up the application a little differently to load the profile:
``` ```
NODE_APP_INSTANCE=alice yarn run start NODE_APP_INSTANCE=alice npm start
``` ```
This changes the `userData` directory from `%appData%/Signal` to `%appData%/Signal-aliceProfile`. This changes the `userData` directory from `%appData%/Signal` to `%appData%/Signal-aliceProfile`.
@ -174,15 +212,15 @@ Please write tests! Our testing framework is
[mocha](http://mochajs.org/) and our assertion library is [mocha](http://mochajs.org/) and our assertion library is
[chai](http://chaijs.com/api/assert/). [chai](http://chaijs.com/api/assert/).
The easiest way to run all tests at once is `yarn test`, which will run them on the The easiest way to run all tests at once is `npm test`, which will run them on the
command line. You can run the client-side tests in an interactive session with command line. You can run the client-side tests in an interactive session with
`NODE_ENV=test yarn run start`. `NODE_ENV=test npm start`.
## Pull requests ## Pull requests
So you wanna make a pull request? Please observe the following guidelines. So you wanna make a pull request? Please observe the following guidelines.
- First, make sure that your `yarn ready` run passes - it's very similar to what our - First, make sure that your `npm run ready` run passes - it's very similar to what our
Continuous Integration servers do to test the app. Continuous Integration servers do to test the app.
- Please do not submit pull requests for translation fixes. - Please do not submit pull requests for translation fixes.
- Never use plain strings right in the source code - pull them from `messages.json`! - Never use plain strings right in the source code - pull them from `messages.json`!
@ -261,8 +299,27 @@ will go to your new development desktop app instead of your phone.
To test changes to the build system, build a release using To test changes to the build system, build a release using
``` ```
yarn generate npm run generate
yarn build npm run build
``` ```
Then, run the tests using `yarn test-release`. Then, run the tests using `npm run test-release`.
### Testing MacOS builds
macOS requires apps to be code signed with an Apple certificate. To test development builds
you can ad-hoc sign the packaged app which will let you run it locally.
1. In `package.json` remove the macOS signing script: `"sign": "./ts/scripts/sign-macos.js",`
2. Build the app and ad-hoc sign the app bundle:
```
npm run generate
npm run build
cd release
# Pick the desired app bundle: mac, mac-arm64, or mac-universal
cd mac-arm64
codesign --force --deep --sign - Signal.app
```
3. Now you can run the app locally.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

113
app/EmojiService.ts Normal file
View file

@ -0,0 +1,113 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import * as z from 'zod';
import { protocol } from 'electron';
import LRU from 'lru-cache';
import type { OptionalResourceService } from './OptionalResourceService';
import { SignalService as Proto } from '../ts/protobuf';
const MANIFEST_PATH = join(__dirname, '..', 'build', 'jumbomoji.json');
const manifestSchema = z.record(z.string(), z.string().array());
function utf16ToEmoji(utf16: string): string {
const codePoints = new Array<number>();
const buf = Buffer.from(utf16, 'hex');
for (let i = 0; i < buf.length; i += 2) {
codePoints.push(buf.readUint16BE(i));
}
return String.fromCodePoint(...codePoints);
}
export type ManifestType = z.infer<typeof manifestSchema>;
type EmojiEntryType = Readonly<{
utf16: string;
sheet: string;
}>;
type SheetCacheEntry = Map<string, Uint8Array>;
export class EmojiService {
private readonly emojiMap = new Map<string, EmojiEntryType>();
private readonly sheetCache = new LRU<string, SheetCacheEntry>({
// Each sheet is roughly 500kb
max: 10,
});
private constructor(
private readonly resourceService: OptionalResourceService,
manifest: ManifestType
) {
protocol.handle('emoji', async req => {
const url = new URL(req.url);
const emoji = url.searchParams.get('emoji');
if (!emoji) {
return new Response('invalid', { status: 400 });
}
return this.fetch(emoji);
});
for (const [sheet, emojiList] of Object.entries(manifest)) {
for (const utf16 of emojiList) {
this.emojiMap.set(utf16ToEmoji(utf16), { sheet, utf16 });
}
}
}
public static async create(
resourceService: OptionalResourceService
): Promise<EmojiService> {
const json = await readFile(MANIFEST_PATH, 'utf8');
const manifest = manifestSchema.parse(JSON.parse(json));
return new EmojiService(resourceService, manifest);
}
private async fetch(emoji: string): Promise<Response> {
const entry = this.emojiMap.get(emoji);
if (!entry) {
return new Response('entry not found', { status: 404 });
}
const { sheet, utf16 } = entry;
let imageMap = this.sheetCache.get(sheet);
if (!imageMap) {
const proto = await this.resourceService.getData(
`emoji-sheet-${sheet}.proto`
);
if (!proto) {
return new Response('resource not found', { status: 404 });
}
const pack = Proto.JumbomojiPack.decode(proto);
imageMap = new Map(
pack.items.map(({ name, image }) => [
name ?? '',
image || new Uint8Array(0),
])
);
this.sheetCache.set(sheet, imageMap);
}
const image = imageMap.get(utf16);
if (!image) {
return new Response('image not found', { status: 404 });
}
return new Response(image, {
status: 200,
headers: {
'content-type': 'image/webp',
'cache-control': 'public, max-age=2592000, immutable',
},
});
}
}

View file

@ -0,0 +1,188 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { join, dirname } from 'node:path';
import { mkdir, readFile, readdir, writeFile, unlink } from 'node:fs/promises';
import { createHash, timingSafeEqual } from 'node:crypto';
import { ipcMain } from 'electron';
import LRU from 'lru-cache';
import got from 'got';
import PQueue from 'p-queue';
import type {
OptionalResourceType,
OptionalResourcesDictType,
} from '../ts/types/OptionalResource';
import { OptionalResourcesDictSchema } from '../ts/types/OptionalResource';
import * as log from '../ts/logging/log';
import { getGotOptions } from '../ts/updater/got';
import { drop } from '../ts/util/drop';
const RESOURCES_DICT_PATH = join(
__dirname,
'..',
'build',
'optional-resources.json'
);
const MAX_CACHE_SIZE = 50 * 1024 * 1024;
export class OptionalResourceService {
private maybeDeclaration: OptionalResourcesDictType | undefined;
private readonly cache = new LRU<string, Buffer>({
max: MAX_CACHE_SIZE,
length: buf => buf.length,
});
private readonly fileQueues = new Map<string, PQueue>();
private constructor(private readonly resourcesDir: string) {
ipcMain.handle('OptionalResourceService:getData', (_event, name) =>
this.getData(name)
);
drop(this.lazyInit());
}
public static create(resourcesDir: string): OptionalResourceService {
return new OptionalResourceService(resourcesDir);
}
public async getData(name: string): Promise<Buffer | undefined> {
await this.lazyInit();
const decl = this.declaration[name];
if (!decl) {
return undefined;
}
const inMemory = this.cache.get(name);
if (inMemory) {
return inMemory;
}
const filePath = join(this.resourcesDir, name);
return this.queueFileWork(filePath, async () => {
try {
const onDisk = await readFile(filePath);
const digest = createHash('sha512').update(onDisk).digest();
// Same digest and size
if (
timingSafeEqual(digest, Buffer.from(decl.digest, 'base64')) &&
onDisk.length === decl.size
) {
log.warn(`OptionalResourceService: loaded ${name} from disk`);
this.cache.set(name, onDisk);
return onDisk;
}
log.warn(`OptionalResourceService: ${name} is no longer valid on disk`);
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
// We get here if file doesn't exist or if its digest/size is different
try {
await unlink(filePath);
} catch {
// Just do our best effort and move forward
}
return this.fetch(name, decl, filePath);
});
}
//
// Private
//
private async lazyInit(): Promise<void> {
if (this.maybeDeclaration !== undefined) {
return;
}
const json = JSON.parse(await readFile(RESOURCES_DICT_PATH, 'utf8'));
this.maybeDeclaration = OptionalResourcesDictSchema.parse(json);
// Clean unknown resources
let subPaths: Array<string>;
try {
subPaths = await readdir(this.resourcesDir);
} catch (error) {
// Directory wasn't created yet
if (error.code === 'ENOENT') {
return;
}
throw error;
}
await Promise.all(
subPaths.map(async subPath => {
if (this.declaration[subPath]) {
return;
}
const fullPath = join(this.resourcesDir, subPath);
try {
await unlink(fullPath);
} catch (error) {
log.error(
`OptionalResourceService: failed to cleanup ${subPath}`,
error
);
}
})
);
}
private get declaration(): OptionalResourcesDictType {
if (this.maybeDeclaration === undefined) {
throw new Error('optional-resources.json not loaded yet');
}
return this.maybeDeclaration;
}
private async queueFileWork<R>(
filePath: string,
body: () => Promise<R>
): Promise<R> {
let queue = this.fileQueues.get(filePath);
if (!queue) {
queue = new PQueue({ concurrency: 1 });
this.fileQueues.set(filePath, queue);
}
try {
return await queue.add(body);
} finally {
if (queue.size === 0) {
this.fileQueues.delete(filePath);
}
}
}
private async fetch(
name: string,
decl: OptionalResourceType,
destPath: string
): Promise<Buffer> {
const result = await got(decl.url, await getGotOptions()).buffer();
this.cache.set(name, result);
try {
await mkdir(dirname(destPath), { recursive: true });
await writeFile(destPath, result);
} catch (error) {
log.error('OptionalResourceService: failed to save file', error);
// Still return the data that we just fetched
}
return result;
}
}

View file

@ -8,7 +8,6 @@ import {
SystemTraySetting, SystemTraySetting,
} from '../ts/types/SystemTraySetting'; } from '../ts/types/SystemTraySetting';
import { isSystemTraySupported } from '../ts/types/Settings'; import { isSystemTraySupported } from '../ts/types/Settings';
import type { MainSQL } from '../ts/sql/main';
import type { ConfigType } from './base_config'; import type { ConfigType } from './base_config';
/** /**
@ -21,10 +20,8 @@ export class SystemTraySettingCache {
private getPromise: undefined | Promise<SystemTraySetting>; private getPromise: undefined | Promise<SystemTraySetting>;
constructor( constructor(
private readonly sql: Pick<MainSQL, 'sqlCall'>,
private readonly ephemeralConfig: Pick<ConfigType, 'get' | 'set'>, private readonly ephemeralConfig: Pick<ConfigType, 'get' | 'set'>,
private readonly argv: Array<string>, private readonly argv: Array<string>
private readonly appVersion: string
) {} ) {}
async get(): Promise<SystemTraySetting> { async get(): Promise<SystemTraySetting> {
@ -55,16 +52,12 @@ export class SystemTraySettingCache {
log.info( log.info(
`getSystemTraySetting saw --use-tray-icon flag. Returning ${result}` `getSystemTraySetting saw --use-tray-icon flag. Returning ${result}`
); );
} else if (isSystemTraySupported(OS, this.appVersion)) { } else if (isSystemTraySupported(OS)) {
const fastValue = this.ephemeralConfig.get('system-tray-setting'); const value = this.ephemeralConfig.get('system-tray-setting');
if (fastValue !== undefined) { if (value !== undefined) {
log.info('getSystemTraySetting got fast value', fastValue); log.info('getSystemTraySetting got value', value);
} }
const value =
fastValue ??
(await this.sql.sqlCall('getItemById', 'system-tray-setting'))?.value;
if (value !== undefined) { if (value !== undefined) {
result = parseSystemTraySetting(value); result = parseSystemTraySetting(value);
log.info(`getSystemTraySetting returning ${result}`); log.info(`getSystemTraySetting returning ${result}`);
@ -73,7 +66,7 @@ export class SystemTraySettingCache {
log.info(`getSystemTraySetting got no value, returning ${result}`); log.info(`getSystemTraySetting got no value, returning ${result}`);
} }
if (result !== fastValue) { if (result !== value) {
this.ephemeralConfig.set('system-tray-setting', result); this.ephemeralConfig.set('system-tray-setting', result);
} }
} else { } else {

View file

@ -1,10 +1,16 @@
// Copyright 2018 Signal Messenger, LLC // Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { ipcMain } from 'electron'; import { ipcMain, protocol } from 'electron';
import { createReadStream } from 'node:fs';
import { join, normalize } from 'node:path';
import { Readable, PassThrough } from 'node:stream';
import z from 'zod';
import * as rimraf from 'rimraf'; import * as rimraf from 'rimraf';
import { RangeFinder, DefaultStorage } from '@indutny/range-finder';
import { import {
getAllAttachments, getAllAttachments,
getAvatarsPath,
getPath, getPath,
getStickersPath, getStickersPath,
getTempPath, getTempPath,
@ -20,6 +26,13 @@ import type { MainSQL } from '../ts/sql/main';
import type { MessageAttachmentsCursorType } from '../ts/sql/Interface'; import type { MessageAttachmentsCursorType } from '../ts/sql/Interface';
import * as Errors from '../ts/types/errors'; import * as Errors from '../ts/types/errors';
import { sleep } from '../ts/util/sleep'; import { sleep } from '../ts/util/sleep';
import { isPathInside } from '../ts/util/isPathInside';
import { missingCaseError } from '../ts/util/missingCaseError';
import { safeParseInteger } from '../ts/util/numbers';
import { SECOND } from '../ts/util/durations';
import { drop } from '../ts/util/drop';
import { strictAssert } from '../ts/util/assert';
import { decryptAttachmentV2ToSink } from '../ts/AttachmentCrypto';
let initialized = false; let initialized = false;
@ -31,6 +44,89 @@ const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments';
const INTERACTIVITY_DELAY = 50; const INTERACTIVITY_DELAY = 50;
type RangeFinderContextType = Readonly<
| {
type: 'ciphertext';
path: string;
keysBase64: string;
size: number;
}
| {
type: 'plaintext';
path: string;
}
>;
async function safeDecryptToSink(
...args: Parameters<typeof decryptAttachmentV2ToSink>
): Promise<void> {
try {
await decryptAttachmentV2ToSink(...args);
} catch (error) {
// These errors happen when canceling fetch from `attachment://` urls,
// ignore them to avoid noise in the logs.
if (
error.name === 'AbortError' ||
error.code === 'ERR_STREAM_PREMATURE_CLOSE'
) {
return;
}
console.error(
'handleAttachmentRequest: decryption error',
Errors.toLogFormat(error)
);
}
}
const storage = new DefaultStorage<RangeFinderContextType>(
ctx => {
if (ctx.type === 'plaintext') {
return createReadStream(ctx.path);
}
if (ctx.type === 'ciphertext') {
const options = {
ciphertextPath: ctx.path,
idForLogging: 'attachment_channel',
keysBase64: ctx.keysBase64,
type: 'local' as const,
size: ctx.size,
};
const plaintext = new PassThrough();
drop(safeDecryptToSink(options, plaintext));
return plaintext;
}
throw missingCaseError(ctx);
},
{
maxSize: 10,
ttl: SECOND,
cacheKey: ctx => {
if (ctx.type === 'ciphertext') {
return `${ctx.type}:${ctx.path}:${ctx.size}:${ctx.keysBase64}`;
}
if (ctx.type === 'plaintext') {
return `${ctx.type}:${ctx.path}`;
}
throw missingCaseError(ctx);
},
}
);
const rangeFinder = new RangeFinder<RangeFinderContextType>(storage, {
noActiveReuse: true,
});
const dispositionSchema = z.enum([
'attachment',
'temporary',
'draft',
'sticker',
'avatarData',
]);
type DeleteOrphanedAttachmentsOptionsType = Readonly<{ type DeleteOrphanedAttachmentsOptionsType = Readonly<{
orphanedAttachments: Set<string>; orphanedAttachments: Set<string>;
sql: MainSQL; sql: MainSQL;
@ -48,11 +144,11 @@ async function cleanupOrphanedAttachments({
}: CleanupOrphanedAttachmentsOptionsType): Promise<void> { }: CleanupOrphanedAttachmentsOptionsType): Promise<void> {
await deleteAllBadges({ await deleteAllBadges({
userDataPath, userDataPath,
pathsToKeep: await sql.sqlCall('getAllBadgeImageFileLocalPaths'), pathsToKeep: await sql.sqlRead('getAllBadgeImageFileLocalPaths'),
}); });
const allStickers = await getAllStickers(userDataPath); const allStickers = await getAllStickers(userDataPath);
const orphanedStickers = await sql.sqlCall( const orphanedStickers = await sql.sqlWrite(
'removeKnownStickers', 'removeKnownStickers',
allStickers allStickers
); );
@ -62,7 +158,7 @@ async function cleanupOrphanedAttachments({
}); });
const allDraftAttachments = await getAllDraftAttachments(userDataPath); const allDraftAttachments = await getAllDraftAttachments(userDataPath);
const orphanedDraftAttachments = await sql.sqlCall( const orphanedDraftAttachments = await sql.sqlWrite(
'removeKnownDraftAttachments', 'removeKnownDraftAttachments',
allDraftAttachments allDraftAttachments
); );
@ -80,7 +176,7 @@ async function cleanupOrphanedAttachments({
); );
{ {
const attachments: ReadonlyArray<string> = await sql.sqlCall( const attachments: ReadonlyArray<string> = await sql.sqlRead(
'getKnownConversationAttachments' 'getKnownConversationAttachments'
); );
@ -122,7 +218,7 @@ function deleteOrphanedAttachments({
let attachments: ReadonlyArray<string>; let attachments: ReadonlyArray<string>;
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
({ attachments, cursor } = await sql.sqlCall( ({ attachments, cursor } = await sql.sqlRead(
'getKnownMessageAttachments', 'getKnownMessageAttachments',
cursor cursor
)); ));
@ -146,7 +242,7 @@ function deleteOrphanedAttachments({
} while (cursor !== undefined && !cursor.done); } while (cursor !== undefined && !cursor.done);
} finally { } finally {
if (cursor !== undefined) { if (cursor !== undefined) {
await sql.sqlCall('finishGetKnownMessageAttachments', cursor); await sql.sqlRead('finishGetKnownMessageAttachments', cursor);
} }
} }
@ -181,6 +277,12 @@ function deleteOrphanedAttachments({
void runSafe(); void runSafe();
} }
let attachmentsDir: string | undefined;
let stickersDir: string | undefined;
let tempDir: string | undefined;
let draftDir: string | undefined;
let avatarDataDir: string | undefined;
export function initialize({ export function initialize({
configDir, configDir,
sql, sql,
@ -193,15 +295,28 @@ export function initialize({
} }
initialized = true; initialized = true;
const attachmentsDir = getPath(configDir); attachmentsDir = getPath(configDir);
const stickersDir = getStickersPath(configDir); stickersDir = getStickersPath(configDir);
const tempDir = getTempPath(configDir); tempDir = getTempPath(configDir);
const draftDir = getDraftPath(configDir); draftDir = getDraftPath(configDir);
avatarDataDir = getAvatarsPath(configDir);
ipcMain.handle(ERASE_TEMP_KEY, () => rimraf.sync(tempDir)); ipcMain.handle(ERASE_TEMP_KEY, () => {
ipcMain.handle(ERASE_ATTACHMENTS_KEY, () => rimraf.sync(attachmentsDir)); strictAssert(tempDir != null, 'not initialized');
ipcMain.handle(ERASE_STICKERS_KEY, () => rimraf.sync(stickersDir)); rimraf.sync(tempDir);
ipcMain.handle(ERASE_DRAFTS_KEY, () => rimraf.sync(draftDir)); });
ipcMain.handle(ERASE_ATTACHMENTS_KEY, () => {
strictAssert(attachmentsDir != null, 'not initialized');
rimraf.sync(attachmentsDir);
});
ipcMain.handle(ERASE_STICKERS_KEY, () => {
strictAssert(stickersDir != null, 'not initialized');
rimraf.sync(stickersDir);
});
ipcMain.handle(ERASE_DRAFTS_KEY, () => {
strictAssert(draftDir != null, 'not initialized');
rimraf.sync(draftDir);
});
ipcMain.handle(CLEANUP_ORPHANED_ATTACHMENTS_KEY, async () => { ipcMain.handle(CLEANUP_ORPHANED_ATTACHMENTS_KEY, async () => {
const start = Date.now(); const start = Date.now();
@ -209,4 +324,172 @@ export function initialize({
const duration = Date.now() - start; const duration = Date.now() - start;
console.log(`cleanupOrphanedAttachments: took ${duration}ms`); console.log(`cleanupOrphanedAttachments: took ${duration}ms`);
}); });
protocol.handle('attachment', handleAttachmentRequest);
}
export async function handleAttachmentRequest(req: Request): Promise<Response> {
const url = new URL(req.url);
if (url.host !== 'v1' && url.host !== 'v2') {
return new Response('Unknown host', { status: 404 });
}
// Disposition
let disposition: z.infer<typeof dispositionSchema> = 'attachment';
const dispositionParam = url.searchParams.get('disposition');
if (dispositionParam != null) {
disposition = dispositionSchema.parse(dispositionParam);
}
strictAssert(attachmentsDir != null, 'not initialized');
strictAssert(tempDir != null, 'not initialized');
strictAssert(draftDir != null, 'not initialized');
strictAssert(stickersDir != null, 'not initialized');
strictAssert(avatarDataDir != null, 'not initialized');
let parentDir: string;
switch (disposition) {
case 'attachment':
parentDir = attachmentsDir;
break;
case 'temporary':
parentDir = tempDir;
break;
case 'draft':
parentDir = draftDir;
break;
case 'sticker':
parentDir = stickersDir;
break;
case 'avatarData':
parentDir = avatarDataDir;
break;
default:
throw missingCaseError(disposition);
}
// Remove first slash
const path = normalize(
join(parentDir, ...url.pathname.slice(1).split(/\//g))
);
if (!isPathInside(path, parentDir)) {
return new Response('Access denied', { status: 401 });
}
// Get attachment size to trim the padding
const sizeParam = url.searchParams.get('size');
let maybeSize: number | undefined;
if (sizeParam != null) {
const intValue = safeParseInteger(sizeParam);
if (intValue != null) {
maybeSize = intValue;
}
}
let context: RangeFinderContextType;
// Legacy plaintext attachments
if (url.host === 'v1') {
context = {
type: 'plaintext',
path,
};
} else {
// Encrypted attachments
// Get AES+MAC key
const maybeKeysBase64 = url.searchParams.get('key');
if (maybeKeysBase64 == null) {
return new Response('Missing key', { status: 400 });
}
// Size is required for trimming padding
if (maybeSize == null) {
return new Response('Missing size', { status: 400 });
}
context = {
type: 'ciphertext',
path,
keysBase64: maybeKeysBase64,
size: maybeSize,
};
}
try {
return handleRangeRequest({
request: req,
size: maybeSize,
context,
});
} catch (error) {
console.error('handleAttachmentRequest: error', Errors.toLogFormat(error));
throw error;
}
}
type HandleRangeRequestOptionsType = Readonly<{
request: Request;
size: number | undefined;
context: RangeFinderContextType;
}>;
function handleRangeRequest({
request,
size,
context,
}: HandleRangeRequestOptionsType): Response {
const url = new URL(request.url);
// Get content-type
const contentType = url.searchParams.get('contentType');
const headers: HeadersInit = {
'cache-control': 'no-cache, no-store',
'content-type': contentType || 'application/octet-stream',
};
if (size != null) {
headers['content-length'] = size.toString();
}
const create200Response = (): Response => {
const plaintext = rangeFinder.get(0, context);
return new Response(Readable.toWeb(plaintext) as ReadableStream<Buffer>, {
status: 200,
headers,
});
};
const range = request.headers.get('range');
if (range == null) {
return create200Response();
}
// Chromium only sends open-ended ranges: "start-"
const match = range.match(/^bytes=(\d+)-$/);
if (match == null) {
console.error(`attachment_channel: invalid range header: ${range}`);
return create200Response();
}
const startParam = safeParseInteger(match[1]);
if (startParam == null) {
console.error(`attachment_channel: invalid range header: ${range}`);
return create200Response();
}
const start = Math.min(startParam, size || Infinity);
headers['content-range'] = `bytes ${start}-/${size ?? '*'}`;
if (size !== undefined) {
headers['content-length'] = (size - start).toString();
}
const stream = rangeFinder.get(start, context);
return new Response(Readable.toWeb(stream) as ReadableStream<Buffer>, {
status: 206,
headers,
});
} }

View file

@ -1,12 +1,19 @@
// Copyright 2018 Signal Messenger, LLC // Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { PassThrough } from 'node:stream';
import { join, relative, normalize } from 'path'; import { join, relative, normalize } from 'path';
import fastGlob from 'fast-glob'; import fastGlob from 'fast-glob';
import fse from 'fs-extra'; import fse from 'fs-extra';
import { map, isString } from 'lodash'; import { map, isString } from 'lodash';
import normalizePath from 'normalize-path'; import normalizePath from 'normalize-path';
import { isPathInside } from '../ts/util/isPathInside'; import { isPathInside } from '../ts/util/isPathInside';
import {
generateKeys,
decryptAttachmentV2ToSink,
encryptAttachmentV2ToDisk,
} from '../ts/AttachmentCrypto';
import type { LocalAttachmentV2Type } from '../ts/types/Attachment';
const PATH = 'attachments.noindex'; const PATH = 'attachments.noindex';
const AVATAR_PATH = 'avatars.noindex'; const AVATAR_PATH = 'avatars.noindex';
@ -190,3 +197,57 @@ export const deleteAllDraftAttachments = async ({
console.log(`deleteAllDraftAttachments: deleted ${attachments.length} files`); console.log(`deleteAllDraftAttachments: deleted ${attachments.length} files`);
}; };
export const readAndDecryptDataFromDisk = async ({
absolutePath,
keysBase64,
size,
}: {
absolutePath: string;
keysBase64: string;
size: number;
}): Promise<Uint8Array> => {
const sink = new PassThrough();
const chunks = new Array<Buffer>();
sink.on('data', chunk => chunks.push(chunk));
sink.resume();
await decryptAttachmentV2ToSink(
{
ciphertextPath: absolutePath,
idForLogging: 'attachments/readAndDecryptDataFromDisk',
keysBase64,
size,
type: 'local',
},
sink
);
return Buffer.concat(chunks);
};
export const writeNewAttachmentData = async ({
data,
getAbsoluteAttachmentPath,
}: {
data: Uint8Array;
getAbsoluteAttachmentPath: (relativePath: string) => string;
}): Promise<LocalAttachmentV2Type> => {
const keys = generateKeys();
const { plaintextHash, path } = await encryptAttachmentV2ToDisk({
plaintext: { data },
getAbsoluteAttachmentPath,
keys,
});
return {
version: 2,
plaintextHash,
size: data.byteLength,
path,
localKey: Buffer.from(keys).toString('base64'),
};
};

View file

@ -1,7 +1,8 @@
// Copyright 2018 Signal Messenger, LLC // Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { readFileSync, writeFileSync, unlinkSync } from 'fs'; import { readFileSync, unlinkSync } from 'fs';
import { sync as writeFileSync } from 'write-file-atomic';
import { get } from 'lodash'; import { get } from 'lodash';
import { set } from 'lodash/fp'; import { set } from 'lodash/fp';

View file

@ -1,7 +1,7 @@
// Copyright 2017 Signal Messenger, LLC // Copyright 2017 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { join } from 'path'; import { join, basename } from 'path';
import { app } from 'electron'; import { app } from 'electron';
import type { IConfig } from 'config'; import type { IConfig } from 'config';
@ -15,9 +15,12 @@ import {
// In production mode, NODE_ENV cannot be customized by the user // In production mode, NODE_ENV cannot be customized by the user
if (app.isPackaged) { if (app.isPackaged) {
setEnvironment(Environment.Production); setEnvironment(Environment.Production, false);
} else { } else {
setEnvironment(parseEnvironment(process.env.NODE_ENV || 'development')); setEnvironment(
parseEnvironment(process.env.NODE_ENV || 'development'),
Boolean(process.env.MOCK_TEST)
);
} }
// Set environment vars to configure node-config before requiring it // Set environment vars to configure node-config before requiring it
@ -44,6 +47,12 @@ if (getEnvironment() === Environment.Production) {
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const config: IConfig = require('config'); const config: IConfig = require('config');
if (getEnvironment() !== Environment.Production) {
config.util.getConfigSources().forEach(source => {
console.log(`config: Using config source ${basename(source.name)}`);
});
}
// Log resulting env vars in use by config // Log resulting env vars in use by config
[ [
'NODE_ENV', 'NODE_ENV',

View file

@ -6,7 +6,8 @@ import { readFileSync } from 'fs';
import { merge } from 'lodash'; import { merge } from 'lodash';
import * as LocaleMatcher from '@formatjs/intl-localematcher'; import * as LocaleMatcher from '@formatjs/intl-localematcher';
import { z } from 'zod'; import { z } from 'zod';
import { setupI18n } from '../ts/util/setupI18n'; import { setupI18n } from '../ts/util/setupI18nMain';
import { shouldNeverBeCalled } from '../ts/util/shouldNeverBeCalled';
import type { LoggerType } from '../ts/types/Logging'; import type { LoggerType } from '../ts/types/Logging';
import type { HourCyclePreference, LocaleMessagesType } from '../ts/types/I18N'; import type { HourCyclePreference, LocaleMessagesType } from '../ts/types/I18N';
@ -142,7 +143,9 @@ export function load({
// We start with english, then overwrite that with anything present in locale // We start with english, then overwrite that with anything present in locale
const finalMessages = merge(englishMessages, matchedLocaleMessages); const finalMessages = merge(englishMessages, matchedLocaleMessages);
const i18n = setupI18n(matchedLocale, finalMessages); const i18n = setupI18n(matchedLocale, finalMessages, {
renderEmojify: shouldNeverBeCalled,
});
const direction = const direction =
localeDirectionTestingOverride ?? getLocaleDirection(matchedLocale, logger); localeDirectionTestingOverride ?? getLocaleDirection(matchedLocale, logger);
logger.info(`locale: Text info direction for ${matchedLocale}: ${direction}`); logger.info(`locale: Text info direction for ${matchedLocale}: ${direction}`);

View file

@ -28,6 +28,8 @@ import {
shell, shell,
systemPreferences, systemPreferences,
Notification, Notification,
safeStorage,
protocol as electronProtocol,
} from 'electron'; } from 'electron';
import type { MenuItemConstructorOptions, Settings } from 'electron'; import type { MenuItemConstructorOptions, Settings } from 'electron';
import { z } from 'zod'; import { z } from 'zod';
@ -79,12 +81,17 @@ import { updateDefaultSession } from './updateDefaultSession';
import { PreventDisplaySleepService } from './PreventDisplaySleepService'; import { PreventDisplaySleepService } from './PreventDisplaySleepService';
import { SystemTrayService, focusAndForceToTop } from './SystemTrayService'; import { SystemTrayService, focusAndForceToTop } from './SystemTrayService';
import { SystemTraySettingCache } from './SystemTraySettingCache'; import { SystemTraySettingCache } from './SystemTraySettingCache';
import { OptionalResourceService } from './OptionalResourceService';
import { EmojiService } from './EmojiService';
import { import {
SystemTraySetting, SystemTraySetting,
shouldMinimizeToSystemTray, shouldMinimizeToSystemTray,
parseSystemTraySetting, parseSystemTraySetting,
} from '../ts/types/SystemTraySetting'; } from '../ts/types/SystemTraySetting';
import { isSystemTraySupported } from '../ts/types/Settings'; import {
getDefaultSystemTraySetting,
isSystemTraySupported,
} from '../ts/types/Settings';
import * as ephemeralConfig from './ephemeral_config'; import * as ephemeralConfig from './ephemeral_config';
import * as logging from '../ts/logging/main_process_logging'; import * as logging from '../ts/logging/main_process_logging';
import { MainSQL } from '../ts/sql/main'; import { MainSQL } from '../ts/sql/main';
@ -109,13 +116,15 @@ import { load as loadLocale } from './locale';
import type { LoggerType } from '../ts/types/Logging'; import type { LoggerType } from '../ts/types/Logging';
import { HourCyclePreference } from '../ts/types/I18N'; import { HourCyclePreference } from '../ts/types/I18N';
import { ScreenShareStatus } from '../ts/types/Calling';
import { DBVersionFromFutureError } from '../ts/sql/migrations'; import { DBVersionFromFutureError } from '../ts/sql/migrations';
import type { ParsedSignalRoute } from '../ts/util/signalRoutes'; import type { ParsedSignalRoute } from '../ts/util/signalRoutes';
import { parseSignalRoute } from '../ts/util/signalRoutes'; import { parseSignalRoute } from '../ts/util/signalRoutes';
import * as dns from '../ts/util/dns'; import * as dns from '../ts/util/dns';
import { ZoomFactorService } from '../ts/services/ZoomFactorService'; import { ZoomFactorService } from '../ts/services/ZoomFactorService';
import { SafeStorageBackendChangeError } from '../ts/types/SafeStorageBackendChangeError';
const STICKER_CREATOR_PARTITION = 'sticker-creator'; import { LINUX_PASSWORD_STORE_FLAGS } from '../ts/util/linuxPasswordStoreFlags';
import { getOwn } from '../ts/util/getOwn';
const animationSettings = systemPreferences.getAnimationSettings(); const animationSettings = systemPreferences.getAnimationSettings();
@ -174,6 +183,8 @@ nativeThemeNotifier.initialize();
let appStartInitialSpellcheckSetting = true; let appStartInitialSpellcheckSetting = true;
let macInitialOpenUrlRoute: ParsedSignalRoute | undefined;
const cliParser = createParser({ const cliParser = createParser({
allowUnknown: true, allowUnknown: true,
options: [ options: [
@ -203,6 +214,7 @@ const defaultWebPrefs = {
const DISABLE_GPU = const DISABLE_GPU =
OS.isLinux() && !process.argv.some(arg => arg === '--enable-gpu'); OS.isLinux() && !process.argv.some(arg => arg === '--enable-gpu');
const DISABLE_IPV6 = process.argv.some(arg => arg === '--disable-ipv6');
const FORCE_ENABLE_CRASH_REPORTS = process.argv.some( const FORCE_ENABLE_CRASH_REPORTS = process.argv.some(
arg => arg === '--enable-crash-reports' arg => arg === '--enable-crash-reports'
); );
@ -275,10 +287,19 @@ if (!process.mas) {
return true; return true;
}); });
// This event is received in macOS packaged builds.
app.on('open-url', (event, incomingHref) => { app.on('open-url', (event, incomingHref) => {
event.preventDefault(); event.preventDefault();
const route = parseSignalRoute(incomingHref); const route = parseSignalRoute(incomingHref);
if (route != null) { if (route != null) {
// When the app isn't open and you click a signal link to open the app, then
// this event will emit before mainWindow is ready. We save the value for later.
if (mainWindow == null || !mainWindow.webContents) {
macInitialOpenUrlRoute = route;
return;
}
handleSignalRoute(route); handleSignalRoute(route);
} }
}); });
@ -292,22 +313,18 @@ const sql = new MainSQL();
const heicConverter = getHeicConverter(); const heicConverter = getHeicConverter();
async function getSpellCheckSetting(): Promise<boolean> { async function getSpellCheckSetting(): Promise<boolean> {
const fastValue = ephemeralConfig.get('spell-check'); const value = ephemeralConfig.get('spell-check');
if (typeof fastValue === 'boolean') { if (typeof value === 'boolean') {
getLogger().info('got fast spellcheck setting', fastValue); getLogger().info('got fast spellcheck setting', value);
return fastValue; return value;
} }
const json = await sql.sqlCall('getItemById', 'spell-check');
// Default to `true` if setting doesn't exist yet // Default to `true` if setting doesn't exist yet
const slowValue = typeof json?.value === 'boolean' ? json.value : true; ephemeralConfig.set('spell-check', true);
ephemeralConfig.set('spell-check', slowValue); getLogger().info('initializing spellcheck setting', true);
getLogger().info('got slow spellcheck setting', slowValue); return true;
return slowValue;
} }
type GetThemeSettingOptionsType = Readonly<{ type GetThemeSettingOptionsType = Readonly<{
@ -317,29 +334,22 @@ type GetThemeSettingOptionsType = Readonly<{
async function getThemeSetting({ async function getThemeSetting({
ephemeralOnly = false, ephemeralOnly = false,
}: GetThemeSettingOptionsType = {}): Promise<ThemeSettingType> { }: GetThemeSettingOptionsType = {}): Promise<ThemeSettingType> {
let result: unknown; const value = ephemeralConfig.get('theme-setting');
if (value !== undefined) {
const fastValue = ephemeralConfig.get('theme-setting'); getLogger().info('got fast theme-setting value', value);
if (fastValue !== undefined) {
getLogger().info('got fast theme-setting value', fastValue);
result = fastValue;
} else if (ephemeralOnly) { } else if (ephemeralOnly) {
return 'system'; return 'system';
} else {
const json = await sql.sqlCall('getItemById', 'theme-setting');
result = json?.value;
} }
// Default to `system` if setting doesn't exist or is invalid // Default to `system` if setting doesn't exist or is invalid
const validatedResult = const validatedResult =
result === 'light' || result === 'dark' || result === 'system' value === 'light' || value === 'dark' || value === 'system'
? result ? value
: 'system'; : 'system';
if (fastValue !== validatedResult) { if (value !== validatedResult) {
ephemeralConfig.set('theme-setting', validatedResult); ephemeralConfig.set('theme-setting', validatedResult);
getLogger().info('got slow theme-setting value', result); getLogger().info('saving theme-setting value', validatedResult);
} }
return validatedResult; return validatedResult;
@ -372,35 +382,31 @@ async function getBackgroundColor(
} }
async function getLocaleOverrideSetting(): Promise<string | null> { async function getLocaleOverrideSetting(): Promise<string | null> {
const fastValue = ephemeralConfig.get('localeOverride'); const value = ephemeralConfig.get('localeOverride');
// eslint-disable-next-line eqeqeq -- Checking for null explicitly // eslint-disable-next-line eqeqeq -- Checking for null explicitly
if (typeof fastValue === 'string' || fastValue === null) { if (typeof value === 'string' || value === null) {
getLogger().info('got fast localeOverride setting', fastValue); getLogger().info('got fast localeOverride setting', value);
return fastValue; return value;
} }
const json = await sql.sqlCall('getItemById', 'localeOverride');
// Default to `null` if setting doesn't exist yet // Default to `null` if setting doesn't exist yet
const slowValue = typeof json?.value === 'string' ? json.value : null; ephemeralConfig.set('localeOverride', null);
ephemeralConfig.set('localeOverride', slowValue); getLogger().info('initializing localeOverride setting', null);
getLogger().info('got slow localeOverride setting', slowValue); return null;
return slowValue;
} }
const zoomFactorService = new ZoomFactorService({ const zoomFactorService = new ZoomFactorService({
async getZoomFactorSetting() { async getZoomFactorSetting() {
const item = await sql.sqlCall('getItemById', 'zoomFactor'); const item = await sql.sqlRead('getItemById', 'zoomFactor');
if (typeof item?.value !== 'number') { if (typeof item?.value !== 'number') {
return null; return null;
} }
return item.value; return item.value;
}, },
async setZoomFactorSetting(zoomFactor) { async setZoomFactorSetting(zoomFactor) {
await sql.sqlCall('createOrUpdateItem', { await sql.sqlWrite('createOrUpdateItem', {
id: 'zoomFactor', id: 'zoomFactor',
value: zoomFactor, value: zoomFactor,
}); });
@ -409,10 +415,8 @@ const zoomFactorService = new ZoomFactorService({
let systemTrayService: SystemTrayService | undefined; let systemTrayService: SystemTrayService | undefined;
const systemTraySettingCache = new SystemTraySettingCache( const systemTraySettingCache = new SystemTraySettingCache(
sql,
ephemeralConfig, ephemeralConfig,
process.argv, process.argv
app.getVersion()
); );
const windowFromUserConfig = userConfig.get('window'); const windowFromUserConfig = userConfig.get('window');
@ -672,10 +676,19 @@ async function createWindow() {
const usePreloadBundle = const usePreloadBundle =
!isTestEnvironment(getEnvironment()) || forcePreloadBundle; !isTestEnvironment(getEnvironment()) || forcePreloadBundle;
const primaryDisplay = screen.getPrimaryDisplay();
const { width: maxWidth, height: maxHeight } = primaryDisplay.workAreaSize;
const width = windowConfig
? Math.min(windowConfig.width, maxWidth)
: DEFAULT_WIDTH;
const height = windowConfig
? Math.min(windowConfig.height, maxHeight)
: DEFAULT_HEIGHT;
const windowOptions: Electron.BrowserWindowConstructorOptions = { const windowOptions: Electron.BrowserWindowConstructorOptions = {
show: false, show: false,
width: DEFAULT_WIDTH, width,
height: DEFAULT_HEIGHT, height,
minWidth: MIN_WIDTH, minWidth: MIN_WIDTH,
minHeight: MIN_HEIGHT, minHeight: MIN_HEIGHT,
autoHideMenuBar: false, autoHideMenuBar: false,
@ -700,7 +713,7 @@ async function createWindow() {
disableBlinkFeatures: 'Accelerated2dCanvas,AcceleratedSmallCanvases', disableBlinkFeatures: 'Accelerated2dCanvas,AcceleratedSmallCanvases',
}, },
icon: windowIcon, icon: windowIcon,
...pick(windowConfig, ['autoHideMenuBar', 'width', 'height', 'x', 'y']), ...pick(windowConfig, ['autoHideMenuBar', 'x', 'y']),
}; };
if (!isNumber(windowOptions.width) || windowOptions.width < MIN_WIDTH) { if (!isNumber(windowOptions.width) || windowOptions.width < MIN_WIDTH) {
@ -880,15 +893,18 @@ async function createWindow() {
* if the user is in fullscreen mode and closes the window, not the * if the user is in fullscreen mode and closes the window, not the
* application, we need them leave fullscreen first before closing it to * application, we need them leave fullscreen first before closing it to
* prevent a black screen. * prevent a black screen.
* Also check for mainWindow because it might become undefined while
* waiting for close confirmation.
* *
* issue: https://github.com/signalapp/Signal-Desktop/issues/4348 * issue: https://github.com/signalapp/Signal-Desktop/issues/4348
*/ */
if (mainWindow) {
if (mainWindow.isFullScreen()) { if (mainWindow.isFullScreen()) {
mainWindow.once('leave-full-screen', () => mainWindow?.hide()); mainWindow.once('leave-full-screen', () => mainWindow?.hide());
mainWindow.setFullScreen(false); mainWindow.setFullScreen(false);
} else { } else {
mainWindow.hide(); mainWindow.hide();
}
} }
// On Mac, or on other platforms when the tray icon is in use, the window // On Mac, or on other platforms when the tray icon is in use, the window
@ -896,7 +912,11 @@ async function createWindow() {
const usingTrayIcon = shouldMinimizeToSystemTray( const usingTrayIcon = shouldMinimizeToSystemTray(
await systemTraySettingCache.get() await systemTraySettingCache.get()
); );
if (!windowState.shouldQuit() && (usingTrayIcon || OS.isMacOS())) { if (
mainWindow &&
!windowState.shouldQuit() &&
(usingTrayIcon || OS.isMacOS())
) {
if (usingTrayIcon) { if (usingTrayIcon) {
const shownTrayNotice = ephemeralConfig.get('shown-tray-notice'); const shownTrayNotice = ephemeralConfig.get('shown-tray-notice');
if (shownTrayNotice) { if (shownTrayNotice) {
@ -1011,21 +1031,24 @@ ipc.handle('database-ready', async () => {
getLogger().info('sending `database-ready`'); getLogger().info('sending `database-ready`');
}); });
ipc.handle('get-art-creator-auth', () => { ipc.handle(
const { promise, resolve } = explodePromise<unknown>(); 'art-creator:uploadStickerPack',
strictAssert(mainWindow, 'Main window did not exist'); (_event: Electron.Event, data: unknown) => {
const { promise, resolve } = explodePromise<unknown>();
strictAssert(mainWindow, 'Main window did not exist');
mainWindow.webContents.send('open-art-creator'); mainWindow.webContents.send('art-creator:uploadStickerPack', data);
ipc.handleOnce('open-art-creator', (_event, { username, password }) => { ipc.once('art-creator:uploadStickerPack:done', (_doneEvent, response) => {
resolve({ resolve(response);
baseUrl: config.get<string>('artCreatorUrl'),
username,
password,
}); });
});
return promise; return promise;
}
);
ipc.on('art-creator:onUploadProgress', () => {
stickerCreatorWindow?.webContents.send('art-creator:onUploadProgress');
}); });
ipc.on('show-window', () => { ipc.on('show-window', () => {
@ -1091,12 +1114,17 @@ async function readyForUpdates() {
isReadyForUpdates = true; isReadyForUpdates = true;
// First, install requested sticker pack // First, handle requested signal URLs
const incomingHref = maybeGetIncomingSignalRoute(process.argv); const incomingHref = maybeGetIncomingSignalRoute(process.argv);
if (incomingHref) { if (incomingHref) {
handleSignalRoute(incomingHref); handleSignalRoute(incomingHref);
} else if (macInitialOpenUrlRoute) {
handleSignalRoute(macInitialOpenUrlRoute);
} }
// Discard value even if we don't handle a saved URL.
macInitialOpenUrlRoute = undefined;
// Second, start checking for app updates // Second, start checking for app updates
try { try {
strictAssert( strictAssert(
@ -1247,6 +1275,67 @@ async function showScreenShareWindow(sourceName: string) {
); );
} }
let callingDevToolsWindow: BrowserWindow | undefined;
async function showCallingDevToolsWindow() {
if (callingDevToolsWindow) {
callingDevToolsWindow.show();
return;
}
const options = {
height: 1200,
width: 1000,
alwaysOnTop: false,
autoHideMenuBar: true,
backgroundColor: '#ffffff',
darkTheme: false,
frame: true,
fullscreenable: true,
maximizable: true,
minimizable: true,
resizable: true,
show: false,
title: getResolvedMessagesLocale().i18n('icu:callingDeveloperTools'),
titleBarStyle: nonMainTitleBarStyle,
webPreferences: {
...defaultWebPrefs,
nodeIntegration: false,
nodeIntegrationInWorker: false,
sandbox: true,
contextIsolation: true,
nativeWindowOpen: true,
preload: join(__dirname, '../bundles/calling-tools/preload.js'),
},
};
callingDevToolsWindow = new BrowserWindow(options);
await handleCommonWindowEvents(callingDevToolsWindow);
callingDevToolsWindow.once('closed', () => {
callingDevToolsWindow = undefined;
mainWindow?.webContents.send('calling:set-rtc-stats-interval', null);
});
ipc.on('calling:set-rtc-stats-interval', (_, intervalMillis: number) => {
mainWindow?.webContents.send(
'calling:set-rtc-stats-interval',
intervalMillis
);
});
ipc.on('calling:rtc-stats-report', (_, report) => {
callingDevToolsWindow?.webContents.send('calling:rtc-stats-report', report);
});
await safeLoadURL(
callingDevToolsWindow,
await prepareFileUrl([__dirname, '../calling_tools.html'])
);
callingDevToolsWindow.show();
}
let aboutWindow: BrowserWindow | undefined; let aboutWindow: BrowserWindow | undefined;
async function showAbout() { async function showAbout() {
if (aboutWindow) { if (aboutWindow) {
@ -1347,8 +1436,8 @@ async function showSettingsWindow() {
async function getIsLinked() { async function getIsLinked() {
try { try {
const number = await sql.sqlCall('getItemById', 'number_id'); const number = await sql.sqlRead('getItemById', 'number_id');
const password = await sql.sqlCall('getItemById', 'password'); const password = await sql.sqlRead('getItemById', 'password');
return Boolean(number && password); return Boolean(number && password);
} catch (e) { } catch (e) {
return false; return false;
@ -1514,7 +1603,7 @@ const runSQLCorruptionHandler = async () => {
`Restarting the application immediately. Error: ${error.message}` `Restarting the application immediately. Error: ${error.message}`
); );
await onDatabaseError(Errors.toLogFormat(error)); await onDatabaseError(error);
}; };
const runSQLReadonlyHandler = async () => { const runSQLReadonlyHandler = async () => {
@ -1531,31 +1620,139 @@ const runSQLReadonlyHandler = async () => {
throw error; throw error;
}; };
async function initializeSQL( function generateSQLKey(): string {
userDataPath: string getLogger().info(
): Promise<{ ok: true; error: undefined } | { ok: false; error: Error }> { 'key/initialize: Generating new encryption key, since we did not find it on disk'
let key: string | undefined; );
const keyFromConfig = userConfig.get('key'); // https://www.zetetic.net/sqlcipher/sqlcipher-api/#key
if (typeof keyFromConfig === 'string') { return randomBytes(32).toString('hex');
key = keyFromConfig; }
} else if (keyFromConfig) {
getLogger().warn( function getSQLKey(): string {
"initializeSQL: got key from config, but it wasn't a string" let update = false;
const isLinux = OS.isLinux();
const legacyKeyValue = userConfig.get('key');
const modernKeyValue = userConfig.get('encryptedKey');
const previousBackend = isLinux
? userConfig.get('safeStorageBackend')
: undefined;
const safeStorageBackend: string | undefined = isLinux
? safeStorage.getSelectedStorageBackend()
: undefined;
const isEncryptionAvailable =
safeStorage.isEncryptionAvailable() &&
(!isLinux || safeStorageBackend !== 'basic_text');
// On Linux the backend can change based on desktop environment and command line flags.
// If the backend changes we won't be able to decrypt the key.
if (
isLinux &&
typeof previousBackend === 'string' &&
previousBackend !== safeStorageBackend
) {
console.error(
`Detected change in safeStorage backend, can't decrypt DB key (previous: ${previousBackend}, current: ${safeStorageBackend})`
); );
throw new SafeStorageBackendChangeError({
currentBackend: String(safeStorageBackend),
previousBackend,
});
} }
if (!key) {
getLogger().info( let key: string;
'key/initialize: Generating new encryption key, since we did not find it on disk' if (typeof modernKeyValue === 'string') {
); if (!isEncryptionAvailable) {
// https://www.zetetic.net/sqlcipher/sqlcipher-api/#key throw new Error("Can't decrypt database key");
key = randomBytes(32).toString('hex'); }
getLogger().info('getSQLKey: decrypting key');
const encrypted = Buffer.from(modernKeyValue, 'hex');
key = safeStorage.decryptString(encrypted);
if (legacyKeyValue != null) {
getLogger().info('getSQLKey: removing legacy key');
userConfig.set('key', undefined);
}
if (isLinux && previousBackend == null) {
getLogger().info(
`getSQLKey: saving safeStorageBackend: ${safeStorageBackend}`
);
userConfig.set('safeStorageBackend', safeStorageBackend);
}
} else if (typeof legacyKeyValue === 'string') {
key = legacyKeyValue;
update = isEncryptionAvailable;
if (update) {
getLogger().info('getSQLKey: migrating key');
} else {
getLogger().info('getSQLKey: using legacy key');
}
} else {
getLogger().warn("getSQLKey: got key from config, but it wasn't a string");
key = generateSQLKey();
update = true;
}
if (!update) {
return key;
}
if (isEncryptionAvailable) {
getLogger().info('getSQLKey: updating encrypted key in the config');
const encrypted = safeStorage.encryptString(key).toString('hex');
userConfig.set('encryptedKey', encrypted);
userConfig.set('key', undefined);
if (isLinux && safeStorageBackend) {
getLogger().info(
`getSQLKey: saving safeStorageBackend: ${safeStorageBackend}`
);
userConfig.set('safeStorageBackend', safeStorageBackend);
}
} else {
getLogger().info('getSQLKey: updating plaintext key in the config');
userConfig.set('key', key); userConfig.set('key', key);
} }
return key;
}
async function initializeSQL(
userDataPath: string
): Promise<{ ok: true; error: undefined } | { ok: false; error: Error }> {
sqlInitTimeStart = Date.now(); sqlInitTimeStart = Date.now();
let key: string;
try {
key = getSQLKey();
} catch (error) {
try {
// Initialize with *some* key to setup paths
await sql.initialize({
appVersion: app.getVersion(),
configDir: userDataPath,
key: 'abcd',
logger: getLogger(),
});
} catch {
// Do nothing, we fail right below anyway.
}
if (error instanceof Error) {
return { ok: false, error };
}
return {
ok: false,
error: new Error(`initializeSQL: Caught a non-error '${error}'`),
};
}
try { try {
// This should be the first awaited call in this function, otherwise // This should be the first awaited call in this function, otherwise
// `sql.sqlCall` will throw an uninitialized error instead of waiting for // `sql.sqlRead` will throw an uninitialized error instead of waiting for
// init to finish. // init to finish.
await sql.initialize({ await sql.initialize({
appVersion: app.getVersion(), appVersion: app.getVersion(),
@ -1583,7 +1780,7 @@ async function initializeSQL(
return { ok: true, error: undefined }; return { ok: true, error: undefined };
} }
const onDatabaseError = async (error: string) => { const onDatabaseError = async (error: Error) => {
// Prevent window from re-opening // Prevent window from re-opening
ready = false; ready = false;
@ -1601,17 +1798,35 @@ const onDatabaseError = async (error: string) => {
const copyErrorAndQuitButtonIndex = 0; const copyErrorAndQuitButtonIndex = 0;
const SIGNAL_SUPPORT_LINK = 'https://support.signal.org/error'; const SIGNAL_SUPPORT_LINK = 'https://support.signal.org/error';
if (error.includes(DBVersionFromFutureError.name)) { if (error instanceof DBVersionFromFutureError) {
// If the DB version is too new, the user likely opened an older version of Signal, // If the DB version is too new, the user likely opened an older version of Signal,
// and they would almost never want to delete their data as a result, so we don't show // and they would almost never want to delete their data as a result, so we don't show
// that option // that option
messageDetail = i18n('icu:databaseError__startOldVersion'); messageDetail = i18n('icu:databaseError__startOldVersion');
} else if (error instanceof SafeStorageBackendChangeError) {
const { currentBackend, previousBackend } = error;
const previousBackendFlag = getOwn(
LINUX_PASSWORD_STORE_FLAGS,
previousBackend
);
messageDetail = previousBackendFlag
? i18n('icu:databaseError__safeStorageBackendChangeWithPreviousFlag', {
currentBackend,
previousBackend,
previousBackendFlag,
})
: i18n('icu:databaseError__safeStorageBackendChange', {
currentBackend,
previousBackend,
});
} else { } else {
// Otherwise, this is some other kind of DB error, let's give them the option to // Otherwise, this is some other kind of DB error, let's give them the option to
// delete. // delete.
messageDetail = i18n('icu:databaseError__detail', { messageDetail = i18n(
link: SIGNAL_SUPPORT_LINK, 'icu:databaseError__detail',
}); { link: SIGNAL_SUPPORT_LINK },
{ bidi: 'strip' }
);
buttons.push(i18n('icu:deleteAndRestart')); buttons.push(i18n('icu:deleteAndRestart'));
deleteAllDataButtonIndex = 1; deleteAllDataButtonIndex = 1;
@ -1628,7 +1843,9 @@ const onDatabaseError = async (error: string) => {
}); });
if (buttonIndex === copyErrorAndQuitButtonIndex) { if (buttonIndex === copyErrorAndQuitButtonIndex) {
clipboard.writeText(`Database startup error:\n\n${redactAll(error)}`); clipboard.writeText(
`Database startup error:\n\n${redactAll(Errors.toLogFormat(error))}`
);
} else if ( } else if (
typeof deleteAllDataButtonIndex === 'number' && typeof deleteAllDataButtonIndex === 'number' &&
buttonIndex === deleteAllDataButtonIndex buttonIndex === deleteAllDataButtonIndex
@ -1668,10 +1885,6 @@ let sqlInitPromise:
| Promise<{ ok: true; error: undefined } | { ok: false; error: Error }> | Promise<{ ok: true; error: undefined } | { ok: false; error: Error }>
| undefined; | undefined;
ipc.on('database-error', (_event: Electron.Event, error: string) => {
drop(onDatabaseError(error));
});
ipc.on('database-readonly', (_event: Electron.Event, error: string) => { ipc.on('database-readonly', (_event: Electron.Event, error: string) => {
// Just let global_errors.ts handle it // Just let global_errors.ts handle it
throw new Error(error); throw new Error(error);
@ -1721,21 +1934,32 @@ const featuresToDisable = `HardwareMediaKeyHandling,${app.commandLine.getSwitchV
)}`; )}`;
app.commandLine.appendSwitch('disable-features', featuresToDisable); app.commandLine.appendSwitch('disable-features', featuresToDisable);
// If we don't set this, Desktop will ask for access to keychain/keyring on startup
app.commandLine.appendSwitch('password-store', 'basic');
// <canvas/> rendering is often utterly broken on Linux when using GPU // <canvas/> rendering is often utterly broken on Linux when using GPU
// acceleration. // acceleration.
if (DISABLE_GPU) { if (DISABLE_GPU) {
app.disableHardwareAcceleration(); app.disableHardwareAcceleration();
} }
// This has to run before the 'ready' event.
electronProtocol.registerSchemesAsPrivileged([
{
scheme: 'attachment',
privileges: {
supportFetchAPI: true,
stream: true,
},
},
]);
// This method will be called when Electron has finished // This method will be called when Electron has finished
// initialization and is ready to create browser windows. // initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs. // Some APIs can only be used after this event occurs.
let ready = false; let ready = false;
app.on('ready', async () => { app.on('ready', async () => {
dns.setFallback(await getDNSFallback()); dns.setFallback(await getDNSFallback());
if (DISABLE_IPV6) {
dns.setIPv6Enabled(false);
}
const [userDataPath, crashDumpsPath, installPath] = await Promise.all([ const [userDataPath, crashDumpsPath, installPath] = await Promise.all([
realpath(app.getPath('userData')), realpath(app.getPath('userData')),
@ -1743,19 +1967,15 @@ app.on('ready', async () => {
realpath(app.getAppPath()), realpath(app.getAppPath()),
]); ]);
const webSession = session.fromPartition(STICKER_CREATOR_PARTITION); updateDefaultSession(session.defaultSession);
for (const s of [session.defaultSession, webSession]) { if (getEnvironment() !== Environment.Test) {
updateDefaultSession(s); installFileHandler({
session: session.defaultSession,
if (getEnvironment() !== Environment.Test) { userDataPath,
installFileHandler({ installPath,
session: s, isWindows: OS.isWindows(),
userDataPath, });
installPath,
isWindows: OS.isWindows(),
});
}
} }
installWebHandler({ installWebHandler({
@ -1763,17 +1983,15 @@ app.on('ready', async () => {
session: session.defaultSession, session: session.defaultSession,
}); });
installWebHandler({
enableHttp: true,
session: webSession,
});
logger = await logging.initialize(getMainWindow); logger = await logging.initialize(getMainWindow);
// Write buffered information into newly created logger. // Write buffered information into newly created logger.
consoleLogger.writeBufferInto(logger); consoleLogger.writeBufferInto(logger);
sqlInitPromise = initializeSQL(userDataPath); const resourceService = OptionalResourceService.create(
join(userDataPath, 'optionalResources')
);
await EmojiService.create(resourceService);
if (!resolvedTranslationsLocale) { if (!resolvedTranslationsLocale) {
preferredSystemLocales = resolveCanonicalLocales( preferredSystemLocales = resolveCanonicalLocales(
@ -1799,24 +2017,21 @@ app.on('ready', async () => {
}); });
} }
sqlInitPromise = initializeSQL(userDataPath);
// First run: configure Signal to minimize to tray. Additionally, on Windows // First run: configure Signal to minimize to tray. Additionally, on Windows
// enable auto-start with start-in-tray so that starting from a Desktop icon // enable auto-start with start-in-tray so that starting from a Desktop icon
// would still show the window. // would still show the window.
// (User can change these settings later) // (User can change these settings later)
if ( if (
isSystemTraySupported(OS, app.getVersion()) && isSystemTraySupported(OS) &&
(await systemTraySettingCache.get()) === SystemTraySetting.Uninitialized (await systemTraySettingCache.get()) === SystemTraySetting.Uninitialized
) { ) {
const newValue = SystemTraySetting.MinimizeToSystemTray; const newValue = getDefaultSystemTraySetting(OS, app.getVersion());
getLogger().info(`app.ready: setting system-tray-setting to ${newValue}`); getLogger().info(`app.ready: setting system-tray-setting to ${newValue}`);
systemTraySettingCache.set(newValue); systemTraySettingCache.set(newValue);
// Update both stores
ephemeralConfig.set('system-tray-setting', newValue); ephemeralConfig.set('system-tray-setting', newValue);
await sql.sqlCall('createOrUpdateItem', {
id: 'system-tray-setting',
value: newValue,
});
if (OS.isWindows()) { if (OS.isWindows()) {
getLogger().info('app.ready: enabling open at login'); getLogger().info('app.ready: enabling open at login');
@ -1832,6 +2047,32 @@ app.on('ready', async () => {
settingsChannel = new SettingsChannel(); settingsChannel = new SettingsChannel();
settingsChannel.install(); settingsChannel.install();
settingsChannel.on('change:systemTraySetting', async rawSystemTraySetting => {
const { openAtLogin } = app.getLoginItemSettings(
await getDefaultLoginItemSettings()
);
const systemTraySetting = parseSystemTraySetting(rawSystemTraySetting);
systemTraySettingCache.set(systemTraySetting);
if (systemTrayService) {
const isEnabled = shouldMinimizeToSystemTray(systemTraySetting);
systemTrayService.setEnabled(isEnabled);
}
// Default login item settings might have changed, so update the object.
getLogger().info('refresh-auto-launch: new value', openAtLogin);
app.setLoginItemSettings({
...(await getDefaultLoginItemSettings()),
openAtLogin,
});
});
settingsChannel.on(
'ephemeral-setting-changed',
sendPreferencesChangedEventToWindows
);
// We use this event only a single time to log the startup time of the app // We use this event only a single time to log the startup time of the app
// from when it's first ready until the loading screen disappears. // from when it's first ready until the loading screen disappears.
ipc.once('signal-app-loaded', (event, info) => { ipc.once('signal-app-loaded', (event, info) => {
@ -1984,7 +2225,7 @@ app.on('ready', async () => {
if (sqlError) { if (sqlError) {
getLogger().error('sql.initialize was unsuccessful; returning early'); getLogger().error('sql.initialize was unsuccessful; returning early');
await onDatabaseError(Errors.toLogFormat(sqlError)); await onDatabaseError(sqlError);
return; return;
} }
@ -1993,10 +2234,10 @@ app.on('ready', async () => {
try { try {
const IDB_KEY = 'indexeddb-delete-needed'; const IDB_KEY = 'indexeddb-delete-needed';
const item = await sql.sqlCall('getItemById', IDB_KEY); const item = await sql.sqlRead('getItemById', IDB_KEY);
if (item && item.value) { if (item && item.value) {
await sql.sqlCall('removeIndexedDBFiles'); await sql.sqlWrite('removeIndexedDBFiles');
await sql.sqlCall('removeItemById', IDB_KEY); await sql.sqlWrite('removeItemById', IDB_KEY);
} }
} catch (err) { } catch (err) {
getLogger().error( getLogger().error(
@ -2047,6 +2288,7 @@ function setupMenu(options?: Partial<CreateTemplateOptionsType>) {
setupAsStandalone, setupAsStandalone,
showAbout, showAbout,
showDebugLog: showDebugLogWindow, showDebugLog: showDebugLogWindow,
showCallingDevTools: showCallingDevToolsWindow,
showKeyboardShortcuts, showKeyboardShortcuts,
showSettings: showSettingsWindow, showSettings: showSettingsWindow,
showWindow, showWindow,
@ -2160,12 +2402,15 @@ async function requestShutdown() {
// exits the app before we've set everything up in preload() (so the browser isn't // exits the app before we've set everything up in preload() (so the browser isn't
// yet listening for these events), or if there are a whole lot of stacked-up tasks. // yet listening for these events), or if there are a whole lot of stacked-up tasks.
// Note: two minutes is also our timeout for SQL tasks in data.js in the browser. // Note: two minutes is also our timeout for SQL tasks in data.js in the browser.
timeout = setTimeout(() => { timeout = setTimeout(
getLogger().error( () => {
'requestShutdown: Response never received; forcing shutdown.' getLogger().error(
); 'requestShutdown: Response never received; forcing shutdown.'
resolveFn(); );
}, 2 * 60 * 1000); resolveFn();
},
2 * 60 * 1000
);
}); });
try { try {
@ -2178,11 +2423,19 @@ async function requestShutdown() {
function getWindowDebugInfo() { function getWindowDebugInfo() {
const windows = BrowserWindow.getAllWindows(); const windows = BrowserWindow.getAllWindows();
return { try {
windowCount: windows.length, return {
mainWindowExists: windows.some(win => win === mainWindow), windowCount: windows.length,
mainWindowIsFullScreen: mainWindow?.isFullScreen(), mainWindowExists: windows.some(win => win === mainWindow),
}; mainWindowIsFullScreen: mainWindow?.isFullScreen(),
};
} catch {
return {
windowCount: 0,
mainWindowExists: false,
mainWindowIsFullScreen: false,
};
}
} }
app.on('before-quit', e => { app.on('before-quit', e => {
@ -2318,36 +2571,21 @@ ipc.on(
} }
); );
ipc.handle( ipc.on(
'update-system-tray-setting', 'screen-share:status-change',
async (_event, rawSystemTraySetting /* : Readonly<unknown> */) => { (_event: Electron.Event, status: ScreenShareStatus) => {
const { openAtLogin } = app.getLoginItemSettings( if (!screenShareWindow) {
await getDefaultLoginItemSettings() return;
);
const systemTraySetting = parseSystemTraySetting(rawSystemTraySetting);
systemTraySettingCache.set(systemTraySetting);
if (systemTrayService) {
const isEnabled = shouldMinimizeToSystemTray(systemTraySetting);
systemTrayService.setEnabled(isEnabled);
} }
// Default login item settings might have changed, so update the object. if (status === ScreenShareStatus.Disconnected) {
getLogger().info('refresh-auto-launch: new value', openAtLogin); screenShareWindow.close();
app.setLoginItemSettings({ } else {
...(await getDefaultLoginItemSettings()), screenShareWindow.webContents.send('status-change', status);
openAtLogin, }
});
} }
); );
ipc.on('close-screen-share-controller', () => {
if (screenShareWindow) {
screenShareWindow.close();
}
});
ipc.on('stop-screen-share', () => { ipc.on('stop-screen-share', () => {
if (mainWindow) { if (mainWindow) {
mainWindow.webContents.send('stop-screen-share'); mainWindow.webContents.send('stop-screen-share');
@ -2451,7 +2689,6 @@ ipc.on('get-config', async event => {
storageUrl: config.get<string>('storageUrl'), storageUrl: config.get<string>('storageUrl'),
updatesUrl: config.get<string>('updatesUrl'), updatesUrl: config.get<string>('updatesUrl'),
resourcesUrl: config.get<string>('resourcesUrl'), resourcesUrl: config.get<string>('resourcesUrl'),
artCreatorUrl: config.get<string>('artCreatorUrl'),
cdnUrl0: config.get<string>('cdn.0'), cdnUrl0: config.get<string>('cdn.0'),
cdnUrl2: config.get<string>('cdn.2'), cdnUrl2: config.get<string>('cdn.2'),
cdnUrl3: config.get<string>('cdn.3'), cdnUrl3: config.get<string>('cdn.3'),
@ -2460,9 +2697,12 @@ ipc.on('get-config', async event => {
!isTestEnvironment(getEnvironment()) && ciMode !isTestEnvironment(getEnvironment()) && ciMode
? Environment.Production ? Environment.Production
: getEnvironment(), : getEnvironment(),
isMockTestEnvironment: Boolean(process.env.MOCK_TEST),
ciMode, ciMode,
// Should be already computed and cached at this point // Should be already computed and cached at this point
dnsFallback: await getDNSFallback(), dnsFallback: await getDNSFallback(),
disableIPv6: DISABLE_IPV6,
ciBackupPath: config.get<string | null>('ciBackupPath') || undefined,
nodeVersion: process.versions.node, nodeVersion: process.versions.node,
hostname: os.hostname(), hostname: os.hostname(),
osRelease: os.release(), osRelease: os.release(),
@ -2476,6 +2716,7 @@ ipc.on('get-config', async event => {
serverPublicParams: config.get<string>('serverPublicParams'), serverPublicParams: config.get<string>('serverPublicParams'),
serverTrustRoot: config.get<string>('serverTrustRoot'), serverTrustRoot: config.get<string>('serverTrustRoot'),
genericServerPublicParams: config.get<string>('genericServerPublicParams'), genericServerPublicParams: config.get<string>('genericServerPublicParams'),
backupServerPublicParams: config.get<string>('backupServerPublicParams'),
theme, theme,
appStartInitialSpellcheckSetting, appStartInitialSpellcheckSetting,
@ -2553,24 +2794,20 @@ ipc.handle('DebugLogs.upload', async (_event, content: string) => {
}); });
}); });
ipc.on('user-config-key', event => {
// eslint-disable-next-line no-param-reassign
event.returnValue = userConfig.get('key');
});
ipc.on('get-user-data-path', event => { ipc.on('get-user-data-path', event => {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
event.returnValue = app.getPath('userData'); event.returnValue = app.getPath('userData');
}); });
// Refresh the settings window whenever preferences change // Refresh the settings window whenever preferences change
ipc.on('preferences-changed', () => { const sendPreferencesChangedEventToWindows = () => {
for (const window of activeWindows) { for (const window of activeWindows) {
if (window.webContents) { if (window.webContents) {
window.webContents.send('preferences-changed'); window.webContents.send('preferences-changed');
} }
} }
}); };
ipc.on('preferences-changed', sendPreferencesChangedEventToWindows);
function maybeGetIncomingSignalRoute(argv: Array<string>) { function maybeGetIncomingSignalRoute(argv: Array<string>) {
for (const arg of argv) { for (const arg of argv) {
@ -2597,11 +2834,6 @@ function handleSignalRoute(route: ParsedSignalRoute) {
packId: route.args.packId, packId: route.args.packId,
packKey: Buffer.from(route.args.packKey, 'hex').toString('base64'), packKey: Buffer.from(route.args.packKey, 'hex').toString('base64'),
}); });
} else if (route.key === 'artAuth') {
mainWindow.webContents.send('authorize-art-creator', {
token: route.args.token,
pubKeyBase64: route.args.pubKey,
});
} else if (route.key === 'groupInvites') { } else if (route.key === 'groupInvites') {
mainWindow.webContents.send('show-group-via-link', { mainWindow.webContents.send('show-group-via-link', {
value: route.args.inviteCode, value: route.args.inviteCode,
@ -2887,7 +3119,6 @@ async function showStickerCreatorWindow() {
show: false, show: false,
webPreferences: { webPreferences: {
...defaultWebPrefs, ...defaultWebPrefs,
partition: STICKER_CREATOR_PARTITION,
nodeIntegration: false, nodeIntegration: false,
nodeIntegrationInWorker: false, nodeIntegrationInWorker: false,
sandbox: true, sandbox: true,
@ -2916,18 +3147,27 @@ async function showStickerCreatorWindow() {
} }
if (isTestEnvironment(getEnvironment())) { if (isTestEnvironment(getEnvironment())) {
ipc.on('ci:test-electron:getArgv', event => {
// eslint-disable-next-line no-param-reassign
event.returnValue = process.argv;
});
ipc.handle('ci:test-electron:debug', async (_event, info) => { ipc.handle('ci:test-electron:debug', async (_event, info) => {
process.stdout.write(`ci:test-electron:debug=${JSON.stringify(info)}\n`); process.stdout.write(`ci:test-electron:debug=${JSON.stringify(info)}\n`);
}); });
ipc.handle('ci:test-electron:done', async (_event, info) => { ipc.handle('ci:test-electron:event', async (_event, event) => {
if (!process.env.TEST_QUIT_ON_COMPLETE) {
return;
}
process.stdout.write( process.stdout.write(
`ci:test-electron:done=${JSON.stringify(info)}\n`, `ci:test-electron:event=${JSON.stringify(event)}\n`,
() => app.quit() () => {
if (event.type !== 'end') {
return;
}
if (!process.env.TEST_QUIT_ON_COMPLETE) {
return;
}
app.quit();
}
); );
}); });
} }

View file

@ -35,6 +35,7 @@ export const createTemplate = (
forceUpdate, forceUpdate,
showAbout, showAbout,
showDebugLog, showDebugLog,
showCallingDevTools,
showKeyboardShortcuts, showKeyboardShortcuts,
showSettings, showSettings,
openArtCreator, openArtCreator,
@ -146,6 +147,10 @@ export const createTemplate = (
role: 'toggleDevTools' as const, role: 'toggleDevTools' as const,
label: i18n('icu:viewMenuToggleDevTools'), label: i18n('icu:viewMenuToggleDevTools'),
}, },
{
label: i18n('icu:viewMenuOpenCallingDevTools'),
click: showCallingDevTools,
},
] ]
: []), : []),
...(devTools && platform !== 'linux' ...(devTools && platform !== 'linux'

View file

@ -31,6 +31,12 @@ function _createPermissionHandler(
// We default 'media' permission to false, but the user can override that for // We default 'media' permission to false, but the user can override that for
// the microphone and camera. // the microphone and camera.
if (permission === 'media') { if (permission === 'media') {
// Pacifying typescript because it is always there for 'media' permission
if (!('mediaTypes' in details)) {
callback(false);
return;
}
if ( if (
details.mediaTypes?.includes('audio') || details.mediaTypes?.includes('audio') ||
details.mediaTypes?.includes('video') details.mediaTypes?.includes('video')

View file

@ -3,7 +3,6 @@
import type { BrowserWindow } from 'electron'; import type { BrowserWindow } from 'electron';
import { Menu, clipboard, nativeImage } from 'electron'; import { Menu, clipboard, nativeImage } from 'electron';
import { fileURLToPath } from 'url';
import * as LocaleMatcher from '@formatjs/intl-localematcher'; import * as LocaleMatcher from '@formatjs/intl-localematcher';
import { maybeParseUrl } from '../ts/util/url'; import { maybeParseUrl } from '../ts/util/url';
@ -12,8 +11,9 @@ import type { MenuListType } from '../ts/types/menu';
import type { LocalizerType } from '../ts/types/Util'; import type { LocalizerType } from '../ts/types/Util';
import { strictAssert } from '../ts/util/assert'; import { strictAssert } from '../ts/util/assert';
import type { LoggerType } from '../ts/types/Logging'; import type { LoggerType } from '../ts/types/Logging';
import { handleAttachmentRequest } from './attachment_channel';
export const FAKE_DEFAULT_LOCALE = 'en-x-ignore'; // -x- is an extension space for attaching other metadata to the locale export const FAKE_DEFAULT_LOCALE = 'und'; // 'und' is the BCP 47 subtag for "undetermined"
strictAssert( strictAssert(
new Intl.Locale(FAKE_DEFAULT_LOCALE).toString() === FAKE_DEFAULT_LOCALE, new Intl.Locale(FAKE_DEFAULT_LOCALE).toString() === FAKE_DEFAULT_LOCALE,
@ -151,23 +151,35 @@ export const setup = (
}; };
label = i18n('icu:contextMenuCopyLink'); label = i18n('icu:contextMenuCopyLink');
} else if (isImage) { } else if (isImage) {
const urlIsViewOnce = click = async () => {
params.srcURL?.includes('/temp/') ||
params.srcURL?.includes('\\temp\\');
if (urlIsViewOnce) {
return;
}
click = () => {
const parsedSrcUrl = maybeParseUrl(params.srcURL); const parsedSrcUrl = maybeParseUrl(params.srcURL);
if (!parsedSrcUrl || parsedSrcUrl.protocol !== 'file:') { if (!parsedSrcUrl || parsedSrcUrl.protocol !== 'attachment:') {
return; return;
} }
const image = nativeImage.createFromPath( const urlIsViewOnce =
fileURLToPath(params.srcURL) parsedSrcUrl.searchParams.get('disposition') === 'temporary';
); if (urlIsViewOnce) {
clipboard.writeImage(image); return;
}
const req = new Request(parsedSrcUrl, {
method: 'GET',
});
try {
const res = await handleAttachmentRequest(req);
if (!res.ok) {
return;
}
const image = nativeImage.createFromBuffer(
Buffer.from(await res.arrayBuffer())
);
clipboard.writeImage(image);
} catch (error) {
logger.error('Failed to load image', error);
}
}; };
label = i18n('icu:contextMenuCopyImage'); label = i18n('icu:contextMenuCopyImage');
} else { } else {

View file

@ -7,14 +7,22 @@ import type { MainSQL } from '../ts/sql/main';
import { remove as removeUserConfig } from './user_config'; import { remove as removeUserConfig } from './user_config';
import { remove as removeEphemeralConfig } from './ephemeral_config'; import { remove as removeEphemeralConfig } from './ephemeral_config';
let sql: Pick<MainSQL, 'sqlCall'> | undefined; let sql:
| Pick<
MainSQL,
'sqlRead' | 'sqlWrite' | 'pauseWriteAccess' | 'resumeWriteAccess'
>
| undefined;
let initialized = false; let initialized = false;
const SQL_CHANNEL_KEY = 'sql-channel'; const SQL_READ_KEY = 'sql-channel:read';
const SQL_WRITE_KEY = 'sql-channel:write';
const ERASE_SQL_KEY = 'erase-sql-key'; const ERASE_SQL_KEY = 'erase-sql-key';
const PAUSE_WRITE_ACCESS = 'pause-sql-writes';
const RESUME_WRITE_ACCESS = 'resume-sql-writes';
export function initialize(mainSQL: Pick<MainSQL, 'sqlCall'>): void { export function initialize(mainSQL: typeof sql): void {
if (initialized) { if (initialized) {
throw new Error('sqlChannels: already initialized!'); throw new Error('sqlChannels: already initialized!');
} }
@ -22,15 +30,36 @@ export function initialize(mainSQL: Pick<MainSQL, 'sqlCall'>): void {
sql = mainSQL; sql = mainSQL;
ipcMain.handle(SQL_CHANNEL_KEY, (_event, callName, ...args) => { ipcMain.handle(SQL_READ_KEY, (_event, callName, ...args) => {
if (!sql) { if (!sql) {
throw new Error(`${SQL_CHANNEL_KEY}: Not yet initialized!`); throw new Error(`${SQL_READ_KEY}: Not yet initialized!`);
} }
return sql.sqlCall(callName, ...args); return sql.sqlRead(callName, ...args);
});
ipcMain.handle(SQL_WRITE_KEY, (_event, callName, ...args) => {
if (!sql) {
throw new Error(`${SQL_WRITE_KEY}: Not yet initialized!`);
}
return sql.sqlWrite(callName, ...args);
}); });
ipcMain.handle(ERASE_SQL_KEY, () => { ipcMain.handle(ERASE_SQL_KEY, () => {
removeUserConfig(); removeUserConfig();
removeEphemeralConfig(); removeEphemeralConfig();
}); });
ipcMain.handle(PAUSE_WRITE_ACCESS, () => {
if (!sql) {
throw new Error(`${PAUSE_WRITE_ACCESS}: Not yet initialized!`);
}
return sql.pauseWriteAccess();
});
ipcMain.handle(RESUME_WRITE_ACCESS, () => {
if (!sql) {
throw new Error(`${PAUSE_WRITE_ACCESS}: Not yet initialized!`);
}
return sql.resumeWriteAccess();
});
} }

View file

@ -1,7 +1,7 @@
<!-- Copyright 2014 Signal Messenger, LLC --> <!-- Copyright 2014 Signal Messenger, LLC -->
<!-- SPDX-License-Identifier: AGPL-3.0-only --> <!-- SPDX-License-Identifier: AGPL-3.0-only -->
<!DOCTYPE html> <!doctype html>
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@ -16,12 +16,12 @@
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'none'; content="default-src 'none';
child-src 'self'; child-src 'self';
connect-src 'self' https: wss:; connect-src 'self' https: wss: attachment:;
font-src 'self'; font-src 'self';
form-action 'self'; form-action 'self';
frame-src 'none'; frame-src 'none';
img-src 'self' blob: data:; img-src 'self' blob: data: emoji: attachment:;
media-src 'self' blob:; media-src 'self' blob: attachment:;
object-src 'none'; object-src 'none';
script-src 'self' 'sha256-Qu05oqDmBO5fZacm7tr/oerJcqsW0G/XqP4PRCziovc=' 'sha256-eLeGwSfPmXJ+EUiLfIeXABvLiUqDbiKgNLpHITaabgQ='; script-src 'self' 'sha256-Qu05oqDmBO5fZacm7tr/oerJcqsW0G/XqP4PRCziovc=' 'sha256-eLeGwSfPmXJ+EUiLfIeXABvLiUqDbiKgNLpHITaabgQ=';
style-src 'self' 'unsafe-inline';" style-src 'self' 'unsafe-inline';"
@ -96,7 +96,7 @@
<div class="module-title-bar-drag-area"></div> <div class="module-title-bar-drag-area"></div>
<div class="module-splash-screen__logo module-img--150"></div> <div class="module-splash-screen__logo module-img--150"></div>
<div class="container"> <div class="dot-container">
<span class="dot"></span> <span class="dot"></span>
<span class="dot"></span> <span class="dot"></span>
<span class="dot"></span> <span class="dot"></span>

View file

@ -13,8 +13,8 @@ LangString signalMinAppVersionErr 1031 "Eine neuere Version von Signal ist berei
LangString signalMinWinVersionErr 1036 "La version Desktop de Signal ne fonctionne plus sur cet ordinateur. Pour continuer dutiliser la version Desktop de Signal, veuillez mettre à jour la version Windows de votre ordinateur." LangString signalMinWinVersionErr 1036 "La version Desktop de Signal ne fonctionne plus sur cet ordinateur. Pour continuer dutiliser la version Desktop de Signal, veuillez mettre à jour la version Windows de votre ordinateur."
LangString signalMinAppVersionErr 1036 "Une nouvelle version de Signal est déjà installée. Êtes-vous sûr de vouloir continuer ?" LangString signalMinAppVersionErr 1036 "Une nouvelle version de Signal est déjà installée. Êtes-vous sûr de vouloir continuer ?"
# es_ES # es_ES
LangString signalMinWinVersionErr 3082 "Signal para Escritorio ya no funciona en este ordenador. Para volver a usar Signal para Escritorio, actualiza la versión de Windows de tu ordenador." LangString signalMinWinVersionErr 3082 "Signal Desktop ya no funciona en este ordenador. Para volver a usar Signal en tu scritorio, actualiza la versión de Windows de tu ordenador."
LangString signalMinAppVersionErr 3082 "Ya está instalada una versión más reciente de Signal. ¿Segurx que quieres continuar?" LangString signalMinAppVersionErr 3082 "Ya está instalada una versión más reciente de Signal. ¿Continuar de todos modos?"
# zh_CN # zh_CN
LangString signalMinWinVersionErr 2052 "Signal desktop 无法在此电脑上运行。如您希望再次使用 Signal desktop请更新您电脑的 Windows 版本。" LangString signalMinWinVersionErr 2052 "Signal desktop 无法在此电脑上运行。如您希望再次使用 Signal desktop请更新您电脑的 Windows 版本。"
LangString signalMinAppVersionErr 2052 "更新版 Signal 已安装完毕。您确定要继续吗?" LangString signalMinAppVersionErr 2052 "更新版 Signal 已安装完毕。您确定要继续吗?"

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