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
js/Mp3LameEncoder.min.js
js/WebAudioRecorderMp3.js
js/calling-tools/**
# TypeScript generated files
app/**/*.js

View file

@ -17,7 +17,7 @@ Remember, you can preview this before saving it.
- [ ] 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 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
### Description

View file

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

View file

@ -9,11 +9,13 @@ on:
- main
- '[0-9]+.[0-9]+.x'
pull_request:
schedule:
- cron: '0 */12 * * *'
jobs:
linux:
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
steps:
@ -23,35 +25,35 @@ jobs:
run: uname -a
- name: Clone Desktop repo
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: '20.9.0'
node-version-file: '.nvmrc'
- 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
run: sudo apt-get install xvfb
- name: Cache Desktop node_modules
id: cache-desktop-modules
uses: actions/cache@v3
uses: actions/cache@v4
with:
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
if: steps.cache-desktop-modules.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
run: npm ci
env:
NPM_CONFIG_LOGLEVEL: verbose
- name: Build typescript
run: yarn generate
run: npm run generate
- name: Bundle
run: yarn build:esbuild:prod
run: npm run build:esbuild:prod
- name: Run startup benchmarks
run: |
@ -128,13 +130,13 @@ jobs:
- name: Upload benchmark logs on failure
if: failure()
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: logs
path: artifacts
- name: Clone benchmark repo
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
repository: 'signalapp/Signal-Desktop-Benchmarks-Private'
path: 'benchmark-results'

View file

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

View file

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

View file

@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
token: ${{ secrets.AUTOMATED_GITHUB_PAT }}
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
timeout-minutes: 30
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@v4
- name: Setup node.js
uses: actions/setup-node@v4
with:
node-version: '20.9.0'
cache: 'yarn'
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
- name: Install global dependencies
run: npm install -g yarn@1.22.10 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/**') }}
run: npm install -g npm@10.2.5
- name: Install Desktop node_modules
if: steps.cache-desktop-modules.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile --prefer-offline
run: npm ci
env:
CHILD_CONCURRENCY: 1
NPM_CONFIG_LOGLEVEL: verbose
- run: yarn build:storybook
- run: npm run build:storybook
- 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/*
build/curve25519_compiled.js
build/dns-fallback.json
build/compact-locales
stylesheets/*.css.map
/dist
.DS_Store
config/local.json
config/local-*.json
config/local-*
*.provisionprofile
release/
/dev-app-update.yml
@ -26,6 +27,7 @@ js/components.js
js/util_worker.js
libtextsecure/components.js
stylesheets/*.css
!stylesheets/webrtc_internals.css
/storybook-static/
preload.bundle.*
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
build/locale-display-names.json
build/country-display-names.json
build/compact-locales/**/*.json
release/**
# Third-party files
node_modules/**
danger/node_modules/**
sticker-creator/node_modules/**
components/**
js/curve/**
js/Mp3LameEncoder.min.js
js/WebAudioRecorderMp3.js
js/calling-tools/**
# Assets
/images/

View file

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

View file

@ -21,6 +21,9 @@ import {
ScrollerLockContext,
createScrollerLock,
} from '../ts/hooks/useScrollLock';
import { Environment, setEnvironment } from '../ts/environment.ts';
setEnvironment(Environment.Development, true);
const i18n = setupI18n('en', messages);
@ -80,6 +83,7 @@ const noop = () => {};
window.Whisper = window.Whisper || {};
window.Whisper.events = {
on: noop,
off: noop,
};
window.SignalContext = {
@ -93,7 +97,6 @@ window.SignalContext = {
unregisterForChange: noop,
},
isTestOrMockEnvironment: () => false,
nativeThemeListener: {
getSystemTheme: () => 'light',
subscribe: noop,
@ -116,7 +119,6 @@ window.SignalContext = {
getHourCyclePreference: () => HourCyclePreference.UnknownPreference,
getPreferredSystemLocales: () => ['en'],
getResolvedMessagesLocaleDirection: () => 'ltr',
getLocaleOverride: () => null,
getLocaleDisplayNames: () => ({ en: { en: 'English' } }),
};
@ -133,6 +135,9 @@ const withGlobalTypesProvider = (Story, context) => {
const mode = context.globals.mode;
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
// components that are rendered outside of this decorator container
if (theme === 'light') {
@ -193,3 +198,4 @@ export const parameters = {
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:
```
npm install --global yarn # Make sure you have have `yarn`
git clone https://github.com/signalapp/Signal-Desktop.git
cd Signal-Desktop
yarn install --frozen-lockfile # Install and build dependencies (this will take a while)
yarn generate # Generate final JS and CSS assets
yarn test # A good idea to make sure tests run first
yarn start # Start Signal!
npm install # Install and build dependencies (this will take a while)
npm run generate # Generate final JS and CSS assets
npm test # A good idea to make sure tests run first
npm start # Start Signal!
```
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).
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
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:
```
yarn dev:transpile # recompiles when you change .ts files
yarn dev:sass # recompiles when you change .scss files
npm run dev:transpile # recompiles when you change .ts 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
@ -85,7 +123,7 @@ You can run a development server for these parts of the app with the
following command:
```
yarn dev
npm run dev
```
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:
```
SIGNAL_ENABLE_HTTP=1 yarn start
SIGNAL_ENABLE_HTTP=1 npm start
```
## 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:
```
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`.
@ -174,15 +212,15 @@ Please write tests! Our testing framework is
[mocha](http://mochajs.org/) and our assertion library is
[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
`NODE_ENV=test yarn run start`.
`NODE_ENV=test npm start`.
## Pull requests
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.
- Please do not submit pull requests for translation fixes.
- 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
```
yarn generate
yarn build
npm run generate
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,
} from '../ts/types/SystemTraySetting';
import { isSystemTraySupported } from '../ts/types/Settings';
import type { MainSQL } from '../ts/sql/main';
import type { ConfigType } from './base_config';
/**
@ -21,10 +20,8 @@ export class SystemTraySettingCache {
private getPromise: undefined | Promise<SystemTraySetting>;
constructor(
private readonly sql: Pick<MainSQL, 'sqlCall'>,
private readonly ephemeralConfig: Pick<ConfigType, 'get' | 'set'>,
private readonly argv: Array<string>,
private readonly appVersion: string
private readonly argv: Array<string>
) {}
async get(): Promise<SystemTraySetting> {
@ -55,16 +52,12 @@ export class SystemTraySettingCache {
log.info(
`getSystemTraySetting saw --use-tray-icon flag. Returning ${result}`
);
} else if (isSystemTraySupported(OS, this.appVersion)) {
const fastValue = this.ephemeralConfig.get('system-tray-setting');
if (fastValue !== undefined) {
log.info('getSystemTraySetting got fast value', fastValue);
} else if (isSystemTraySupported(OS)) {
const value = this.ephemeralConfig.get('system-tray-setting');
if (value !== undefined) {
log.info('getSystemTraySetting got value', value);
}
const value =
fastValue ??
(await this.sql.sqlCall('getItemById', 'system-tray-setting'))?.value;
if (value !== undefined) {
result = parseSystemTraySetting(value);
log.info(`getSystemTraySetting returning ${result}`);
@ -73,7 +66,7 @@ export class SystemTraySettingCache {
log.info(`getSystemTraySetting got no value, returning ${result}`);
}
if (result !== fastValue) {
if (result !== value) {
this.ephemeralConfig.set('system-tray-setting', result);
}
} else {

View file

@ -1,10 +1,16 @@
// Copyright 2018 Signal Messenger, LLC
// 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 { RangeFinder, DefaultStorage } from '@indutny/range-finder';
import {
getAllAttachments,
getAvatarsPath,
getPath,
getStickersPath,
getTempPath,
@ -20,6 +26,13 @@ import type { MainSQL } from '../ts/sql/main';
import type { MessageAttachmentsCursorType } from '../ts/sql/Interface';
import * as Errors from '../ts/types/errors';
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;
@ -31,6 +44,89 @@ const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments';
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<{
orphanedAttachments: Set<string>;
sql: MainSQL;
@ -48,11 +144,11 @@ async function cleanupOrphanedAttachments({
}: CleanupOrphanedAttachmentsOptionsType): Promise<void> {
await deleteAllBadges({
userDataPath,
pathsToKeep: await sql.sqlCall('getAllBadgeImageFileLocalPaths'),
pathsToKeep: await sql.sqlRead('getAllBadgeImageFileLocalPaths'),
});
const allStickers = await getAllStickers(userDataPath);
const orphanedStickers = await sql.sqlCall(
const orphanedStickers = await sql.sqlWrite(
'removeKnownStickers',
allStickers
);
@ -62,7 +158,7 @@ async function cleanupOrphanedAttachments({
});
const allDraftAttachments = await getAllDraftAttachments(userDataPath);
const orphanedDraftAttachments = await sql.sqlCall(
const orphanedDraftAttachments = await sql.sqlWrite(
'removeKnownDraftAttachments',
allDraftAttachments
);
@ -80,7 +176,7 @@ async function cleanupOrphanedAttachments({
);
{
const attachments: ReadonlyArray<string> = await sql.sqlCall(
const attachments: ReadonlyArray<string> = await sql.sqlRead(
'getKnownConversationAttachments'
);
@ -122,7 +218,7 @@ function deleteOrphanedAttachments({
let attachments: ReadonlyArray<string>;
// eslint-disable-next-line no-await-in-loop
({ attachments, cursor } = await sql.sqlCall(
({ attachments, cursor } = await sql.sqlRead(
'getKnownMessageAttachments',
cursor
));
@ -146,7 +242,7 @@ function deleteOrphanedAttachments({
} while (cursor !== undefined && !cursor.done);
} finally {
if (cursor !== undefined) {
await sql.sqlCall('finishGetKnownMessageAttachments', cursor);
await sql.sqlRead('finishGetKnownMessageAttachments', cursor);
}
}
@ -181,6 +277,12 @@ function deleteOrphanedAttachments({
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({
configDir,
sql,
@ -193,15 +295,28 @@ export function initialize({
}
initialized = true;
const attachmentsDir = getPath(configDir);
const stickersDir = getStickersPath(configDir);
const tempDir = getTempPath(configDir);
const draftDir = getDraftPath(configDir);
attachmentsDir = getPath(configDir);
stickersDir = getStickersPath(configDir);
tempDir = getTempPath(configDir);
draftDir = getDraftPath(configDir);
avatarDataDir = getAvatarsPath(configDir);
ipcMain.handle(ERASE_TEMP_KEY, () => rimraf.sync(tempDir));
ipcMain.handle(ERASE_ATTACHMENTS_KEY, () => rimraf.sync(attachmentsDir));
ipcMain.handle(ERASE_STICKERS_KEY, () => rimraf.sync(stickersDir));
ipcMain.handle(ERASE_DRAFTS_KEY, () => rimraf.sync(draftDir));
ipcMain.handle(ERASE_TEMP_KEY, () => {
strictAssert(tempDir != null, 'not initialized');
rimraf.sync(tempDir);
});
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 () => {
const start = Date.now();
@ -209,4 +324,172 @@ export function initialize({
const duration = Date.now() - start;
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
// SPDX-License-Identifier: AGPL-3.0-only
import { PassThrough } from 'node:stream';
import { join, relative, normalize } from 'path';
import fastGlob from 'fast-glob';
import fse from 'fs-extra';
import { map, isString } from 'lodash';
import normalizePath from 'normalize-path';
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 AVATAR_PATH = 'avatars.noindex';
@ -190,3 +197,57 @@ export const deleteAllDraftAttachments = async ({
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
// 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 { set } from 'lodash/fp';

View file

@ -1,7 +1,7 @@
// Copyright 2017 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { join } from 'path';
import { join, basename } from 'path';
import { app } from 'electron';
import type { IConfig } from 'config';
@ -15,9 +15,12 @@ import {
// In production mode, NODE_ENV cannot be customized by the user
if (app.isPackaged) {
setEnvironment(Environment.Production);
setEnvironment(Environment.Production, false);
} 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
@ -44,6 +47,12 @@ if (getEnvironment() === Environment.Production) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
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
[
'NODE_ENV',

View file

@ -6,7 +6,8 @@ import { readFileSync } from 'fs';
import { merge } from 'lodash';
import * as LocaleMatcher from '@formatjs/intl-localematcher';
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 { 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
const finalMessages = merge(englishMessages, matchedLocaleMessages);
const i18n = setupI18n(matchedLocale, finalMessages);
const i18n = setupI18n(matchedLocale, finalMessages, {
renderEmojify: shouldNeverBeCalled,
});
const direction =
localeDirectionTestingOverride ?? getLocaleDirection(matchedLocale, logger);
logger.info(`locale: Text info direction for ${matchedLocale}: ${direction}`);

View file

@ -28,6 +28,8 @@ import {
shell,
systemPreferences,
Notification,
safeStorage,
protocol as electronProtocol,
} from 'electron';
import type { MenuItemConstructorOptions, Settings } from 'electron';
import { z } from 'zod';
@ -79,12 +81,17 @@ import { updateDefaultSession } from './updateDefaultSession';
import { PreventDisplaySleepService } from './PreventDisplaySleepService';
import { SystemTrayService, focusAndForceToTop } from './SystemTrayService';
import { SystemTraySettingCache } from './SystemTraySettingCache';
import { OptionalResourceService } from './OptionalResourceService';
import { EmojiService } from './EmojiService';
import {
SystemTraySetting,
shouldMinimizeToSystemTray,
parseSystemTraySetting,
} 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 logging from '../ts/logging/main_process_logging';
import { MainSQL } from '../ts/sql/main';
@ -109,13 +116,15 @@ import { load as loadLocale } from './locale';
import type { LoggerType } from '../ts/types/Logging';
import { HourCyclePreference } from '../ts/types/I18N';
import { ScreenShareStatus } from '../ts/types/Calling';
import { DBVersionFromFutureError } from '../ts/sql/migrations';
import type { ParsedSignalRoute } from '../ts/util/signalRoutes';
import { parseSignalRoute } from '../ts/util/signalRoutes';
import * as dns from '../ts/util/dns';
import { ZoomFactorService } from '../ts/services/ZoomFactorService';
const STICKER_CREATOR_PARTITION = 'sticker-creator';
import { SafeStorageBackendChangeError } from '../ts/types/SafeStorageBackendChangeError';
import { LINUX_PASSWORD_STORE_FLAGS } from '../ts/util/linuxPasswordStoreFlags';
import { getOwn } from '../ts/util/getOwn';
const animationSettings = systemPreferences.getAnimationSettings();
@ -174,6 +183,8 @@ nativeThemeNotifier.initialize();
let appStartInitialSpellcheckSetting = true;
let macInitialOpenUrlRoute: ParsedSignalRoute | undefined;
const cliParser = createParser({
allowUnknown: true,
options: [
@ -203,6 +214,7 @@ const defaultWebPrefs = {
const DISABLE_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(
arg => arg === '--enable-crash-reports'
);
@ -275,10 +287,19 @@ if (!process.mas) {
return true;
});
// This event is received in macOS packaged builds.
app.on('open-url', (event, incomingHref) => {
event.preventDefault();
const route = parseSignalRoute(incomingHref);
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);
}
});
@ -292,22 +313,18 @@ const sql = new MainSQL();
const heicConverter = getHeicConverter();
async function getSpellCheckSetting(): Promise<boolean> {
const fastValue = ephemeralConfig.get('spell-check');
if (typeof fastValue === 'boolean') {
getLogger().info('got fast spellcheck setting', fastValue);
return fastValue;
const value = ephemeralConfig.get('spell-check');
if (typeof value === 'boolean') {
getLogger().info('got fast spellcheck setting', value);
return value;
}
const json = await sql.sqlCall('getItemById', 'spell-check');
// 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 slowValue;
return true;
}
type GetThemeSettingOptionsType = Readonly<{
@ -317,29 +334,22 @@ type GetThemeSettingOptionsType = Readonly<{
async function getThemeSetting({
ephemeralOnly = false,
}: GetThemeSettingOptionsType = {}): Promise<ThemeSettingType> {
let result: unknown;
const fastValue = ephemeralConfig.get('theme-setting');
if (fastValue !== undefined) {
getLogger().info('got fast theme-setting value', fastValue);
result = fastValue;
const value = ephemeralConfig.get('theme-setting');
if (value !== undefined) {
getLogger().info('got fast theme-setting value', value);
} else if (ephemeralOnly) {
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
const validatedResult =
result === 'light' || result === 'dark' || result === 'system'
? result
value === 'light' || value === 'dark' || value === 'system'
? value
: 'system';
if (fastValue !== validatedResult) {
if (value !== validatedResult) {
ephemeralConfig.set('theme-setting', validatedResult);
getLogger().info('got slow theme-setting value', result);
getLogger().info('saving theme-setting value', validatedResult);
}
return validatedResult;
@ -372,35 +382,31 @@ async function getBackgroundColor(
}
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
if (typeof fastValue === 'string' || fastValue === null) {
getLogger().info('got fast localeOverride setting', fastValue);
return fastValue;
if (typeof value === 'string' || value === null) {
getLogger().info('got fast localeOverride setting', value);
return value;
}
const json = await sql.sqlCall('getItemById', 'localeOverride');
// 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 slowValue;
return null;
}
const zoomFactorService = new ZoomFactorService({
async getZoomFactorSetting() {
const item = await sql.sqlCall('getItemById', 'zoomFactor');
const item = await sql.sqlRead('getItemById', 'zoomFactor');
if (typeof item?.value !== 'number') {
return null;
}
return item.value;
},
async setZoomFactorSetting(zoomFactor) {
await sql.sqlCall('createOrUpdateItem', {
await sql.sqlWrite('createOrUpdateItem', {
id: 'zoomFactor',
value: zoomFactor,
});
@ -409,10 +415,8 @@ const zoomFactorService = new ZoomFactorService({
let systemTrayService: SystemTrayService | undefined;
const systemTraySettingCache = new SystemTraySettingCache(
sql,
ephemeralConfig,
process.argv,
app.getVersion()
process.argv
);
const windowFromUserConfig = userConfig.get('window');
@ -672,10 +676,19 @@ async function createWindow() {
const usePreloadBundle =
!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 = {
show: false,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
width,
height,
minWidth: MIN_WIDTH,
minHeight: MIN_HEIGHT,
autoHideMenuBar: false,
@ -700,7 +713,7 @@ async function createWindow() {
disableBlinkFeatures: 'Accelerated2dCanvas,AcceleratedSmallCanvases',
},
icon: windowIcon,
...pick(windowConfig, ['autoHideMenuBar', 'width', 'height', 'x', 'y']),
...pick(windowConfig, ['autoHideMenuBar', 'x', 'y']),
};
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
* application, we need them leave fullscreen first before closing it to
* 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
*/
if (mainWindow.isFullScreen()) {
mainWindow.once('leave-full-screen', () => mainWindow?.hide());
mainWindow.setFullScreen(false);
} else {
mainWindow.hide();
if (mainWindow) {
if (mainWindow.isFullScreen()) {
mainWindow.once('leave-full-screen', () => mainWindow?.hide());
mainWindow.setFullScreen(false);
} else {
mainWindow.hide();
}
}
// 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(
await systemTraySettingCache.get()
);
if (!windowState.shouldQuit() && (usingTrayIcon || OS.isMacOS())) {
if (
mainWindow &&
!windowState.shouldQuit() &&
(usingTrayIcon || OS.isMacOS())
) {
if (usingTrayIcon) {
const shownTrayNotice = ephemeralConfig.get('shown-tray-notice');
if (shownTrayNotice) {
@ -1011,21 +1031,24 @@ ipc.handle('database-ready', async () => {
getLogger().info('sending `database-ready`');
});
ipc.handle('get-art-creator-auth', () => {
const { promise, resolve } = explodePromise<unknown>();
strictAssert(mainWindow, 'Main window did not exist');
ipc.handle(
'art-creator:uploadStickerPack',
(_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 }) => {
resolve({
baseUrl: config.get<string>('artCreatorUrl'),
username,
password,
ipc.once('art-creator:uploadStickerPack:done', (_doneEvent, response) => {
resolve(response);
});
});
return promise;
return promise;
}
);
ipc.on('art-creator:onUploadProgress', () => {
stickerCreatorWindow?.webContents.send('art-creator:onUploadProgress');
});
ipc.on('show-window', () => {
@ -1091,12 +1114,17 @@ async function readyForUpdates() {
isReadyForUpdates = true;
// First, install requested sticker pack
// First, handle requested signal URLs
const incomingHref = maybeGetIncomingSignalRoute(process.argv);
if (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
try {
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;
async function showAbout() {
if (aboutWindow) {
@ -1347,8 +1436,8 @@ async function showSettingsWindow() {
async function getIsLinked() {
try {
const number = await sql.sqlCall('getItemById', 'number_id');
const password = await sql.sqlCall('getItemById', 'password');
const number = await sql.sqlRead('getItemById', 'number_id');
const password = await sql.sqlRead('getItemById', 'password');
return Boolean(number && password);
} catch (e) {
return false;
@ -1514,7 +1603,7 @@ const runSQLCorruptionHandler = async () => {
`Restarting the application immediately. Error: ${error.message}`
);
await onDatabaseError(Errors.toLogFormat(error));
await onDatabaseError(error);
};
const runSQLReadonlyHandler = async () => {
@ -1531,31 +1620,139 @@ const runSQLReadonlyHandler = async () => {
throw error;
};
async function initializeSQL(
userDataPath: string
): Promise<{ ok: true; error: undefined } | { ok: false; error: Error }> {
let key: string | undefined;
const keyFromConfig = userConfig.get('key');
if (typeof keyFromConfig === 'string') {
key = keyFromConfig;
} else if (keyFromConfig) {
getLogger().warn(
"initializeSQL: got key from config, but it wasn't a string"
function generateSQLKey(): string {
getLogger().info(
'key/initialize: Generating new encryption key, since we did not find it on disk'
);
// https://www.zetetic.net/sqlcipher/sqlcipher-api/#key
return randomBytes(32).toString('hex');
}
function getSQLKey(): 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(
'key/initialize: Generating new encryption key, since we did not find it on disk'
);
// https://www.zetetic.net/sqlcipher/sqlcipher-api/#key
key = randomBytes(32).toString('hex');
let key: string;
if (typeof modernKeyValue === 'string') {
if (!isEncryptionAvailable) {
throw new Error("Can't decrypt database key");
}
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);
}
return key;
}
async function initializeSQL(
userDataPath: string
): Promise<{ ok: true; error: undefined } | { ok: false; error: Error }> {
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 {
// 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.
await sql.initialize({
appVersion: app.getVersion(),
@ -1583,7 +1780,7 @@ async function initializeSQL(
return { ok: true, error: undefined };
}
const onDatabaseError = async (error: string) => {
const onDatabaseError = async (error: Error) => {
// Prevent window from re-opening
ready = false;
@ -1601,17 +1798,35 @@ const onDatabaseError = async (error: string) => {
const copyErrorAndQuitButtonIndex = 0;
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,
// and they would almost never want to delete their data as a result, so we don't show
// that option
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 {
// Otherwise, this is some other kind of DB error, let's give them the option to
// delete.
messageDetail = i18n('icu:databaseError__detail', {
link: SIGNAL_SUPPORT_LINK,
});
messageDetail = i18n(
'icu:databaseError__detail',
{ link: SIGNAL_SUPPORT_LINK },
{ bidi: 'strip' }
);
buttons.push(i18n('icu:deleteAndRestart'));
deleteAllDataButtonIndex = 1;
@ -1628,7 +1843,9 @@ const onDatabaseError = async (error: string) => {
});
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 (
typeof deleteAllDataButtonIndex === 'number' &&
buttonIndex === deleteAllDataButtonIndex
@ -1668,10 +1885,6 @@ let sqlInitPromise:
| Promise<{ ok: true; error: undefined } | { ok: false; error: Error }>
| undefined;
ipc.on('database-error', (_event: Electron.Event, error: string) => {
drop(onDatabaseError(error));
});
ipc.on('database-readonly', (_event: Electron.Event, error: string) => {
// Just let global_errors.ts handle it
throw new Error(error);
@ -1721,21 +1934,32 @@ const featuresToDisable = `HardwareMediaKeyHandling,${app.commandLine.getSwitchV
)}`;
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
// acceleration.
if (DISABLE_GPU) {
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
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
let ready = false;
app.on('ready', async () => {
dns.setFallback(await getDNSFallback());
if (DISABLE_IPV6) {
dns.setIPv6Enabled(false);
}
const [userDataPath, crashDumpsPath, installPath] = await Promise.all([
realpath(app.getPath('userData')),
@ -1743,19 +1967,15 @@ app.on('ready', async () => {
realpath(app.getAppPath()),
]);
const webSession = session.fromPartition(STICKER_CREATOR_PARTITION);
updateDefaultSession(session.defaultSession);
for (const s of [session.defaultSession, webSession]) {
updateDefaultSession(s);
if (getEnvironment() !== Environment.Test) {
installFileHandler({
session: s,
userDataPath,
installPath,
isWindows: OS.isWindows(),
});
}
if (getEnvironment() !== Environment.Test) {
installFileHandler({
session: session.defaultSession,
userDataPath,
installPath,
isWindows: OS.isWindows(),
});
}
installWebHandler({
@ -1763,17 +1983,15 @@ app.on('ready', async () => {
session: session.defaultSession,
});
installWebHandler({
enableHttp: true,
session: webSession,
});
logger = await logging.initialize(getMainWindow);
// Write buffered information into newly created logger.
consoleLogger.writeBufferInto(logger);
sqlInitPromise = initializeSQL(userDataPath);
const resourceService = OptionalResourceService.create(
join(userDataPath, 'optionalResources')
);
await EmojiService.create(resourceService);
if (!resolvedTranslationsLocale) {
preferredSystemLocales = resolveCanonicalLocales(
@ -1799,24 +2017,21 @@ app.on('ready', async () => {
});
}
sqlInitPromise = initializeSQL(userDataPath);
// 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
// would still show the window.
// (User can change these settings later)
if (
isSystemTraySupported(OS, app.getVersion()) &&
isSystemTraySupported(OS) &&
(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}`);
systemTraySettingCache.set(newValue);
// Update both stores
ephemeralConfig.set('system-tray-setting', newValue);
await sql.sqlCall('createOrUpdateItem', {
id: 'system-tray-setting',
value: newValue,
});
if (OS.isWindows()) {
getLogger().info('app.ready: enabling open at login');
@ -1832,6 +2047,32 @@ app.on('ready', async () => {
settingsChannel = new SettingsChannel();
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
// from when it's first ready until the loading screen disappears.
ipc.once('signal-app-loaded', (event, info) => {
@ -1984,7 +2225,7 @@ app.on('ready', async () => {
if (sqlError) {
getLogger().error('sql.initialize was unsuccessful; returning early');
await onDatabaseError(Errors.toLogFormat(sqlError));
await onDatabaseError(sqlError);
return;
}
@ -1993,10 +2234,10 @@ app.on('ready', async () => {
try {
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) {
await sql.sqlCall('removeIndexedDBFiles');
await sql.sqlCall('removeItemById', IDB_KEY);
await sql.sqlWrite('removeIndexedDBFiles');
await sql.sqlWrite('removeItemById', IDB_KEY);
}
} catch (err) {
getLogger().error(
@ -2047,6 +2288,7 @@ function setupMenu(options?: Partial<CreateTemplateOptionsType>) {
setupAsStandalone,
showAbout,
showDebugLog: showDebugLogWindow,
showCallingDevTools: showCallingDevToolsWindow,
showKeyboardShortcuts,
showSettings: showSettingsWindow,
showWindow,
@ -2160,12 +2402,15 @@ async function requestShutdown() {
// 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.
// Note: two minutes is also our timeout for SQL tasks in data.js in the browser.
timeout = setTimeout(() => {
getLogger().error(
'requestShutdown: Response never received; forcing shutdown.'
);
resolveFn();
}, 2 * 60 * 1000);
timeout = setTimeout(
() => {
getLogger().error(
'requestShutdown: Response never received; forcing shutdown.'
);
resolveFn();
},
2 * 60 * 1000
);
});
try {
@ -2178,11 +2423,19 @@ async function requestShutdown() {
function getWindowDebugInfo() {
const windows = BrowserWindow.getAllWindows();
return {
windowCount: windows.length,
mainWindowExists: windows.some(win => win === mainWindow),
mainWindowIsFullScreen: mainWindow?.isFullScreen(),
};
try {
return {
windowCount: windows.length,
mainWindowExists: windows.some(win => win === mainWindow),
mainWindowIsFullScreen: mainWindow?.isFullScreen(),
};
} catch {
return {
windowCount: 0,
mainWindowExists: false,
mainWindowIsFullScreen: false,
};
}
}
app.on('before-quit', e => {
@ -2318,36 +2571,21 @@ ipc.on(
}
);
ipc.handle(
'update-system-tray-setting',
async (_event, rawSystemTraySetting /* : Readonly<unknown> */) => {
const { openAtLogin } = app.getLoginItemSettings(
await getDefaultLoginItemSettings()
);
const systemTraySetting = parseSystemTraySetting(rawSystemTraySetting);
systemTraySettingCache.set(systemTraySetting);
if (systemTrayService) {
const isEnabled = shouldMinimizeToSystemTray(systemTraySetting);
systemTrayService.setEnabled(isEnabled);
ipc.on(
'screen-share:status-change',
(_event: Electron.Event, status: ScreenShareStatus) => {
if (!screenShareWindow) {
return;
}
// Default login item settings might have changed, so update the object.
getLogger().info('refresh-auto-launch: new value', openAtLogin);
app.setLoginItemSettings({
...(await getDefaultLoginItemSettings()),
openAtLogin,
});
if (status === ScreenShareStatus.Disconnected) {
screenShareWindow.close();
} else {
screenShareWindow.webContents.send('status-change', status);
}
}
);
ipc.on('close-screen-share-controller', () => {
if (screenShareWindow) {
screenShareWindow.close();
}
});
ipc.on('stop-screen-share', () => {
if (mainWindow) {
mainWindow.webContents.send('stop-screen-share');
@ -2451,7 +2689,6 @@ ipc.on('get-config', async event => {
storageUrl: config.get<string>('storageUrl'),
updatesUrl: config.get<string>('updatesUrl'),
resourcesUrl: config.get<string>('resourcesUrl'),
artCreatorUrl: config.get<string>('artCreatorUrl'),
cdnUrl0: config.get<string>('cdn.0'),
cdnUrl2: config.get<string>('cdn.2'),
cdnUrl3: config.get<string>('cdn.3'),
@ -2460,9 +2697,12 @@ ipc.on('get-config', async event => {
!isTestEnvironment(getEnvironment()) && ciMode
? Environment.Production
: getEnvironment(),
isMockTestEnvironment: Boolean(process.env.MOCK_TEST),
ciMode,
// Should be already computed and cached at this point
dnsFallback: await getDNSFallback(),
disableIPv6: DISABLE_IPV6,
ciBackupPath: config.get<string | null>('ciBackupPath') || undefined,
nodeVersion: process.versions.node,
hostname: os.hostname(),
osRelease: os.release(),
@ -2476,6 +2716,7 @@ ipc.on('get-config', async event => {
serverPublicParams: config.get<string>('serverPublicParams'),
serverTrustRoot: config.get<string>('serverTrustRoot'),
genericServerPublicParams: config.get<string>('genericServerPublicParams'),
backupServerPublicParams: config.get<string>('backupServerPublicParams'),
theme,
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 => {
// eslint-disable-next-line no-param-reassign
event.returnValue = app.getPath('userData');
});
// Refresh the settings window whenever preferences change
ipc.on('preferences-changed', () => {
const sendPreferencesChangedEventToWindows = () => {
for (const window of activeWindows) {
if (window.webContents) {
window.webContents.send('preferences-changed');
}
}
});
};
ipc.on('preferences-changed', sendPreferencesChangedEventToWindows);
function maybeGetIncomingSignalRoute(argv: Array<string>) {
for (const arg of argv) {
@ -2597,11 +2834,6 @@ function handleSignalRoute(route: ParsedSignalRoute) {
packId: route.args.packId,
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') {
mainWindow.webContents.send('show-group-via-link', {
value: route.args.inviteCode,
@ -2887,7 +3119,6 @@ async function showStickerCreatorWindow() {
show: false,
webPreferences: {
...defaultWebPrefs,
partition: STICKER_CREATOR_PARTITION,
nodeIntegration: false,
nodeIntegrationInWorker: false,
sandbox: true,
@ -2916,18 +3147,27 @@ async function showStickerCreatorWindow() {
}
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) => {
process.stdout.write(`ci:test-electron:debug=${JSON.stringify(info)}\n`);
});
ipc.handle('ci:test-electron:done', async (_event, info) => {
if (!process.env.TEST_QUIT_ON_COMPLETE) {
return;
}
ipc.handle('ci:test-electron:event', async (_event, event) => {
process.stdout.write(
`ci:test-electron:done=${JSON.stringify(info)}\n`,
() => app.quit()
`ci:test-electron:event=${JSON.stringify(event)}\n`,
() => {
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,
showAbout,
showDebugLog,
showCallingDevTools,
showKeyboardShortcuts,
showSettings,
openArtCreator,
@ -146,6 +147,10 @@ export const createTemplate = (
role: 'toggleDevTools' as const,
label: i18n('icu:viewMenuToggleDevTools'),
},
{
label: i18n('icu:viewMenuOpenCallingDevTools'),
click: showCallingDevTools,
},
]
: []),
...(devTools && platform !== 'linux'

View file

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

View file

@ -3,7 +3,6 @@
import type { BrowserWindow } from 'electron';
import { Menu, clipboard, nativeImage } from 'electron';
import { fileURLToPath } from 'url';
import * as LocaleMatcher from '@formatjs/intl-localematcher';
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 { strictAssert } from '../ts/util/assert';
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(
new Intl.Locale(FAKE_DEFAULT_LOCALE).toString() === FAKE_DEFAULT_LOCALE,
@ -151,23 +151,35 @@ export const setup = (
};
label = i18n('icu:contextMenuCopyLink');
} else if (isImage) {
const urlIsViewOnce =
params.srcURL?.includes('/temp/') ||
params.srcURL?.includes('\\temp\\');
if (urlIsViewOnce) {
return;
}
click = () => {
click = async () => {
const parsedSrcUrl = maybeParseUrl(params.srcURL);
if (!parsedSrcUrl || parsedSrcUrl.protocol !== 'file:') {
if (!parsedSrcUrl || parsedSrcUrl.protocol !== 'attachment:') {
return;
}
const image = nativeImage.createFromPath(
fileURLToPath(params.srcURL)
);
clipboard.writeImage(image);
const urlIsViewOnce =
parsedSrcUrl.searchParams.get('disposition') === 'temporary';
if (urlIsViewOnce) {
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');
} else {

View file

@ -7,14 +7,22 @@ import type { MainSQL } from '../ts/sql/main';
import { remove as removeUserConfig } from './user_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;
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 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) {
throw new Error('sqlChannels: already initialized!');
}
@ -22,15 +30,36 @@ export function initialize(mainSQL: Pick<MainSQL, 'sqlCall'>): void {
sql = mainSQL;
ipcMain.handle(SQL_CHANNEL_KEY, (_event, callName, ...args) => {
ipcMain.handle(SQL_READ_KEY, (_event, callName, ...args) => {
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, () => {
removeUserConfig();
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 -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
@ -16,12 +16,12 @@
http-equiv="Content-Security-Policy"
content="default-src 'none';
child-src 'self';
connect-src 'self' https: wss:;
connect-src 'self' https: wss: attachment:;
font-src 'self';
form-action 'self';
frame-src 'none';
img-src 'self' blob: data:;
media-src 'self' blob:;
img-src 'self' blob: data: emoji: attachment:;
media-src 'self' blob: attachment:;
object-src 'none';
script-src 'self' 'sha256-Qu05oqDmBO5fZacm7tr/oerJcqsW0G/XqP4PRCziovc=' 'sha256-eLeGwSfPmXJ+EUiLfIeXABvLiUqDbiKgNLpHITaabgQ=';
style-src 'self' 'unsafe-inline';"
@ -96,7 +96,7 @@
<div class="module-title-bar-drag-area"></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>

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 signalMinAppVersionErr 1036 "Une nouvelle version de Signal est déjà installée. Êtes-vous sûr de vouloir continuer ?"
# 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 signalMinAppVersionErr 3082 "Ya está instalada una versión más reciente de Signal. ¿Segurx que quieres continuar?"
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. ¿Continuar de todos modos?"
# zh_CN
LangString signalMinWinVersionErr 2052 "Signal desktop 无法在此电脑上运行。如您希望再次使用 Signal desktop请更新您电脑的 Windows 版本。"
LangString signalMinAppVersionErr 2052 "更新版 Signal 已安装完毕。您确定要继续吗?"

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