diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index e8fb52285..17215ffdd 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -150,6 +150,8 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} USE_HARD_LINKS: false # see https://github.com/electron-userland/electron-builder/issues/7093 + GH_PUBLISH_OWNER: ${{ github.repository_owner }} + GH_PUBLISH_REPO: ${{ github.event.repository.name }} steps: - name: Checkout git repo @@ -194,7 +196,7 @@ jobs: - name: Make release build & publish ${{ matrix.identifier }} if: ${{ needs.create_draft_release_if_needed.outputs.version_tag != '' }} run: | - sed -i 's/"target": "deb"/"target": "${{ matrix.electron_target }}"/g' package.json && pnpm run build-release-publish + sed -i 's/"target": "deb"/"target": "${{ matrix.electron_target }}"/g' package.json && pnpm run build-release-publish --config.publish.provider=github --config.publish.owner="${GH_PUBLISH_OWNER}" --config.publish.repo="${GH_PUBLISH_REPO}" - name: Backup release metadata # only run this on "push" to "master" or alpha releases @@ -261,6 +263,8 @@ jobs: needs: [create_draft_release_if_needed, lint] env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_PUBLISH_OWNER: ${{ github.repository_owner }} + GH_PUBLISH_REPO: ${{ github.event.repository.name }} name: "windows x64" steps: - run: git config --global core.autocrlf false @@ -303,7 +307,7 @@ jobs: - name: Make release build & publish if: ${{ needs.create_draft_release_if_needed.outputs.version_tag != '' }} - run: pnpm run build-release-publish # No other args needed for windows publish + run: pnpm run build-release-publish --config.publish.provider=github --config.publish.owner="${env:GH_PUBLISH_OWNER}" --config.publish.repo="${env:GH_PUBLISH_REPO}" # We want both arm64 and intel mac builds, and according to this https://docs.github.com/en/actions/using-github-hosted-runners/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources macos-14 and above is always arm64 and macos-15 is the last intel runner # NOTE x64 builds made on an arm64 host will not bundle the native modules correctly https://github.com/electron-userland/electron-builder/issues/8646 @@ -329,6 +333,8 @@ jobs: SIGNING_APPLE_ID: ${{ secrets.SIGNING_APPLE_ID }} SIGNING_APP_PASSWORD: ${{ secrets.SIGNING_APP_PASSWORD }} SIGNING_TEAM_ID: ${{ secrets.SIGNING_TEAM_ID }} + GH_PUBLISH_OWNER: ${{ github.repository_owner }} + GH_PUBLISH_REPO: ${{ github.event.repository.name }} steps: - run: git config --global core.autocrlf false diff --git a/actions/make_release_build/action.yml b/actions/make_release_build/action.yml index a608e7b71..d8b587358 100644 --- a/actions/make_release_build/action.yml +++ b/actions/make_release_build/action.yml @@ -33,7 +33,7 @@ runs: shell: bash run: | source ./build/setup-mac-certificate.sh - pnpm run build-release-publish --config.mac.bundleVersion=${{ github.ref }} + pnpm run build-release-publish --config.mac.bundleVersion=${{ github.ref }} --config.publish.provider=github --config.publish.owner="${GH_PUBLISH_OWNER}" --config.publish.repo="${GH_PUBLISH_REPO}" # Note: We need to backup the latest.yml file because other jobs can overwrite it when they are complete e.g. macOS arm64 and x64 - name: Backup release metadata diff --git a/package.json b/package.json index 298da1402..12e7d87c6 100644 --- a/package.json +++ b/package.json @@ -96,7 +96,7 @@ "framer-motion": "^12.5.0", "fs-extra": "11.3.0", "image-type": "^4.1.0", - "libsession_util_nodejs": "https://github.com/session-foundation/libsession-util-nodejs/releases/download/v0.6.16/libsession_util_nodejs-v0.6.16.tar.gz", + "libsession_util_nodejs": "https://github.com/session-foundation/libsession-util-nodejs/releases/download/v0.6.17/libsession_util_nodejs-v0.6.17.tar.gz", "libsodium-wrappers-sumo": "^0.7.15", "linkify-it": "^5.0.0", "lodash": "^4.17.21", @@ -110,6 +110,7 @@ "pino": "^9.6.0", "protobufjs": "^7.4.0", "punycode": "^2.3.1", + "socks-proxy-agent": "^8.0.5", "qrcode.react": "4.2.0", "react": "19.2.1", "react-contexify": "https://github.com/session-foundation/session-react-contexify/releases/download/v7.0.0/react-contexify-7.0.0.tgz", @@ -348,6 +349,9 @@ "!node_modules/linkify-it/**/*.mjs", "!node_modules/linkify-it/node_modules/**", "node_modules/linkify-it/node_modules/uc.micro/build/index.cjs.js", + "node_modules/socks-proxy-agent/**/*", + "node_modules/socks/build/**/*.js", + "node_modules/smart-buffer/build/**/*.js", "node_modules/uc.micro/build/index.cjs.js", "!node_modules/uc.micro/**/*.mjs", "node_modules/libsession_util_nodejs/build/Release/*.node", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af342bff3..56b63231a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,8 +99,8 @@ importers: specifier: ^4.1.0 version: 4.1.0 libsession_util_nodejs: - specifier: https://github.com/session-foundation/libsession-util-nodejs/releases/download/v0.6.16/libsession_util_nodejs-v0.6.16.tar.gz - version: https://github.com/session-foundation/libsession-util-nodejs/releases/download/v0.6.16/libsession_util_nodejs-v0.6.16.tar.gz + specifier: https://github.com/session-foundation/libsession-util-nodejs/releases/download/v0.6.17/libsession_util_nodejs-v0.6.17.tar.gz + version: https://github.com/session-foundation/libsession-util-nodejs/releases/download/v0.6.17/libsession_util_nodejs-v0.6.17.tar.gz libsodium-wrappers-sumo: specifier: ^0.7.15 version: 0.7.15 @@ -194,6 +194,9 @@ importers: sharp: specifier: https://github.com/session-foundation/sharp/releases/download/v0.34.5/sharp-0.34.5.tgz version: https://github.com/session-foundation/sharp/releases/download/v0.34.5/sharp-0.34.5.tgz + socks-proxy-agent: + specifier: ^8.0.5 + version: 8.0.5 styled-components: specifier: 6.1.19 version: 6.1.19(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -1157,11 +1160,11 @@ packages: resolution: {integrity: sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==} '@emoji-mart/data@https://github.com/session-foundation/session-emoji-mart/releases/download/v5.6.0/emoji-mart-data-v1.2.1.tgz': - resolution: {tarball: https://github.com/session-foundation/session-emoji-mart/releases/download/v5.6.0/emoji-mart-data-v1.2.1.tgz} + resolution: {integrity: sha512-JR40zY+VXUMdI9itDS8PGJK8ThRpxKb2eL4lHv/uUFVHdqt2GdBNj1mhKg1RiAqq4/MZDQYQyia8kxDX/TsdtA==, tarball: https://github.com/session-foundation/session-emoji-mart/releases/download/v5.6.0/emoji-mart-data-v1.2.1.tgz} version: 1.2.1 '@emoji-mart/react@https://github.com/session-foundation/session-emoji-mart/releases/download/v5.6.0/emoji-mart-react-v1.1.1.tgz': - resolution: {tarball: https://github.com/session-foundation/session-emoji-mart/releases/download/v5.6.0/emoji-mart-react-v1.1.1.tgz} + resolution: {integrity: sha512-yysTeYN2fOaK9e9rgohm7UiDnVaX5DFooQziHKutRL/m44AILqqpbgwzNDsHt5Q+E7kF/tIO09O0OHWbAEWHkg==, tarball: https://github.com/session-foundation/session-emoji-mart/releases/download/v5.6.0/emoji-mart-react-v1.1.1.tgz} version: 1.1.1 peerDependencies: emoji-mart: ^5.2 @@ -1220,72 +1223,72 @@ packages: engines: {node: '>=18'} '@img/sharp-darwin-arm64@https://github.com/session-foundation/sharp/releases/download/v0.34.5/img-sharp-darwin-arm64-0.34.5.tgz': - resolution: {tarball: https://github.com/session-foundation/sharp/releases/download/v0.34.5/img-sharp-darwin-arm64-0.34.5.tgz} + resolution: {integrity: sha512-0OrdgJ6O6iS3EJwhqIBKUCgFlXGj+IuTehPtkents2kfg7Bi6rOgxEH4E1tPBaPAvO1ot0np/DbJ5a8yTqxUEQ==, tarball: https://github.com/session-foundation/sharp/releases/download/v0.34.5/img-sharp-darwin-arm64-0.34.5.tgz} version: 0.34.5 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [darwin] '@img/sharp-darwin-x64@https://github.com/session-foundation/sharp/releases/download/v0.34.5/img-sharp-darwin-x64-0.34.5.tgz': - resolution: {tarball: https://github.com/session-foundation/sharp/releases/download/v0.34.5/img-sharp-darwin-x64-0.34.5.tgz} + resolution: {integrity: sha512-lDdMW6VAz8/lCLRVsdbepsqqohxpJqXDYxU6xHU+amtI9eE16VTqdMdTBU3cc3PqTWF/rX1qhIIi5UbhB69i+w==, tarball: https://github.com/session-foundation/sharp/releases/download/v0.34.5/img-sharp-darwin-x64-0.34.5.tgz} version: 0.34.5 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] '@img/sharp-libvips-darwin-arm64@https://github.com/session-foundation/sharp-libvips/releases/download/v1.2.4/sharp-libvips-darwin-arm64-1.2.4.tar.gz': - resolution: {tarball: https://github.com/session-foundation/sharp-libvips/releases/download/v1.2.4/sharp-libvips-darwin-arm64-1.2.4.tar.gz} + resolution: {integrity: sha512-29f2Pg9m4JrkDn6Zw7bM8egPFfFVg6csVZt+CHvkjsEcxa86g9zFdsOOCiezlqeLx1Q8TtmzY5Qb1v7YAJd+ug==, tarball: https://github.com/session-foundation/sharp-libvips/releases/download/v1.2.4/sharp-libvips-darwin-arm64-1.2.4.tar.gz} version: 1.2.4 cpu: [arm64] os: [darwin] '@img/sharp-libvips-darwin-x64@https://github.com/session-foundation/sharp-libvips/releases/download/v1.2.4/sharp-libvips-darwin-x64-1.2.4.tar.gz': - resolution: {tarball: https://github.com/session-foundation/sharp-libvips/releases/download/v1.2.4/sharp-libvips-darwin-x64-1.2.4.tar.gz} + resolution: {integrity: sha512-kKENFPJsmcCApNQCClvu/CBfNZ2F3D7ne8QVfTQCNd4xOTAqbUSJIWjWk/O+8isR+Z7cRJel4bbDPkIPcAHfCg==, tarball: https://github.com/session-foundation/sharp-libvips/releases/download/v1.2.4/sharp-libvips-darwin-x64-1.2.4.tar.gz} version: 1.2.4 cpu: [x64] os: [darwin] '@img/sharp-libvips-linux-arm64@https://github.com/session-foundation/sharp-libvips/releases/download/v1.2.4/sharp-libvips-linux-arm64-1.2.4.tar.gz': - resolution: {tarball: https://github.com/session-foundation/sharp-libvips/releases/download/v1.2.4/sharp-libvips-linux-arm64-1.2.4.tar.gz} + resolution: {integrity: sha512-yO7Tjt8yDg7gD0nco7MTkmn47JVRS+NvES4kDYleommmyZpyXIFSqxn1uej80O9+HJrBZNCI3joIb8UoyFkDIw==, tarball: https://github.com/session-foundation/sharp-libvips/releases/download/v1.2.4/sharp-libvips-linux-arm64-1.2.4.tar.gz} version: 1.2.4 cpu: [arm64] os: [linux] '@img/sharp-libvips-linux-sse2-x64@https://github.com/session-foundation/sharp-libvips/releases/download/v1.2.4/sharp-libvips-linux-sse2-x64-1.2.4.tar.gz': - resolution: {tarball: https://github.com/session-foundation/sharp-libvips/releases/download/v1.2.4/sharp-libvips-linux-sse2-x64-1.2.4.tar.gz} + resolution: {integrity: sha512-uEDadTSKFHrkW+spQBKEE+cIBGA7niUbAs0tdpmiWsXnFjvcmPlNM6vBikDcgUtkor7I1hS2OFWUusJhjO6kew==, tarball: https://github.com/session-foundation/sharp-libvips/releases/download/v1.2.4/sharp-libvips-linux-sse2-x64-1.2.4.tar.gz} version: 1.2.4 cpu: [x64] os: [linux] '@img/sharp-libvips-linux-x64@https://github.com/session-foundation/sharp-libvips/releases/download/v1.2.4/sharp-libvips-linux-x64-1.2.4.tar.gz': - resolution: {tarball: https://github.com/session-foundation/sharp-libvips/releases/download/v1.2.4/sharp-libvips-linux-x64-1.2.4.tar.gz} + resolution: {integrity: sha512-2BRxNvxW4ZmjilIsS2MPBygl6PAQVFc2GE0+4bFrtUuVuMJG/+xwstfBIHWVzZY8zc436RFNlg11uIxMIy7WcQ==, tarball: https://github.com/session-foundation/sharp-libvips/releases/download/v1.2.4/sharp-libvips-linux-x64-1.2.4.tar.gz} version: 1.2.4 cpu: [x64] os: [linux] '@img/sharp-linux-arm64@https://github.com/session-foundation/sharp/releases/download/v0.34.5/img-sharp-linux-arm64-0.34.5.tgz': - resolution: {tarball: https://github.com/session-foundation/sharp/releases/download/v0.34.5/img-sharp-linux-arm64-0.34.5.tgz} + resolution: {integrity: sha512-yffSwlxntiFtk4rcAZDRdX6nMTfW7W4W8+tEOYLSYYwzmPGaRo1uLsKxJQZs1a02HXD1ToXs1mx6KItMY78SQw==, tarball: https://github.com/session-foundation/sharp/releases/download/v0.34.5/img-sharp-linux-arm64-0.34.5.tgz} version: 0.34.5 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] '@img/sharp-linux-sse2-x64@https://github.com/session-foundation/sharp/releases/download/v0.34.5/img-sharp-linux-sse2-x64-0.34.5.tgz': - resolution: {tarball: https://github.com/session-foundation/sharp/releases/download/v0.34.5/img-sharp-linux-sse2-x64-0.34.5.tgz} + resolution: {integrity: sha512-Is4HvNKBGw2wIJuyO9zxmDnpO+AAOitAMXw44ioasiDSi1haOBH6knGu4pII0zwzX2XjR+Wp3OyksXZ43xqFcw==, tarball: https://github.com/session-foundation/sharp/releases/download/v0.34.5/img-sharp-linux-sse2-x64-0.34.5.tgz} version: 0.34.5 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] '@img/sharp-linux-x64@https://github.com/session-foundation/sharp/releases/download/v0.34.5/img-sharp-linux-x64-0.34.5.tgz': - resolution: {tarball: https://github.com/session-foundation/sharp/releases/download/v0.34.5/img-sharp-linux-x64-0.34.5.tgz} + resolution: {integrity: sha512-Lta92mZ5KsN1GmAdQSfpIXxRy44TSY6ZUIKaTPw0LSUl3LtrqPeMJAHLulZpEBRLBh6lW/Cv0AENtvWEVZtu1A==, tarball: https://github.com/session-foundation/sharp/releases/download/v0.34.5/img-sharp-linux-x64-0.34.5.tgz} version: 0.34.5 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] '@img/sharp-win32-x64@https://github.com/session-foundation/sharp/releases/download/v0.34.5/img-sharp-win32-x64-0.34.5.tgz': - resolution: {tarball: https://github.com/session-foundation/sharp/releases/download/v0.34.5/img-sharp-win32-x64-0.34.5.tgz} + resolution: {integrity: sha512-NkWwtSB5y/F3ACj3cW284BhgXsP2KIdW8k0WWj4hqGGbZd3lE5zpcZYdcZjvOPQQkSSX2ePw88PQ5WrK2llCHQ==, tarball: https://github.com/session-foundation/sharp/releases/download/v0.34.5/img-sharp-win32-x64-0.34.5.tgz} version: 0.34.5 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] @@ -2963,7 +2966,7 @@ packages: hasBin: true emoji-mart@https://github.com/session-foundation/session-emoji-mart/releases/download/v5.6.0/emoji-mart-v5.6.0.tgz: - resolution: {tarball: https://github.com/session-foundation/session-emoji-mart/releases/download/v5.6.0/emoji-mart-v5.6.0.tgz} + resolution: {integrity: sha512-sonXhghObY+cG5B6m57cUhDsNBsuVFShK0ZareQqBtUNyHeMMMdRVCvcS1RnV9qBrwnGoh2nUa8szFITv93gJQ==, tarball: https://github.com/session-foundation/session-emoji-mart/releases/download/v5.6.0/emoji-mart-v5.6.0.tgz} version: 5.6.0 emoji-regex@8.0.0: @@ -4020,9 +4023,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - libsession_util_nodejs@https://github.com/session-foundation/libsession-util-nodejs/releases/download/v0.6.16/libsession_util_nodejs-v0.6.16.tar.gz: - resolution: {integrity: sha512-jbsnksU+kYvUsQ2ykhHoco+wgB3r+X6N8UIc7IlBrPZ1LQ0j0uvQVN5c+Ym9Bsi2LXhVLnDsbN0YTpOBlwKO0A==, tarball: https://github.com/session-foundation/libsession-util-nodejs/releases/download/v0.6.16/libsession_util_nodejs-v0.6.16.tar.gz} - version: 0.6.16 + libsession_util_nodejs@https://github.com/session-foundation/libsession-util-nodejs/releases/download/v0.6.17/libsession_util_nodejs-v0.6.17.tar.gz: + resolution: {integrity: sha512-SBAWNOhxl3W/h93e7noH8OS4YqGtpDu/5j10LTmxwrcPmRrgMpUHmyV76/Gl/rifX07Bf2jr7GIuVp7Hp711HQ==, tarball: https://github.com/session-foundation/libsession-util-nodejs/releases/download/v0.6.17/libsession_util_nodejs-v0.6.17.tar.gz} + version: 0.6.17 libsodium-sumo@0.7.15: resolution: {integrity: sha512-5tPmqPmq8T8Nikpm1Nqj0hBHvsLFCXvdhBFV7SGOitQPZAA6jso8XoL0r4L7vmfKXr486fiQInvErHtEvizFMw==} @@ -5206,7 +5209,7 @@ packages: resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} sharp@https://github.com/session-foundation/sharp/releases/download/v0.34.5/sharp-0.34.5.tgz: - resolution: {tarball: https://github.com/session-foundation/sharp/releases/download/v0.34.5/sharp-0.34.5.tgz} + resolution: {integrity: sha512-sW1/dqImBRgiIdVf3ku68Ci6aFA2s1fXzEBDYxuuXZQwwWarAJZOzoFCXhWs5XGepLfrQ+T//jaxYqgPgkHQmA==, tarball: https://github.com/session-foundation/sharp/releases/download/v0.34.5/sharp-0.34.5.tgz} version: 0.34.5 engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -10123,7 +10126,7 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - libsession_util_nodejs@https://github.com/session-foundation/libsession-util-nodejs/releases/download/v0.6.16/libsession_util_nodejs-v0.6.16.tar.gz: + libsession_util_nodejs@https://github.com/session-foundation/libsession-util-nodejs/releases/download/v0.6.17/libsession_util_nodejs-v0.6.17.tar.gz: dependencies: cmake-js: 7.3.1 node-addon-api: 8.5.0 diff --git a/ts/components/dialog/user-settings/pages/network/SessionNetworkPage.tsx b/ts/components/dialog/user-settings/pages/network/SessionNetworkPage.tsx index fc2822166..5a3f5eeb5 100644 --- a/ts/components/dialog/user-settings/pages/network/SessionNetworkPage.tsx +++ b/ts/components/dialog/user-settings/pages/network/SessionNetworkPage.tsx @@ -9,6 +9,7 @@ import { useLastRefreshedTimestamp, } from '../../../../../state/selectors/networkModal'; import { NetworkSection } from './sections/network/NetworkSection'; +import { ProxySection } from './sections/network/ProxySection'; import { useDataIsStale } from '../../../../../state/selectors/networkData'; import { SessionLucideIconButton } from '../../../../icon/SessionIconButton'; import { LUCIDE_ICONS_UNICODE } from '../../../../icon/lucide'; @@ -71,6 +72,7 @@ export function SessionNetworkPage(modalState: UserSettingsModalState) { onClose={closeAction || undefined} > + {!dataIsStale && lastRefreshedTimestamp && !loading ? ( <> diff --git a/ts/components/dialog/user-settings/pages/network/sections/network/ProxySection.tsx b/ts/components/dialog/user-settings/pages/network/sections/network/ProxySection.tsx new file mode 100644 index 000000000..8fa052aac --- /dev/null +++ b/ts/components/dialog/user-settings/pages/network/sections/network/ProxySection.tsx @@ -0,0 +1,285 @@ +import { ipcRenderer } from 'electron'; +import { useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { Flex } from '../../../../../../basic/Flex'; +import { SessionToggle } from '../../../../../../basic/SessionToggle'; +import { SectionHeading, SessionNetworkParagraph, Block } from '../../components'; +import { ModalSimpleSessionInput } from '../../../../../../inputs/SessionInput'; +import { + SessionButton, + SessionButtonColor, + SessionButtonShape, + SessionButtonType, +} from '../../../../../../basic/SessionButton'; +import { SpacerMD, SpacerXS } from '../../../../../../basic/Text'; +import { ToastUtils } from '../../../../../../../session/utils'; +import { SettingsKey } from '../../../../../../../data/settings-key'; +import { normalizeProxySettings } from '../../../../../../../session/utils/ProxySettings'; + +const PROXY_COPY = { + title: 'SOCKS5 Proxy', + description: 'Route supported Session traffic and the auto-updater through a SOCKS5 proxy.', + enableTitle: 'Enable SOCKS5 proxy', + enableDescription: 'Apply SOCKS5 settings to supported Session traffic.', + host: 'Host', + hostRequired: 'Host is required.', + port: 'Port', + portInvalid: 'Port must be between 1 and 65535.', + usernameOptional: 'Username (optional)', + passwordOptional: 'Password (optional)', + validationError: 'Invalid SOCKS5 proxy settings.', + saved: 'SOCKS5 proxy settings saved.', + applyFailed: 'Failed to apply SOCKS5 proxy settings.', + save: 'Save', + saving: 'Saving...', +} as const; + +type ProxyDraft = { + enabled: boolean; + host: string; + port: string; + username: string; + password: string; +}; + +type ValidationResult = { + hostError?: string; + portError?: string; +}; + +const ToggleRow = styled.button` + width: 100%; + border: 0; + padding: var(--margins-md); + text-align: start; + background: transparent; + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--margins-md); + cursor: pointer; + color: var(--text-primary-color); +`; + +const ToggleCopy = styled.div` + display: flex; + flex-direction: column; + gap: var(--margins-xs); +`; + +const ToggleTitle = styled.p` + margin: 0; + font-size: var(--font-size-lg); + font-weight: 700; +`; + +const ToggleDescription = styled.p` + margin: 0; + font-size: var(--font-size-md); + color: var(--text-secondary-color); +`; + +const InputsContainer = styled(Flex)` + width: 100%; + gap: var(--margins-sm); +`; + +function loadDraft(): ProxyDraft { + return { + enabled: Boolean(window.getSettingValue(SettingsKey.proxyEnabled)), + host: (window.getSettingValue(SettingsKey.proxyHost) as string) || '', + port: String(window.getSettingValue(SettingsKey.proxyPort) || ''), + username: (window.getSettingValue(SettingsKey.proxyUsername) as string) || '', + password: (window.getSettingValue(SettingsKey.proxyPassword) as string) || '', + }; +} + +function validateDraft(draft: ProxyDraft): ValidationResult { + if (!draft.enabled) { + return {}; + } + + if (!draft.host.trim()) { + return { hostError: PROXY_COPY.hostRequired }; + } + + const port = Number.parseInt(draft.port, 10); + if (Number.isNaN(port) || port < 1 || port > 65535) { + return { portError: PROXY_COPY.portInvalid }; + } + + return {}; +} + +async function applyProxySettings() { + return new Promise(resolve => { + ipcRenderer.once('apply-proxy-settings-response', (_event, error) => { + resolve(error ?? null); + }); + ipcRenderer.send('apply-proxy-settings'); + }); +} + +export function ProxySection() { + const [draft, setDraft] = useState(loadDraft); + const [saving, setSaving] = useState(false); + const validation = useMemo(() => validateDraft(draft), [draft]); + + const saveDisabled = + saving || (!!draft.enabled && (!!validation.hostError || !!validation.portError)); + + const saveSettings = async () => { + if (saveDisabled) { + if (validation.hostError || validation.portError) { + ToastUtils.pushToastError( + 'proxyValidationError', + validation.hostError || validation.portError || PROXY_COPY.validationError + ); + } + return; + } + + setSaving(true); + + try { + await window.setSettingValue(SettingsKey.proxyEnabled, draft.enabled); + await window.setSettingValue(SettingsKey.proxyHost, draft.host.trim()); + await window.setSettingValue(SettingsKey.proxyPort, Number.parseInt(draft.port, 10) || 0); + await window.setSettingValue(SettingsKey.proxyUsername, draft.username.trim()); + await window.setSettingValue(SettingsKey.proxyPassword, draft.password.trim()); + + const error = await applyProxySettings(); + if (error) { + throw error; + } + + const normalized = normalizeProxySettings({ + enabled: draft.enabled, + host: draft.host, + port: draft.port, + username: draft.username, + password: draft.password, + }); + + setDraft( + normalized + ? { + enabled: normalized.enabled, + host: normalized.host, + port: String(normalized.port), + username: normalized.username || '', + password: normalized.password || '', + } + : { + ...draft, + host: draft.host.trim(), + username: draft.username.trim(), + password: draft.password.trim(), + } + ); + + ToastUtils.pushToastSuccess('proxySaved', PROXY_COPY.saved); + } catch { + ToastUtils.pushToastError('proxyApplyFailed', PROXY_COPY.applyFailed); + } finally { + setSaving(false); + } + }; + + return ( + + {PROXY_COPY.title} + {PROXY_COPY.description} + + + setDraft(current => ({ ...current, enabled: !current.enabled }))} + > + + {PROXY_COPY.enableTitle} + {PROXY_COPY.enableDescription} + + + + {draft.enabled ? ( + <> + + setDraft(current => ({ ...current, host: value }))} + onEnterPressed={() => { + void saveSettings(); + }} + providedError={validation.hostError} + errorDataTestId={'error-message'} + /> + setDraft(current => ({ ...current, port: value }))} + onEnterPressed={() => { + void saveSettings(); + }} + providedError={validation.portError} + errorDataTestId={'error-message'} + /> + setDraft(current => ({ ...current, username: value }))} + onEnterPressed={() => { + void saveSettings(); + }} + providedError={undefined} + errorDataTestId={'error-message'} + /> + setDraft(current => ({ ...current, password: value }))} + onEnterPressed={() => { + void saveSettings(); + }} + providedError={undefined} + errorDataTestId={'error-message'} + type="password" + /> + + + ) : null} + + + + + + + ); +} diff --git a/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx b/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx index 8759add0c..a844a8440 100644 --- a/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx +++ b/ts/components/dialog/user-settings/pages/user-pro/ProNonOriginatingPage.tsx @@ -509,6 +509,7 @@ function ProInfoBlockRefund() { case ProPaymentProvider.GooglePlayStore: return ; case ProPaymentProvider.Nil: + case ProPaymentProvider.Rangeproof: return ; default: return assertUnreachable(data.provider, `Unknown pro payment provider: ${data.provider}`); diff --git a/ts/data/settings-key.ts b/ts/data/settings-key.ts index 254e9bd85..1b4796f30 100644 --- a/ts/data/settings-key.ts +++ b/ts/data/settings-key.ts @@ -14,6 +14,11 @@ const hasSyncedInitialConfigurationItem = 'hasSyncedInitialConfigurationItem'; const hasLinkPreviewPopupBeenDisplayed = 'hasLinkPreviewPopupBeenDisplayed'; const hasFollowSystemThemeEnabled = 'hasFollowSystemThemeEnabled'; const hideRecoveryPassword = 'hideRecoveryPassword'; +const proxyEnabled = 'proxyEnabled'; +const proxyHost = 'proxyHost'; +const proxyPort = 'proxyPort'; +const proxyUsername = 'proxyUsername'; +const proxyPassword = 'proxyPassword'; // Pro stats counters const proLongerMessagesSent = 'proLongerMessagesSent'; @@ -52,6 +57,11 @@ export const SettingsKey = { settingsAudioNotification, hasSyncedInitialConfigurationItem, hasLinkPreviewPopupBeenDisplayed, + proxyEnabled, + proxyHost, + proxyPort, + proxyUsername, + proxyPassword, latestUserProfileEnvelopeTimestamp, latestUserGroupEnvelopeTimestamp, latestUserContactsEnvelopeTimestamp, diff --git a/ts/mains/main_node.ts b/ts/mains/main_node.ts index b297791b8..6f2ee2be9 100644 --- a/ts/mains/main_node.ts +++ b/ts/mains/main_node.ts @@ -15,6 +15,7 @@ import { Menu, nativeTheme, screen, + session, shell, systemPreferences, } from 'electron'; @@ -196,6 +197,7 @@ import LIBSESSION_CONSTANTS from '../session/utils/libsession/libsession_constan import { isReleaseChannel } from '../updater/types'; import { canAutoUpdate, checkForUpdates } from '../updater/updater'; import { initializeMainProcessLogger } from '../util/logger/main_process_logging'; +import { buildProxyUrl, normalizeProxySettings } from '../session/utils/ProxySettings'; import * as log from '../util/logger/log'; import { DURATION } from '../session/constants'; @@ -843,6 +845,7 @@ async function showMainWindow(sqlKey: string, passwordAttempt = false) { tray = createTrayIcon(getMainWindow); } + await applyProxySettings(); setupMenu(); } @@ -1237,6 +1240,60 @@ ipc.on('media-access', async () => { await askForMediaAccess(); }); +async function applyProxySettings() { + const settings = normalizeProxySettings({ + enabled: sqlNode.getItemById(SettingsKey.proxyEnabled)?.value, + host: sqlNode.getItemById(SettingsKey.proxyHost)?.value, + port: sqlNode.getItemById(SettingsKey.proxyPort)?.value, + username: sqlNode.getItemById(SettingsKey.proxyUsername)?.value, + password: sqlNode.getItemById(SettingsKey.proxyPassword)?.value, + }); + + if (!settings) { + await session.defaultSession.setProxy({ proxyRules: '' }); + delete process.env.HTTPS_PROXY; + delete process.env.HTTP_PROXY; + delete process.env.NO_PROXY; + return; + } + + const proxyRules = buildProxyUrl(settings, { includeAuth: false, protocol: 'socks5' }); + const proxyEnv = buildProxyUrl(settings, { includeAuth: true, protocol: 'socks5' }); + + await session.defaultSession.setProxy({ + proxyRules, + proxyBypassRules: '', + }); + + process.env.HTTPS_PROXY = proxyEnv; + process.env.HTTP_PROXY = proxyEnv; + process.env.NO_PROXY = ''; +} + +app.on('login', (event, _webContents, _request, authInfo, callback) => { + const settings = normalizeProxySettings({ + enabled: sqlNode.getItemById(SettingsKey.proxyEnabled)?.value, + host: sqlNode.getItemById(SettingsKey.proxyHost)?.value, + port: sqlNode.getItemById(SettingsKey.proxyPort)?.value, + username: sqlNode.getItemById(SettingsKey.proxyUsername)?.value, + password: sqlNode.getItemById(SettingsKey.proxyPassword)?.value, + }); + + if (authInfo.isProxy && settings?.username && settings.password) { + event.preventDefault(); + callback(settings.username, settings.password); + } +}); + +ipc.on('apply-proxy-settings', async event => { + try { + await applyProxySettings(); + event.sender.send('apply-proxy-settings-response', null); + } catch (error) { + event.sender.send('apply-proxy-settings-response', error); + } +}); + ipc.handle('get-storage-profile', async (): Promise => { return app.getPath('userData'); }); diff --git a/ts/session/apis/pro_backend_api/types.ts b/ts/session/apis/pro_backend_api/types.ts index 896749d49..9b592a967 100644 --- a/ts/session/apis/pro_backend_api/types.ts +++ b/ts/session/apis/pro_backend_api/types.ts @@ -30,6 +30,7 @@ export enum ProPaymentProvider { Nil = 0, GooglePlayStore = 1, iOSAppStore = 2, + Rangeproof = 3, } export function getProPaymentProviderFromProOriginatingPlatform( @@ -42,6 +43,8 @@ export function getProPaymentProviderFromProOriginatingPlatform( return ProPaymentProvider.GooglePlayStore; case 'iOS': return ProPaymentProvider.iOSAppStore; + case 'Rangeproof': + return ProPaymentProvider.Rangeproof; default: assertUnreachable(v, 'getProPaymentProviderFromProOriginatingPlatform'); throw new Error('getProPaymentProviderFromProOriginatingPlatform: case not handled'); @@ -58,6 +61,8 @@ export function getProOriginatingPlatformFromProPaymentProvider( return 'Google'; case ProPaymentProvider.iOSAppStore: return 'iOS'; + case ProPaymentProvider.Rangeproof: + return 'Rangeproof'; default: assertUnreachable(v, 'getProOriginatingPlatformFromProPaymentProvider'); throw new Error('getProOriginatingPlatformFromProPaymentProvider: case not handled'); diff --git a/ts/session/apis/seed_node_api/SeedNodeAPI.ts b/ts/session/apis/seed_node_api/SeedNodeAPI.ts index 9cb87584b..44ce0f6aa 100644 --- a/ts/session/apis/seed_node_api/SeedNodeAPI.ts +++ b/ts/session/apis/seed_node_api/SeedNodeAPI.ts @@ -18,7 +18,7 @@ import { getDataFeatureFlag, getFeatureFlag, } from '../../../state/ducks/types/releasedFeaturesReduxTypes'; -import { FetchDestination, insecureNodeFetch } from '../../utils/InsecureNodeFetch'; +import { FetchDestination, insecureNodeFetch, isProxyEnabled } from '../../utils/InsecureNodeFetch'; import { zodSafeParse } from '../../../util/zod'; import { ServiceNodesResponseSchema, @@ -333,10 +333,13 @@ async function getSnodesFromSeedUrl(urlObj: URL) { urlObj.hostname, urlObj.protocol !== Constants.PROTOCOLS.HTTP ); + const tlsOptions = (sslAgent as (https.Agent & { options?: https.AgentOptions }) | undefined) + ?.options; + const requestTimeout = isProxyEnabled() ? 10000 : 5000; const fetchOptions = { method: 'POST', - timeout: 5000, + timeout: requestTimeout, body: JSON.stringify(body), headers: { 'User-Agent': 'WhatsApp', @@ -354,6 +357,7 @@ async function getSnodesFromSeedUrl(urlObj: URL) { fetchOptions, destination: FetchDestination.SEED_NODE, caller: 'getSnodesFromSeedUrl', + tlsOptions, }); if (response.status !== 200) { diff --git a/ts/session/apis/snode_api/onions.ts b/ts/session/apis/snode_api/onions.ts index 8c2b04a13..2f45dacf2 100644 --- a/ts/session/apis/snode_api/onions.ts +++ b/ts/session/apis/snode_api/onions.ts @@ -1130,6 +1130,7 @@ const sendOnionRequestNoRetries = async ({ fetchOptions: guardFetchOptions, destination, caller: `${caller} -> sendOnionRequestNoRetries`, + tlsOptions: { rejectUnauthorized: false }, }); return { response, decodingSymmetricKey: destCtx.symmetricKey }; }; diff --git a/ts/session/apis/snode_api/sessionRpc.ts b/ts/session/apis/snode_api/sessionRpc.ts index 29e835839..de3cb54c0 100644 --- a/ts/session/apis/snode_api/sessionRpc.ts +++ b/ts/session/apis/snode_api/sessionRpc.ts @@ -91,6 +91,7 @@ async function doRequestNoRetries({ }, destination: FetchDestination.SERVICE_NODE, caller: 'doRequestNoRetries', + tlsOptions: fetchOptions.agent ? { rejectUnauthorized: false } : undefined, }); if (!response.ok) { throw new HTTPError('Loki_rpc error', response); diff --git a/ts/session/onions/onionPath.ts b/ts/session/onions/onionPath.ts index b5ccaf4ea..92071a21b 100644 --- a/ts/session/onions/onionPath.ts +++ b/ts/session/onions/onionPath.ts @@ -37,7 +37,7 @@ import { } from '../../state/ducks/types/releasedFeaturesReduxTypes'; import { logDebugWithCat } from '../../util/logger/debugLog'; import { stringify } from '../../types/sqlSharedTypes'; -import { FetchDestination, insecureNodeFetch } from '../utils/InsecureNodeFetch'; +import { FetchDestination, insecureNodeFetch, isProxyEnabled } from '../utils/InsecureNodeFetch'; export function getOnionPathMinTimeout() { return DURATION.SECONDS; @@ -353,6 +353,8 @@ export async function testGuardNode(snode: Snode) { params, }; + const requestTimeout = isProxyEnabled() ? 20000 : 10000; + const fetchOptions = { method: 'POST', body: JSON.stringify(body), @@ -361,7 +363,7 @@ export async function testGuardNode(snode: Snode) { 'User-Agent': 'WhatsApp', 'Accept-Language': 'en-us', }, - timeout: 10000, // 10s, we want a smaller timeout for testing + timeout: requestTimeout, agent: snodeHttpsAgent, }; @@ -377,6 +379,7 @@ export async function testGuardNode(snode: Snode) { fetchOptions, destination: FetchDestination.SERVICE_NODE, caller: 'testGuardNode', + tlsOptions: { rejectUnauthorized: false }, }); } catch (e) { if (e.type === 'request-timeout') { diff --git a/ts/session/utils/InsecureNodeFetch.ts b/ts/session/utils/InsecureNodeFetch.ts index e3ec069b1..610cc10ac 100644 --- a/ts/session/utils/InsecureNodeFetch.ts +++ b/ts/session/utils/InsecureNodeFetch.ts @@ -4,10 +4,13 @@ import nodeFetch, { type RequestInit, type Response, } from 'node-fetch'; +import { SocksProxyAgent } from 'socks-proxy-agent'; +import { SettingsKey } from '../../data/settings-key'; import { ReduxOnionSelectors } from '../../state/selectors/onions'; import { ERROR_CODE_NO_CONNECT } from '../apis/snode_api/SNodeAPI'; import { getFeatureFlag } from '../../state/ducks/types/releasedFeaturesReduxTypes'; import { updateIsOnline } from '../../state/ducks/onions'; +import { buildProxyUrl, normalizeProxySettings } from './ProxySettings'; function debugLogRequestIfEnabled(params: NodeFetchParams) { if (getFeatureFlag('debugInsecureNodeFetch')) { @@ -52,6 +55,48 @@ export enum FetchDestination { PUBLIC = 5, } +type TlsOptionKey = + | 'ca' + | 'cert' + | 'key' + | 'rejectUnauthorized' + | 'checkServerIdentity' + | 'servername' + | 'ciphers' + | 'minVersion' + | 'maxVersion'; + +type NodeFetchParams = { + url: RequestInfo; + fetchOptions?: RequestInit; + destination: FetchDestination; + caller: string; + tlsOptions?: Partial>; +}; + +class SocksProxyAgentWithTls extends SocksProxyAgent { + private readonly tlsOptions?: Partial>; + + constructor( + proxyUrl: string, + options: ConstructorParameters[1], + tlsOptions?: Partial> + ) { + super(proxyUrl, options); + this.tlsOptions = tlsOptions; + } + + async connect(req: unknown, opts: any) { + if (opts?.secureEndpoint && this.tlsOptions) { + Object.assign(opts, this.tlsOptions); + } + + return super.connect(req as any, opts); + } +} + +const cachedAgents = new Map(); + function isFetchError(e: Error): e is FetchError { return 'code' in e; } @@ -60,7 +105,6 @@ function isClientOfflineFromError(e: FetchError) { return e.code === NodeFetchErrorCode.EHOSTUNREACH || e.code === NodeFetchErrorCode.ENETUNREACH; } -// If a request succeeds and was for any destination on the Session Network we want to set online to true. function handleGoOnline(response: Response, destination: FetchDestination) { if ( response.ok && @@ -73,7 +117,6 @@ function handleGoOnline(response: Response, destination: FetchDestination) { } } -// Only go offline if the device itself is offline function handleGoOffline(error: Error) { if ( !navigator.onLine || @@ -84,23 +127,127 @@ function handleGoOffline(error: Error) { } } -type NodeFetchParams = { - url: RequestInfo; - fetchOptions?: RequestInit; - destination: FetchDestination; - caller: string; -}; +function getProxySettings() { + if (typeof window === 'undefined' || !window.getSettingValue) { + return undefined; + } + + return normalizeProxySettings({ + enabled: window.getSettingValue(SettingsKey.proxyEnabled), + host: window.getSettingValue(SettingsKey.proxyHost), + port: window.getSettingValue(SettingsKey.proxyPort), + username: window.getSettingValue(SettingsKey.proxyUsername), + password: window.getSettingValue(SettingsKey.proxyPassword), + }); +} + +export function isProxyEnabled(): boolean { + return getProxySettings() !== undefined; +} + +function hasTlsOptions(tlsOptions?: Partial>): boolean { + return !!tlsOptions && Object.keys(tlsOptions).length > 0; +} + +export function buildTlsOptionsCacheKey( + tlsOptions?: Partial> +): string | undefined { + if (!hasTlsOptions(tlsOptions)) { + return 'no-tls'; + } + + if ( + tlsOptions && + 'checkServerIdentity' in tlsOptions && + typeof (tlsOptions as Record).checkServerIdentity === 'function' + ) { + return undefined; + } + + const parts = Object.entries(tlsOptions || {}) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => { + if (Array.isArray(value)) { + return `${key}:${value.map(item => String(item)).join(',')}`; + } + + return `${key}:${String(value)}`; + }); + + return parts.length ? parts.join('|') : 'no-tls'; +} + +function getProxyAgent( + tlsOptions: Partial> | undefined, + _destination?: FetchDestination +): SocksProxyAgent | undefined { + const settings = getProxySettings(); + if (!settings) { + return undefined; + } + + const proxyUrl = buildProxyUrl(settings, { includeAuth: true, protocol: 'socks5h' }); + const tlsOpts = hasTlsOptions(tlsOptions) ? tlsOptions : undefined; + const tlsOptionsKey = buildTlsOptionsCacheKey(tlsOpts); + const cacheKey = tlsOptionsKey ? `${proxyUrl}:${tlsOptionsKey}` : undefined; + + if (cacheKey) { + const cachedAgent = cachedAgents.get(cacheKey); + if (cachedAgent) { + return cachedAgent; + } + } + + const agent = new SocksProxyAgentWithTls( + proxyUrl, + { + timeout: 30000, + }, + tlsOpts + ); + + if (cacheKey) { + cachedAgents.set(cacheKey, agent); + } + + return agent; +} + +function buildAgentForRequest(params: NodeFetchParams): RequestInit['agent'] | undefined { + const proxyAgent = getProxyAgent(params.tlsOptions, params.destination); + if (proxyAgent) { + window?.log?.info( + `insecureNodeFetch: using SOCKS5 proxy for ${FetchDestination[params.destination]}` + ); + return proxyAgent; + } + + return params.fetchOptions?.agent; +} export async function insecureNodeFetch(params: NodeFetchParams) { try { + const finalAgent = buildAgentForRequest(params); + const fetchOptions = { + ...params.fetchOptions, + ...(finalAgent ? { agent: finalAgent } : {}), + } as RequestInit; + debugLogRequestIfEnabled(params); - const result = await nodeFetch(params.url, params.fetchOptions); + const result = await nodeFetch(params.url, fetchOptions); handleGoOnline(result, params.destination); debugLogResponseIfEnabled(result); return result; } catch (e) { - handleGoOffline(e); - window?.log?.error('insecureNodeFetch', e); + handleGoOffline(e as Error); + window?.log?.error(`insecureNodeFetch error: ${(e as Error).message}`); + window?.log?.debug('insecureNodeFetch error details', { + code: (e as any).code, + errno: (e as any).errno, + syscall: (e as any).syscall, + type: (e as any).type, + stack: (e as Error).stack?.split('\n').slice(0, 3).join('\n'), + }); throw e; } } diff --git a/ts/session/utils/ProxySettings.ts b/ts/session/utils/ProxySettings.ts new file mode 100644 index 000000000..2c91bcaab --- /dev/null +++ b/ts/session/utils/ProxySettings.ts @@ -0,0 +1,69 @@ +export type ProxySettings = { + enabled: true; + host: string; + port: number; + username?: string; + password?: string; +}; + +export type ProxySettingsInput = { + enabled?: unknown; + host?: unknown; + port?: unknown; + username?: unknown; + password?: unknown; +}; + +function normalizeString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizePort(value: unknown): number { + if (typeof value === 'number' && Number.isInteger(value)) { + return value; + } + + if (typeof value === 'string' && value.trim()) { + return Number.parseInt(value.trim(), 10); + } + + return Number.NaN; +} + +export function normalizeProxySettings(input: ProxySettingsInput): ProxySettings | undefined { + if (!input.enabled) { + return undefined; + } + + const host = normalizeString(input.host); + const port = normalizePort(input.port); + + if (!host || Number.isNaN(port) || port < 1 || port > 65535) { + return undefined; + } + + const username = normalizeString(input.username); + const password = normalizeString(input.password); + + return { + enabled: true, + host, + port, + username: username || undefined, + password: password || undefined, + }; +} + +export function buildProxyUrl( + settings: ProxySettings, + options?: { includeAuth?: boolean; protocol?: 'socks5' | 'socks5h' } +): string { + const includeAuth = options?.includeAuth ?? true; + const protocol = options?.protocol ?? 'socks5'; + const auth = + includeAuth && settings.username && settings.password + ? `${encodeURIComponent(settings.username)}:${encodeURIComponent(settings.password)}@` + : ''; + + return `${protocol}://${auth}${settings.host}:${settings.port}`; +} diff --git a/ts/test/session/unit/utils/ProxySettings_test.ts b/ts/test/session/unit/utils/ProxySettings_test.ts new file mode 100644 index 000000000..24322d978 --- /dev/null +++ b/ts/test/session/unit/utils/ProxySettings_test.ts @@ -0,0 +1,79 @@ +import { expect } from 'chai'; + +import { buildTlsOptionsCacheKey } from '../../../../session/utils/InsecureNodeFetch'; +import { buildProxyUrl, normalizeProxySettings } from '../../../../session/utils/ProxySettings'; + +describe('ProxySettings', () => { + it('normalizes valid proxy settings', () => { + const settings = normalizeProxySettings({ + enabled: true, + host: ' 127.0.0.1 ', + port: '9050', + username: ' alice ', + password: ' secret ', + }); + + expect(settings).to.deep.equal({ + enabled: true, + host: '127.0.0.1', + port: 9050, + username: 'alice', + password: 'secret', + }); + }); + + it('rejects invalid proxy settings', () => { + expect( + normalizeProxySettings({ + enabled: true, + host: '', + port: '9050', + }) + ).to.be.eq(undefined); + + expect( + normalizeProxySettings({ + enabled: true, + host: '127.0.0.1', + port: '99999', + }) + ).to.be.eq(undefined); + }); + + it('builds proxy urls with and without auth', () => { + const settings = normalizeProxySettings({ + enabled: true, + host: '127.0.0.1', + port: 9050, + username: 'user', + password: 'pass', + }); + + if (!settings) { + throw new Error('Expected settings to normalize'); + } + + expect(buildProxyUrl(settings, { includeAuth: true, protocol: 'socks5' })).to.be.eq( + 'socks5://user:pass@127.0.0.1:9050' + ); + expect(buildProxyUrl(settings, { includeAuth: false, protocol: 'socks5h' })).to.be.eq( + 'socks5h://127.0.0.1:9050' + ); + }); + + it('omits tls cache keys when checkServerIdentity is a function', () => { + expect(buildTlsOptionsCacheKey()).to.be.eq('no-tls'); + expect( + buildTlsOptionsCacheKey({ + rejectUnauthorized: false, + checkServerIdentity: () => undefined, + }) + ).to.be.eq(undefined); + expect( + buildTlsOptionsCacheKey({ + servername: 'seed1.getsession.org', + rejectUnauthorized: false, + }) + ).to.be.eq('rejectUnauthorized:false|servername:seed1.getsession.org'); + }); +}); diff --git a/ts/test/session/unit/utils/TimerBucket_test.ts b/ts/test/session/unit/utils/TimerBucket_test.ts index 307fa9a6a..6a90e6835 100644 --- a/ts/test/session/unit/utils/TimerBucket_test.ts +++ b/ts/test/session/unit/utils/TimerBucket_test.ts @@ -1,4 +1,5 @@ import { expect } from 'chai'; +import Sinon from 'sinon'; import { getIncrement, getTimerBucketIcon } from '../../../../util/timer'; describe('getIncrement', () => { @@ -54,23 +55,33 @@ describe('getIncrement', () => { }); describe('getTimerBucketIcon', () => { + const mockNow = 1000000; + + beforeEach(() => { + Sinon.stub(Date, 'now').returns(mockNow); + }); + + afterEach(() => { + Sinon.restore(); + }); + describe('absolute values', () => { it('delta < 0', () => { - expect(getTimerBucketIcon(Date.now() - 1000, 100)).to.be.equal( + expect(getTimerBucketIcon(mockNow - 1000, 100)).to.be.equal( 'timer60', 'should have return timer60' ); }); it('delta > length by a little', () => { - expect(getTimerBucketIcon(Date.now() + 105, 100)).to.be.equal( + expect(getTimerBucketIcon(mockNow + 105, 100)).to.be.equal( 'timer00', 'should have return timer00' ); }); it('delta > length by a lot', () => { - expect(getTimerBucketIcon(Date.now() + 10100000, 100)).to.be.equal( + expect(getTimerBucketIcon(mockNow + 10100000, 100)).to.be.equal( 'timer00', 'should have return timer00' ); @@ -80,90 +91,90 @@ describe('getTimerBucketIcon', () => { describe('calculated values for length 1000', () => { const length = 1000; it('delta = 0', () => { - expect(getTimerBucketIcon(Date.now(), length)).to.be.equal( + expect(getTimerBucketIcon(mockNow, length)).to.be.equal( 'timer00', 'should have return timer00' ); }); it('delta = 1/12 of length', () => { - expect(getTimerBucketIcon(Date.now() + (1 / 12) * length, length)).to.be.equal( + expect(getTimerBucketIcon(mockNow + (1 / 12) * length, length)).to.be.equal( 'timer05', 'should have return timer05' ); }); it('delta = 2/12 of length', () => { - expect(getTimerBucketIcon(Date.now() + (2 / 12) * length, length)).to.be.equal( + expect(getTimerBucketIcon(mockNow + (2 / 12) * length, length)).to.be.equal( 'timer10', 'should have return timer10' ); }); it('delta = 3/12 of length', () => { - expect(getTimerBucketIcon(Date.now() + (3 / 12) * length, length)).to.be.equal( + expect(getTimerBucketIcon(mockNow + (3 / 12) * length, length)).to.be.equal( 'timer15', 'should have return timer15' ); }); it('delta = 4/12 of length', () => { - expect(getTimerBucketIcon(Date.now() + (4 / 12) * length, length)).to.be.equal( + expect(getTimerBucketIcon(mockNow + (4 / 12) * length, length)).to.be.equal( 'timer20', 'should have return timer20' ); }); it('delta = 5/12 of length', () => { - expect(getTimerBucketIcon(Date.now() + (5 / 12) * length, length)).to.be.equal( + expect(getTimerBucketIcon(mockNow + (5 / 12) * length, length)).to.be.equal( 'timer25', 'should have return timer25' ); }); it('delta = 6/12 of length', () => { - expect(getTimerBucketIcon(Date.now() + (6 / 12) * length, length)).to.be.equal( + expect(getTimerBucketIcon(mockNow + (6 / 12) * length, length)).to.be.equal( 'timer30', 'should have return timer30' ); }); it('delta = 7/12 of length', () => { - expect(getTimerBucketIcon(Date.now() + (7 / 12) * length, length)).to.be.equal( + expect(getTimerBucketIcon(mockNow + (7 / 12) * length, length)).to.be.equal( 'timer35', 'should have return timer35' ); }); it('delta = 8/12 of length', () => { - expect(getTimerBucketIcon(Date.now() + (8 / 12) * length, length)).to.be.equal( + expect(getTimerBucketIcon(mockNow + (8 / 12) * length, length)).to.be.equal( 'timer40', 'should have return timer40' ); }); it('delta = 9/12 of length', () => { - expect(getTimerBucketIcon(Date.now() + (9 / 12) * length, length)).to.be.equal( + expect(getTimerBucketIcon(mockNow + (9 / 12) * length, length)).to.be.equal( 'timer45', 'should have return timer45' ); }); it('delta = 10/12 of length', () => { - expect(getTimerBucketIcon(Date.now() + (10 / 12) * length, length)).to.be.equal( + expect(getTimerBucketIcon(mockNow + (10 / 12) * length, length)).to.be.equal( 'timer50', 'should have return timer50' ); }); it('delta = 11/12 of length', () => { - expect(getTimerBucketIcon(Date.now() + (11 / 12) * length, length)).to.be.equal( + expect(getTimerBucketIcon(mockNow + (11 / 12) * length, length)).to.be.equal( 'timer55', 'should have return timer55' ); }); it('delta = 12/12 of length', () => { - expect(getTimerBucketIcon(Date.now() + (12 / 12) * length, length)).to.be.equal( + expect(getTimerBucketIcon(mockNow + (12 / 12) * length, length)).to.be.equal( 'timer60', 'should have return timer60' ); diff --git a/ts/updater/updater.ts b/ts/updater/updater.ts index 6e78ad0a3..e513a9414 100644 --- a/ts/updater/updater.ts +++ b/ts/updater/updater.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-misused-promises */ /* eslint-disable no-console */ -import { app, type BrowserWindow } from 'electron'; +import { app, session, type BrowserWindow } from 'electron'; import { autoUpdater, DOWNLOAD_PROGRESS, type UpdateInfo } from 'electron-updater'; import * as fs from 'fs-extra'; import * as path from 'path'; @@ -15,11 +15,15 @@ import { getLatestRelease } from '../node/latest_desktop_release'; import { Errors } from '../types/Errors'; import type { LoggerType } from '../util/logger/Logging'; import { isDebianBased, isRunningViaAppImage } from '../OS'; +import { sqlNode } from '../node/sql'; +import { SettingsKey } from '../data/settings-key'; +import { buildProxyUrl, normalizeProxySettings } from '../session/utils/ProxySettings'; let isUpdating = false; let downloadIgnored = false; let interval: NodeJS.Timeout | undefined; let stopped = false; +let lastAppliedProxy: string | null = null; autoUpdater.on(DOWNLOAD_PROGRESS, eventDownloadProgress => { console.log( @@ -74,6 +78,8 @@ export async function checkForUpdates( logger: LoggerType, force?: boolean ) { + await configureUpdaterProxy(logger); + if (stopped || isUpdating || (downloadIgnored && !force)) { logger.info( `[updater] checkForUpdates is returning early stopped ${stopped} isUpdating ${isUpdating} downloadIgnored ${downloadIgnored}` @@ -239,3 +245,46 @@ export async function canAutoUpdate(): Promise { return false; } } + +async function configureUpdaterProxy(logger: LoggerType) { + const settings = normalizeProxySettings({ + enabled: sqlNode.getItemById(SettingsKey.proxyEnabled)?.value, + host: sqlNode.getItemById(SettingsKey.proxyHost)?.value, + port: sqlNode.getItemById(SettingsKey.proxyPort)?.value, + username: sqlNode.getItemById(SettingsKey.proxyUsername)?.value, + password: sqlNode.getItemById(SettingsKey.proxyPassword)?.value, + }); + const updaterAny = autoUpdater as any; + const getUpdaterSession = () => updaterAny._netSession || session.defaultSession; + const setUpdaterSession = (targetSession: Electron.Session | null) => { + updaterAny._netSession = targetSession; + }; + + if (!settings) { + if (lastAppliedProxy) { + setUpdaterSession(null); + lastAppliedProxy = null; + } + return; + } + + try { + const proxyRules = buildProxyUrl(settings, { includeAuth: false, protocol: 'socks5' }); + const proxyUrl = buildProxyUrl(settings, { includeAuth: true, protocol: 'socks5' }); + const targetSession = session.defaultSession; + + if (lastAppliedProxy === proxyUrl && getUpdaterSession() === targetSession) { + return; + } + + await targetSession.setProxy({ + proxyRules, + proxyBypassRules: '', + }); + + setUpdaterSession(targetSession); + lastAppliedProxy = proxyUrl; + } catch (error) { + logger.warn('[updater] failed to apply proxy for auto-updater', Errors.toString(error)); + } +}