mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 20:22:11 +09:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
487febac8e | ||
|
|
a3454e8245 | ||
|
|
bf7567b768 | ||
|
|
ac3450ab32 | ||
|
|
5d034becb8 | ||
|
|
f9397d0db9 | ||
|
|
9005139dc8 | ||
|
|
d93604afa2 | ||
|
|
c4f0b2ece0 | ||
|
|
98a42f612b | ||
|
|
cc75e1318d | ||
|
|
9027cbdf3e | ||
|
|
2119e10ed5 | ||
|
|
87f3eb3c81 | ||
|
|
f9bb8bd585 | ||
|
|
bc47a724af | ||
|
|
f32356801d | ||
|
|
efd677f32b | ||
|
|
3cec27ba57 | ||
|
|
1f810ba04d |
@@ -11,11 +11,6 @@ on:
|
||||
- stable
|
||||
- canary
|
||||
default: stable
|
||||
test_build:
|
||||
description: Stash artifacts under desktop-test/ instead of desktop/ (API will not pick these up as a release).
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
build_version:
|
||||
description: Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation.
|
||||
required: false
|
||||
@@ -32,13 +27,12 @@ permissions:
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: desktop-${{ inputs.channel }}-${{ inputs.test_build && 'test' || 'release' }}
|
||||
group: desktop-${{ inputs.channel }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CHANNEL: ${{ inputs.channel }}
|
||||
BUILD_CHANNEL: ${{ inputs.channel == 'canary' && 'canary' || 'stable' }}
|
||||
TEST_BUILD: ${{ inputs.test_build && 'true' || 'false' }}
|
||||
|
||||
jobs:
|
||||
meta:
|
||||
@@ -53,8 +47,6 @@ jobs:
|
||||
pub_date: ${{ steps.meta.outputs.pub_date }}
|
||||
channel: ${{ steps.meta.outputs.channel }}
|
||||
build_channel: ${{ steps.meta.outputs.build_channel }}
|
||||
test_build: ${{ steps.meta.outputs.test_build }}
|
||||
s3_prefix: ${{ steps.meta.outputs.s3_prefix }}
|
||||
source_sha: ${{ steps.meta.outputs.source_sha }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
@@ -85,7 +77,6 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step set_metadata
|
||||
--channel "${{ inputs.channel }}"
|
||||
--test-build "${{ inputs.test_build }}"
|
||||
|
||||
matrix:
|
||||
name: Resolve build matrix
|
||||
@@ -113,7 +104,7 @@ jobs:
|
||||
--skip-targets "${{ inputs.skip_targets }}"
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.platform }} (${{ matrix.arch }}, ${{ matrix.desktop_variant }})
|
||||
name: Build ${{ matrix.platform }} (${{ matrix.arch }})
|
||||
needs:
|
||||
- meta
|
||||
- matrix
|
||||
@@ -137,15 +128,8 @@ jobs:
|
||||
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
DESKTOP_PLATFORM: ${{ matrix.platform }}
|
||||
DESKTOP_ARCH: ${{ matrix.arch }}
|
||||
DESKTOP_VARIANT: ${{ matrix.desktop_variant }}
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
ARCH: ${{ matrix.arch }}
|
||||
ELECTRON_ARCH: ${{ matrix.electron_arch }}
|
||||
@@ -477,6 +461,12 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step prepare_artifacts_unix
|
||||
|
||||
- name: Build AppImage update feed (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_appimage_update_feed
|
||||
|
||||
- name: Normalize updater YAML (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
run: >-
|
||||
@@ -495,13 +485,23 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step generate_checksums_windows
|
||||
|
||||
- name: Upload artifacts to S3 handoff
|
||||
- name: Stage build artifacts
|
||||
id: handoff
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
|
||||
--step upload_handoff
|
||||
--step stage_handoff
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: ${{ steps.handoff.outputs.artifact_name }}
|
||||
path: upload_staging
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
upload:
|
||||
name: Upload to S3
|
||||
name: Assemble desktop release assets
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' }}
|
||||
needs:
|
||||
- meta
|
||||
@@ -520,17 +520,8 @@ jobs:
|
||||
BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
|
||||
TEST_BUILD: ${{ needs.meta.outputs.test_build }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
@@ -542,12 +533,13 @@ jobs:
|
||||
with:
|
||||
toolchain: "1.98.1"
|
||||
|
||||
- name: Download S3 handoff artifacts
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step download_handoff
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
path: artifacts
|
||||
pattern: fluxer-desktop-${{ needs.meta.outputs.build_channel }}-*
|
||||
|
||||
- name: Build S3 payload layout (+ manifest.json)
|
||||
- name: Build payload layout (+ manifest.json)
|
||||
env:
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
|
||||
@@ -556,42 +548,27 @@ jobs:
|
||||
--step build_payload
|
||||
|
||||
- name: Prepare GitHub release assets
|
||||
if: needs.meta.outputs.test_build != 'true'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step prepare_release_assets
|
||||
|
||||
- name: Publish GitHub release descriptor
|
||||
if: needs.meta.outputs.test_build != 'true'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_release_descriptor
|
||||
|
||||
- name: Upload payload to S3
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step upload_payload
|
||||
|
||||
- name: Upload GitHub release asset handoff
|
||||
if: needs.meta.outputs.test_build != 'true'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step upload_release_assets
|
||||
- name: Upload GitHub release assets
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: fluxer-desktop-release-assets
|
||||
path: release_assets
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
- name: Build summary
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_summary
|
||||
|
||||
- name: Cleanup S3 handoff
|
||||
if: ${{ success() }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step cleanup_handoff
|
||||
|
||||
publish_release:
|
||||
name: Publish GitHub desktop release
|
||||
if: ${{ !cancelled() && needs.upload.result == 'success' && needs.meta.outputs.test_build != 'true' }}
|
||||
if: ${{ !cancelled() && needs.upload.result == 'success' }}
|
||||
needs:
|
||||
- meta
|
||||
- upload
|
||||
@@ -603,13 +580,6 @@ jobs:
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
@@ -622,9 +592,10 @@ jobs:
|
||||
toolchain: "1.98.1"
|
||||
|
||||
- name: Download GitHub release assets
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step download_release_assets
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
name: fluxer-desktop-release-assets
|
||||
path: release_assets
|
||||
|
||||
- name: Create token
|
||||
id: create-token
|
||||
@@ -656,15 +627,3 @@ jobs:
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
|
||||
|
||||
- name: Publish GitHub release readiness marker
|
||||
env:
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_release_marker
|
||||
|
||||
- name: Publish payload metadata to S3
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_payload_metadata
|
||||
|
||||
+101
-207
@@ -1,108 +1,64 @@
|
||||
# Every variable docker-compose.yml reads is named here, uncommented when it has
|
||||
# no default and commented with its default when it has one. A name absent from
|
||||
# this file reaches a service only through a Compose override. Compose expands
|
||||
# top to bottom, so a line using ${...} must sit below every name it reads.
|
||||
# Every variable docker-compose.yml reads, uncommented when it has no default and
|
||||
# commented with its default when it has one. Compose expands top to bottom, so a
|
||||
# line using ${...} must sit below every name it reads.
|
||||
|
||||
FLUXER_DOMAIN=chat.example.com
|
||||
FLUXER_PUBLIC_SCHEME=https
|
||||
FLUXER_PUBLIC_PORT=443
|
||||
|
||||
# The lines above are the address browsers use, and every advertised endpoint
|
||||
# carries FLUXER_PUBLIC_PORT. They do not move what the host publishes.
|
||||
# FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT below do that, and a non-default port
|
||||
# needs the matching one set as well. Complete recipes sit beside them.
|
||||
# The address browsers use. FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT below decide
|
||||
# which host ports Fluxer binds.
|
||||
|
||||
# How browsers reach this instance.
|
||||
#
|
||||
# Default: Fluxer binds 80 and 443 and gets its own Let's Encrypt certificate.
|
||||
# Point DNS at this host.
|
||||
#
|
||||
# Behind your own reverse proxy (nginx, Traefik, HAProxy, Cloudflare Tunnel,
|
||||
# another Caddy): uncomment COMPOSE_FILE below. Fluxer then serves plain HTTP on
|
||||
# 127.0.0.1:8080 instead, and your proxy forwards everything to it. Keep
|
||||
# FLUXER_PUBLIC_SCHEME and FLUXER_PUBLIC_PORT describing the PUBLIC address your
|
||||
# proxy serves, not this local port.
|
||||
# By default Fluxer binds 80 and 443 and gets its own certificate. Point DNS here.
|
||||
# Behind your own reverse proxy, uncomment this instead: Fluxer then serves plain
|
||||
# HTTP on 127.0.0.1:8080. Keep the scheme and port above describing the public
|
||||
# address, not this one.
|
||||
#COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
|
||||
|
||||
# Where the plain-HTTP port binds when the proxy overlay is in use. Leave it on
|
||||
# loopback when the proxy runs on this host. Use 0.0.0.0:8080 only when the proxy
|
||||
# is on another machine, and firewall the port to that machine.
|
||||
# Where that plain-HTTP port binds. Use 0.0.0.0:8080 only when the proxy is on
|
||||
# another machine, and firewall it to that machine.
|
||||
#FLUXER_EDGE_BIND=127.0.0.1:8080
|
||||
|
||||
# Which upstream hops may set X-Forwarded-For. Fluxer rewrites the header from
|
||||
# this to the real client address, so IP bans, rate limits and abuse detection
|
||||
# see the caller rather than the proxy. The default covers proxies on private or
|
||||
# loopback addresses, which is every same-host setup. Set it to your proxy's
|
||||
# address if it reaches Fluxer from a public IP.
|
||||
# Which hops may set X-Forwarded-For. The default covers private and loopback
|
||||
# addresses. Set your proxy's address if it reaches Fluxer from a public IP.
|
||||
#FLUXER_EDGE_TRUSTED_PROXIES=private_ranges
|
||||
|
||||
# The origin browsers see, without a trailing slash. Leave it unset and each
|
||||
# service builds one from the three values at the top of this file. Set it and it
|
||||
# wins: every service reads the host, the scheme and the port out of it and
|
||||
# ignores those three names. Use it when browsers reach the instance on a host
|
||||
# FLUXER_DOMAIN does not name. It has to be a bare origin, a scheme and a host
|
||||
# and an optional port and nothing after them, or the services refuse to start.
|
||||
# It does not move the edge listener or the published ports either, so set the
|
||||
# publish below to the port written here.
|
||||
# The origin browsers see, no trailing slash. Set it when browsers reach the
|
||||
# instance on a host FLUXER_DOMAIN does not name, and it wins over the three
|
||||
# values above. Scheme, host and optional port only. It does not move the
|
||||
# published ports.
|
||||
#FLUXER_PUBLIC_ORIGIN=https://chat.example.com
|
||||
|
||||
# Overrides the address the edge listens on inside its container. Compose builds
|
||||
# it from FLUXER_PUBLIC_SCHEME and FLUXER_DOMAIN with no port, and the edge keeps
|
||||
# its container ports at 80 and 443 whatever the public port is. Caddy matches a
|
||||
# site by host and ignores the port in the Host header, so a request arriving on
|
||||
# a non-default published port still lands on this site. Put a port in this value
|
||||
# only if you also publish that same container port below, or nothing will be
|
||||
# listening where the publish points. Honoured in the default mode only:
|
||||
# docker-compose.proxy.yml sets the literal :8080 and tunnel.compose.yml the
|
||||
# literal :80, and Compose lets the last file win, so a value here is discarded
|
||||
# under either overlay with no warning. Set it for an unusual default-mode
|
||||
# layout, such as serving several hostnames. Write the scheme into it: a bare
|
||||
# hostname means automatic HTTPS on 443 whatever FLUXER_PUBLIC_SCHEME says.
|
||||
# The address the edge listens on inside its container. Both proxy overlays set
|
||||
# this themselves, so a value here is ignored under either. Include the scheme.
|
||||
#FLUXER_EDGE_SITE_ADDRESS=https://chat.example.com
|
||||
|
||||
# The old name for the value above, read only when FLUXER_EDGE_SITE_ADDRESS is
|
||||
# unset, so an existing .env keeps the listener it already had.
|
||||
# The old name for the line above, read only when it is unset.
|
||||
#FLUXER_CADDY_SITE_ADDRESS=
|
||||
|
||||
# Host side of the edge's publishes, and the only names that decide which host
|
||||
# ports Fluxer binds. The container side is fixed. Container 80 carries the
|
||||
# HTTP to HTTPS redirect and the Let's Encrypt HTTP challenge under an https
|
||||
# scheme, and the site itself under an http one. Container 443 carries the TLS
|
||||
# site. FLUXER_HTTPS_PORT moves the TCP and the UDP publish together, because
|
||||
# HTTP/3 needs both on the same port. Both take an optional bind address in front
|
||||
# of the port, and 127.0.0.1 keeps the publish off every public interface. Give
|
||||
# them different host ports: the same host port on both is two publishes of one
|
||||
# port and the edge refuses to start.
|
||||
# Host ports. Container 80 handles the redirect and the certificate challenge,
|
||||
# container 443 the TLS site. FLUXER_HTTPS_PORT moves TCP and UDP together, since
|
||||
# HTTP/3 needs both. Both accept a bind address. Give them different host ports.
|
||||
#FLUXER_HTTP_PORT=80
|
||||
#FLUXER_HTTPS_PORT=443
|
||||
#FLUXER_HTTP_PORT=127.0.0.1:80
|
||||
#FLUXER_HTTPS_PORT=127.0.0.1:443
|
||||
|
||||
# HTTPS on 8443, complete. Host 80 stays published and still answers the ACME
|
||||
# challenge. Let's Encrypt only ever connects to the public 80 or 443, so the
|
||||
# certificate is issued if a router in front forwards public 80 to this host and
|
||||
# is not issued otherwise. Serve your own certificate from the Caddyfile when it
|
||||
# cannot.
|
||||
# HTTPS on 8443. Host 80 stays published for the certificate challenge, which
|
||||
# only ever arrives on public 80 or 443. Serve your own certificate if nothing
|
||||
# forwards those.
|
||||
#FLUXER_PUBLIC_PORT=8443
|
||||
#FLUXER_HTTPS_PORT=8443
|
||||
|
||||
# Plain HTTP on 19080, complete. The port 80 publish moves to 19080, so nothing
|
||||
# binds host 80. Under an http scheme nothing listens on container 443, so the
|
||||
# last line parks that publish on loopback for a host that wants 443 for
|
||||
# something else. Drop it and 443 is published and idle, which is what earlier
|
||||
# releases did.
|
||||
# Plain HTTP on 19080. Nothing binds host 80, and the last line parks the idle
|
||||
# 443 publish on loopback.
|
||||
#FLUXER_PUBLIC_SCHEME=http
|
||||
#FLUXER_PUBLIC_PORT=19080
|
||||
#FLUXER_HTTP_PORT=19080
|
||||
#FLUXER_HTTPS_PORT=127.0.0.1:443
|
||||
|
||||
# A tunnel or another proxy in front of the stack needs no HTTPS publish at all.
|
||||
# tunnel.compose.yml ships beside this file and replaces Caddy's published ports
|
||||
# with a single loopback HTTP publish, so nothing binds 443, and points the edge
|
||||
# at plain HTTP on that publish so it stops redirecting to https. FLUXER_HTTP_PORT
|
||||
# still moves that one publish. Set the line below and plain docker compose
|
||||
# commands pick the file up, or add it to your own -f flags if you pass any. The
|
||||
# file uses the !override tag, which needs Compose 2.24.4 or newer.
|
||||
# A tunnel needs no HTTPS publish. tunnel.compose.yml ships beside this file and
|
||||
# leaves one loopback HTTP publish. Needs Compose 2.24.4 or newer.
|
||||
#COMPOSE_FILE=docker-compose.yml:tunnel.compose.yml
|
||||
|
||||
FLUXER_REGISTRY_OWNER=fluxerapp
|
||||
@@ -111,11 +67,8 @@ FLUXER_IMAGE_TAG=v1
|
||||
|
||||
POSTGRES_PASSWORD=CHANGE_ME
|
||||
MEILI_MASTER_KEY=CHANGE_ME
|
||||
# The stack ships its own Postgres and its own object store, and points at both
|
||||
# by service name. Set these to run either one outside the stack. Leave them
|
||||
# unset and the bundled services are used. Taking a service out of the stack
|
||||
# means an upgrade skips the backup step that reaches into it, and backing that
|
||||
# store up belongs to whoever runs it.
|
||||
# Set these to run Postgres or the object store outside the stack. Backing up a
|
||||
# store you moved out is yours to arrange, and an upgrade skips it.
|
||||
#FLUXER_POSTGRES_HOST=db.example.com
|
||||
#FLUXER_POSTGRES_PORT=5432
|
||||
#FLUXER_POSTGRES_DATABASE=fluxer
|
||||
@@ -125,8 +78,7 @@ MEILI_MASTER_KEY=CHANGE_ME
|
||||
#FLUXER_S3_PUBLIC_ENDPOINT=https://cdn.example.com
|
||||
#FLUXER_S3_REGION=eu-central-1
|
||||
#FLUXER_S3_FORCE_PATH_STYLE=false
|
||||
# Bucket names. The bundled object store creates whichever names these hold, so
|
||||
# the two stay in step. An object store outside the stack needs the buckets to
|
||||
# Bucket names. The bundled store creates these. An outside store needs them to
|
||||
# exist already.
|
||||
#FLUXER_S3_BUCKET_CDN=fluxer
|
||||
#FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
|
||||
@@ -134,10 +86,8 @@ MEILI_MASTER_KEY=CHANGE_ME
|
||||
#FLUXER_S3_BUCKET_REPORTS=fluxer-reports
|
||||
#FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
|
||||
|
||||
# The rest of the bundled services, pointed somewhere else the same way. Leave a
|
||||
# line unset and the service in the stack is used. Taking a service out of the
|
||||
# stack goes in an override file listed in COMPOSE_FILE, because an upgrade
|
||||
# replaces docker-compose.yml.
|
||||
# The other bundled services, pointed elsewhere. Removing a service from the
|
||||
# stack belongs in an override file, since an upgrade replaces docker-compose.yml.
|
||||
#FLUXER_KV_URL=redis://cache.example.com:6379/0
|
||||
#FLUXER_NATS_URL=nats://mq.example.com:4222
|
||||
#FLUXER_NATS_JETSTREAM_URL=nats://mq.example.com:4222
|
||||
@@ -145,24 +95,27 @@ MEILI_MASTER_KEY=CHANGE_ME
|
||||
#FLUXER_SEARCH_URL=https://search.example.com
|
||||
#FLUXER_LIVEKIT_INTERNAL_URL=http://livekit.example.com:7880
|
||||
|
||||
# Voice off. The livekit service still runs until an override file takes it out.
|
||||
# Voice off. The livekit service still runs until an override removes it.
|
||||
#FLUXER_LIVEKIT_ENABLED=false
|
||||
|
||||
# Optional systems, each off unless the instance is configured for it.
|
||||
# Optional systems, each off unless configured.
|
||||
#FLUXER_SMS_ENABLED=false
|
||||
#FLUXER_STRIPE_ENABLED=false
|
||||
#FLUXER_NCMEC_ENABLED=false
|
||||
#FLUXER_CLAMAV_ENABLED=false
|
||||
|
||||
# The client address. Set the header name a proxy in front actually writes, and
|
||||
# turn the trust off when nothing sits in front, because a trusted header an
|
||||
# attacker can set is a spoofed client address.
|
||||
# Outside lookups, off unless turned on. The Tor exit list comes from
|
||||
# onionoo.torproject.org and the breached password check asks
|
||||
# api.pwnedpasswords.com.
|
||||
#FLUXER_TOR_EXIT_LIST_ENABLED=true
|
||||
#FLUXER_BREACHED_PASSWORD_CHECK_ENABLED=true
|
||||
|
||||
# The client address. Name the header your proxy actually writes, and turn the
|
||||
# trust off when nothing sits in front.
|
||||
#FLUXER_CLIENT_IP_HEADER_NAME=cf-connecting-ip
|
||||
#FLUXER_TRUST_CLIENT_IP_HEADER=true
|
||||
|
||||
# How much the services write. trace, debug, info, warn, error or fatal. Every
|
||||
# service names the object storage endpoint and its addressing at info on start,
|
||||
# so a bucket that answers 404 is visible without raising this.
|
||||
# How much the services write. trace, debug, info, warn, error or fatal.
|
||||
#LOG_LEVEL=debug
|
||||
|
||||
FLUXER_S3_ACCESS_KEY=fluxer
|
||||
@@ -177,62 +130,43 @@ FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=CHANGE_ME
|
||||
FLUXER_ADMIN_SECRET_KEY_BASE=CHANGE_ME
|
||||
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=CHANGE_ME
|
||||
|
||||
# The token every service sends to NATS. The bundled NATS runs without
|
||||
# authentication, so this stays empty unless a Compose override points the stack
|
||||
# at an external NATS that requires a token. Compose forwards the name to every
|
||||
# container that connects.
|
||||
# The token every service sends to NATS. The bundled NATS needs none, so this
|
||||
# stays empty unless an override points at an external one.
|
||||
#FLUXER_NATS_AUTH_TOKEN=
|
||||
|
||||
FLUXER_VAPID_PUBLIC_KEY=CHANGE_ME
|
||||
FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
|
||||
# The VAPID contact address defaults to admin@ followed by FLUXER_DOMAIN. Set it
|
||||
# only if that mailbox does not exist.
|
||||
# Defaults to admin@ followed by FLUXER_DOMAIN. Set it if that mailbox does not
|
||||
# exist.
|
||||
#[email protected]
|
||||
|
||||
# Passkeys follow FLUXER_DOMAIN by default. Set these only if browsers reach the
|
||||
# instance on a different host, and note that changing FLUXER_PASSKEY_RP_ID
|
||||
# invalidates every passkey already registered against the old value.
|
||||
# Passkeys follow FLUXER_DOMAIN. Set these only if browsers use another host.
|
||||
# Changing the RP ID invalidates every passkey registered against the old value.
|
||||
#FLUXER_PASSKEY_RP_ID=chat.example.com
|
||||
#FLUXER_PASSKEY_RP_NAME=Fluxer
|
||||
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=https://chat.example.com
|
||||
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=http://chat.example.com:19080
|
||||
|
||||
# Two optional media policies, both off unless you turn them on. Nothing below is
|
||||
# needed for a working instance, and an upgrade never adds any of it.
|
||||
# Optional media policies, both off by default. See the operator docs.
|
||||
#
|
||||
# The first limits which web origins may read media through CORS. A request with
|
||||
# no Origin header is always served, so direct links, image tags and native
|
||||
# clients keep working. The allowlist defaults to the public origin above, which
|
||||
# is where this instance serves its web app. The hosted web client and the
|
||||
# desktop app run on https://web.fluxer.app, so add that origin, as in the second
|
||||
# allowlist line, if people use them with this instance.
|
||||
# CORS limits which web origins may read media. A request with no Origin is
|
||||
# always served. Add https://web.fluxer.app if people use the hosted client.
|
||||
#
|
||||
# The second makes an attachment read need a signed URL, which stops a copied
|
||||
# link working forever elsewhere. Turning it on takes two settings: a secret, and
|
||||
# the mode. Generate the secret with openssl rand -base64 32. It is a comma
|
||||
# separated list, the first entry signs and every entry verifies. To rotate, add
|
||||
# the new secret second and run docker compose up -d, then move it first and run
|
||||
# again. Remove the old secret no sooner than a day after that, because an
|
||||
# ordinary URL it signed stays valid for up to a day. A data package URL inside a
|
||||
# harvest export never expires, so removing a secret ends every data package URL
|
||||
# it signed and those exports have to be rebuilt.
|
||||
# Signatures make an attachment read need a signed URL, so a copied link stops
|
||||
# working. Needs a secret from openssl rand -base64 32, first entry signs and
|
||||
# every entry verifies.
|
||||
#
|
||||
# Each mode is off, report or enforce on its own. Set a mode to report first to
|
||||
# log what enforce would refuse while refusing nothing. media-proxy reads these
|
||||
# at container start, so apply a change with docker compose up -d media-proxy.
|
||||
# docker compose restart media-proxy keeps the old environment.
|
||||
# Each mode is off, report or enforce. Start at report. media-proxy reads these
|
||||
# at start, so apply with docker compose up -d media-proxy.
|
||||
#FLUXER_MEDIA_PROXY_CORS_MODE=enforce
|
||||
#FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS=https://chat.example.com
|
||||
#FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS=https://chat.example.com,https://web.fluxer.app
|
||||
#FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64=
|
||||
#FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE=enforce
|
||||
|
||||
# Extra Content-Security-Policy sources, appended to the built-in ones. Set these
|
||||
# only when a browser must reach an origin the defaults do not cover, such as a
|
||||
# voice server hosted on a domain other than FLUXER_DOMAIN. Separate several
|
||||
# sources with spaces or commas. Every one of them is empty by default, and the
|
||||
# three carrying a value below are illustrations, not defaults.
|
||||
# Extra Content-Security-Policy sources, appended to the built-in ones. Set one
|
||||
# only when a browser must reach an origin the defaults do not cover. Separate
|
||||
# several with spaces or commas. The three values below are illustrations.
|
||||
#FLUXER_CSP_EXTRA_DEFAULT_SRC=
|
||||
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
|
||||
#FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com
|
||||
@@ -244,39 +178,29 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
#FLUXER_CSP_EXTRA_WORKER_SRC=
|
||||
#FLUXER_CSP_EXTRA_MANIFEST_SRC=
|
||||
|
||||
# One report-uri for Content-Security-Policy violation reports. Empty leaves the
|
||||
# directive off the header.
|
||||
# One report-uri for CSP violation reports. Empty leaves the directive off.
|
||||
#FLUXER_CSP_REPORT_URI=
|
||||
|
||||
# Allow the SSO identity provider to resolve to a private or internal address.
|
||||
# Off by default: the API refuses to call non-public addresses so a misconfigured
|
||||
# provider URL cannot be used to reach internal services. Turn it on only when the
|
||||
# provider genuinely lives on your own network, such as split-horizon DNS or a LAN
|
||||
# identity provider, and only when you trust everyone who can configure SSO.
|
||||
# Let the SSO provider resolve to a private address. Off by default, so a
|
||||
# misconfigured provider URL cannot reach internal services. Turn it on only for
|
||||
# a provider on your own network.
|
||||
#FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES=true
|
||||
|
||||
# Both reach LiveKit as LIVEKIT_KEYS and the webhook signing key, and the API as
|
||||
# FLUXER_LIVEKIT_API_KEY and FLUXER_LIVEKIT_API_SECRET. Change them together.
|
||||
# These reach both LiveKit and the api. Change them together.
|
||||
LIVEKIT_API_KEY=fluxer
|
||||
LIVEKIT_API_SECRET=CHANGE_ME
|
||||
|
||||
# The URL browsers use for voice signalling. Compose builds it from
|
||||
# FLUXER_PUBLIC_ORIGIN, or from FLUXER_PUBLIC_SCHEME, FLUXER_DOMAIN and
|
||||
# FLUXER_PUBLIC_PORT, as that origin followed by /livekit. The client rewrites a
|
||||
# leading http to ws itself. Set it only when LiveKit is served from another
|
||||
# host.
|
||||
# The URL browsers use for voice signalling. Built from the public origin plus
|
||||
# /livekit. Set it only when LiveKit is served from another host.
|
||||
#FLUXER_LIVEKIT_URL=
|
||||
|
||||
# Media ports. LiveKit advertises these in ICE candidates, so the host must
|
||||
# forward the same numbers.
|
||||
# Media ports. LiveKit advertises these, so forward the same numbers.
|
||||
#FLUXER_LIVEKIT_TCP_PORT=7881
|
||||
#FLUXER_LIVEKIT_UDP_PORT=7882
|
||||
|
||||
# LiveKit finds the address browsers dial by asking a STUN server. A host that
|
||||
# cannot reach one over UDP stops with "could not resolve external IP", and the
|
||||
# address is then set by hand: put it in FLUXER_LIVEKIT_NODE_IP and set
|
||||
# FLUXER_LIVEKIT_USE_EXTERNAL_IP to false. Point the STUN entries at another
|
||||
# server to keep the lookup and leave Google out of it.
|
||||
# LiveKit finds its public address over STUN. A host that cannot reach one stops
|
||||
# with "could not resolve external IP", so set the address by hand instead, or
|
||||
# point STUN elsewhere.
|
||||
#FLUXER_LIVEKIT_USE_EXTERNAL_IP=false
|
||||
#FLUXER_LIVEKIT_NODE_IP=203.0.113.10
|
||||
#FLUXER_LIVEKIT_STUN_PRIMARY=stun.l.google.com:19302
|
||||
@@ -303,11 +227,9 @@ FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
|
||||
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
|
||||
FLUXER_DISCOVERY_ENABLED=true
|
||||
|
||||
# Container memory. The limits sum to 18.25 GiB, which is a sum of ceilings and
|
||||
# not an allocation, so the defaults fit a host with 8 GB and are sized for 16 GB.
|
||||
# The reservations are cgroup memory.low, which biases the kernel away from
|
||||
# reclaiming from services whose death takes the instance down. They reserve
|
||||
# nothing. Lower the limits on a smaller host.
|
||||
# Container memory. These are ceilings, not allocations, and the defaults suit a
|
||||
# 16 GB host. The reservations bias the kernel away from reclaiming from services
|
||||
# whose death takes the instance down. Lower the limits on a smaller host.
|
||||
#FLUXER_CADDY_MEMORY_LIMIT=256mb
|
||||
#FLUXER_POSTGRES_MEMORY_LIMIT=5gb
|
||||
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
|
||||
@@ -338,32 +260,22 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_UNFURL_SHARD_MEMORY_LIMIT=256mb
|
||||
#FLUXER_ADMIN_MEMORY_LIMIT=256mb
|
||||
|
||||
# Meilisearch indexing memory. Keep it well under FLUXER_MEILISEARCH_MEMORY_LIMIT,
|
||||
# which is the container ceiling the indexer shares with the search process.
|
||||
# Meilisearch indexing memory. Keep it well under the container limit above.
|
||||
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
|
||||
|
||||
# SeaweedFS heap ceiling. Go collects against this value instead of against the
|
||||
# container limit, which it cannot see, so without it an upload burst grows the
|
||||
# heap past FLUXER_SEAWEEDFS_MEMORY_LIMIT and the kernel OOM-kills the container
|
||||
# mid-upload (exit 137). Keep it near three quarters of that limit, and raise both
|
||||
# together: the peak is the parts of one upload in flight at once, which is 25 MB
|
||||
# times 20 for a 500 MB attachment.
|
||||
# SeaweedFS heap ceiling. Go cannot see the container limit, so without this an
|
||||
# upload burst gets the container OOM-killed. Keep it near three quarters of
|
||||
# FLUXER_SEAWEEDFS_MEMORY_LIMIT and raise both together.
|
||||
#FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB
|
||||
|
||||
# Node sizes its own heap from the container memory limit by default, at roughly
|
||||
# 55 percent of it, which always leaves room for the buffers and stacks that live
|
||||
# outside the heap. Leave these unset unless you have a reason to pin the value.
|
||||
# Any value set here must stay well below the container limit above: a heap ceiling
|
||||
# above the container limit makes the kernel OOM-kill the container (exit 137, no
|
||||
# diagnostics) instead of Node reporting a JavaScript heap out of memory error.
|
||||
# Node sizes its heap from the container limit by default. Leave these unset
|
||||
# unless you need to pin it. A heap ceiling above the container limit gets the
|
||||
# container OOM-killed instead of reporting a heap error.
|
||||
#FLUXER_API_NODE_HEAP_MB=1792
|
||||
#FLUXER_WORKER_NODE_HEAP_MB=1792
|
||||
|
||||
# Bundled Postgres tuning. Keep these consistent with FLUXER_POSTGRES_MEMORY_LIMIT:
|
||||
# budget roughly shared_buffers + (server max_connections x 12 MB) +
|
||||
# (3 x autovacuum_work_mem) + 300 MB for page cache and WAL. Note this is the
|
||||
# server setting, distinct from the per-service FLUXER_POSTGRES_MAX_CONNECTIONS
|
||||
# pool sizes used by the api, worker and shards.
|
||||
# Bundled Postgres tuning. Keep it consistent with the memory limit above. This
|
||||
# is the server setting, not the per-service pool sizes.
|
||||
#FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS=150
|
||||
#FLUXER_POSTGRES_SHARED_BUFFERS=512MB
|
||||
#FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE=2GB
|
||||
@@ -371,48 +283,30 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_POSTGRES_MAINTENANCE_WORK_MEM=256MB
|
||||
#FLUXER_POSTGRES_AUTOVACUUM_WORK_MEM=128MB
|
||||
|
||||
# The bundled Valkey holds durable state as well as cache. The bulk message
|
||||
# deletion queue and the account deletion queue are sorted sets with no expiry,
|
||||
# and nothing else stores the first of the two. It therefore runs with an
|
||||
# append-only file on a named volume and with noeviction, so an over-limit write
|
||||
# fails loudly instead of silently deleting queued work. Distributed locks all
|
||||
# carry a TTL and are not what the durability is for. Only change the policy if
|
||||
# you have moved that durable state elsewhere.
|
||||
# The bundled Valkey holds durable state as well as cache, so it runs with an
|
||||
# append-only file and with noeviction, which fails an over-limit write instead
|
||||
# of dropping queued work. Change the policy only if that state lives elsewhere.
|
||||
#FLUXER_VALKEY_MAXMEMORY=192mb
|
||||
#FLUXER_VALKEY_MAXMEMORY_POLICY=noeviction
|
||||
|
||||
# The gateway derives its BEAM scheduler count from the container CPU quota,
|
||||
# clamped to this range. The floor matters: a single scheduler lets one blocking
|
||||
# operation stall every websocket on the node. The ceiling stops a large host
|
||||
# from starting far more schedulers than the container can actually use.
|
||||
# The gateway derives its scheduler count from the CPU quota, clamped here. One
|
||||
# scheduler lets a single blocking operation stall every websocket on the node.
|
||||
#FLUXER_ERLANG_SCHEDULERS_MIN=2
|
||||
#FLUXER_ERLANG_SCHEDULERS_MAX=16
|
||||
|
||||
# In-flight request ceiling for the services Compose forwards it to: the users
|
||||
# and messages routers and their shards. Leave it unset and each service uses its
|
||||
# built-in default. Set it and the one value replaces that default on all of
|
||||
# them, so size it for the busiest. The built-in defaults are 192 for
|
||||
# messages, 320 for snowflakes and 64 elsewhere, and they govern every service
|
||||
# Compose does not forward this to. A router holds a slot for the whole round
|
||||
# trip to its shard, so this is a ceiling on requests in flight at once and not a
|
||||
# rate: too low a value does not slow requests down, it rejects them. The api
|
||||
# turns that rejection into a 503 and logs "shard rejected the request because
|
||||
# it is at its concurrency limit".
|
||||
# In-flight request ceiling for the users and messages routers and their shards.
|
||||
# One value replaces the built-in default on all of them, so size it for the
|
||||
# busiest. Too low a value rejects requests rather than slowing them, and the api
|
||||
# turns that into a 503.
|
||||
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=192
|
||||
|
||||
# The api and the Rust services name their fixed Postgres statement shapes so the
|
||||
# server can reuse their plans. Named prepared statements require a session that
|
||||
# outlives the transaction, so set this to false if you put a transaction-pooling
|
||||
# connection pooler such as PgBouncer in front of Postgres. One setting governs
|
||||
# every service. The bundled compose talks to Postgres directly, where naming is
|
||||
# a win and the default is correct.
|
||||
# Named prepared statements need a session that outlives the transaction, so set
|
||||
# this to false behind a transaction-pooling connection pooler. The bundled
|
||||
# compose talks to Postgres directly, where the default is correct.
|
||||
#FLUXER_POSTGRES_PREPARED_STATEMENTS=true
|
||||
|
||||
# The api bounds how long a client may take to send a request. The header timeout
|
||||
# covers the request line and headers only, while the request timeout covers the
|
||||
# whole exchange, so a slow uploader is bounded by the second value and not by
|
||||
# the first. Raise both if you front large uploads or serve clients on high
|
||||
# latency links. The header timeout is clamped down to the request timeout, so
|
||||
# raising it alone does nothing. Both are milliseconds, between 1000 and 3600000.
|
||||
# How long a client may take to send a request. The header timeout covers the
|
||||
# request line and headers, the request timeout the whole exchange, and the first
|
||||
# is clamped down to the second. Milliseconds, 1000 to 3600000.
|
||||
#FLUXER_API_HEADERS_TIMEOUT_MS=30000
|
||||
#FLUXER_API_REQUEST_TIMEOUT_MS=120000
|
||||
|
||||
@@ -24,6 +24,8 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-x-forwarded-for}
|
||||
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
|
||||
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000}
|
||||
FLUXER_TOR_EXIT_LIST_ENABLED: "${FLUXER_TOR_EXIT_LIST_ENABLED:-false}"
|
||||
FLUXER_BREACHED_PASSWORD_CHECK_ENABLED: "${FLUXER_BREACHED_PASSWORD_CHECK_ENABLED:-false}"
|
||||
|
||||
FLUXER_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0}
|
||||
FLUXER_NATS_URL: ${FLUXER_NATS_URL:-nats://nats:4222}
|
||||
|
||||
@@ -15213,7 +15213,6 @@
|
||||
"required": ["guild_id", "backend"]
|
||||
}
|
||||
},
|
||||
"stereo_enabled": {"type": "boolean"},
|
||||
"suppression_strength": {"type": "integer", "minimum": 0, "maximum": 100}
|
||||
}
|
||||
},
|
||||
@@ -15288,7 +15287,6 @@
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"stereo_enabled": {"default": false, "type": "boolean"},
|
||||
"suppression_strength": {"default": 80, "type": "integer", "minimum": 0, "maximum": 100}
|
||||
},
|
||||
"required": [
|
||||
@@ -15302,7 +15300,6 @@
|
||||
"included_user_ids",
|
||||
"excluded_user_ids",
|
||||
"guild_overrides",
|
||||
"stereo_enabled",
|
||||
"suppression_strength"
|
||||
],
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -492,7 +492,6 @@ pub struct VoiceNoiseSuppressionConfigResponse {
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
pub guild_overrides: Vec<VoiceNoiseSuppressionGuildOverride>,
|
||||
pub stereo_enabled: bool,
|
||||
pub suppression_strength: u32,
|
||||
}
|
||||
|
||||
@@ -509,7 +508,6 @@ impl Default for VoiceNoiseSuppressionConfigResponse {
|
||||
included_user_ids: Vec::new(),
|
||||
excluded_user_ids: Vec::new(),
|
||||
guild_overrides: Vec::new(),
|
||||
stereo_enabled: false,
|
||||
suppression_strength: 80,
|
||||
}
|
||||
}
|
||||
@@ -536,8 +534,6 @@ pub struct VoiceNoiseSuppressionConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guild_overrides: Option<Vec<VoiceNoiseSuppressionGuildOverride>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stereo_enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suppression_strength: Option<u32>,
|
||||
}
|
||||
|
||||
|
||||
@@ -447,10 +447,10 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe
|
||||
}
|
||||
}
|
||||
|
||||
const VOICE_NS_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
|
||||
const EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000;
|
||||
const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100;
|
||||
const VOICE_NS_MAX_ROLLOUT_SALT_CHARS: usize = 64;
|
||||
const VOICE_NS_MAX_SNOWFLAKE_LENGTH: usize = 20;
|
||||
const EXPERIMENT_MAX_ROLLOUT_SALT_CHARS: usize = 64;
|
||||
const EXPERIMENT_MAX_SNOWFLAKE_LENGTH: usize = 20;
|
||||
const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60;
|
||||
const EXPERIMENT_MAX_POLL_INTERVAL_SECONDS: u64 = 86_400;
|
||||
const EXPERIMENT_MAX_POLL_JITTER_PERCENT: u32 = 50;
|
||||
@@ -476,35 +476,36 @@ where
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_rollout_salt(
|
||||
fn parse_experiment_rollout_salt(
|
||||
form: &MultiValueForm,
|
||||
key: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(raw) = form.first("voice_ns_rollout_salt") else {
|
||||
let Some(raw) = form.first(key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let salt = raw.trim();
|
||||
if salt.is_empty() || salt.encode_utf16().count() > VOICE_NS_MAX_ROLLOUT_SALT_CHARS {
|
||||
if salt.is_empty() || salt.encode_utf16().count() > EXPERIMENT_MAX_ROLLOUT_SALT_CHARS {
|
||||
return Err(format!(
|
||||
"Rollout salt must be between 1 and {VOICE_NS_MAX_ROLLOUT_SALT_CHARS} characters"
|
||||
"Rollout salt must be between 1 and {EXPERIMENT_MAX_ROLLOUT_SALT_CHARS} characters"
|
||||
));
|
||||
}
|
||||
Ok(Some(salt.to_owned()))
|
||||
}
|
||||
|
||||
fn is_voice_noise_suppression_snowflake(value: &str) -> bool {
|
||||
fn is_experiment_snowflake(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= VOICE_NS_MAX_SNOWFLAKE_LENGTH
|
||||
&& value.len() <= EXPERIMENT_MAX_SNOWFLAKE_LENGTH
|
||||
&& value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
|
||||
fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, String> {
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
for (index, candidate) in value.split([',', '\n', '\r']).enumerate() {
|
||||
let candidate = candidate.trim();
|
||||
if candidate.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !is_voice_noise_suppression_snowflake(candidate) {
|
||||
if !is_experiment_snowflake(candidate) {
|
||||
return Err(format!(
|
||||
"{label} entry {} must contain 1 to 20 decimal digits",
|
||||
index + 1
|
||||
@@ -536,7 +537,7 @@ fn parse_voice_noise_suppression_guild_overrides(
|
||||
format!("Guild overrides line {line_number} must use guild_id=backend")
|
||||
})?;
|
||||
let guild_id = guild_id.trim();
|
||||
if !is_voice_noise_suppression_snowflake(guild_id) {
|
||||
if !is_experiment_snowflake(guild_id) {
|
||||
return Err(format!(
|
||||
"Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits"
|
||||
));
|
||||
@@ -602,21 +603,20 @@ fn build_voice_noise_suppression_update(
|
||||
"voice_ns_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
VOICE_NS_ROLLOUT_BASIS_POINTS_MAX,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_voice_noise_suppression_rollout_salt(form)?,
|
||||
included_user_ids: Some(parse_voice_noise_suppression_user_ids(
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "voice_ns_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_ns_included_user_ids").unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
excluded_user_ids: Some(parse_voice_noise_suppression_user_ids(
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("voice_ns_excluded_user_ids").unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
guild_overrides: Some(parse_voice_noise_suppression_guild_overrides(
|
||||
form.first("voice_ns_guild_overrides").unwrap_or_default(),
|
||||
)?),
|
||||
stereo_enabled: Some(form.bool_value("voice_ns_stereo_enabled")),
|
||||
suppression_strength: parse_form_number(
|
||||
form,
|
||||
"voice_ns_suppression_strength",
|
||||
@@ -1246,7 +1246,6 @@ mod tests {
|
||||
.expect("voice noise suppression update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.allow_user_override, Some(true));
|
||||
assert_eq!(update.stereo_enabled, Some(false));
|
||||
assert_eq!(
|
||||
update.default_backend,
|
||||
Some(NoiseSuppressionBackend::Rnnoise)
|
||||
@@ -1272,7 +1271,6 @@ mod tests {
|
||||
serde_json::json!({"voice_noise_suppression": {
|
||||
"enabled": false,
|
||||
"allow_user_override": false,
|
||||
"stereo_enabled": false,
|
||||
"enabled_backends": [],
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
@@ -1307,9 +1305,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_splits_newlines_and_commas() {
|
||||
fn parse_experiment_user_ids_splits_newlines_and_commas() {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
|
||||
parse_experiment_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
|
||||
.expect("valid IDs"),
|
||||
vec![
|
||||
"1".to_owned(),
|
||||
@@ -1322,16 +1320,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_dedupes_preserving_order() {
|
||||
fn parse_experiment_user_ids_dedupes_preserving_order() {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids("20,10,20,10,30", "Included user IDs")
|
||||
.expect("valid IDs"),
|
||||
parse_experiment_user_ids("20,10,20,10,30", "Included user IDs").expect("valid IDs"),
|
||||
vec!["20".to_owned(), "10".to_owned(), "30".to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_rejects_non_digit_and_overlong_values() {
|
||||
fn parse_experiment_user_ids_rejects_non_digit_and_overlong_values() {
|
||||
for value in [
|
||||
"abc",
|
||||
"12a",
|
||||
@@ -1341,11 +1338,8 @@ mod tests {
|
||||
"<script>",
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(
|
||||
&format!("123,{value}"),
|
||||
"Included user IDs"
|
||||
)
|
||||
.expect_err("invalid ID"),
|
||||
parse_experiment_user_ids(&format!("123,{value}"), "Included user IDs")
|
||||
.expect_err("invalid ID"),
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
"{value}"
|
||||
);
|
||||
@@ -1353,18 +1347,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_voice_noise_suppression_user_ids_rejects_exceeding_the_cap() {
|
||||
fn parse_experiment_user_ids_rejects_exceeding_the_cap() {
|
||||
let value = (0..VOICE_NS_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let ids =
|
||||
parse_voice_noise_suppression_user_ids(&format!("{value}\n999"), "Included user IDs")
|
||||
.expect("valid IDs at cap");
|
||||
let ids = parse_experiment_user_ids(&format!("{value}\n999"), "Included user IDs")
|
||||
.expect("valid IDs at cap");
|
||||
assert_eq!(ids.len(), VOICE_NS_MAX_TARGETED_USERS);
|
||||
assert_eq!(ids.last(), Some(&"999".to_owned()));
|
||||
assert_eq!(
|
||||
parse_voice_noise_suppression_user_ids(&format!("{value}\n1000"), "Included user IDs")
|
||||
parse_experiment_user_ids(&format!("{value}\n1000"), "Included user IDs")
|
||||
.expect_err("too many IDs"),
|
||||
"Included user IDs must contain at most 1000 unique IDs"
|
||||
);
|
||||
|
||||
@@ -1157,17 +1157,6 @@ fn voice_noise_suppression_section(
|
||||
}
|
||||
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Processing" }
|
||||
(checkbox(
|
||||
"voice_ns_stereo_enabled",
|
||||
"true",
|
||||
"Process stereo input instead of downmixing to mono",
|
||||
voice_noise_suppression.stereo_enabled,
|
||||
true,
|
||||
))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Costs more CPU on the client. Leave off unless you are testing stereo \
|
||||
capture."
|
||||
}
|
||||
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||
(number_field(
|
||||
"voice_ns_suppression_strength",
|
||||
|
||||
@@ -403,7 +403,6 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
"stereo_enabled": false,
|
||||
"suppression_strength": 80,
|
||||
"future_presentation_knob": "verbose",
|
||||
"future_knob": 7,
|
||||
|
||||
@@ -1194,7 +1194,6 @@ fn instance_config() -> Value {
|
||||
"included_user_ids": [],
|
||||
"excluded_user_ids": [],
|
||||
"guild_overrides": [],
|
||||
"stereo_enabled": false,
|
||||
"suppression_strength": 80
|
||||
},
|
||||
"experiment_delivery": {
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
|
||||
trustClientIpHeader: config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: config.proxy.client_ip_header,
|
||||
maxInflightRequests: config.maxInflightRequests,
|
||||
torExitBlockingEnabled: config.torExitList.enabled,
|
||||
});
|
||||
routes.onError(AbuseAwareAppErrorHandler);
|
||||
routes.notFound(AppNotFoundHandler);
|
||||
|
||||
@@ -166,3 +166,57 @@ describe('buildAPIConfigFromMaster stripe legacy prices', () => {
|
||||
expect(buildAPIConfigFromMaster(withStripeLegacyPrices(master, undefined)).stripe.legacyPrices).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function withOptionalOutboundLookups(
|
||||
master: MasterConfig,
|
||||
selfHosted: boolean,
|
||||
overrides: {torExitList?: boolean; breachedPasswordCheck?: boolean} = {},
|
||||
): MasterConfig {
|
||||
return {
|
||||
...master,
|
||||
integrations: {
|
||||
...master.integrations,
|
||||
tor_exit_list: {enabled: overrides.torExitList},
|
||||
breached_password_check: {enabled: overrides.breachedPasswordCheck},
|
||||
},
|
||||
instance: {
|
||||
...master.instance,
|
||||
self_hosted: selfHosted,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAPIConfigFromMaster optional outbound lookups', () => {
|
||||
let master: MasterConfig;
|
||||
beforeAll(async () => {
|
||||
master = await loadConfig();
|
||||
});
|
||||
|
||||
it('keeps both lookups on when the instance is not self-hosted', () => {
|
||||
const config = buildAPIConfigFromMaster(withOptionalOutboundLookups(master, false));
|
||||
expect(config.torExitList.enabled).toBe(true);
|
||||
expect(config.breachedPasswordCheck.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves both lookups off on a self-hosted instance', () => {
|
||||
const config = buildAPIConfigFromMaster(withOptionalOutboundLookups(master, true));
|
||||
expect(config.torExitList.enabled).toBe(false);
|
||||
expect(config.breachedPasswordCheck.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a self-hosted operator switch each lookup on', () => {
|
||||
const config = buildAPIConfigFromMaster(
|
||||
withOptionalOutboundLookups(master, true, {torExitList: true, breachedPasswordCheck: true}),
|
||||
);
|
||||
expect(config.torExitList.enabled).toBe(true);
|
||||
expect(config.breachedPasswordCheck.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('lets an operator switch each lookup off when the instance is not self-hosted', () => {
|
||||
const config = buildAPIConfigFromMaster(
|
||||
withOptionalOutboundLookups(master, false, {torExitList: false, breachedPasswordCheck: false}),
|
||||
);
|
||||
expect(config.torExitList.enabled).toBe(false);
|
||||
expect(config.breachedPasswordCheck.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -348,6 +348,12 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
blocklistFeeds: {
|
||||
enabled: master.integrations.blocklist_feeds.enabled ?? !master.instance.self_hosted,
|
||||
},
|
||||
torExitList: {
|
||||
enabled: master.integrations.tor_exit_list.enabled ?? !master.instance.self_hosted,
|
||||
},
|
||||
breachedPasswordCheck: {
|
||||
enabled: master.integrations.breached_password_check.enabled ?? !master.instance.self_hosted,
|
||||
},
|
||||
captcha: {
|
||||
enabled: master.integrations.captcha.enabled,
|
||||
provider: master.integrations.captcha.provider,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {BaseUserUpdatePropagator} from '@app/api/user/services/BaseUserUpdatePropagator';
|
||||
import {propagatePartialUserChange} from '@app/api/user/services/PartialUserChangePropagation';
|
||||
import {hasPartialUserFieldsChanged} from '@app/api/user/UserMappers';
|
||||
|
||||
interface AdminUserUpdatePropagatorDeps {
|
||||
@@ -26,41 +25,11 @@ export class AdminUserUpdatePropagator extends BaseUserUpdatePropagator {
|
||||
});
|
||||
}
|
||||
|
||||
async propagateUserUpdate({
|
||||
userId,
|
||||
oldUser,
|
||||
updatedUser,
|
||||
}: {
|
||||
userId: UserID;
|
||||
oldUser: User;
|
||||
updatedUser: User;
|
||||
}): Promise<void> {
|
||||
async propagateUserUpdate(params: {userId: UserID; oldUser: User; updatedUser: User}): Promise<void> {
|
||||
const {oldUser, updatedUser} = params;
|
||||
await this.dispatchUserUpdate(updatedUser);
|
||||
if (hasPartialUserFieldsChanged(oldUser, updatedUser)) {
|
||||
await this.updateUserCache(updatedUser);
|
||||
await this.propagateToGuilds(userId);
|
||||
await propagatePartialUserChange(this.deps, updatedUser);
|
||||
}
|
||||
}
|
||||
|
||||
private async propagateToGuilds(userId: UserID): Promise<void> {
|
||||
const {userRepository, guildRepository, gatewayService, userCacheService} = this.deps;
|
||||
const guildIds = await userRepository.getUserGuildIds(userId);
|
||||
if (guildIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const requestCache = createRequestCache();
|
||||
for (const guildId of guildIds) {
|
||||
const member = await guildRepository.getMember(guildId, userId);
|
||||
if (!member) {
|
||||
continue;
|
||||
}
|
||||
const memberResponse = await mapGuildMemberToResponse(member, userCacheService, requestCache);
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_MEMBER_UPDATE',
|
||||
data: memberResponse,
|
||||
});
|
||||
}
|
||||
requestCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,9 +137,11 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
|
||||
await initializeRefreshCache(ipBanCache, 'IP ban cache', logger);
|
||||
await startAbuseReplicationSubscriber(kvClient);
|
||||
logger.info('Abusive-IP auto-banner replication started');
|
||||
torExitListCache.setKvClient(kvClient);
|
||||
await torExitListCache.initialize();
|
||||
logger.info('Tor exit list cache initialized');
|
||||
if (config.torExitList.enabled) {
|
||||
torExitListCache.setKvClient(kvClient);
|
||||
await torExitListCache.initialize();
|
||||
logger.info('Tor exit list cache initialized');
|
||||
}
|
||||
const {urlBlocklistCache} = await import('@app/api/middleware/UrlBlocklistCache');
|
||||
urlBlocklistCache.setRefreshSubscriber(kvClient);
|
||||
const {getStorageService} = await import('@app/api/middleware/ServiceSingletons');
|
||||
|
||||
@@ -29,10 +29,19 @@ interface MiddlewarePipelineOptions {
|
||||
trustClientIpHeader: boolean;
|
||||
clientIpHeaderName?: string;
|
||||
maxInflightRequests: number;
|
||||
torExitBlockingEnabled: boolean;
|
||||
}
|
||||
|
||||
export function configureMiddleware(routes: HonoApp, options: MiddlewarePipelineOptions): void {
|
||||
const {logger, nodeEnv, corsOrigins, trustClientIpHeader, clientIpHeaderName, maxInflightRequests} = options;
|
||||
const {
|
||||
logger,
|
||||
nodeEnv,
|
||||
corsOrigins,
|
||||
trustClientIpHeader,
|
||||
clientIpHeaderName,
|
||||
maxInflightRequests,
|
||||
torExitBlockingEnabled,
|
||||
} = options;
|
||||
const resolvedHeader = resolveClientIpHeaderName(clientIpHeaderName);
|
||||
routes.use('/webhooks/:webhook_id/:token', cors({origins: '*'}));
|
||||
routes.use('/webhooks/:webhook_id/:token/messages/:message_id', cors({origins: '*'}));
|
||||
@@ -100,7 +109,9 @@ export function configureMiddleware(routes: HonoApp, options: MiddlewarePipeline
|
||||
}),
|
||||
);
|
||||
}
|
||||
routes.use(TorExitMiddleware);
|
||||
if (torExitBlockingEnabled) {
|
||||
routes.use(TorExitMiddleware);
|
||||
}
|
||||
routes.use(AuditLogMiddleware);
|
||||
routes.use(RequireClientIpMiddleware());
|
||||
routes.use(ServiceMiddleware);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {configureMiddleware} from '@app/api/app/MiddlewarePipeline';
|
||||
import {TorExitMiddleware} from '@app/api/middleware/TorExitMiddleware';
|
||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {Hono} from 'hono';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function registeredHandlers(torExitBlockingEnabled: boolean): Array<unknown> {
|
||||
const routes = new Hono<HonoEnv>({strict: true});
|
||||
configureMiddleware(routes, {
|
||||
logger: new NoopLogger(),
|
||||
nodeEnv: 'test',
|
||||
corsOrigins: ['http://localhost:3000'],
|
||||
trustClientIpHeader: true,
|
||||
clientIpHeaderName: 'x-forwarded-for',
|
||||
maxInflightRequests: 100,
|
||||
torExitBlockingEnabled,
|
||||
});
|
||||
return routes.routes.map((route) => route.handler);
|
||||
}
|
||||
|
||||
describe('tor exit blocking in the middleware pipeline', () => {
|
||||
it('registers the tor exit middleware when the switch is on', () => {
|
||||
expect(registeredHandlers(true)).toContain(TorExitMiddleware);
|
||||
});
|
||||
|
||||
it('leaves the tor exit middleware unregistered when the switch is off', () => {
|
||||
expect(registeredHandlers(false)).not.toContain(TorExitMiddleware);
|
||||
});
|
||||
});
|
||||
@@ -51,10 +51,6 @@ export function signDataPackageAttachmentUrl(url: string, nowSecs?: number): str
|
||||
return options === null ? url : signDataPackageWithSecret(url, options);
|
||||
}
|
||||
|
||||
export function stripAttachmentSignature(url: string): string {
|
||||
return stripSignature(url);
|
||||
}
|
||||
|
||||
export function stripOwnAttachmentSignature(url: string): string {
|
||||
return attachmentStorageKeyFromUrl(url, Config.endpoints.media) === null ? url : stripSignature(url);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {ApiContext} from '@app/api/ApiContext';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import * as AuthUtility from '@app/api/auth/AuthUtility';
|
||||
import {createMfaTicket, createPasswordResetToken} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
|
||||
@@ -116,6 +117,9 @@ export async function verifyPassword(
|
||||
}
|
||||
|
||||
export async function isPasswordPwned(_ctx: ApiContext, password: string): Promise<boolean> {
|
||||
if (!Config.breachedPasswordCheck.enabled) {
|
||||
return false;
|
||||
}
|
||||
const hashed = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
|
||||
const hashPrefix = hashed.slice(0, 5);
|
||||
const hashSuffix = hashed.slice(5);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import {isPasswordPwned, resetPwnedPasswordCacheForTesting} from '@app/api/auth/AuthPassword';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {server} from '@app/api/test/msw/server';
|
||||
import {delay, HttpResponse, http} from 'msw';
|
||||
import {beforeEach, describe, expect, test} from 'vitest';
|
||||
@@ -68,6 +69,19 @@ describe('isPasswordPwned', () => {
|
||||
await expect(isPasswordPwned(ctx, SAFE_PASSWORD_SAME_PREFIX)).resolves.toBe(false);
|
||||
expect(requestedPrefixes).toHaveLength(1);
|
||||
});
|
||||
test('makes no upstream call when the check is switched off', async () => {
|
||||
const config = getConfig();
|
||||
const originalEnabled = config.breachedPasswordCheck.enabled;
|
||||
const requestedPrefixes: Array<string> = [];
|
||||
server.use(rangeHandler(requestedPrefixes, [suffixOf(PWNED_PASSWORD)]));
|
||||
try {
|
||||
config.breachedPasswordCheck.enabled = false;
|
||||
await expect(isPasswordPwned(ctx, PWNED_PASSWORD)).resolves.toBe(false);
|
||||
expect(requestedPrefixes).toHaveLength(0);
|
||||
} finally {
|
||||
config.breachedPasswordCheck.enabled = originalEnabled;
|
||||
}
|
||||
});
|
||||
test('fails open on a non-OK response', async () => {
|
||||
server.use(http.get('https://api.pwnedpasswords.com/range/:prefix', () => HttpResponse.text('', {status: 503})));
|
||||
await expect(isPasswordPwned(ctx, PWNED_PASSWORD)).resolves.toBe(false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import fs from 'node:fs';
|
||||
import {createAttachmentID, createGuildID, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {createAttachmentID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {AttachmentToProcess} from '@app/api/channel/AttachmentDTOs';
|
||||
import type {AttachmentUploadTraceRepository} from '@app/api/channel/repositories/message/AttachmentUploadTraceRepository';
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
makeAttachmentCdnKey,
|
||||
validateAttachmentIds,
|
||||
} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {scheduleUploadSegmentSignal} from '@app/api/channel/services/message/UploadSegmentSignal';
|
||||
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
|
||||
import {contentModerationService, type ModerationContext} from '@app/api/infrastructure/ContentModerationService';
|
||||
import type {
|
||||
@@ -174,24 +173,6 @@ export class AttachmentProcessingService {
|
||||
}
|
||||
return result.attachment;
|
||||
});
|
||||
scheduleUploadSegmentSignal({
|
||||
userId: params.uploadUserId,
|
||||
guildId: params.guild ? createGuildID(BigInt(params.guild.id)) : null,
|
||||
guildOwnerId: params.guild ? createUserID(BigInt(params.guild.owner_id)) : null,
|
||||
channelId: params.message.channelId,
|
||||
messageId: params.message.id,
|
||||
attachments: processedAttachments.map((attachment, index) => ({
|
||||
attachmentId: attachment.attachment_id,
|
||||
uploadKey: results[index].copyOperation.sourceKey,
|
||||
filename: attachment.filename,
|
||||
contentType: attachment.content_type,
|
||||
size: attachment.size,
|
||||
duration: attachment.duration ?? null,
|
||||
waveform: attachment.waveform ?? null,
|
||||
sniffedContentType: results[index].sniffedContentType,
|
||||
requestIp: bindingResults[index].bound?.request_ip ?? null,
|
||||
})),
|
||||
});
|
||||
return {attachments: processedAttachments, hasVirusDetected: false};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {AttachmentID, ChannelID, GuildID, MessageID, UserID} from '@app/api/BrandedTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
|
||||
const WINDOW_MS = 600000;
|
||||
const USER_THRESHOLD = 60;
|
||||
const GUILD_THRESHOLD = 120;
|
||||
const FRESH_GUILD_THRESHOLD = 20;
|
||||
const FRESH_GUILD_MAX_AGE_MS = 604800000;
|
||||
const SEGMENT_MAX_BYTES = 16 * 1024 * 1024;
|
||||
const SEGMENT_MAX_DURATION_SECONDS = 30;
|
||||
|
||||
const KEY_PREFIX = 'abuse:upload_segment:';
|
||||
const WINDOW_TTL_SECONDS = WINDOW_MS / 1000;
|
||||
const MAX_IN_FLIGHT_SIGNALS = 32;
|
||||
const SURFACE = 'message_attachment';
|
||||
const SIGNAL_MESSAGE = 'content_moderation.upload_segment_pattern';
|
||||
const FAILURE_MESSAGE = 'content_moderation.upload_segment_signal_failed';
|
||||
const DROPPED_MESSAGE = 'content_moderation.upload_segment_signal_dropped';
|
||||
const PLAYLIST_CONTENT_TYPES = new Set(['application/vnd.apple.mpegurl', 'application/x-mpegurl']);
|
||||
|
||||
export type UploadSegmentScope = 'user' | 'guild' | 'fresh_guild';
|
||||
|
||||
export interface UploadSegmentAttachment {
|
||||
attachmentId: AttachmentID;
|
||||
uploadKey: string;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
size: bigint;
|
||||
duration: number | null;
|
||||
waveform: string | null;
|
||||
sniffedContentType: string | null;
|
||||
requestIp: string | null;
|
||||
}
|
||||
|
||||
export interface UploadSegmentSignalInput {
|
||||
userId: UserID;
|
||||
guildId: GuildID | null;
|
||||
guildOwnerId: UserID | null;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
attachments: ReadonlyArray<UploadSegmentAttachment>;
|
||||
}
|
||||
|
||||
interface CountedScope {
|
||||
scope: UploadSegmentScope;
|
||||
key: string;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
function baseContentType(contentType: string): string {
|
||||
const [base] = contentType.split(';');
|
||||
return (base ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function isSegmentShapedAttachment(attachment: UploadSegmentAttachment): boolean {
|
||||
if (attachment.waveform !== null) {
|
||||
return false;
|
||||
}
|
||||
if (attachment.sniffedContentType !== null) {
|
||||
return true;
|
||||
}
|
||||
const contentType = baseContentType(attachment.contentType);
|
||||
const isSegmentType =
|
||||
contentType.startsWith('video/') || contentType.startsWith('audio/') || PLAYLIST_CONTENT_TYPES.has(contentType);
|
||||
if (!isSegmentType) {
|
||||
return false;
|
||||
}
|
||||
if (attachment.size > BigInt(SEGMENT_MAX_BYTES)) {
|
||||
return false;
|
||||
}
|
||||
return attachment.duration === null || attachment.duration <= SEGMENT_MAX_DURATION_SECONDS;
|
||||
}
|
||||
|
||||
export function isRungValue(count: number, threshold: number): boolean {
|
||||
if (count < threshold || count % threshold !== 0) {
|
||||
return false;
|
||||
}
|
||||
const multiple = count / threshold;
|
||||
return (multiple & (multiple - 1)) === 0;
|
||||
}
|
||||
|
||||
function uploaderOwnsGuild(input: UploadSegmentSignalInput): boolean {
|
||||
return input.guildOwnerId !== null && input.guildOwnerId === input.userId;
|
||||
}
|
||||
|
||||
function resolveScopes(input: UploadSegmentSignalInput, windowIndex: number, nowMs: number): Array<CountedScope> {
|
||||
const scopes: Array<CountedScope> = [
|
||||
{
|
||||
scope: 'user',
|
||||
key: `${KEY_PREFIX}user:${input.userId.toString()}:${windowIndex}`,
|
||||
threshold: USER_THRESHOLD,
|
||||
},
|
||||
];
|
||||
if (input.guildId === null) {
|
||||
return scopes;
|
||||
}
|
||||
const guildAgeMs = nowMs - snowflakeToDate(input.guildId).getTime();
|
||||
const isFreshGuild = uploaderOwnsGuild(input) && guildAgeMs < FRESH_GUILD_MAX_AGE_MS;
|
||||
scopes.push({
|
||||
scope: isFreshGuild ? 'fresh_guild' : 'guild',
|
||||
key: `${KEY_PREFIX}guild:${input.guildId.toString()}:${windowIndex}`,
|
||||
threshold: isFreshGuild ? FRESH_GUILD_THRESHOLD : GUILD_THRESHOLD,
|
||||
});
|
||||
return scopes;
|
||||
}
|
||||
|
||||
function buildSignalFields(params: {
|
||||
input: UploadSegmentSignalInput;
|
||||
segments: ReadonlyArray<UploadSegmentAttachment>;
|
||||
windowIndex: number;
|
||||
scope: CountedScope;
|
||||
count: number;
|
||||
}): Record<string, unknown> {
|
||||
const {input, segments, windowIndex, scope, count} = params;
|
||||
const requestIps = new Set<string>();
|
||||
for (const segment of segments) {
|
||||
if (segment.requestIp !== null) {
|
||||
requestIps.add(segment.requestIp);
|
||||
}
|
||||
}
|
||||
return {
|
||||
surface: SURFACE,
|
||||
scope: scope.scope,
|
||||
count,
|
||||
threshold: scope.threshold,
|
||||
windowMs: WINDOW_MS,
|
||||
windowStartedAt: new Date(windowIndex * WINDOW_MS).toISOString(),
|
||||
userId: input.userId.toString(),
|
||||
userCreatedAt: snowflakeToDate(input.userId).toISOString(),
|
||||
guildId: input.guildId === null ? null : input.guildId.toString(),
|
||||
guildCreatedAt: input.guildId === null ? null : snowflakeToDate(input.guildId).toISOString(),
|
||||
guildOwnerId: input.guildOwnerId === null ? null : input.guildOwnerId.toString(),
|
||||
uploaderOwnsGuild: uploaderOwnsGuild(input),
|
||||
channelId: input.channelId.toString(),
|
||||
messageId: input.messageId.toString(),
|
||||
attachmentIds: segments.map((segment) => segment.attachmentId.toString()),
|
||||
uploadKeys: segments.map((segment) => segment.uploadKey),
|
||||
requestIps: Array.from(requestIps),
|
||||
filenames: segments.map((segment) => segment.filename),
|
||||
contentTypes: segments.map((segment) => segment.contentType),
|
||||
disguisedCount: segments.filter((segment) => segment.sniffedContentType !== null).length,
|
||||
};
|
||||
}
|
||||
|
||||
function scopeFields(input: UploadSegmentSignalInput): Record<string, unknown> {
|
||||
return {
|
||||
surface: SURFACE,
|
||||
userId: input.userId.toString(),
|
||||
guildId: input.guildId === null ? null : input.guildId.toString(),
|
||||
channelId: input.channelId.toString(),
|
||||
messageId: input.messageId.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
const inFlightSignals = new Set<Promise<void>>();
|
||||
|
||||
export function scheduleUploadSegmentSignal(input: UploadSegmentSignalInput): void {
|
||||
if (!input.attachments.some(isSegmentShapedAttachment)) {
|
||||
return;
|
||||
}
|
||||
if (inFlightSignals.size >= MAX_IN_FLIGHT_SIGNALS) {
|
||||
Logger.warn(scopeFields(input), DROPPED_MESSAGE);
|
||||
return;
|
||||
}
|
||||
const pending = recordUploadSegmentSignal(input).finally(() => {
|
||||
inFlightSignals.delete(pending);
|
||||
});
|
||||
inFlightSignals.add(pending);
|
||||
}
|
||||
|
||||
export async function flushUploadSegmentSignals(): Promise<void> {
|
||||
while (inFlightSignals.size > 0) {
|
||||
await Promise.all([...inFlightSignals]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordUploadSegmentSignal(input: UploadSegmentSignalInput): Promise<void> {
|
||||
try {
|
||||
const segments = input.attachments.filter(isSegmentShapedAttachment);
|
||||
if (segments.length === 0) {
|
||||
return;
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
const windowIndex = Math.floor(nowMs / WINDOW_MS);
|
||||
const scopes = resolveScopes(input, windowIndex, nowMs);
|
||||
const kv = getKVClient();
|
||||
const counts: Array<number> = [];
|
||||
for (const scope of scopes) {
|
||||
const count = await kv.incr(scope.key);
|
||||
counts.push(count);
|
||||
if (count === 1) {
|
||||
await kv.expire(scope.key, WINDOW_TTL_SECONDS);
|
||||
}
|
||||
}
|
||||
for (const [index, scope] of scopes.entries()) {
|
||||
const count = counts[index];
|
||||
if (!isRungValue(count, scope.threshold)) {
|
||||
continue;
|
||||
}
|
||||
Logger.warn(buildSignalFields({input, segments, windowIndex, scope, count}), SIGNAL_MESSAGE);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn({error, ...scopeFields(input)}, FAILURE_MESSAGE);
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,12 @@ export interface APIConfig {
|
||||
blocklistFeeds: {
|
||||
enabled: boolean;
|
||||
};
|
||||
torExitList: {
|
||||
enabled: boolean;
|
||||
};
|
||||
breachedPasswordCheck: {
|
||||
enabled: boolean;
|
||||
};
|
||||
captcha: {
|
||||
enabled: boolean;
|
||||
provider: 'hcaptcha' | 'turnstile' | 'none';
|
||||
|
||||
@@ -32,11 +32,12 @@ export function ExperimentController(app: HonoApp) {
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getVoiceNoiseSuppressionConfig(),
|
||||
]);
|
||||
const userId = ctx.get('user').id.toString();
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: delivery.poll_interval_seconds,
|
||||
poll_jitter_percent: delivery.poll_jitter_percent,
|
||||
assignments: {
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, ctx.get('user').id.toString()),
|
||||
voice_noise_suppression: resolveVoiceNoiseSuppressionAssignment(voiceConfig, userId),
|
||||
},
|
||||
};
|
||||
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
|
||||
|
||||
@@ -49,7 +49,9 @@ describe('GET /experiments', () => {
|
||||
expect(body).toEqual({
|
||||
poll_interval_seconds: DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
|
||||
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
assignments: {voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT},
|
||||
assignments: {
|
||||
voice_noise_suppression: INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -304,7 +304,6 @@ describe('InstanceConfigRepository', () => {
|
||||
included_user_ids: ['1400000000000000001'],
|
||||
excluded_user_ids: ['1400000000000000002'],
|
||||
guild_overrides: [{guild_id: '2400000000000000001', backend: 'rnnoise'}],
|
||||
stereo_enabled: true,
|
||||
suppression_strength: 55,
|
||||
};
|
||||
await repository.setVoiceNoiseSuppressionConfig(config);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
|
||||
import {getIpInfoService} from '@app/api/middleware/ServiceMiddleware';
|
||||
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
|
||||
import {getCacheService} from '@app/api/middleware/ServiceSingletons';
|
||||
import {isAutoBanExemptAsn} from '@app/api/risk/AutoBanAsnExemptions';
|
||||
import {isIpBanExempt} from '@app/api/risk/IpBanExemptions';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {parseJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
|
||||
@@ -18,7 +19,7 @@ import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
|
||||
import type {IKVProvider, IKVSubscription} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
|
||||
type IpClass = 'datacenter' | 'anonymous' | 'mobile' | 'residential' | 'unknown';
|
||||
type IpClass = 'datacenter' | 'anonymous' | 'mobile' | 'residential' | 'unknown' | 'exempt';
|
||||
type TriggerKind = 'score' | 'token_diversity' | 'score_and_token_diversity';
|
||||
|
||||
interface AbuseRecord {
|
||||
@@ -104,7 +105,7 @@ const IP_CLASS_CLAIM_TTL_SECONDS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_
|
||||
const DEFAULT_IP_CLASS_PENDING_TTL_MS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_PENDING_TTL_MS', 20_000);
|
||||
const DEFAULT_IP_CLASS_NEGATIVE_TTL_MS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_NEGATIVE_TTL_MS', 300_000);
|
||||
const DEFAULT_IP_CLASS_HINT_TTL_MS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_HINT_TTL_MS', 600_000);
|
||||
const IP_CLASSES = ['datacenter', 'anonymous', 'mobile', 'residential', 'unknown'] as const;
|
||||
const IP_CLASSES = ['datacenter', 'anonymous', 'mobile', 'residential', 'unknown', 'exempt'] as const;
|
||||
const POD_ID = process.env.HOSTNAME ?? randomUUID();
|
||||
|
||||
type ReplicatedTick = [banKey: string, scoreDelta: number, tokenHashes: Array<string>, lookupIp: string];
|
||||
@@ -185,6 +186,7 @@ function scoreThresholdFor(ipClass: IpClass): number {
|
||||
return THRESHOLD_MOBILE;
|
||||
case 'residential':
|
||||
case 'unknown':
|
||||
case 'exempt':
|
||||
return THRESHOLD_RESIDENTIAL;
|
||||
}
|
||||
}
|
||||
@@ -199,6 +201,7 @@ function tokenDiversityThresholdFor(ipClass: IpClass): number {
|
||||
return TOKEN_DIVERSITY_MOBILE;
|
||||
case 'residential':
|
||||
case 'unknown':
|
||||
case 'exempt':
|
||||
return TOKEN_DIVERSITY_RESIDENTIAL;
|
||||
}
|
||||
}
|
||||
@@ -390,6 +393,10 @@ async function claimIpClassLookup(key: string): Promise<boolean> {
|
||||
|
||||
async function runIpClassLookup(key: string, lookupIp: string): Promise<void> {
|
||||
try {
|
||||
if (await isAutoBanExemptAsn(lookupIp)) {
|
||||
setOwnIpClass(key, lookupIp, 'exempt', false);
|
||||
return;
|
||||
}
|
||||
if (!(await claimIpClassLookup(key))) return;
|
||||
const result = await getIpInfoService().lookup(lookupIp, {source: 'AbusiveIpAutoBanner', reason: 'classify'});
|
||||
setOwnIpClass(key, lookupIp, classifyIpInfo(result), !result.available);
|
||||
@@ -427,6 +434,14 @@ function maybeFireAutoBan(key: string, rec: AbuseRecord): void {
|
||||
if (resolved.blocked) {
|
||||
return;
|
||||
}
|
||||
if (ipClass === 'exempt') {
|
||||
rec.autoBanFired = true;
|
||||
Logger.warn(
|
||||
{ip: key, ipClass, score: rec.score, distinctTokens: rec.distinctTokenHashes.size},
|
||||
'[abuse-auto-ban] Skipping automatic IP ban because the ASN is exempt',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (shouldSkipAutoBanForIpClass(ipClass)) {
|
||||
rec.autoBanFired = true;
|
||||
Logger.warn(
|
||||
|
||||
@@ -13,13 +13,19 @@ import {
|
||||
} from '@app/api/middleware/AbusiveIpAutoBanner';
|
||||
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
|
||||
import {setInjectedIpInfoService} from '@app/api/middleware/ServiceMiddleware';
|
||||
import {resetAutoBanAsnExemptionsForTesting, setInjectedAutoBanAsnLookup} from '@app/api/risk/AutoBanAsnExemptions';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import type {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import type {GeoipAsnResult} from '@pkgs/geoip/src/GeoipLookup';
|
||||
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
function asnResult(asn: number | null): GeoipAsnResult {
|
||||
return {normalizedIp: null, asn, asnOrg: null, available: asn !== null};
|
||||
}
|
||||
|
||||
function ipInfoResult(ip: string, overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
|
||||
return {
|
||||
ip,
|
||||
@@ -96,6 +102,7 @@ describe('AbusiveIpAutoBanner', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let adminRepository: AdminRepository;
|
||||
let lookupCount = 0;
|
||||
let asnLookupCount = 0;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
adminRepository = new AdminRepository();
|
||||
@@ -104,6 +111,9 @@ describe('AbusiveIpAutoBanner', () => {
|
||||
await harness.reset();
|
||||
resetAbuseTrackingForTests();
|
||||
ipBanCache.resetCaches();
|
||||
delete process.env.FLUXER_ABUSE_EXEMPT_ASNS;
|
||||
resetAutoBanAsnExemptionsForTesting();
|
||||
asnLookupCount = 0;
|
||||
lookupCount = 0;
|
||||
setInjectedIpInfoService({
|
||||
async lookup(ip: string) {
|
||||
@@ -117,6 +127,8 @@ describe('AbusiveIpAutoBanner', () => {
|
||||
afterAll(async () => {
|
||||
await stopAbuseReplicationSubscriber();
|
||||
setInjectedIpInfoService(undefined);
|
||||
delete process.env.FLUXER_ABUSE_EXEMPT_ASNS;
|
||||
resetAutoBanAsnExemptionsForTesting();
|
||||
await harness.shutdown();
|
||||
});
|
||||
it('temporarily bans an IP that tries many distinct invalid tokens', async () => {
|
||||
@@ -175,6 +187,49 @@ describe('AbusiveIpAutoBanner', () => {
|
||||
expect(ipBanCache.isBanned(ip)).toBe(false);
|
||||
await expect(adminRepository.isIpBanned(ip)).resolves.toBe(false);
|
||||
});
|
||||
it('does not auto-ban an IP whose ASN is exempt', async () => {
|
||||
const ip = '9.9.9.9';
|
||||
process.env.FLUXER_ABUSE_EXEMPT_ASNS = '64501, not-an-asn, 64502';
|
||||
resetAutoBanAsnExemptionsForTesting();
|
||||
setInjectedAutoBanAsnLookup(async () => asnResult(64502));
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`exempt-${i}`)});
|
||||
}
|
||||
await drainAbuseIpClassLookupsForTests();
|
||||
await drainAbuseAutoBanTasksForTests();
|
||||
expect(ipBanCache.isBanned(ip)).toBe(false);
|
||||
await expect(adminRepository.isIpBanned(ip)).resolves.toBe(false);
|
||||
expect(lookupCount).toBe(0);
|
||||
});
|
||||
it('still auto-bans an IP whose ASN is not on the exempt list', async () => {
|
||||
const ip = '9.9.9.10';
|
||||
process.env.FLUXER_ABUSE_EXEMPT_ASNS = '64501';
|
||||
resetAutoBanAsnExemptionsForTesting();
|
||||
setInjectedAutoBanAsnLookup(async () => asnResult(64502));
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`not-exempt-${i}`)});
|
||||
}
|
||||
await waitForAssertion(() => {
|
||||
expect(ipBanCache.isBanned(ip)).toBe(true);
|
||||
});
|
||||
await drainAbuseAutoBanTasksForTests();
|
||||
await expect(adminRepository.isIpBanned(ip)).resolves.toBe(true);
|
||||
});
|
||||
it('does not resolve an ASN when no exemptions are configured', async () => {
|
||||
const ip = '9.9.9.11';
|
||||
setInjectedAutoBanAsnLookup(async () => {
|
||||
asnLookupCount += 1;
|
||||
return asnResult(64502);
|
||||
});
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`no-exempt-list-${i}`)});
|
||||
}
|
||||
await waitForAssertion(() => {
|
||||
expect(ipBanCache.isBanned(ip)).toBe(true);
|
||||
});
|
||||
await drainAbuseAutoBanTasksForTests();
|
||||
expect(asnLookupCount).toBe(0);
|
||||
});
|
||||
it('does not auto-ban loopback or private IP addresses', async () => {
|
||||
for (const ip of ['127.0.0.1', '10.0.0.10', '::ffff:127.0.0.1']) {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
|
||||
@@ -31054,7 +31054,6 @@
|
||||
"items": {"$ref": "#/components/schemas/VoiceNoiseSuppressionBackendSchema"}
|
||||
},
|
||||
"allow_user_override": {"type": "boolean"},
|
||||
"stereo_enabled": {"type": "boolean"},
|
||||
"suppression_strength": {"type": "integer", "minimum": 0, "maximum": 100}
|
||||
},
|
||||
"required": [
|
||||
@@ -31066,7 +31065,6 @@
|
||||
"guild_overrides",
|
||||
"enabled_backends",
|
||||
"allow_user_override",
|
||||
"stereo_enabled",
|
||||
"suppression_strength"
|
||||
],
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '@app/api/Config';
|
||||
import {type GeoipAsnResult, lookupAsnByIp} from '@pkgs/geoip/src/GeoipLookup';
|
||||
|
||||
const ASN_ENTRY_REGEX = /^\d+$/u;
|
||||
|
||||
type AsnLookup = (ip: string) => Promise<GeoipAsnResult>;
|
||||
|
||||
let exemptAsns: ReadonlySet<number> | null = null;
|
||||
let injectedAsnLookup: AsnLookup | undefined;
|
||||
|
||||
function getExemptAsns(): ReadonlySet<number> {
|
||||
if (exemptAsns) {
|
||||
return exemptAsns;
|
||||
}
|
||||
const asns = new Set<number>();
|
||||
const rawValue = process.env.FLUXER_ABUSE_EXEMPT_ASNS;
|
||||
if (rawValue) {
|
||||
for (const entry of rawValue.split(',')) {
|
||||
const trimmed = entry.trim();
|
||||
if (!ASN_ENTRY_REGEX.test(trimmed)) continue;
|
||||
const asn = Number.parseInt(trimmed, 10);
|
||||
if (Number.isSafeInteger(asn) && asn > 0) asns.add(asn);
|
||||
}
|
||||
}
|
||||
exemptAsns = asns;
|
||||
return asns;
|
||||
}
|
||||
|
||||
function resolveAsn(ip: string): Promise<GeoipAsnResult> {
|
||||
if (injectedAsnLookup) {
|
||||
return injectedAsnLookup(ip);
|
||||
}
|
||||
return lookupAsnByIp(ip, Config.geoip.maxmindAsnDbPath);
|
||||
}
|
||||
|
||||
export async function isAutoBanExemptAsn(ip: string): Promise<boolean> {
|
||||
const asns = getExemptAsns();
|
||||
if (asns.size === 0) return false;
|
||||
const result = await resolveAsn(ip);
|
||||
return result.asn !== null && asns.has(result.asn);
|
||||
}
|
||||
|
||||
export function setInjectedAutoBanAsnLookup(lookup: AsnLookup | undefined): void {
|
||||
injectedAsnLookup = lookup;
|
||||
}
|
||||
|
||||
export function resetAutoBanAsnExemptionsForTesting(): void {
|
||||
exemptAsns = null;
|
||||
injectedAsnLookup = undefined;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {resetServiceSingletonsForTesting} from '@app/api/middleware/ServiceSingl
|
||||
import {torExitListCache} from '@app/api/middleware/TorExitListCache';
|
||||
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
|
||||
import {resetAdminSecretHashForTesting} from '@app/api/oauth/repositories/ApplicationRepository';
|
||||
import {resetAutoBanAsnExemptionsForTesting} from '@app/api/risk/AutoBanAsnExemptions';
|
||||
import {resetIpBanExemptionsForTesting} from '@app/api/risk/IpBanExemptions';
|
||||
import {setThemeCssMaxBytesForTesting} from '@app/api/theme/ThemeService';
|
||||
import {resetGeoipReadersForTesting} from '@pkgs/geoip/src/GeoipLookup';
|
||||
@@ -23,6 +24,7 @@ export async function resetServiceStateForTesting(): Promise<void> {
|
||||
resetServiceSingletonsForTesting();
|
||||
resetServiceMiddlewareForTesting();
|
||||
resetIpBanExemptionsForTesting();
|
||||
resetAutoBanAsnExemptionsForTesting();
|
||||
resetGlobalLimitConfigServiceForTesting();
|
||||
resetSudoModeServiceForTesting();
|
||||
resetSsoRequestUrlPolicyForTesting();
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {GuildID, UserID} from '@app/api/BrandedTypes';
|
||||
import {mapGuildMemberToResponse} from '@app/api/guild/GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {updateUserCache} from '@app/api/user/UserCacheHelpers';
|
||||
|
||||
interface UserGuildIdReader {
|
||||
getUserGuildIds(userId: UserID): Promise<Array<GuildID>>;
|
||||
}
|
||||
|
||||
export interface PartialUserChangePropagationDeps {
|
||||
userCacheService: UserCacheService;
|
||||
gatewayService: IGatewayService;
|
||||
userRepository: UserGuildIdReader;
|
||||
guildRepository: Pick<IGuildRepositoryAggregate, 'getMember'>;
|
||||
}
|
||||
|
||||
export async function propagatePartialUserChange(deps: PartialUserChangePropagationDeps, user: User): Promise<void> {
|
||||
const {userCacheService, gatewayService, userRepository, guildRepository} = deps;
|
||||
await updateUserCache({user, userCacheService});
|
||||
const guildIds = await userRepository.getUserGuildIds(user.id);
|
||||
if (guildIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const requestCache = createRequestCache();
|
||||
for (const guildId of guildIds) {
|
||||
const member = await guildRepository.getMember(guildId, user.id);
|
||||
if (!member) {
|
||||
continue;
|
||||
}
|
||||
const memberResponse = await mapGuildMemberToResponse(member, userCacheService, requestCache);
|
||||
await gatewayService.dispatchGuild({
|
||||
guildId,
|
||||
event: 'GUILD_MEMBER_UPDATE',
|
||||
data: memberResponse,
|
||||
});
|
||||
}
|
||||
requestCache.clear();
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export class UserAccountLifecycleService {
|
||||
if (updatedUser) {
|
||||
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser);
|
||||
if (hasPartialUserFieldsChanged(user, updatedUser)) {
|
||||
await this.deps.updatePropagator.updateUserCache(updatedUser);
|
||||
await this.deps.updatePropagator.propagatePartialUserChange(updatedUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export class UserAccountLifecycleService {
|
||||
if (updatedUser) {
|
||||
await this.deps.updatePropagator.dispatchUserUpdate(updatedUser);
|
||||
if (hasPartialUserFieldsChanged(user, updatedUser)) {
|
||||
await this.deps.updatePropagator.updateUserCache(updatedUser);
|
||||
await this.deps.updatePropagator.propagatePartialUserChange(updatedUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ export class UserAccountService {
|
||||
gatewayService,
|
||||
mediaService,
|
||||
userRepository: userAccountRepository,
|
||||
guildRepository,
|
||||
});
|
||||
this.lookupService = new UserAccountLookupService({
|
||||
userAccountRepository,
|
||||
@@ -193,7 +194,7 @@ export class UserAccountService {
|
||||
() => this.updatePropagator.dispatchUserUpdate(updatedUser),
|
||||
async () => {
|
||||
if (hasPartialUserFieldsChanged(user, updatedUser)) {
|
||||
await this.updatePropagator.updateUserCache(updatedUser);
|
||||
await this.updatePropagator.propagatePartialUserChange(updatedUser);
|
||||
}
|
||||
},
|
||||
async () => {
|
||||
@@ -240,7 +241,7 @@ export class UserAccountService {
|
||||
const updatedUser = await this.userAccountRepository.patchUpsert(user.id, updates, user.toRow());
|
||||
await this.updatePropagator.dispatchUserUpdate(updatedUser);
|
||||
if (hasPartialUserFieldsChanged(user, updatedUser)) {
|
||||
await this.updatePropagator.updateUserCache(updatedUser);
|
||||
await this.updatePropagator.propagatePartialUserChange(updatedUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {IMediaService} from '@app/api/infrastructure/IMediaService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {UserGuildSettings} from '@app/api/models/UserGuildSettings';
|
||||
import type {UserSettings} from '@app/api/models/UserSettings';
|
||||
import type {IUserAccountRepository} from '@app/api/user/repositories/IUserAccountRepository';
|
||||
import {BaseUserUpdatePropagator} from '@app/api/user/services/BaseUserUpdatePropagator';
|
||||
import {propagatePartialUserChange} from '@app/api/user/services/PartialUserChangePropagation';
|
||||
import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse} from '@app/api/user/UserMappers';
|
||||
|
||||
interface UserAccountUpdatePropagatorDeps {
|
||||
@@ -15,6 +18,7 @@ interface UserAccountUpdatePropagatorDeps {
|
||||
gatewayService: IGatewayService;
|
||||
mediaService: IMediaService;
|
||||
userRepository: IUserAccountRepository;
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
}
|
||||
|
||||
export class UserAccountUpdatePropagator extends BaseUserUpdatePropagator {
|
||||
@@ -25,6 +29,10 @@ export class UserAccountUpdatePropagator extends BaseUserUpdatePropagator {
|
||||
});
|
||||
}
|
||||
|
||||
async propagatePartialUserChange(user: User): Promise<void> {
|
||||
await propagatePartialUserChange(this.deps, user);
|
||||
}
|
||||
|
||||
async dispatchUserSettingsUpdate({userId, settings}: {userId: UserID; settings: UserSettings}): Promise<void> {
|
||||
await this.deps.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import type {GuildMember} from '@app/api/models/GuildMember';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import {
|
||||
type PartialUserChangePropagationDeps,
|
||||
propagatePartialUserChange,
|
||||
} from '@app/api/user/services/PartialUserChangePropagation';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const USER_ID = createUserID(1n);
|
||||
const GUILD_IDS = [createGuildID(10n), createGuildID(11n)];
|
||||
const USER = {id: USER_ID} as unknown as User;
|
||||
|
||||
const USER_PARTIAL: UserPartialResponse = {
|
||||
id: USER_ID.toString(),
|
||||
username: 'ada',
|
||||
discriminator: '0001',
|
||||
global_name: null,
|
||||
avatar: 'newhash',
|
||||
avatar_color: null,
|
||||
flags: 0,
|
||||
};
|
||||
|
||||
function createMember(): GuildMember {
|
||||
return {
|
||||
userId: USER_ID,
|
||||
nickname: null,
|
||||
avatarHash: null,
|
||||
bannerHash: null,
|
||||
accentColor: null,
|
||||
roleIds: new Set(),
|
||||
joinedAt: new Date(0),
|
||||
isMute: false,
|
||||
isDeaf: false,
|
||||
communicationDisabledUntil: null,
|
||||
profileFlags: 0,
|
||||
mentionFlags: 0,
|
||||
isPremiumSanitized: false,
|
||||
} as unknown as GuildMember;
|
||||
}
|
||||
|
||||
function createDeps(guildIds: Array<(typeof GUILD_IDS)[number]> = GUILD_IDS) {
|
||||
const dispatchGuild = vi.fn().mockResolvedValue(undefined);
|
||||
const setUserPartialResponseFromUser = vi.fn().mockResolvedValue(USER_PARTIAL);
|
||||
const getUserGuildIds = vi.fn().mockResolvedValue(guildIds);
|
||||
const getMember = vi.fn().mockResolvedValue(createMember());
|
||||
const deps: PartialUserChangePropagationDeps = {
|
||||
userCacheService: {
|
||||
setUserPartialResponseFromUser,
|
||||
getUserPartialResponse: async () => USER_PARTIAL,
|
||||
} as unknown as UserCacheService,
|
||||
gatewayService: {dispatchGuild} as unknown as IGatewayService,
|
||||
userRepository: {getUserGuildIds},
|
||||
guildRepository: {getMember} as unknown as IGuildRepositoryAggregate,
|
||||
};
|
||||
return {deps, dispatchGuild, setUserPartialResponseFromUser, getUserGuildIds, getMember};
|
||||
}
|
||||
|
||||
describe('propagatePartialUserChange', () => {
|
||||
test('invalidates the users service cache and pushes the new partial to every guild', async () => {
|
||||
const {deps, dispatchGuild, setUserPartialResponseFromUser} = createDeps();
|
||||
|
||||
await propagatePartialUserChange(deps, USER);
|
||||
|
||||
expect(setUserPartialResponseFromUser).toHaveBeenCalledWith(USER);
|
||||
expect(dispatchGuild).toHaveBeenCalledTimes(2);
|
||||
for (const [index, guildId] of GUILD_IDS.entries()) {
|
||||
const call = dispatchGuild.mock.calls[index]![0];
|
||||
expect(call.guildId).toBe(guildId);
|
||||
expect(call.event).toBe('GUILD_MEMBER_UPDATE');
|
||||
expect((call.data as {user: UserPartialResponse}).user).toEqual(USER_PARTIAL);
|
||||
}
|
||||
});
|
||||
|
||||
test('dispatches nothing when the user is in no guilds', async () => {
|
||||
const {deps, dispatchGuild, getMember} = createDeps([]);
|
||||
|
||||
await propagatePartialUserChange(deps, USER);
|
||||
|
||||
expect(getMember).not.toHaveBeenCalled();
|
||||
expect(dispatchGuild).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('skips guilds the user is no longer a member of', async () => {
|
||||
const {deps, dispatchGuild, getMember} = createDeps();
|
||||
getMember.mockResolvedValueOnce(null);
|
||||
|
||||
await propagatePartialUserChange(deps, USER);
|
||||
|
||||
expect(dispatchGuild).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchGuild.mock.calls[0]![0].guildId).toBe(GUILD_IDS[1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestGuild, getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {updateAvatar} from '@app/api/user/tests/UserTestUtils';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest';
|
||||
|
||||
type DispatchGuildSpy = MockInstance<NoopGatewayService['dispatchGuild']>;
|
||||
|
||||
interface MemberUpdateDispatch {
|
||||
guildId: {toString(): string};
|
||||
event: string;
|
||||
data: GuildMemberResponse;
|
||||
}
|
||||
|
||||
function memberUpdatesFor(dispatchGuild: DispatchGuildSpy, guildId: string): Array<MemberUpdateDispatch> {
|
||||
return dispatchGuild.mock.calls
|
||||
.map(([params]) => params as unknown as MemberUpdateDispatch)
|
||||
.filter((params) => params.event === 'GUILD_MEMBER_UPDATE' && params.guildId.toString() === guildId);
|
||||
}
|
||||
|
||||
describe('User Profile Guild Propagation', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('pushes the new avatar to every guild the user is in', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createTestGuild(harness, account.token);
|
||||
const dispatchGuild = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
|
||||
|
||||
const updated = await updateAvatar(harness, account.token, getPngDataUrl());
|
||||
|
||||
expect(updated.avatar).toBeTruthy();
|
||||
const updates = memberUpdatesFor(dispatchGuild, guild.id);
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]!.data.user.id).toBe(account.userId);
|
||||
expect(updates[0]!.data.user.avatar).toBe(updated.avatar);
|
||||
});
|
||||
|
||||
it('does not push a member update when no partial field changed', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createTestGuild(harness, account.token);
|
||||
const dispatchGuild = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
|
||||
|
||||
await updateAvatar(harness, account.token, null);
|
||||
|
||||
expect(memberUpdatesFor(dispatchGuild, guild.id)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,19 @@
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import type {MediaDescription} from 'sdp-transform';
|
||||
import {parse} from 'sdp-transform';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {TrackBitrateInfo} from './PCTransport.ts';
|
||||
import {applyVideoStartBitrate, collectStereoMids, ensureAudioNackAndStereo, ensureOpusFmtp} from './PCTransport.ts';
|
||||
import {
|
||||
applyVideoStartBitrate,
|
||||
collectStereoMids,
|
||||
ensureAudioNackAndStereo,
|
||||
ensureOpusFmtp,
|
||||
ensureVideoDDExtension,
|
||||
placeholderMidsFromTransceivers,
|
||||
videoSectionCanReceiveAV1,
|
||||
} from './PCTransport.ts';
|
||||
import {ddExtensionURI} from './utils.ts';
|
||||
|
||||
function videoMedia(
|
||||
trackId: string,
|
||||
@@ -166,3 +176,89 @@ describe('collectStereoMids', () => {
|
||||
expect(collectStereoMids([audioBitrateInfo(null, 'mic-track', false)], media)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const singlePcOffer = `v=0
|
||||
o=- 0 0 IN IP4 127.0.0.1
|
||||
s=-
|
||||
t=0 0
|
||||
a=group:BUNDLE 0 1 2
|
||||
m=video 9 UDP/TLS/RTP/SAVPF 96 45
|
||||
c=IN IP4 0.0.0.0
|
||||
a=mid:0
|
||||
a=sendonly
|
||||
a=msid:s cam
|
||||
a=rtpmap:96 VP8/90000
|
||||
a=rtpmap:45 AV1/90000
|
||||
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
|
||||
a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id
|
||||
m=video 9 UDP/TLS/RTP/SAVPF 96 45
|
||||
c=IN IP4 0.0.0.0
|
||||
a=mid:1
|
||||
a=recvonly
|
||||
a=rtpmap:96 VP8/90000
|
||||
a=rtpmap:45 AV1/90000
|
||||
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
|
||||
m=video 9 UDP/TLS/RTP/SAVPF 96
|
||||
c=IN IP4 0.0.0.0
|
||||
a=mid:2
|
||||
a=recvonly
|
||||
a=rtpmap:96 VP8/90000
|
||||
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time`;
|
||||
|
||||
const sectionOf = (sdp: ReturnType<typeof parse>, mid: string) => sdp.media.find((media) => `${media.mid}` === mid)!;
|
||||
const ddOf = (sdp: ReturnType<typeof parse>, mid: string) =>
|
||||
sectionOf(sdp, mid).ext?.find((ext) => ext.uri === ddExtensionURI)?.value;
|
||||
|
||||
describe('videoSectionCanReceiveAV1', () => {
|
||||
it('is true for a section we receive on that kept AV1, false otherwise', () => {
|
||||
const sdp = parse(singlePcOffer);
|
||||
expect(videoSectionCanReceiveAV1(sectionOf(sdp, '1'))).toBe(true);
|
||||
expect(videoSectionCanReceiveAV1(sectionOf(sdp, '0'))).toBe(false);
|
||||
expect(videoSectionCanReceiveAV1(sectionOf(sdp, '2'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureVideoDDExtension', () => {
|
||||
it('assigns an id above every extension in the bundle and reuses it', () => {
|
||||
const sdp = parse(singlePcOffer);
|
||||
expect(ensureVideoDDExtension(sectionOf(sdp, '1'), sdp, 0)).toBe(12);
|
||||
expect(ddOf(sdp, '1')).toBe(12);
|
||||
expect(ensureVideoDDExtension(sectionOf(sdp, '2'), sdp, 12)).toBe(12);
|
||||
expect(ddOf(sdp, '2')).toBe(12);
|
||||
});
|
||||
|
||||
it('adopts an id the bundle already maps the extension to', () => {
|
||||
const sdp = parse(singlePcOffer);
|
||||
sectionOf(sdp, '0').ext!.push({value: 13, uri: ddExtensionURI});
|
||||
expect(ensureVideoDDExtension(sectionOf(sdp, '1'), sdp, 7)).toBe(13);
|
||||
expect(ddOf(sdp, '1')).toBe(13);
|
||||
});
|
||||
|
||||
it('leaves a section that already carries the extension alone', () => {
|
||||
const sdp = parse(`${singlePcOffer}\na=extmap:3 ${ddExtensionURI}`);
|
||||
expect(ensureVideoDDExtension(sectionOf(sdp, '2'), sdp, 0)).toBe(3);
|
||||
expect(sectionOf(sdp, '2').ext).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeholderMidsFromTransceivers', () => {
|
||||
function transceiver(
|
||||
mid: string | null,
|
||||
track: MediaStreamTrack | null,
|
||||
currentDirection: RTCRtpTransceiverDirection | null,
|
||||
): RTCRtpTransceiver {
|
||||
return {mid, currentDirection, sender: {track}} as unknown as RTCRtpTransceiver;
|
||||
}
|
||||
|
||||
it('keeps the trackless recvonly sections that still hold an m-line', () => {
|
||||
const mids = placeholderMidsFromTransceivers([
|
||||
transceiver('3', null, 'recvonly'),
|
||||
transceiver('7', {} as MediaStreamTrack, 'sendonly'),
|
||||
]);
|
||||
expect(mids).toEqual(new Set(['3']));
|
||||
});
|
||||
|
||||
it('drops a transceiver that unpublish stopped so its recycled m-section is not fmtp-conformed', () => {
|
||||
expect(placeholderMidsFromTransceivers([transceiver('7', null, 'stopped')])).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import log, {getLogger, LoggerNames} from '../logger.ts';
|
||||
import {debounce} from './debounce.ts';
|
||||
import {NegotiationError, UnexpectedConnectionState} from './errors.ts';
|
||||
import type {LoggerOptions} from './types.ts';
|
||||
import {ddExtensionURI, isFireFox, isSafari, isSVCCodec} from './utils.ts';
|
||||
import {ddExtensionURI, isChromiumBased, isFireFox, isSafari, isSVCCodec} from './utils.ts';
|
||||
|
||||
export interface TrackBitrateInfo {
|
||||
cid?: string;
|
||||
@@ -372,6 +372,9 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
||||
if (media.type === 'audio') {
|
||||
ensureAudioNackAndStereo(media, stereoMids, []);
|
||||
} else if (media.type === 'video') {
|
||||
if (isChromiumBased() && videoSectionCanReceiveAV1(media)) {
|
||||
this.ddExtID = ensureVideoDDExtension(media, sdpParsed, this.ddExtID);
|
||||
}
|
||||
this.trackBitrates.some((trackbr): boolean => {
|
||||
if (!trackbr.cid) {
|
||||
return false;
|
||||
@@ -677,6 +680,11 @@ export function ensureVideoDDExtension(
|
||||
return id;
|
||||
}
|
||||
|
||||
export function videoSectionCanReceiveAV1(media: MediaDescription): boolean {
|
||||
if (media.direction !== 'recvonly' && media.direction !== 'sendrecv') return false;
|
||||
return media.rtp.some((rtp) => rtp.codec.toLowerCase() === 'av1');
|
||||
}
|
||||
|
||||
function ddExtensionIDFor(sdp: SessionDescription, cachedID: number): number | undefined {
|
||||
const mapped = mappedExtensionID(sdp, ddExtensionURI);
|
||||
if (mapped !== undefined) {
|
||||
@@ -841,6 +849,9 @@ export function collectStereoMids(
|
||||
export function placeholderMidsFromTransceivers(transceivers: ReadonlyArray<RTCRtpTransceiver>): Set<string> {
|
||||
const mids = new Set<string>();
|
||||
for (const transceiver of transceivers) {
|
||||
if (transceiver.currentDirection === 'stopped') {
|
||||
continue;
|
||||
}
|
||||
if (transceiver.mid && !transceiver.sender.track) {
|
||||
mids.add(transceiver.mid);
|
||||
}
|
||||
|
||||
@@ -106,11 +106,6 @@ export class PCTransportManager {
|
||||
this.isPublisherConnectionRequired = mode !== 'subscriber-primary';
|
||||
this.isSubscriberConnectionRequired = mode === 'subscriber-primary';
|
||||
this.publisher = new PCTransport(rtcConfig, loggerOptions);
|
||||
if (subscriberVideoCodecExclusions?.length) {
|
||||
for (const codec of subscriberVideoCodecExclusions) {
|
||||
this.publisher.excludedVideoDecoderMimeTypes.add(`video/${codec}`);
|
||||
}
|
||||
}
|
||||
this._mode = mode;
|
||||
if (mode !== 'publisher-only') {
|
||||
this.subscriber = new PCTransport(rtcConfig, loggerOptions);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {selectPublisherCodecPreferences} from './RTCEngine.ts';
|
||||
import type {InternalRoomOptions} from '../options.ts';
|
||||
import RTCEngine, {selectPublisherCodecPreferences} from './RTCEngine.ts';
|
||||
|
||||
function codec(
|
||||
mimeType: string,
|
||||
@@ -18,19 +19,41 @@ function codec(
|
||||
}
|
||||
|
||||
describe('selectPublisherCodecPreferences', () => {
|
||||
it('prefers H.264 profiles that use Chromium external encoders before OpenH264', () => {
|
||||
const openH264 = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f');
|
||||
const externalBaseline = codec(
|
||||
it('offers the profiles a hardware encoder accepts before Constrained Baseline, which is always software on Windows', () => {
|
||||
const constrainedBaseline = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
);
|
||||
const baseline = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f');
|
||||
const highProfile = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=64001f');
|
||||
const rtx = codec('video/rtx');
|
||||
const preferences = selectPublisherCodecPreferences('h264', [openH264, rtx, externalBaseline, highProfile]);
|
||||
expect(preferences).toEqual([externalBaseline, openH264, highProfile, rtx]);
|
||||
const preferences = selectPublisherCodecPreferences('h264', [constrainedBaseline, rtx, baseline, highProfile]);
|
||||
expect(preferences).toEqual([highProfile, baseline, constrainedBaseline, rtx]);
|
||||
});
|
||||
|
||||
it('ranks Constrained Baseline above Main, High and Constrained High', () => {
|
||||
it('offers High first out of the capabilities Chromium reports, so the only hardware profile this server registers wins', () => {
|
||||
const capabilities = [
|
||||
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f'),
|
||||
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f'),
|
||||
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f'),
|
||||
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f'),
|
||||
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f'),
|
||||
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f'),
|
||||
codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640034'),
|
||||
];
|
||||
const preferences = selectPublisherCodecPreferences('h264', capabilities);
|
||||
expect(preferences.map((entry) => entry.sdpFmtpLine)).toEqual([
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640034',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f',
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f',
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ranks High, Constrained High, Main and Baseline above Constrained Baseline, whatever level each one carries', () => {
|
||||
const constrainedBaseline = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
@@ -56,15 +79,15 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
constrainedBaseline,
|
||||
]);
|
||||
expect(preferences).toEqual([
|
||||
constrainedBaseline,
|
||||
mainProfile,
|
||||
highProfileLevel31,
|
||||
highProfileLevel51,
|
||||
constrainedHigh,
|
||||
mainProfile,
|
||||
constrainedBaseline,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps Constrained Baseline packetization-mode=1 ahead of Constrained Baseline packetization-mode=0 and High', () => {
|
||||
it('ranks packetization-mode=1 above packetization-mode=0, which no hardware encoder takes', () => {
|
||||
const constrainedBaselineMode0 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f',
|
||||
@@ -73,19 +96,94 @@ describe('selectPublisherCodecPreferences', () => {
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
);
|
||||
const highProfile = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640033');
|
||||
const highProfileMode0 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=640033',
|
||||
);
|
||||
const highProfileMode1 = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640033',
|
||||
);
|
||||
const preferences = selectPublisherCodecPreferences('h264', [
|
||||
highProfile,
|
||||
highProfileMode0,
|
||||
constrainedBaselineMode0,
|
||||
constrainedBaselineMode1,
|
||||
highProfileMode1,
|
||||
]);
|
||||
expect(preferences).toEqual([
|
||||
highProfileMode1,
|
||||
constrainedBaselineMode1,
|
||||
highProfileMode0,
|
||||
constrainedBaselineMode0,
|
||||
]);
|
||||
expect(preferences).toEqual([constrainedBaselineMode1, constrainedBaselineMode0, highProfile]);
|
||||
});
|
||||
|
||||
it('keeps non-H.264 codecs in browser capability order and appends RTX', () => {
|
||||
it('puts the chosen codec first and keeps every other codec in browser capability order', () => {
|
||||
const vp9 = codec('video/VP9');
|
||||
const vp8 = codec('video/VP8');
|
||||
const rtx = codec('video/rtx');
|
||||
expect(selectPublisherCodecPreferences('vp9', [vp8, rtx, vp9])).toEqual([vp9, rtx]);
|
||||
expect(selectPublisherCodecPreferences('vp9', [vp8, rtx, vp9])).toEqual([vp9, vp8, rtx]);
|
||||
});
|
||||
|
||||
it('keeps the other codecs so a later publication on the same connection can negotiate them', () => {
|
||||
const vp9 = codec('video/VP9');
|
||||
const av1 = codec('video/AV1');
|
||||
const h264 = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f');
|
||||
const preferences = selectPublisherCodecPreferences('vp9', [av1, h264, vp9]);
|
||||
expect(preferences.map((entry) => entry.mimeType)).toEqual(['video/VP9', 'video/AV1', 'video/H264']);
|
||||
});
|
||||
|
||||
it('ranks the H.264 profiles it leaves behind the chosen codec', () => {
|
||||
const vp8 = codec('video/VP8');
|
||||
const highProfile = codec('video/H264', 'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640033');
|
||||
const constrainedBaseline = codec(
|
||||
'video/H264',
|
||||
'level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f',
|
||||
);
|
||||
const preferences = selectPublisherCodecPreferences('vp8', [vp8, highProfile, constrainedBaseline]);
|
||||
expect(preferences).toEqual([vp8, highProfile, constrainedBaseline]);
|
||||
});
|
||||
|
||||
it('returns nothing when the sender cannot encode the chosen codec', () => {
|
||||
expect(selectPublisherCodecPreferences('av1', [codec('video/VP8'), codec('video/rtx')])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publisher data channels before negotiation', () => {
|
||||
function engineWithPublisherChannels(hasPublisherChannels: boolean) {
|
||||
const engine = new RTCEngine({} as InternalRoomOptions);
|
||||
const created: Array<string> = [];
|
||||
const internals = engine as unknown as {
|
||||
_isClosed: boolean;
|
||||
pcManager: unknown;
|
||||
dataChannels: {hasPublisherChannels: boolean; createPublisherChannels: () => void};
|
||||
};
|
||||
internals._isClosed = false;
|
||||
internals.dataChannels = {
|
||||
hasPublisherChannels,
|
||||
createPublisherChannels: () => created.push('publisher'),
|
||||
};
|
||||
internals.pcManager = {
|
||||
requirePublisher: () => {},
|
||||
negotiate: async () => {},
|
||||
publisher: {
|
||||
off: () => {},
|
||||
once: () => {},
|
||||
getTransceivers: () => [{} as RTCRtpTransceiver],
|
||||
},
|
||||
};
|
||||
return {engine, created};
|
||||
}
|
||||
|
||||
it('creates them on a renegotiation that already carries transceivers', async () => {
|
||||
const {engine, created} = engineWithPublisherChannels(false);
|
||||
await engine.negotiate();
|
||||
expect(created).toEqual(['publisher']);
|
||||
});
|
||||
|
||||
it('leaves the existing channels alone', async () => {
|
||||
const {engine, created} = engineWithPublisherChannels(true);
|
||||
await engine.negotiate();
|
||||
expect(created).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,8 +122,16 @@ const videoCodecMimeTypes: Record<VideoCodec, Array<string>> = {
|
||||
vp9: ['video/vp9'],
|
||||
vp8: ['video/vp8'],
|
||||
};
|
||||
const h264OpenH264ProfileLevelId = '42e01f';
|
||||
const h264PreferredHardwareProfileLevelIds = new Set(['42001f']);
|
||||
const h264ProfileRanks = new Map([
|
||||
['6400', 0],
|
||||
['640c', 1],
|
||||
['4d00', 2],
|
||||
['4200', 3],
|
||||
['42e0', 4],
|
||||
]);
|
||||
const h264UnrankedProfileScore = 5;
|
||||
const h264MissingProfileScore = 6;
|
||||
const h264PacketizationMode0Score = 10;
|
||||
type RtpCodecCapability = RTCRtpCapabilities['codecs'][number] & {sdpFmtpLine?: string};
|
||||
|
||||
enum PCState {
|
||||
@@ -1034,7 +1042,13 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
||||
const capabilities = RTCRtpSender.getCapabilities('video');
|
||||
if (!capabilities) return;
|
||||
const preferences = selectPublisherCodecPreferences(codec, capabilities.codecs);
|
||||
if (preferences.length === 0) return;
|
||||
if (preferences.length === 0) {
|
||||
this.log.warn('sender cannot encode the requested codec, leaving the browser order in place', {
|
||||
...this.logContext,
|
||||
codec,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
transceiver.setCodecPreferences(preferences);
|
||||
} catch (error) {
|
||||
@@ -1553,7 +1567,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit
|
||||
}
|
||||
|
||||
this.pcManager.requirePublisher();
|
||||
if (this.pcManager.publisher.getTransceivers().length === 0 && !this.dataChannels.hasPublisherChannels) {
|
||||
if (!this.dataChannels.hasPublisherChannels) {
|
||||
this.createDataChannels();
|
||||
}
|
||||
|
||||
@@ -1806,11 +1820,9 @@ function getFmtpParameter(sdpFmtpLine: string | undefined, key: string): string
|
||||
function getH264PublisherCodecScore(codec: RtpCodecCapability): number {
|
||||
const profileLevelId = getFmtpParameter(codec.sdpFmtpLine, 'profile-level-id');
|
||||
const packetizationMode = getFmtpParameter(codec.sdpFmtpLine, 'packetization-mode');
|
||||
const packetizationScore = packetizationMode === '1' ? 0 : 1;
|
||||
if (profileLevelId && h264PreferredHardwareProfileLevelIds.has(profileLevelId)) return packetizationScore;
|
||||
if (profileLevelId === h264OpenH264ProfileLevelId) return 10 + packetizationScore;
|
||||
if (profileLevelId) return 20 + packetizationScore;
|
||||
return 30 + packetizationScore;
|
||||
const packetizationScore = packetizationMode === '1' ? 0 : h264PacketizationMode0Score;
|
||||
if (!profileLevelId) return packetizationScore + h264MissingProfileScore;
|
||||
return packetizationScore + (h264ProfileRanks.get(profileLevelId.slice(0, 4)) ?? h264UnrankedProfileScore);
|
||||
}
|
||||
|
||||
function preferHardwareH264Codecs(codecs: ReadonlyArray<RtpCodecCapability>): Array<RtpCodecCapability> {
|
||||
@@ -1828,8 +1840,12 @@ export function selectPublisherCodecPreferences(
|
||||
const selected = codecs.filter((entry) => mimeTypes.has(entry.mimeType.toLowerCase()));
|
||||
if (selected.length === 0) return [];
|
||||
const preferred = codec === 'h264' ? preferHardwareH264Codecs(selected) : selected;
|
||||
const rtx = codecs.filter((entry) => entry.mimeType.toLowerCase() === 'video/rtx');
|
||||
return [...preferred, ...rtx];
|
||||
const isH264 = (entry: RtpCodecCapability): boolean => entry.mimeType.toLowerCase() === 'video/h264';
|
||||
const remaining = codecs.filter((entry) => !mimeTypes.has(entry.mimeType.toLowerCase()));
|
||||
const rankedH264 = preferHardwareH264Codecs(remaining.filter(isH264));
|
||||
let nextH264 = 0;
|
||||
const rest = remaining.map((entry) => (isH264(entry) ? rankedH264[nextH264++] : entry));
|
||||
return [...preferred, ...rest];
|
||||
}
|
||||
|
||||
export type EngineEventCallbacks = {
|
||||
|
||||
@@ -12,7 +12,6 @@ export const publishDefaults: TrackPublishDefaults = {
|
||||
audioPreset: AudioPresets.music,
|
||||
dtx: false,
|
||||
red: true,
|
||||
forceStereo: false,
|
||||
simulcast: true,
|
||||
screenShareEncoding: ScreenSharePresets.original.encoding,
|
||||
stopMicTrackOnMute: false,
|
||||
|
||||
@@ -108,6 +108,7 @@ import {
|
||||
isWeb,
|
||||
selectPreferredVideoCodec,
|
||||
sleep,
|
||||
stopTransceiversForSender,
|
||||
supportsVideoCodec,
|
||||
usesLegacySVCEncodings,
|
||||
} from '../utils.ts';
|
||||
@@ -436,8 +437,9 @@ export default class LocalParticipant extends Participant {
|
||||
enabled: boolean,
|
||||
options?: ScreenShareCaptureOptions,
|
||||
publishOptions?: TrackPublishOptions,
|
||||
audioPublishOptions?: TrackPublishOptions,
|
||||
): Promise<LocalTrackPublication | undefined> {
|
||||
return this.setTrackEnabled(Track.Source.ScreenShare, enabled, options, publishOptions);
|
||||
return this.setTrackEnabled(Track.Source.ScreenShare, enabled, options, publishOptions, audioPublishOptions);
|
||||
}
|
||||
|
||||
async setE2EEEnabled(enabled: boolean) {
|
||||
@@ -474,12 +476,14 @@ export default class LocalParticipant extends Participant {
|
||||
enabled: boolean,
|
||||
options?: ScreenShareCaptureOptions,
|
||||
publishOptions?: TrackPublishOptions,
|
||||
audioPublishOptions?: TrackPublishOptions,
|
||||
): Promise<LocalTrackPublication | undefined>;
|
||||
private async setTrackEnabled(
|
||||
source: Track.Source,
|
||||
enabled: true,
|
||||
options?: VideoCaptureOptions | AudioCaptureOptions | ScreenShareCaptureOptions,
|
||||
publishOptions?: TrackPublishOptions,
|
||||
audioPublishOptions?: TrackPublishOptions,
|
||||
) {
|
||||
this.log.debug('setTrackEnabled', {source, enabled});
|
||||
if (this.republishPromise) {
|
||||
@@ -548,7 +552,12 @@ export default class LocalParticipant extends Participant {
|
||||
for (const localTrack of localTracks) {
|
||||
this.log.info('publishing track', getLogContextFromTrack(localTrack));
|
||||
|
||||
publishPromises.push(this.publishTrack(localTrack, publishOptions));
|
||||
publishPromises.push(
|
||||
this.publishTrack(
|
||||
localTrack,
|
||||
audioPublishOptions && isAudioTrack(localTrack) ? audioPublishOptions : publishOptions,
|
||||
),
|
||||
);
|
||||
}
|
||||
const publishedTracks = await Promise.all(publishPromises);
|
||||
|
||||
@@ -1421,29 +1430,30 @@ export default class LocalParticipant extends Participant {
|
||||
const trackSender = track.sender;
|
||||
track.sender = undefined;
|
||||
if (this.engine.pcManager && this.engine.pcManager.currentState < PCTransportState.FAILED && trackSender) {
|
||||
const publisher = this.engine.pcManager.publisher;
|
||||
try {
|
||||
for (const transceiver of this.engine.pcManager.publisher.getTransceivers()) {
|
||||
if (transceiver.sender === trackSender) {
|
||||
transceiver.direction = 'inactive';
|
||||
negotiationNeeded = true;
|
||||
}
|
||||
}
|
||||
try {
|
||||
negotiationNeeded = this.engine.removeTrack(trackSender);
|
||||
} catch (e) {
|
||||
this.log.warn(e);
|
||||
negotiationNeeded = true;
|
||||
}
|
||||
if (stopTransceiversForSender(publisher.getTransceivers(), trackSender)) {
|
||||
negotiationNeeded = true;
|
||||
}
|
||||
|
||||
if (isLocalVideoTrack(track)) {
|
||||
for (const [, trackInfo] of track.simulcastCodecs) {
|
||||
if (trackInfo.sender) {
|
||||
try {
|
||||
negotiationNeeded = this.engine.removeTrack(trackInfo.sender);
|
||||
negotiationNeeded = this.engine.removeTrack(trackInfo.sender) || negotiationNeeded;
|
||||
} catch (e) {
|
||||
this.log.warn(e);
|
||||
negotiationNeeded = true;
|
||||
}
|
||||
if (stopTransceiversForSender(publisher.getTransceivers(), trackInfo.sender)) {
|
||||
negotiationNeeded = true;
|
||||
}
|
||||
trackInfo.sender = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {screenCaptureToDisplayMediaStreamOptions} from './track/utils.ts';
|
||||
import {selectPreferredVideoCodec, supportsAV1, supportsH265, supportsVideoCodec, supportsVP9} from './utils.ts';
|
||||
import {
|
||||
selectPreferredVideoCodec,
|
||||
stopTransceiversForSender,
|
||||
supportsAV1,
|
||||
supportsH265,
|
||||
supportsVideoCodec,
|
||||
supportsVP9,
|
||||
} from './utils.ts';
|
||||
|
||||
const originalNavigator = globalThis.navigator;
|
||||
const originalSender = globalThis.RTCRtpSender;
|
||||
@@ -91,3 +98,46 @@ describe('screenCaptureToDisplayMediaStreamOptions', () => {
|
||||
expect(options.systemAudio).toBe('exclude');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopTransceiversForSender', () => {
|
||||
type FakeTransceiver = {sender: RTCRtpSender; direction: string; stopCalls: number; stop?: () => void};
|
||||
|
||||
function fakeTransceiver(sender: RTCRtpSender, canStop = true): FakeTransceiver {
|
||||
const transceiver: FakeTransceiver = {sender, direction: 'sendonly', stopCalls: 0};
|
||||
if (canStop) {
|
||||
transceiver.stop = () => {
|
||||
transceiver.stopCalls += 1;
|
||||
transceiver.direction = 'stopped';
|
||||
};
|
||||
}
|
||||
return transceiver;
|
||||
}
|
||||
|
||||
function asTransceivers(list: Array<FakeTransceiver>): ReadonlyArray<RTCRtpTransceiver> {
|
||||
return list as unknown as ReadonlyArray<RTCRtpTransceiver>;
|
||||
}
|
||||
|
||||
it('stops the transceiver holding the sender so the m-section can be recycled', () => {
|
||||
const sender = {} as RTCRtpSender;
|
||||
const mine = fakeTransceiver(sender);
|
||||
const theirs = fakeTransceiver({} as RTCRtpSender);
|
||||
expect(stopTransceiversForSender(asTransceivers([theirs, mine]), sender)).toBe(true);
|
||||
expect(mine.stopCalls).toBe(1);
|
||||
expect(mine.direction).toBe('stopped');
|
||||
expect(theirs.stopCalls).toBe(0);
|
||||
expect(theirs.direction).toBe('sendonly');
|
||||
});
|
||||
|
||||
it('reports no match when the sender is not on the connection', () => {
|
||||
const mine = fakeTransceiver({} as RTCRtpSender);
|
||||
expect(stopTransceiversForSender(asTransceivers([mine]), {} as RTCRtpSender)).toBe(false);
|
||||
expect(mine.stopCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to inactive where stop is unavailable', () => {
|
||||
const sender = {} as RTCRtpSender;
|
||||
const mine = fakeTransceiver(sender, false);
|
||||
expect(stopTransceiversForSender(asTransceivers([mine]), sender)).toBe(true);
|
||||
expect(mine.direction).toBe('inactive');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,6 +158,25 @@ export function negotiateDependencyDescriptor(transceiver: RTCRtpTransceiver): b
|
||||
}
|
||||
}
|
||||
|
||||
export function stopTransceiversForSender(
|
||||
transceivers: ReadonlyArray<RTCRtpTransceiver>,
|
||||
sender: RTCRtpSender,
|
||||
): boolean {
|
||||
let matched = false;
|
||||
for (const transceiver of transceivers) {
|
||||
if (transceiver.sender !== sender) {
|
||||
continue;
|
||||
}
|
||||
matched = true;
|
||||
if (typeof transceiver.stop === 'function') {
|
||||
transceiver.stop();
|
||||
} else {
|
||||
transceiver.direction = 'inactive';
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
export function isSVCSimulcast(codec?: string, options?: {simulcast?: boolean; scalabilityMode?: string}): boolean {
|
||||
return isSVCCodec(codec) && !!options?.simulcast && !!options.scalabilityMode?.startsWith('L1T');
|
||||
}
|
||||
|
||||
@@ -696,10 +696,14 @@ class Updater {
|
||||
} finally {
|
||||
this.transition({type: 'manualDownload.finished'});
|
||||
}
|
||||
await this.downloadManualNativeUpdateOrOpen(currentOption.url, currentOption.suggestedName);
|
||||
await this.downloadManualNativeUpdateOrOpen(currentOption.url, currentOption.suggestedName, currentOption.sha256);
|
||||
}
|
||||
|
||||
private async downloadManualNativeUpdateOrOpen(url: string, suggestedName?: string): Promise<void> {
|
||||
private async downloadManualNativeUpdateOrOpen(
|
||||
url: string,
|
||||
suggestedName?: string,
|
||||
sha256?: string | null,
|
||||
): Promise<void> {
|
||||
if (this.manualNativeDownloadInFlight) {
|
||||
return;
|
||||
}
|
||||
@@ -708,10 +712,16 @@ class Updater {
|
||||
const outcome = await downloadWithNative({
|
||||
url,
|
||||
suggestedName: suggestedName ?? this.getManualUpdateSuggestedName(url),
|
||||
sha256,
|
||||
});
|
||||
if (outcome === 'success' || outcome === 'canceled') {
|
||||
return;
|
||||
}
|
||||
if (outcome === 'checksum-mismatch') {
|
||||
logger.error('Native manual update download did not match its published checksum', {url});
|
||||
pushDesktopUpdateDownloadFailedModal();
|
||||
return;
|
||||
}
|
||||
logger.warn('Native manual update download unavailable; opening update URL externally', {outcome});
|
||||
await openExternalUrl(url);
|
||||
} finally {
|
||||
|
||||
@@ -5,5 +5,5 @@ export function shouldShowNativeDesktopUpdateInApp(platform: string | null | und
|
||||
}
|
||||
|
||||
export function shouldShowNativeDesktopUpdateDownloadProgress(platform: string | null | undefined): boolean {
|
||||
return platform === 'win32';
|
||||
return platform === 'win32' || platform === 'linux';
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ const CANARY_ASSIGNMENT: VoiceNoiseSuppressionAssignmentResponse = {
|
||||
guild_overrides: [{guild_id: GUILD_ID, backend: 'speex'}],
|
||||
enabled_backends: ['none', 'speex', 'rnnoise', 'gtcrn'],
|
||||
allow_user_override: true,
|
||||
stereo_enabled: false,
|
||||
suppression_strength: 80,
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -139,6 +139,9 @@
|
||||
{
|
||||
"msgid": "[email protected]"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -1164,6 +1167,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -1401,6 +1407,9 @@
|
||||
{
|
||||
"msgid": "A lower price is available. Switching takes effect on <0>{listPriceEffectiveDate}</0> and changes nothing else."
|
||||
},
|
||||
{
|
||||
"msgid": "A viewer cannot play {pinnedCodec}, so your stream uses {codec}."
|
||||
},
|
||||
{
|
||||
"msgid": "AV1"
|
||||
},
|
||||
@@ -1569,6 +1578,9 @@
|
||||
{
|
||||
"msgid": "Capture card"
|
||||
},
|
||||
{
|
||||
"msgid": "Capture devices use the Gaming preset."
|
||||
},
|
||||
{
|
||||
"msgid": "Capture entire system audio"
|
||||
},
|
||||
@@ -1821,6 +1833,12 @@
|
||||
{
|
||||
"msgid": "Desktop audio isn't available right now on macOS."
|
||||
},
|
||||
{
|
||||
"msgid": "Device audio ({deviceLabel})"
|
||||
},
|
||||
{
|
||||
"msgid": "Device audio only"
|
||||
},
|
||||
{
|
||||
"msgid": "Device volume"
|
||||
},
|
||||
@@ -2136,9 +2154,15 @@
|
||||
{
|
||||
"msgid": "No application windows found"
|
||||
},
|
||||
{
|
||||
"msgid": "No audio from this device"
|
||||
},
|
||||
{
|
||||
"msgid": "No displays found"
|
||||
},
|
||||
{
|
||||
"msgid": "No hardware encoder was found on this device, so Prefer hardware works like Automatic."
|
||||
},
|
||||
{
|
||||
"msgid": "No optional services are available on this instance. You can skip this step."
|
||||
},
|
||||
@@ -2526,6 +2550,12 @@
|
||||
{
|
||||
"msgid": "Send messages in this channel."
|
||||
},
|
||||
{
|
||||
"msgid": "Sending {resolution} at {frameRate} FPS with {codec}"
|
||||
},
|
||||
{
|
||||
"msgid": "Sends both channels of a stereo microphone. Needs all processing off and a channel at 128 kbps or higher."
|
||||
},
|
||||
{
|
||||
"msgid": "Sends both channels of a stereo microphone. Works only with no suppression or standard suppression."
|
||||
},
|
||||
@@ -2559,6 +2589,9 @@
|
||||
{
|
||||
"msgid": "Set a member list position for the role."
|
||||
},
|
||||
{
|
||||
"msgid": "Set by the {preset} preset. Picking a value here switches to Custom."
|
||||
},
|
||||
{
|
||||
"msgid": "Set mature content to follow the community setting."
|
||||
},
|
||||
@@ -2631,6 +2664,9 @@
|
||||
{
|
||||
"msgid": "Set this check aside?"
|
||||
},
|
||||
{
|
||||
"msgid": "Set to send {resolution} at {frameRate} FPS with {codec}"
|
||||
},
|
||||
{
|
||||
"msgid": "Set up {productName}"
|
||||
},
|
||||
@@ -2652,6 +2688,12 @@
|
||||
{
|
||||
"msgid": "Shared window audio"
|
||||
},
|
||||
{
|
||||
"msgid": "Sharp text at 720p, up to 30 FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "Sharp text at up to 4K, up to 15 FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "Sharper text at 720p, 30 FPS"
|
||||
},
|
||||
@@ -2688,6 +2730,9 @@
|
||||
{
|
||||
"msgid": "Sign-in details"
|
||||
},
|
||||
{
|
||||
"msgid": "Silent when the capture device has no audio input of its own"
|
||||
},
|
||||
{
|
||||
"msgid": "Single community"
|
||||
},
|
||||
@@ -2709,9 +2754,18 @@
|
||||
{
|
||||
"msgid": "Slowmode is set to {durationLabel}. Wait before sending another message."
|
||||
},
|
||||
{
|
||||
"msgid": "Smooth 30 FPS at up to 720p"
|
||||
},
|
||||
{
|
||||
"msgid": "Smooth 60 FPS at up to 1440p"
|
||||
},
|
||||
{
|
||||
"msgid": "Smoother video"
|
||||
},
|
||||
{
|
||||
"msgid": "Some stream settings could not be applied to your live stream."
|
||||
},
|
||||
{
|
||||
"msgid": "Something is limiting this stream, so viewers are getting fewer frames than you asked for."
|
||||
},
|
||||
@@ -2775,6 +2829,9 @@
|
||||
{
|
||||
"msgid": "Stream quality"
|
||||
},
|
||||
{
|
||||
"msgid": "Stream settings were adjusted"
|
||||
},
|
||||
{
|
||||
"msgid": "Strongest neural filter for speech, limits audio to the speech range."
|
||||
},
|
||||
@@ -2814,6 +2871,9 @@
|
||||
{
|
||||
"msgid": "The ban was set to expire on {date}."
|
||||
},
|
||||
{
|
||||
"msgid": "The browser is sending {deliveredResolution} instead of {resolution}."
|
||||
},
|
||||
{
|
||||
"msgid": "The camera preview is still stopping. Try streaming again in a moment."
|
||||
},
|
||||
@@ -2886,6 +2946,9 @@
|
||||
{
|
||||
"msgid": "These options change how people use the instance. Some can only be chosen during setup."
|
||||
},
|
||||
{
|
||||
"msgid": "This device could not keep up with {targetResolution} at {targetFrameRate} FPS last time, so your stream started at {resolution} at {frameRate} FPS."
|
||||
},
|
||||
{
|
||||
"msgid": "This emoji is from"
|
||||
},
|
||||
@@ -2940,6 +3003,9 @@
|
||||
{
|
||||
"msgid": "Transferred community ownership to {user}."
|
||||
},
|
||||
{
|
||||
"msgid": "Try full quality again"
|
||||
},
|
||||
{
|
||||
"msgid": "Turn off audio sharing for this source or try again in a moment."
|
||||
},
|
||||
@@ -3003,6 +3069,9 @@
|
||||
{
|
||||
"msgid": "Use your operating system's spellchecker when available. Otherwise, use {productName}'s in-app dictionaries."
|
||||
},
|
||||
{
|
||||
"msgid": "Used in Custom mode. The Gaming and Screen share presets set their own hint."
|
||||
},
|
||||
{
|
||||
"msgid": "Virtual microphone"
|
||||
},
|
||||
@@ -3039,6 +3108,9 @@
|
||||
{
|
||||
"msgid": "Wordmark"
|
||||
},
|
||||
{
|
||||
"msgid": "You asked for {resolution} at {frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "You can still email {supportEmail} for a human review at any time."
|
||||
},
|
||||
@@ -3054,12 +3126,39 @@
|
||||
{
|
||||
"msgid": "Your account works normally again straight away and nothing is removed. We can ask for this check again later."
|
||||
},
|
||||
{
|
||||
"msgid": "Your capture device is sending {deliveredResolution} instead of {resolution}."
|
||||
},
|
||||
{
|
||||
"msgid": "Your carrier says this number isn't in service. Check it and try again, or contact support if it's correct."
|
||||
},
|
||||
{
|
||||
"msgid": "Your connection cannot keep up. Viewers get about {deliveredFrameRate} of {frameRate} FPS at {resolution}."
|
||||
},
|
||||
{
|
||||
"msgid": "Your connection cannot keep up. Your stream is set to {frameRate} FPS at {resolution} and viewers are getting about {deliveredFrameRate} FPS."
|
||||
},
|
||||
{
|
||||
"msgid": "Your device cannot keep up. Viewers get about {deliveredFrameRate} of {frameRate} FPS at {resolution}."
|
||||
},
|
||||
{
|
||||
"msgid": "Your device cannot keep up. Your stream is set to {frameRate} FPS at {resolution} and viewers are getting about {deliveredFrameRate} FPS."
|
||||
},
|
||||
{
|
||||
"msgid": "Your device could not keep up, so your stream now sends {frameRate} FPS to keep {resolution} sharp."
|
||||
},
|
||||
{
|
||||
"msgid": "Your device could not keep up, so your stream now sends {resolution} to keep {frameRate} FPS smooth."
|
||||
},
|
||||
{
|
||||
"msgid": "Your new nickname, or leave blank to reset it."
|
||||
},
|
||||
{
|
||||
"msgid": "Your saved {savedResolution} at {savedFrameRate} FPS needs {premiumProductName}, so shares use {resolution} at {frameRate} FPS."
|
||||
},
|
||||
{
|
||||
"msgid": "Your source is producing about {sourceFrameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "Your subscription can't move to the new price right now."
|
||||
},
|
||||
@@ -3069,6 +3168,9 @@
|
||||
{
|
||||
"msgid": "Your subscription still ends on <0>{cancelDate}</0>. The current price is now {listPriceNewLabel}. If you reactivate, you can switch to it from here."
|
||||
},
|
||||
{
|
||||
"msgid": "Your video encoder stopped, so your stream switched to {codec}."
|
||||
},
|
||||
{
|
||||
"msgid": "deleted-category"
|
||||
},
|
||||
@@ -3564,6 +3666,9 @@
|
||||
{
|
||||
"msgid": "{channelHeading}, {mentionCount, plural, one {# mention} other {# mentions}}"
|
||||
},
|
||||
{
|
||||
"msgid": "{codec} cannot keep up on this device. Set the codec to Automatic in Advanced settings to allow a faster one."
|
||||
},
|
||||
{
|
||||
"msgid": "{count, plural, =0 {∞ No limit} one {# participant} other {# participants}}"
|
||||
},
|
||||
@@ -3594,12 +3699,18 @@
|
||||
{
|
||||
"msgid": "{expressionName} is a custom sticker from this community. Members can use it here."
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{imageCount, plural, one {# image} other {# images}}, {videoCount, plural, one {# video} other {# videos}}"
|
||||
},
|
||||
{
|
||||
"msgid": "{name} copy"
|
||||
},
|
||||
{
|
||||
"msgid": "{pinnedCodec} is not available on this device, so your stream uses {codec}."
|
||||
},
|
||||
{
|
||||
"msgid": "{premiumProductName} costs less now. Switch from {currentPrice} to {newPrice} a month starting {effectiveDate}, and nothing is charged today."
|
||||
},
|
||||
@@ -3936,6 +4047,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -4144,6 +4258,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -4314,6 +4431,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -4915,6 +5035,9 @@
|
||||
{
|
||||
"msgid": "[email protected]"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{messageGroupSpacing}px"
|
||||
},
|
||||
@@ -5165,6 +5288,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -5652,6 +5778,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -6000,6 +6129,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -6158,6 +6290,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -6250,6 +6385,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{messageGroupSpacing}px"
|
||||
},
|
||||
@@ -6324,6 +6462,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -7604,6 +7745,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -7967,6 +8111,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -8659,6 +8806,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -8953,6 +9103,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
@@ -9162,6 +9315,9 @@
|
||||
{
|
||||
"msgid": "{fps} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{frameRate} FPS"
|
||||
},
|
||||
{
|
||||
"msgid": "{kilobits} kbps"
|
||||
},
|
||||
|
||||
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
@@ -212,6 +212,7 @@ export interface DownloadFileOptions {
|
||||
export interface DownloadFileResult {
|
||||
success: boolean;
|
||||
canceled?: boolean;
|
||||
checksumMismatch?: boolean;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -357,7 +358,7 @@ export interface ElectronAPI {
|
||||
requestInputMonitoringPermission?: () => Promise<InputMonitoringPermissionStatus>;
|
||||
getScreenRecordingPermissionStatus?: () => Promise<InputMonitoringPermissionStatus>;
|
||||
requestScreenRecordingPermission?: () => Promise<InputMonitoringPermissionStatus>;
|
||||
downloadFile: (url: string, defaultPath: string) => Promise<DownloadFileResult>;
|
||||
downloadFile: (url: string, defaultPath: string, sha256?: string | null) => Promise<DownloadFileResult>;
|
||||
toggleDevTools: () => void;
|
||||
showNotification: (options: NotificationOptions) => Promise<NotificationResult>;
|
||||
shouldPlayNotificationSound?: () => Promise<boolean>;
|
||||
|
||||
@@ -273,21 +273,27 @@ export function attachExternalLinkInterceptor() {
|
||||
};
|
||||
}
|
||||
|
||||
export type NativeDownloadOutcome = 'success' | 'canceled' | 'failed' | 'unavailable';
|
||||
export type NativeDownloadOutcome = 'success' | 'canceled' | 'checksum-mismatch' | 'failed' | 'unavailable';
|
||||
|
||||
export async function downloadWithNative(options: {
|
||||
url: string;
|
||||
suggestedName?: string;
|
||||
title?: string;
|
||||
sha256?: string | null;
|
||||
}): Promise<NativeDownloadOutcome> {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi) {
|
||||
return 'unavailable';
|
||||
}
|
||||
try {
|
||||
const result = await electronApi.downloadFile(options.url, options.suggestedName ?? 'download');
|
||||
const result = await electronApi.downloadFile(
|
||||
options.url,
|
||||
options.suggestedName ?? 'download',
|
||||
options.sha256 ?? null,
|
||||
);
|
||||
if (result.success) return 'success';
|
||||
if (result.canceled) return 'canceled';
|
||||
if (result.checksumMismatch) return 'checksum-mismatch';
|
||||
return 'failed';
|
||||
} catch (error) {
|
||||
logger.error(' Native download failed, falling back to browser', error);
|
||||
|
||||
@@ -21,17 +21,28 @@ import {getAdvancedSettingsCategorySectionId} from '@app/features/user/component
|
||||
import {CompactComboboxRow} from '@app/features/user/components/modals/tabs/components/CompactComboboxRow';
|
||||
import {useMediaPermission} from '@app/features/user/components/modals/tabs/hooks/useMediaPermission';
|
||||
import styles from '@app/features/user/components/modals/tabs/UserVideoTab.module.css';
|
||||
import {
|
||||
resolveUserVideoTabScreenShareState,
|
||||
SCREEN_SHARE_PRESET_DESCRIPTORS,
|
||||
} from '@app/features/user/components/modals/tabs/UserVideoTabState';
|
||||
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
|
||||
import {CameraPreviewModalStandalone} from '@app/features/voice/components/modals/CameraPreviewModal';
|
||||
import type VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||
import type {CameraResolution, ScreenshareResolution} from '@app/features/voice/state/VoiceSettings';
|
||||
import {
|
||||
resolveScreenShareFrameRate,
|
||||
CAPTURE_DEVICES_USE_THE_GAMING_PRESET_DESCRIPTOR,
|
||||
type OfferedScreenShareResolution,
|
||||
} from '@app/features/voice/components/StreamSettingsMenuContentStateMachine';
|
||||
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
||||
import type VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||
import type {CameraResolution} from '@app/features/voice/state/VoiceSettings';
|
||||
import {
|
||||
resolveScreenShareQualityPick,
|
||||
type ScreenShareQualityInput,
|
||||
type SupportedScreenShareFrameRate,
|
||||
} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {buildSettingsDeviceOptions} from '@app/features/voice/utils/SettingsDeviceOptions';
|
||||
import {hasHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement';
|
||||
import {resolveEffectiveDeviceId} from '@app/features/voice/utils/VoiceDeviceManager';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {CrownIcon, GearIcon} from '@phosphor-icons/react';
|
||||
@@ -39,9 +50,10 @@ import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useMemo} from 'react';
|
||||
|
||||
const FPS_15_DESCRIPTOR = msg({message: '15 FPS'});
|
||||
const FPS_30_DESCRIPTOR = msg({message: '30 FPS'});
|
||||
const FPS_60_DESCRIPTOR = msg({message: '60 FPS'});
|
||||
const FPS_DESCRIPTOR = msg({
|
||||
message: '{frameRate} FPS',
|
||||
comment: 'Frame rate option label in the video tab. FPS is a technical token and frameRate is a whole number.',
|
||||
});
|
||||
|
||||
const LOW_480P_LABEL = '480p';
|
||||
const MEDIUM_720P_LABEL = '720p';
|
||||
@@ -62,13 +74,27 @@ const SELF_HOSTED_VIDEO_QUALITY_LIMIT_DESCRIPTOR = msg({
|
||||
message: 'This instance currently allows screen sharing up to 720p at 30 FPS.',
|
||||
comment: 'Neutral video settings note shown when higher screen share quality is disabled by instance limits.',
|
||||
});
|
||||
const STANDARD_720P_LABEL = '720p';
|
||||
const HIGH_DEFINITION_1080P_LABEL = '1080p';
|
||||
const QUAD_HD_1440P_LABEL = '1440p';
|
||||
const SCREEN_SHARE_RESOLUTION_LABELS: Record<Exclude<OfferedScreenShareResolution, 'source'>, string> = {
|
||||
low_480p: '480p',
|
||||
medium: '720p',
|
||||
high: '1080p',
|
||||
ultra: '1440p',
|
||||
};
|
||||
const SOURCE_DESCRIPTOR = msg({
|
||||
message: 'Source',
|
||||
comment: 'Short label in the video tab for the native source resolution option. Keep it concise.',
|
||||
});
|
||||
const SCREEN_SHARE_PRESET_OWNED_DESCRIPTOR = msg({
|
||||
message: 'Set by the {preset} preset. Picking a value here switches to Custom.',
|
||||
comment:
|
||||
'Video settings note shown when a streaming preset decides the screen share resolution and frame rate. preset is the Gaming or Screen share preset label, and Custom is the third preset label.',
|
||||
});
|
||||
const SCREEN_SHARE_TIER_LIMITED_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Your saved {savedResolution} at {savedFrameRate} FPS needs {premiumProductName}, so shares use {resolution} at {frameRate} FPS.',
|
||||
comment:
|
||||
'Video settings note shown when the saved screen share quality is above what this account can send. Resolutions are short labels such as 1440p or Source and rates are whole numbers. FPS is a technical token.',
|
||||
});
|
||||
const CAMERA_2_DESCRIPTOR = msg({
|
||||
message: 'Camera',
|
||||
comment: 'Short label in the video tab. Keep it concise.',
|
||||
@@ -94,6 +120,10 @@ const OPEN_ADVANCED_MEDIA_SETTINGS_DESCRIPTOR = msg({
|
||||
comment: 'Button label in the video tab that jumps to the advanced settings media section.',
|
||||
});
|
||||
|
||||
function getScreenShareResolutionLabel(i18n: I18n, resolution: OfferedScreenShareResolution): string {
|
||||
return resolution === 'source' ? i18n._(SOURCE_DESCRIPTOR) : SCREEN_SHARE_RESOLUTION_LABELS[resolution];
|
||||
}
|
||||
|
||||
interface VideoTabProps {
|
||||
voiceSettings: typeof VoiceSettings;
|
||||
hasPremium: boolean;
|
||||
@@ -103,7 +133,15 @@ interface VideoTabProps {
|
||||
export const VideoTab: React.FC<VideoTabProps> = observer(
|
||||
({voiceSettings, hasPremium: _hasPremium, autoRequestPermission = false}) => {
|
||||
const {i18n} = useLingui();
|
||||
const {videoDeviceId, cameraResolution, mirrorCamera, screenshareResolution, videoFrameRate} = voiceSettings;
|
||||
const {
|
||||
videoDeviceId,
|
||||
cameraResolution,
|
||||
mirrorCamera,
|
||||
screenshareResolution,
|
||||
videoFrameRate,
|
||||
streamingMode,
|
||||
screenShareContentHint,
|
||||
} = voiceSettings;
|
||||
const hasHigherQuality = hasHigherVideoQuality();
|
||||
const isSelfHosted = RuntimeConfig.isSelfHosted();
|
||||
const {
|
||||
@@ -132,45 +170,30 @@ export const VideoTab: React.FC<VideoTabProps> = observer(
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const screenshareResolutionOptions: ReadonlyArray<ComboboxOption<ScreenshareResolution>> = [
|
||||
{value: 'low_480p', label: LOW_480P_LABEL},
|
||||
{
|
||||
value: 'medium',
|
||||
label: STANDARD_720P_LABEL,
|
||||
},
|
||||
...(hasHigherQuality || !isSelfHosted
|
||||
? [
|
||||
{
|
||||
value: 'high' as const,
|
||||
label: HIGH_DEFINITION_1080P_LABEL,
|
||||
isDisabled: !hasHigherQuality,
|
||||
},
|
||||
{
|
||||
value: 'ultra' as const,
|
||||
label: QUAD_HD_1440P_LABEL,
|
||||
isDisabled: !hasHigherQuality,
|
||||
},
|
||||
{
|
||||
value: 'source' as const,
|
||||
label: i18n._(SOURCE_DESCRIPTOR),
|
||||
isDisabled: !hasHigherQuality,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const frameRateOptions: ReadonlyArray<ComboboxOption<SupportedScreenShareFrameRate>> = hasHigherQuality
|
||||
? [
|
||||
{value: 15, label: i18n._(FPS_15_DESCRIPTOR)},
|
||||
{value: 30, label: i18n._(FPS_30_DESCRIPTOR)},
|
||||
{value: 60, label: i18n._(FPS_60_DESCRIPTOR)},
|
||||
]
|
||||
: [
|
||||
{value: 15, label: i18n._(FPS_15_DESCRIPTOR)},
|
||||
{value: 30, label: i18n._(FPS_30_DESCRIPTOR)},
|
||||
];
|
||||
const resolvedVideoFrameRate = resolveScreenShareFrameRate(videoFrameRate);
|
||||
const effectiveVideoFrameRate: SupportedScreenShareFrameRate =
|
||||
!hasHigherQuality && resolvedVideoFrameRate === 60 ? 30 : resolvedVideoFrameRate;
|
||||
const liveShareContext = ActiveScreenShareSource.getShareContext();
|
||||
const screenShareQuality: ScreenShareQualityInput = {
|
||||
mode: streamingMode,
|
||||
storedResolution: screenshareResolution,
|
||||
storedFrameRate: videoFrameRate,
|
||||
entitled: hasHigherQuality,
|
||||
context: liveShareContext ?? 'display',
|
||||
};
|
||||
const screenShareState = resolveUserVideoTabScreenShareState({
|
||||
quality: screenShareQuality,
|
||||
hintSetting: screenShareContentHint,
|
||||
selfHosted: isSelfHosted,
|
||||
});
|
||||
const screenshareResolutionOptions: ReadonlyArray<ComboboxOption<OfferedScreenShareResolution>> =
|
||||
screenShareState.resolutionOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: getScreenShareResolutionLabel(i18n, option.value),
|
||||
isDisabled: option.isDisabled,
|
||||
}));
|
||||
const frameRateOptions: ReadonlyArray<ComboboxOption<SupportedScreenShareFrameRate>> =
|
||||
screenShareState.frameRateOptions.map((value) => ({
|
||||
value,
|
||||
label: i18n._(FPS_DESCRIPTOR, {frameRate: value}),
|
||||
}));
|
||||
const handleCameraPreview = async () => {
|
||||
const granted = await requestPermission();
|
||||
if (granted) {
|
||||
@@ -184,12 +207,16 @@ export const VideoTab: React.FC<VideoTabProps> = observer(
|
||||
);
|
||||
}
|
||||
};
|
||||
const handleScreenshareResolutionChange = useCallback((value: ScreenshareResolution) => {
|
||||
VoiceSettingsCommands.update({screenshareResolution: value, streamingMode: 'custom'});
|
||||
}, []);
|
||||
const handleVideoFrameRateChange = useCallback((value: SupportedScreenShareFrameRate) => {
|
||||
VoiceSettingsCommands.update({videoFrameRate: value, streamingMode: 'custom'});
|
||||
}, []);
|
||||
const handleScreenshareResolutionChange = (value: OfferedScreenShareResolution) => {
|
||||
const patch = resolveScreenShareQualityPick(screenShareQuality, {axis: 'resolution', resolution: value});
|
||||
if (patch === null) return;
|
||||
VoiceSettingsCommands.update(patch);
|
||||
};
|
||||
const handleVideoFrameRateChange = (value: SupportedScreenShareFrameRate) => {
|
||||
const patch = resolveScreenShareQualityPick(screenShareQuality, {axis: 'frameRate', frameRate: value});
|
||||
if (patch === null) return;
|
||||
VoiceSettingsCommands.update(patch);
|
||||
};
|
||||
const handleOpenAdvancedVideoSettings = useCallback(() => {
|
||||
ComponentBus.dispatch('USER_SETTINGS_TAB_SELECT', {
|
||||
tab: 'advanced_settings',
|
||||
@@ -271,10 +298,10 @@ export const VideoTab: React.FC<VideoTabProps> = observer(
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.controlGroup} data-flx="user.video-tab.screen-sharing-group">
|
||||
<CompactComboboxRow<ScreenshareResolution>
|
||||
<CompactComboboxRow<OfferedScreenShareResolution>
|
||||
label={i18n._(SCREEN_SHARE_QUALITY_DESCRIPTOR)}
|
||||
options={screenshareResolutionOptions}
|
||||
value={screenshareResolution}
|
||||
value={screenShareState.resolution}
|
||||
onChange={handleScreenshareResolutionChange}
|
||||
isSearchable={false}
|
||||
controlWidth="small"
|
||||
@@ -313,7 +340,7 @@ export const VideoTab: React.FC<VideoTabProps> = observer(
|
||||
<CompactComboboxRow<SupportedScreenShareFrameRate>
|
||||
label={<Trans>Frame rate</Trans>}
|
||||
options={frameRateOptions}
|
||||
value={effectiveVideoFrameRate}
|
||||
value={screenShareState.frameRate}
|
||||
onChange={handleVideoFrameRateChange}
|
||||
isSearchable={false}
|
||||
controlWidth="small"
|
||||
@@ -336,6 +363,35 @@ export const VideoTab: React.FC<VideoTabProps> = observer(
|
||||
{i18n._(SELF_HOSTED_VIDEO_QUALITY_LIMIT_DESCRIPTOR)}
|
||||
</div>
|
||||
)}
|
||||
{screenShareState.preset !== null && (
|
||||
<div className={styles.frameRateNote} data-flx="user.video-tab.preset-note">
|
||||
{i18n._(SCREEN_SHARE_PRESET_OWNED_DESCRIPTOR, {
|
||||
preset: i18n._(SCREEN_SHARE_PRESET_DESCRIPTORS[screenShareState.preset]),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{screenShareState.presetOverriddenByContext && (
|
||||
<div className={styles.frameRateNote} data-flx="user.video-tab.preset-context-note">
|
||||
{i18n._(CAPTURE_DEVICES_USE_THE_GAMING_PRESET_DESCRIPTOR)}
|
||||
</div>
|
||||
)}
|
||||
{screenShareState.saved !== null && (
|
||||
<div className={styles.frameRateNote} data-flx="user.video-tab.tier-limit-note">
|
||||
<CrownIcon
|
||||
weight="fill"
|
||||
size={remFromPx(14)}
|
||||
className={styles.frameRateIcon}
|
||||
data-flx="user.video-tab.tier-limit-icon"
|
||||
/>
|
||||
{i18n._(SCREEN_SHARE_TIER_LIMITED_DESCRIPTOR, {
|
||||
frameRate: screenShareState.frameRate,
|
||||
premiumProductName: PREMIUM_PRODUCT_NAME,
|
||||
resolution: getScreenShareResolutionLabel(i18n, screenShareState.resolution),
|
||||
savedFrameRate: screenShareState.saved.frameRate,
|
||||
savedResolution: getScreenShareResolutionLabel(i18n, screenShareState.saved.resolution),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.actionRow} data-flx="user.video-tab.advanced-video-settings-section">
|
||||
<span className={styles.actionLabel} data-flx="user.video-tab.advanced-video-settings-label">
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
contextAllowsScreenShareResolution,
|
||||
OFFERED_SCREEN_SHARE_RESOLUTIONS,
|
||||
type OfferedScreenShareResolution,
|
||||
offeredScreenShareResolution,
|
||||
selectStreamSettingsPresetOverriddenByContext,
|
||||
} from '@app/features/voice/components/StreamSettingsMenuContentStateMachine';
|
||||
import type {StreamingMode} from '@app/features/voice/state/VoiceSettings';
|
||||
import type {ScreenShareContentHint} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import {
|
||||
resolveScreenShareFrameRate,
|
||||
resolveScreenShareTarget,
|
||||
type ScreenShareContext,
|
||||
type ScreenShareQualityInput,
|
||||
SUPPORTED_SCREEN_SHARE_FRAME_RATES,
|
||||
type SupportedScreenShareFrameRate,
|
||||
} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
export const SCREEN_SHARE_PRESET_DESCRIPTORS: Record<Exclude<StreamingMode, 'custom'>, MessageDescriptor> = {
|
||||
gaming: msg({
|
||||
message: 'Gaming',
|
||||
comment: 'Video settings label for the motion-focused screen share quality preset.',
|
||||
}),
|
||||
screenshare: msg({
|
||||
message: 'Screen share',
|
||||
comment: 'Video settings label for the text-focused screen share quality preset.',
|
||||
}),
|
||||
};
|
||||
|
||||
const FREE_SCREEN_SHARE_RESOLUTIONS: ReadonlyArray<OfferedScreenShareResolution> = ['low_480p', 'medium'];
|
||||
const PREMIUM_SCREEN_SHARE_RESOLUTIONS: ReadonlyArray<OfferedScreenShareResolution> = ['high', 'ultra', 'source'];
|
||||
const FREE_SCREEN_SHARE_FRAME_RATES: ReadonlyArray<SupportedScreenShareFrameRate> = [15, 30];
|
||||
const PREMIUM_SCREEN_SHARE_FRAME_RATES: ReadonlyArray<SupportedScreenShareFrameRate> = [15, 30, 60];
|
||||
|
||||
export interface UserVideoTabScreenShareInput {
|
||||
quality: ScreenShareQualityInput;
|
||||
hintSetting: ScreenShareContentHint;
|
||||
selfHosted: boolean;
|
||||
}
|
||||
|
||||
export interface UserVideoTabResolutionOption {
|
||||
value: OfferedScreenShareResolution;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
export interface UserVideoTabScreenShareState {
|
||||
resolution: OfferedScreenShareResolution;
|
||||
frameRate: SupportedScreenShareFrameRate;
|
||||
resolutionOptions: ReadonlyArray<UserVideoTabResolutionOption>;
|
||||
frameRateOptions: ReadonlyArray<SupportedScreenShareFrameRate>;
|
||||
preset: Exclude<StreamingMode, 'custom'> | null;
|
||||
presetOverriddenByContext: boolean;
|
||||
saved: {resolution: OfferedScreenShareResolution; frameRate: SupportedScreenShareFrameRate} | null;
|
||||
}
|
||||
|
||||
function buildResolutionOptions(
|
||||
entitled: boolean,
|
||||
selfHosted: boolean,
|
||||
context: ScreenShareContext,
|
||||
effective: OfferedScreenShareResolution,
|
||||
): ReadonlyArray<UserVideoTabResolutionOption> {
|
||||
const offersPremium = entitled || !selfHosted;
|
||||
return OFFERED_SCREEN_SHARE_RESOLUTIONS.filter(
|
||||
(value) =>
|
||||
value === effective ||
|
||||
FREE_SCREEN_SHARE_RESOLUTIONS.includes(value) ||
|
||||
(offersPremium &&
|
||||
PREMIUM_SCREEN_SHARE_RESOLUTIONS.includes(value) &&
|
||||
contextAllowsScreenShareResolution(value, context)),
|
||||
).map((value) => ({value, isDisabled: !entitled && PREMIUM_SCREEN_SHARE_RESOLUTIONS.includes(value)}));
|
||||
}
|
||||
|
||||
function buildFrameRateOptions(
|
||||
entitled: boolean,
|
||||
effective: SupportedScreenShareFrameRate,
|
||||
): ReadonlyArray<SupportedScreenShareFrameRate> {
|
||||
const offered = entitled ? PREMIUM_SCREEN_SHARE_FRAME_RATES : FREE_SCREEN_SHARE_FRAME_RATES;
|
||||
return SUPPORTED_SCREEN_SHARE_FRAME_RATES.filter((value) => value === effective || offered.includes(value));
|
||||
}
|
||||
|
||||
export function resolveUserVideoTabScreenShareState(input: UserVideoTabScreenShareInput): UserVideoTabScreenShareState {
|
||||
const target = resolveScreenShareTarget({...input.quality, sourceDimensions: null, hintSetting: input.hintSetting});
|
||||
const resolution = offeredScreenShareResolution(target.resolution);
|
||||
return {
|
||||
resolution,
|
||||
frameRate: target.frameRate,
|
||||
resolutionOptions: buildResolutionOptions(
|
||||
input.quality.entitled,
|
||||
input.selfHosted,
|
||||
input.quality.context,
|
||||
resolution,
|
||||
),
|
||||
frameRateOptions: buildFrameRateOptions(input.quality.entitled, target.frameRate),
|
||||
preset: input.quality.mode === 'custom' ? null : input.quality.mode,
|
||||
presetOverriddenByContext: selectStreamSettingsPresetOverriddenByContext(input.quality.mode, input.quality.context),
|
||||
saved: target.tierLimited
|
||||
? {
|
||||
resolution: offeredScreenShareResolution(input.quality.storedResolution),
|
||||
frameRate: resolveScreenShareFrameRate(input.quality.storedFrameRate),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
+22
-87
@@ -11,23 +11,26 @@ import {SwitchGroup, SwitchGroupItem} from '@app/features/ui/components/SwitchGr
|
||||
import PiP from '@app/features/ui/state/PiP';
|
||||
import {getElectronAPI, isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
import styles from '@app/features/user/components/modals/tabs/AdvancedSettingsTab.module.css';
|
||||
import {selectScreenShareEncoderPathDescription} from '@app/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControlsState';
|
||||
import {CompactComboboxRow} from '@app/features/user/components/modals/tabs/components/CompactComboboxRow';
|
||||
import PrivacyPreferences from '@app/features/user/state/PrivacyPreferences';
|
||||
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
|
||||
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||
import type {
|
||||
CodecPreference,
|
||||
ScreenShareBackupCodecMode,
|
||||
ScreenShareContentHint,
|
||||
ScreenShareEncoderMode,
|
||||
ScreenShareScalabilityModePreference,
|
||||
ScreenShareSoftwareQuality,
|
||||
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import {
|
||||
describeScreenShareEncoderPath,
|
||||
getCodecCapabilityReport,
|
||||
selectAutomaticScreenShareCodec,
|
||||
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import {getGpuEncoderReportSync, loadGpuEncoderReport} from '@app/features/voice/utils/GpuEncoderCapabilities';
|
||||
import {
|
||||
getGpuEncoderReportSync,
|
||||
type HardwareEncodeReport,
|
||||
loadGpuEncoderReport,
|
||||
} from '@app/features/voice/utils/GpuEncoderCapabilities';
|
||||
import {setOpenH264Enabled} from '@app/features/voice/utils/OpenH264Status';
|
||||
import {CODEC_DISPLAY_LABEL} from '@app/features/voice/utils/ScreenShareCodecPolicy';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -57,10 +60,6 @@ const ENCODER_PATH_DESCRIPTOR = msg({
|
||||
message: 'Encoder path',
|
||||
comment: 'Label for an advanced screen-share select. Refers to hardware or software video encoding.',
|
||||
});
|
||||
const ENCODER_PATH_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Encoder preference for new screen shares.',
|
||||
comment: 'Description for the encoder path select.',
|
||||
});
|
||||
const ENCODER_PATH_AUTO_DESCRIPTOR = msg({
|
||||
message: 'Automatic',
|
||||
comment: 'Option label for an encoder path select.',
|
||||
@@ -73,26 +72,6 @@ const ENCODER_PATH_SOFTWARE_DESCRIPTOR = msg({
|
||||
message: 'Prefer software',
|
||||
comment: 'Option label for an encoder path select. Refers to software video encoders.',
|
||||
});
|
||||
const SOFTWARE_QUALITY_DESCRIPTOR = msg({
|
||||
message: 'Software encoder quality',
|
||||
comment: 'Label for an advanced screen-share select. Refers to software video encoder quality bias.',
|
||||
});
|
||||
const SOFTWARE_QUALITY_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Higher quality can increase CPU usage and latency.',
|
||||
comment: 'Description for a software encoder quality select. Keep AV1 and CPU literal.',
|
||||
});
|
||||
const SOFTWARE_QUALITY_REALTIME_DESCRIPTOR = msg({
|
||||
message: 'Real-time',
|
||||
comment: 'Option label for a software encoder quality select. Means fastest/lower-latency.',
|
||||
});
|
||||
const SOFTWARE_QUALITY_BALANCED_DESCRIPTOR = msg({
|
||||
message: 'Balanced',
|
||||
comment: 'Option label for a software encoder quality select.',
|
||||
});
|
||||
const SOFTWARE_QUALITY_QUALITY_DESCRIPTOR = msg({
|
||||
message: 'Quality',
|
||||
comment: 'Option label for a software encoder quality select. Means slower/higher-quality.',
|
||||
});
|
||||
const SVC_MODE_DESCRIPTOR = msg({
|
||||
message: 'SVC mode',
|
||||
comment: 'Label for an advanced screen-share select. SVC is a WebRTC acronym and should stay uppercase.',
|
||||
@@ -113,33 +92,14 @@ const SVC_MODE_TEMPORAL_DESCRIPTOR = msg({
|
||||
message: 'Temporal layers',
|
||||
comment: 'Option label for an SVC mode select.',
|
||||
});
|
||||
const SVC_MODE_SPATIAL_DESCRIPTOR = msg({
|
||||
message: 'Spatial and temporal layers',
|
||||
comment: 'Option label for an SVC mode select.',
|
||||
});
|
||||
const BACKUP_CODEC_DESCRIPTOR = msg({
|
||||
message: 'H.264 backup stream',
|
||||
comment: 'Label for an advanced screen-share select. H.264 is a codec name and should stay literal.',
|
||||
});
|
||||
const BACKUP_CODEC_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Adds H.264 for mixed clients. Requires more encoding.',
|
||||
comment: 'Description for an H.264 backup stream select. Keep H.264, CPU, and GPU literal.',
|
||||
});
|
||||
const BACKUP_CODEC_OFF_DESCRIPTOR = msg({
|
||||
message: 'Off',
|
||||
comment: 'Option label for an H.264 backup stream select.',
|
||||
});
|
||||
const BACKUP_CODEC_H264_SIMULCAST_DESCRIPTOR = msg({
|
||||
message: 'H.264 simulcast backup',
|
||||
comment: 'Option label for an H.264 backup stream select. H.264 is a codec name and should stay literal.',
|
||||
});
|
||||
const CONTENT_HINT_DESCRIPTOR = msg({
|
||||
message: 'Content hint',
|
||||
comment: 'Label for an advanced screen-share select. Refers to the WebRTC MediaStreamTrack contentHint value.',
|
||||
});
|
||||
const CONTENT_HINT_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Helps the browser choose motion, detail, or text handling.',
|
||||
comment: 'Description for a WebRTC content hint select. Keep Motion, Detail, and text as plain option concepts.',
|
||||
message: 'Used in Custom mode. The Gaming and Screen share presets set their own hint.',
|
||||
comment:
|
||||
'Description for a WebRTC content hint select. Custom, Gaming and Screen share are the stream quality preset labels.',
|
||||
});
|
||||
const CONTENT_HINT_AUTO_DESCRIPTOR = msg({
|
||||
message: 'Automatic',
|
||||
@@ -191,9 +151,7 @@ const HEVC_SCREEN_SHARE_OPT_IN_DESCRIPTOR = msg({
|
||||
});
|
||||
const SCREEN_SHARE_CODEC_OPTION_ORDER = ['av1', 'h265', 'h264', 'vp9', 'vp8'] as const;
|
||||
|
||||
export const ScreenShareCodecControl = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const encoderMode = VoiceSettings.getScreenShareEncoderMode();
|
||||
function useLoadedGpuEncoderReport(): HardwareEncodeReport | null {
|
||||
const [gpuReport, setGpuReport] = useState(() => getGpuEncoderReportSync());
|
||||
useEffect(() => {
|
||||
if (gpuReport) return;
|
||||
@@ -205,7 +163,14 @@ export const ScreenShareCodecControl = observer(() => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [gpuReport]);
|
||||
const automaticCodec = selectAutomaticScreenShareCodec(encoderMode).codec;
|
||||
return gpuReport;
|
||||
}
|
||||
|
||||
export const ScreenShareCodecControl = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const encoderMode = VoiceSettings.getScreenShareEncoderMode();
|
||||
useLoadedGpuEncoderReport();
|
||||
const automaticCodec = describeScreenShareEncoderPath(encoderMode).codec;
|
||||
const av1OptIn = VoiceSettings.getScreenShareAv1OptIn();
|
||||
const hevcOptIn = VoiceSettings.getScreenShareHevcOptIn();
|
||||
const codecCapabilities = getCodecCapabilityReport();
|
||||
@@ -313,25 +278,17 @@ export const ScreenSharePreviewBehaviorControl = observer(() => {
|
||||
|
||||
const ScreenShareEncoderControlsContent = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
useLoadedGpuEncoderReport();
|
||||
const {hardwareUnavailable} = describeScreenShareEncoderPath(VoiceSettings.screenShareEncoderMode);
|
||||
const encoderModeOptions: ReadonlyArray<ComboboxOption<ScreenShareEncoderMode>> = [
|
||||
{value: 'auto', label: i18n._(ENCODER_PATH_AUTO_DESCRIPTOR)},
|
||||
{value: 'hardware', label: i18n._(ENCODER_PATH_HARDWARE_DESCRIPTOR)},
|
||||
{value: 'software', label: i18n._(ENCODER_PATH_SOFTWARE_DESCRIPTOR)},
|
||||
];
|
||||
const softwareQualityOptions: ReadonlyArray<ComboboxOption<ScreenShareSoftwareQuality>> = [
|
||||
{value: 'realtime', label: i18n._(SOFTWARE_QUALITY_REALTIME_DESCRIPTOR)},
|
||||
{value: 'balanced', label: i18n._(SOFTWARE_QUALITY_BALANCED_DESCRIPTOR)},
|
||||
{value: 'quality', label: i18n._(SOFTWARE_QUALITY_QUALITY_DESCRIPTOR)},
|
||||
];
|
||||
const scalabilityModeOptions: ReadonlyArray<ComboboxOption<ScreenShareScalabilityModePreference>> = [
|
||||
{value: 'auto', label: i18n._(SVC_MODE_AUTO_DESCRIPTOR)},
|
||||
{value: 'single_layer', label: i18n._(SVC_MODE_SINGLE_LAYER_DESCRIPTOR)},
|
||||
{value: 'temporal', label: i18n._(SVC_MODE_TEMPORAL_DESCRIPTOR)},
|
||||
{value: 'spatial', label: i18n._(SVC_MODE_SPATIAL_DESCRIPTOR)},
|
||||
];
|
||||
const backupCodecOptions: ReadonlyArray<ComboboxOption<ScreenShareBackupCodecMode>> = [
|
||||
{value: 'off', label: i18n._(BACKUP_CODEC_OFF_DESCRIPTOR)},
|
||||
{value: 'h264_simulcast', label: i18n._(BACKUP_CODEC_H264_SIMULCAST_DESCRIPTOR)},
|
||||
];
|
||||
const contentHintOptions: ReadonlyArray<ComboboxOption<ScreenShareContentHint>> = [
|
||||
{value: 'auto', label: i18n._(CONTENT_HINT_AUTO_DESCRIPTOR)},
|
||||
@@ -343,7 +300,7 @@ const ScreenShareEncoderControlsContent = observer(() => {
|
||||
<div className={styles.controlStackCompact} data-flx="user.advanced-settings-tab.screen-share-encoder-controls">
|
||||
<CompactComboboxRow<ScreenShareEncoderMode>
|
||||
label={i18n._(ENCODER_PATH_DESCRIPTOR)}
|
||||
description={i18n._(ENCODER_PATH_DESCRIPTION_DESCRIPTOR)}
|
||||
description={i18n._(selectScreenShareEncoderPathDescription(hardwareUnavailable))}
|
||||
value={VoiceSettings.screenShareEncoderMode}
|
||||
options={encoderModeOptions}
|
||||
onChange={(value) => VoiceSettingsCommands.update({screenShareEncoderMode: value})}
|
||||
@@ -352,17 +309,6 @@ const ScreenShareEncoderControlsContent = observer(() => {
|
||||
dataFlx="user.advanced-settings-tab.select.screen-share-encoder-path"
|
||||
data-flx="user.advanced-settings-tab.advanced-video-controls.screen-share-encoder-controls-content.compact-combobox-row.update"
|
||||
/>
|
||||
<CompactComboboxRow<ScreenShareSoftwareQuality>
|
||||
label={i18n._(SOFTWARE_QUALITY_DESCRIPTOR)}
|
||||
description={i18n._(SOFTWARE_QUALITY_DESCRIPTION_DESCRIPTOR)}
|
||||
value={VoiceSettings.screenShareSoftwareQuality}
|
||||
options={softwareQualityOptions}
|
||||
onChange={(value) => VoiceSettingsCommands.update({screenShareSoftwareQuality: value})}
|
||||
isSearchable={false}
|
||||
controlWidth="small"
|
||||
dataFlx="user.advanced-settings-tab.select.screen-share-software-quality"
|
||||
data-flx="user.advanced-settings-tab.advanced-video-controls.screen-share-encoder-controls-content.compact-combobox-row.update--2"
|
||||
/>
|
||||
<CompactComboboxRow<ScreenShareScalabilityModePreference>
|
||||
label={i18n._(SVC_MODE_DESCRIPTOR)}
|
||||
description={i18n._(SVC_MODE_DESCRIPTION_DESCRIPTOR)}
|
||||
@@ -374,17 +320,6 @@ const ScreenShareEncoderControlsContent = observer(() => {
|
||||
dataFlx="user.advanced-settings-tab.select.screen-share-svc-mode"
|
||||
data-flx="user.advanced-settings-tab.advanced-video-controls.screen-share-encoder-controls-content.compact-combobox-row.update--3"
|
||||
/>
|
||||
<CompactComboboxRow<ScreenShareBackupCodecMode>
|
||||
label={i18n._(BACKUP_CODEC_DESCRIPTOR)}
|
||||
description={i18n._(BACKUP_CODEC_DESCRIPTION_DESCRIPTOR)}
|
||||
value={VoiceSettings.screenShareBackupCodecMode}
|
||||
options={backupCodecOptions}
|
||||
onChange={(value) => VoiceSettingsCommands.update({screenShareBackupCodecMode: value})}
|
||||
isSearchable={false}
|
||||
controlWidth="large"
|
||||
dataFlx="user.advanced-settings-tab.select.screen-share-backup-codec"
|
||||
data-flx="user.advanced-settings-tab.advanced-video-controls.screen-share-encoder-controls-content.compact-combobox-row.update--4"
|
||||
/>
|
||||
<CompactComboboxRow<ScreenShareContentHint>
|
||||
label={i18n._(CONTENT_HINT_DESCRIPTOR)}
|
||||
description={i18n._(CONTENT_HINT_DESCRIPTION_DESCRIPTOR)}
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
const ENCODER_PATH_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Encoder preference for new screen shares.',
|
||||
comment: 'Description for the encoder path select.',
|
||||
});
|
||||
const ENCODER_PATH_NO_HARDWARE_DESCRIPTOR = msg({
|
||||
message: 'No hardware encoder was found on this device, so Prefer hardware works like Automatic.',
|
||||
comment:
|
||||
'Description for the encoder path select, shown when this device has no hardware video encoder. Prefer hardware and Automatic are the option labels of the same select.',
|
||||
});
|
||||
|
||||
export function selectScreenShareEncoderPathDescription(hardwareUnavailable: boolean): MessageDescriptor {
|
||||
return hardwareUnavailable ? ENCODER_PATH_NO_HARDWARE_DESCRIPTOR : ENCODER_PATH_DESCRIPTION_DESCRIPTOR;
|
||||
}
|
||||
+1
-1
@@ -171,7 +171,7 @@ const BITRATE_DESCRIPTOR = msg({
|
||||
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
|
||||
});
|
||||
const TUNE_LOW_LEVEL_SCREEN_SHARE_ENCODER_BEHAVIOR_FOR_THE_NEXT_STREAM_DESCRIPTOR = msg({
|
||||
message: 'Encoder, SVC, backup stream, and bitrate presets',
|
||||
message: 'Encoder, SVC, and bitrate presets',
|
||||
comment: 'Settings search entry description. One-line summary of what the settings search entry controls.',
|
||||
});
|
||||
|
||||
|
||||
@@ -46,9 +46,6 @@ const CURRENT_USER_PRIVATE_WIRE_KEYS = [
|
||||
] as const;
|
||||
|
||||
function isPublicOnlyCurrentUserPayload(user: WireUser): boolean {
|
||||
if (typeof user.mention_flags === 'number') {
|
||||
return false;
|
||||
}
|
||||
return !CURRENT_USER_PRIVATE_WIRE_KEYS.some((key) => key in user);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {noteDeliberateScreenShareQualityChange} from '@app/features/voice/engine/ScreenShareUnderperformance';
|
||||
import VoiceSettings, {
|
||||
type CameraResolution,
|
||||
type ScreenshareResolution,
|
||||
@@ -9,11 +8,9 @@ import VoiceSettings, {
|
||||
} from '@app/features/voice/state/VoiceSettings';
|
||||
import type {
|
||||
CodecPreference,
|
||||
ScreenShareBackupCodecMode,
|
||||
ScreenShareContentHint,
|
||||
ScreenShareEncoderMode,
|
||||
ScreenShareScalabilityModePreference,
|
||||
ScreenShareSoftwareQuality,
|
||||
} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import {getActiveInputDeviceLabel, type VoiceProcessingMode} from '@app/features/voice/utils/VoiceProcessingProfile';
|
||||
|
||||
@@ -62,9 +59,7 @@ type VoiceSettingsPatch = Partial<{
|
||||
screenShareHevcOptIn: boolean;
|
||||
screenShareContentHint: ScreenShareContentHint;
|
||||
screenShareEncoderMode: ScreenShareEncoderMode;
|
||||
screenShareSoftwareQuality: ScreenShareSoftwareQuality;
|
||||
screenShareScalabilityMode: ScreenShareScalabilityModePreference;
|
||||
screenShareBackupCodecMode: ScreenShareBackupCodecMode;
|
||||
vadThreshold: number;
|
||||
vadAutoSensitivity: boolean;
|
||||
vadEnhanced: boolean;
|
||||
@@ -107,6 +102,7 @@ const SCREEN_SHARE_CODEC_NEGOTIATION_REFRESH_KEYS: Array<keyof VoiceSettingsPatc
|
||||
'preferredScreenShareCodec',
|
||||
'screenShareAv1OptIn',
|
||||
'screenShareHevcOptIn',
|
||||
'screenShareEncoderMode',
|
||||
];
|
||||
function refreshMicrophone(): void {
|
||||
MediaEngine.refreshMicrophoneFromSettings();
|
||||
@@ -151,44 +147,13 @@ function shouldRefreshCameraCapture(
|
||||
return CAMERA_CAPTURE_REFRESH_KEYS.some((key) => settings[key] !== undefined && before[key] !== after[key]);
|
||||
}
|
||||
|
||||
function readScreenShareQualityValues(): VoiceSettingsPatch {
|
||||
return {
|
||||
screenshareResolution: VoiceSettings.getScreenshareResolution(),
|
||||
videoFrameRate: VoiceSettings.getVideoFrameRate(),
|
||||
streamingMode: VoiceSettings.getStreamingMode(),
|
||||
screenShareContentHint: VoiceSettings.getScreenShareContentHint(),
|
||||
preferredScreenShareCodec: VoiceSettings.getPreferredScreenShareCodec(),
|
||||
screenShareAv1OptIn: VoiceSettings.getScreenShareAv1OptIn(),
|
||||
screenShareHevcOptIn: VoiceSettings.getScreenShareHevcOptIn(),
|
||||
screenShareEncoderMode: VoiceSettings.getScreenShareEncoderMode(),
|
||||
screenShareSoftwareQuality: VoiceSettings.getScreenShareSoftwareQuality(),
|
||||
screenShareScalabilityMode: VoiceSettings.getScreenShareScalabilityMode(),
|
||||
screenShareBackupCodecMode: VoiceSettings.getScreenShareBackupCodecMode(),
|
||||
openH264Enabled: VoiceSettings.getOpenH264Enabled(),
|
||||
};
|
||||
}
|
||||
|
||||
function changesScreenShareQualityDeliberately(
|
||||
settings: VoiceSettingsPatch,
|
||||
before: VoiceSettingsPatch,
|
||||
after: VoiceSettingsPatch,
|
||||
): boolean {
|
||||
return (Object.keys(before) as Array<keyof VoiceSettingsPatch>).some(
|
||||
(key) => settings[key] !== undefined && before[key] !== after[key],
|
||||
);
|
||||
}
|
||||
|
||||
function applyUpdatedVoiceSettings(
|
||||
settings: VoiceSettingsPatch,
|
||||
refreshInput: boolean,
|
||||
options: VoiceSettingsUpdateOptions = {},
|
||||
): void {
|
||||
const cameraCaptureBefore = readCameraCaptureRefreshValues();
|
||||
const screenShareQualityBefore = readScreenShareQualityValues();
|
||||
VoiceSettings.updateSettings(settings);
|
||||
if (changesScreenShareQualityDeliberately(settings, screenShareQualityBefore, readScreenShareQualityValues())) {
|
||||
noteDeliberateScreenShareQualityChange();
|
||||
}
|
||||
if (settings.muteStreamAudio !== undefined) {
|
||||
MediaEngine.setScreenShareAudioMuted(settings.muteStreamAudio);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ export interface ActiveScreenShareMenuProps {
|
||||
shareContextResolved: boolean;
|
||||
iconClassName?: string;
|
||||
additionalActions?: React.ReactNode;
|
||||
showLiveSettings?: boolean;
|
||||
tail?: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -60,7 +59,6 @@ export const ActiveScreenShareMenu: React.FC<ActiveScreenShareMenuProps> = ({
|
||||
shareContextResolved,
|
||||
iconClassName,
|
||||
additionalActions,
|
||||
showLiveSettings = true,
|
||||
tail,
|
||||
}) => {
|
||||
const {i18n} = useLingui();
|
||||
@@ -109,7 +107,7 @@ export const ActiveScreenShareMenu: React.FC<ActiveScreenShareMenuProps> = ({
|
||||
{i18n._(CHANGE_STREAM_DESCRIPTOR)}
|
||||
</MenuItem>
|
||||
{additionalActions}
|
||||
{showLiveSettings && !isWeb && (
|
||||
{!isWeb && (
|
||||
<StreamSettingsMenuContent
|
||||
applyToLiveStream
|
||||
variant="compactLive"
|
||||
|
||||
@@ -8,9 +8,10 @@ import {MenuItemRadio} from '@app/features/ui/action_menu/MenuItemRadio';
|
||||
import {MenuItemSubmenu} from '@app/features/ui/action_menu/MenuItemSubmenu';
|
||||
import {getElectronAPI} from '@app/features/ui/utils/NativeUtils';
|
||||
import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands';
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {useMediaDevices} from '@app/features/voice/hooks/useMediaDevices';
|
||||
import VoiceSettings from '@app/features/voice/state/VoiceSettings';
|
||||
import {
|
||||
filterRoutableLinuxAudioSources,
|
||||
type LinuxAudioSourceFilterOptions,
|
||||
type LinuxAudioSourceItem,
|
||||
linuxAudioSourceItemKey,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
formatScreenShareAudioSummary,
|
||||
MICROPHONE_DESCRIPTOR,
|
||||
MICROPHONE_WITH_DEVICE_DESCRIPTOR,
|
||||
resolveDeviceShareAudioPairing,
|
||||
} from '@app/features/voice/utils/ScreenShareAudioSummary';
|
||||
import type {DisplayShareEnvironment} from '@app/features/voice/utils/ScreenShareEnvironment';
|
||||
import {
|
||||
@@ -142,11 +144,11 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
||||
const includeSources = VoiceSettings.getScreenShareAudioIncludeSources();
|
||||
const excludeSources = VoiceSettings.getScreenShareAudioExcludeSources();
|
||||
const usesDeviceMicrophone = VoiceSettings.getScreenShareDeviceAudioUsesMicrophone();
|
||||
const {inputDevices, videoDevices} = useMediaDevices({autoRefresh: true, requestPermissions: false});
|
||||
const granular = VoiceSettings.getLinuxAudioCaptureGranularSelect();
|
||||
const deviceSelect = VoiceSettings.getLinuxAudioCaptureDeviceSelect();
|
||||
const ignoreVirtual = VoiceSettings.getLinuxAudioCaptureIgnoreVirtual();
|
||||
const [snapshot, setSnapshot] = useState<AudioSourceSnapshot>(EMPTY_SNAPSHOT);
|
||||
const routesSelectedSources = sourceMode === 'specific' && filterRoutableLinuxAudioSources(includeSources).length > 0;
|
||||
const widenedScope = offersWindowScope ? ('system' as const) : undefined;
|
||||
const refresh = useCallback(() => {
|
||||
setSnapshot((prev) => ({...prev, loading: true}));
|
||||
@@ -218,12 +220,17 @@ export const AudioSourcePickerLinuxSubmenu = observer((props: AudioSourcePickerL
|
||||
includeSources,
|
||||
shareContext,
|
||||
microphoneLabel,
|
||||
chosenAudioDeviceId: VoiceSettings.getScreenShareAudioDeviceId(),
|
||||
deviceAudioPairing: resolveDeviceShareAudioPairing(
|
||||
[...videoDevices, ...inputDevices],
|
||||
MediaEngine.getActiveScreenShareVideoDeviceId(),
|
||||
),
|
||||
displayShareEnvironment,
|
||||
windowAudioScope,
|
||||
usesDeviceMicrophone,
|
||||
});
|
||||
const showsWideSourceLists = !offersWindowScope || resolvedScope === 'system';
|
||||
const wideSourceIsSelected = isDeviceShare ? usesDeviceMicrophone || !routesSelectedSources : sourceMode === 'system';
|
||||
const wideSourceIsSelected = isDeviceShare ? usesDeviceMicrophone : sourceMode === 'system';
|
||||
if (!snapshot.available && !snapshot.loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {LiveBadge} from '@app/features/ui/components/LiveBadge';
|
||||
import type {TooltipPosition} from '@app/features/ui/tooltip/Tooltip';
|
||||
import styles from '@app/features/voice/components/StreamInfoPill.module.css';
|
||||
import type {StreamTrackInfo} from '@app/features/voice/components/useStreamTrackInfo';
|
||||
import type {ScreenShareTarget} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {formatScreenShareTargetLabel} from '@app/features/voice/utils/VoiceMessageDescriptors';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {clsx} from 'clsx';
|
||||
@@ -19,8 +21,19 @@ type ResolutionHeight = 240 | 480 | 720 | 1080 | 1440 | 2160;
|
||||
|
||||
const RESOLUTION_HEIGHTS: Array<ResolutionHeight> = [480, 240, 720, 1080, 1440, 2160];
|
||||
|
||||
const RESOLUTION_LABELS: Record<ResolutionHeight, string> = {
|
||||
240: '240p',
|
||||
480: '480p',
|
||||
720: '720p',
|
||||
1080: '1080p',
|
||||
1440: '1440p',
|
||||
2160: '4K',
|
||||
};
|
||||
|
||||
type StreamInfoPillTone = 'default' | 'voice_tile';
|
||||
|
||||
export type StreamInfoPillQuality = StreamTrackInfo | {target: ScreenShareTarget};
|
||||
|
||||
function getClosestResolutionHeight(height: number) {
|
||||
let closest: ResolutionHeight = RESOLUTION_HEIGHTS[0];
|
||||
let smallestDiff = Math.abs(height - closest);
|
||||
@@ -35,7 +48,7 @@ function getClosestResolutionHeight(height: number) {
|
||||
}
|
||||
|
||||
interface StreamInfoPillProps {
|
||||
info: StreamTrackInfo;
|
||||
info: StreamInfoPillQuality;
|
||||
className?: string;
|
||||
showLiveBadge?: boolean;
|
||||
tone?: StreamInfoPillTone;
|
||||
@@ -50,29 +63,17 @@ export function StreamInfoPill({
|
||||
liveBadgeTooltipPosition,
|
||||
}: StreamInfoPillProps) {
|
||||
const {i18n} = useLingui();
|
||||
const resolutionText = useMemo(() => {
|
||||
const targetHeight = getClosestResolutionHeight(info.height);
|
||||
switch (targetHeight) {
|
||||
case 240:
|
||||
return '240p';
|
||||
case 480:
|
||||
return '480p';
|
||||
case 720:
|
||||
return '720p';
|
||||
case 1080:
|
||||
return '1080p';
|
||||
case 1440:
|
||||
return '1440p';
|
||||
case 2160:
|
||||
return '4K';
|
||||
default:
|
||||
return '720p';
|
||||
}
|
||||
}, [info.height]);
|
||||
const labelText = useMemo(() => {
|
||||
if ('target' in info) {
|
||||
return i18n._(RESOLUTION_WITH_FPS_DESCRIPTOR, {
|
||||
resolution: formatScreenShareTargetLabel(i18n, info.target),
|
||||
fps: i18n.number(info.target.frameRate),
|
||||
});
|
||||
}
|
||||
const resolutionText = RESOLUTION_LABELS[getClosestResolutionHeight(info.height)];
|
||||
if (!Number.isFinite(info.fps) || info.fps <= 0) return resolutionText;
|
||||
return i18n._(RESOLUTION_WITH_FPS_DESCRIPTOR, {resolution: resolutionText, fps: i18n.number(info.fps)});
|
||||
}, [info.fps, i18n.locale, resolutionText]);
|
||||
}, [info, i18n.locale]);
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.container, tone === 'voice_tile' && styles.containerOnTile, className)}
|
||||
|
||||
@@ -13,16 +13,21 @@ import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettin
|
||||
import {AudioSourcePickerLinuxSubmenu} from '@app/features/voice/components/AudioSourcePickerLinux';
|
||||
import styles from '@app/features/voice/components/StreamSettingsMenuContent.module.css';
|
||||
import {
|
||||
CAPTURE_DEVICES_USE_THE_GAMING_PRESET_DESCRIPTOR,
|
||||
type OfferedScreenShareResolution,
|
||||
type StreamSettingsAudioMenuViewState,
|
||||
type StreamSettingsQualityWrite,
|
||||
selectStreamSettingsAudioMenuState,
|
||||
selectStreamSettingsPresetOverriddenByContext,
|
||||
selectStreamSettingsQualityMenuState,
|
||||
} from '@app/features/voice/components/StreamSettingsMenuContentStateMachine';
|
||||
import MediaEngine, {useMediaEngineVersion} from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import ScreenShareCodecNegotiation from '@app/features/voice/engine/ScreenShareCodecNegotiation';
|
||||
import {VoiceTrackSource} from '@app/features/voice/engine/VoiceTrackSource';
|
||||
import {resolveConfiguredScreenShareTarget} from '@app/features/voice/engine/voice_screen_share_manager/shared';
|
||||
import {useMediaDevices} from '@app/features/voice/hooks/useMediaDevices';
|
||||
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
||||
import VoiceSettings, {type ScreenshareResolution, type StreamingMode} from '@app/features/voice/state/VoiceSettings';
|
||||
import {resolveScreenShareContentHintForContext} from '@app/features/voice/utils/CodecCapabilityDetector';
|
||||
import VoiceSettings, {type StreamingMode} from '@app/features/voice/state/VoiceSettings';
|
||||
import {filterRoutableLinuxAudioSources} from '@app/features/voice/utils/LinuxAudioSourceRules';
|
||||
import {
|
||||
getNativeAudioAvailabilityCached,
|
||||
@@ -34,20 +39,19 @@ import {
|
||||
usesNativeDisplayShareAudioSelection,
|
||||
} from '@app/features/voice/utils/ScreenShareEnvironment';
|
||||
import {
|
||||
buildScreenShareOptions,
|
||||
normaliseResolutionForContext,
|
||||
normaliseStreamingModeForContext,
|
||||
resolveScreenShareFrameRate,
|
||||
resolveStreamingModeSettings,
|
||||
type ScreenShareContext,
|
||||
type SupportedScreenShareFrameRate,
|
||||
resolveScreenShareTarget,
|
||||
type ScreenShareQualityInput,
|
||||
type ScreenShareTarget,
|
||||
} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {isScreenShareRollbackIncompleteError} from '@app/features/voice/utils/ScreenShareRollbackIncompleteError';
|
||||
import {
|
||||
applyLiveScreenShareAudioSourceChange,
|
||||
buildConfiguredScreenShareOptions,
|
||||
reconfigureActiveDeviceShareAudio,
|
||||
reconfigureActiveLinuxAppShareAudio,
|
||||
reconfigureActiveLinuxScreenShareAudioLink,
|
||||
scheduleConfiguredScreenShareMutation,
|
||||
stopActiveLinuxScreenShareAudioLink,
|
||||
} from '@app/features/voice/utils/ScreenShareStartFlow';
|
||||
import {executeScreenShareOperation, handleScreenShareError} from '@app/features/voice/utils/ScreenShareUtils';
|
||||
@@ -60,9 +64,10 @@ import {
|
||||
import {hasHigherVideoQuality as resolveHigherVideoQuality} from '@app/features/voice/utils/VideoQualityEntitlement';
|
||||
import {formatVoiceAudioDeviceLabel} from '@app/features/voice/utils/VoiceMessageDescriptors';
|
||||
import type {NativeAudioAvailability} from '@app/types/electron.d';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {CrownSimpleIcon, MicrophoneIcon} from '@phosphor-icons/react';
|
||||
import {CrownSimpleIcon, MicrophoneIcon, WaveformIcon} from '@phosphor-icons/react';
|
||||
import type {Track} from 'livekit-client';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
@@ -71,26 +76,26 @@ const GAMING_DESCRIPTOR = msg({
|
||||
message: 'Gaming',
|
||||
comment: 'Streaming preset label in the stream settings menu. Higher frame rate, optimized for video games.',
|
||||
});
|
||||
const FLUID_MOTION_AT_1440P_60_FPS_DESCRIPTOR = msg({
|
||||
message: 'Fluid motion at 1440p 60 FPS',
|
||||
const SMOOTH_60_FPS_AT_UP_TO_1440P_DESCRIPTOR = msg({
|
||||
message: 'Smooth 60 FPS at up to 1440p',
|
||||
comment:
|
||||
'Description for the high-tier Gaming streaming preset (Plutonium). Resolution and frame rate are technical tokens.',
|
||||
});
|
||||
const FLUID_MOTION_AT_720P_30_FPS_DESCRIPTOR = msg({
|
||||
message: 'Fluid motion at 720p 30 FPS',
|
||||
const SMOOTH_30_FPS_AT_UP_TO_720P_DESCRIPTOR = msg({
|
||||
message: 'Smooth 30 FPS at up to 720p',
|
||||
comment: 'Description for the free-tier Gaming streaming preset. Resolution and frame rate are technical tokens.',
|
||||
});
|
||||
const SCREENSHARE_DESCRIPTOR = msg({
|
||||
message: 'Screen share',
|
||||
comment: 'Streaming preset label in the stream settings menu. Optimized for sharp text in screen shares.',
|
||||
});
|
||||
const RAZOR_SHARP_TEXT_AT_NATIVE_SOURCE_15_FPS_DESCRIPTOR = msg({
|
||||
message: 'Razor-sharp text at native source resolution, 15 FPS',
|
||||
const SHARP_TEXT_AT_UP_TO_4K_DESCRIPTOR = msg({
|
||||
message: 'Sharp text at up to 4K, up to 15 FPS',
|
||||
comment:
|
||||
'Description for the high-tier Screen share streaming preset (Plutonium). Source resolution and frame rate are technical tokens.',
|
||||
'Description for the high-tier Screen share streaming preset (Plutonium). Resolution and frame rate are technical tokens.',
|
||||
});
|
||||
const SHARPER_TEXT_AT_720P_30_FPS_DESCRIPTOR = msg({
|
||||
message: 'Sharper text at 720p, 30 FPS',
|
||||
const SHARP_TEXT_AT_720P_DESCRIPTOR = msg({
|
||||
message: 'Sharp text at 720p, up to 30 FPS',
|
||||
comment:
|
||||
'Description for the free-tier Screen share streaming preset. Resolution and frame rate are technical tokens.',
|
||||
});
|
||||
@@ -106,18 +111,16 @@ const SOURCE_DESCRIPTOR = msg({
|
||||
message: 'Source',
|
||||
comment: 'Resolution option in the stream settings menu meaning the original source resolution (no downscale).',
|
||||
});
|
||||
const MESSAGE_15_FPS_DESCRIPTOR = msg({
|
||||
message: '15 FPS',
|
||||
comment: 'Frame rate option label in the stream settings menu. FPS is a technical token.',
|
||||
});
|
||||
const MESSAGE_30_FPS_DESCRIPTOR = msg({
|
||||
message: '30 FPS',
|
||||
comment: 'Frame rate option label in the stream settings menu. FPS is a technical token.',
|
||||
});
|
||||
const MESSAGE_60_FPS_DESCRIPTOR = msg({
|
||||
message: '60 FPS',
|
||||
comment: 'Frame rate option label in the stream settings menu. FPS is a technical token.',
|
||||
const FPS_DESCRIPTOR = msg({
|
||||
message: '{frameRate} FPS',
|
||||
comment: 'Frame rate option label in the video tab. FPS is a technical token and frameRate is a whole number.',
|
||||
});
|
||||
const SCREEN_SHARE_RESOLUTION_LABELS: Record<Exclude<OfferedScreenShareResolution, 'source'>, string> = {
|
||||
low_480p: '480p',
|
||||
medium: '720p',
|
||||
high: '1080p',
|
||||
ultra: '1440p',
|
||||
};
|
||||
const STREAMING_MODE_DESCRIPTOR = msg({
|
||||
message: 'Streaming mode',
|
||||
comment: 'Section header in the stream settings menu. Picks between Gaming / Screen share / Custom presets.',
|
||||
@@ -191,6 +194,10 @@ export function useHasHigherVideoQuality(): boolean {
|
||||
return resolveHigherVideoQuality();
|
||||
}
|
||||
|
||||
function getScreenShareResolutionLabel(i18n: I18n, resolution: OfferedScreenShareResolution): string {
|
||||
return resolution === 'source' ? i18n._(SOURCE_DESCRIPTOR) : SCREEN_SHARE_RESOLUTION_LABELS[resolution];
|
||||
}
|
||||
|
||||
function supportsStreamAudioCapture(shareContext: StreamSettingsShareContext): boolean {
|
||||
if (shareContext === 'device') {
|
||||
return typeof navigator !== 'undefined' && Boolean(navigator.mediaDevices?.getUserMedia);
|
||||
@@ -198,45 +205,21 @@ function supportsStreamAudioCapture(shareContext: StreamSettingsShareContext): b
|
||||
return supportsDesktopScreenShareAudioCapture();
|
||||
}
|
||||
|
||||
function getScreenShareContext(shareContext: StreamSettingsShareContext): ScreenShareContext {
|
||||
return shareContext === 'device' ? 'device' : 'display';
|
||||
}
|
||||
|
||||
function getPreferredDisplaySurface(shareContext: StreamSettingsShareContext): 'window' | 'monitor' | undefined {
|
||||
if (shareContext === 'app') return 'window';
|
||||
if (shareContext === 'display') return 'monitor';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function pushActiveStreamSettings(
|
||||
async function runActiveStreamSettingsPush(
|
||||
shareContext: StreamSettingsShareContext,
|
||||
displayShareEnvironment: DisplayShareEnvironment,
|
||||
hasHigherVideoQuality: boolean,
|
||||
options: PushActiveStreamSettingsOptions = {},
|
||||
): Promise<void> {
|
||||
const mode = VoiceSettings.getStreamingMode();
|
||||
const screenShareContext = getScreenShareContext(shareContext);
|
||||
const normalisedMode = normaliseStreamingModeForContext(mode, screenShareContext);
|
||||
const normalisedResolution = normaliseResolutionForContext(
|
||||
VoiceSettings.getScreenshareResolution(),
|
||||
screenShareContext,
|
||||
hasHigherVideoQuality,
|
||||
);
|
||||
const {resolution, frameRate} = resolveStreamingModeSettings(
|
||||
normalisedMode,
|
||||
normalisedResolution,
|
||||
VoiceSettings.getVideoFrameRate(),
|
||||
hasHigherVideoQuality,
|
||||
);
|
||||
options: PushActiveStreamSettingsOptions,
|
||||
): Promise<boolean> {
|
||||
const sourceDimensions = ActiveScreenShareSource.getSourceDimensions();
|
||||
const target = resolveConfiguredScreenShareTarget(shareContext, sourceDimensions);
|
||||
ActiveScreenShareSource.setTarget(target);
|
||||
const preferredDisplaySurface = getPreferredDisplaySurface(shareContext);
|
||||
const preferredScreenShareCodecPreference = VoiceSettings.getPreferredScreenShareCodec();
|
||||
const preferredVideoCodec = ScreenShareCodecNegotiation.selectScreenShareCodec(preferredScreenShareCodecPreference);
|
||||
const contentHint = resolveScreenShareContentHintForContext(
|
||||
VoiceSettings.getScreenShareContentHintOverride(),
|
||||
preferredVideoCodec,
|
||||
shareContext,
|
||||
normalisedMode,
|
||||
);
|
||||
const canControlAudio = supportsStreamAudioCapture(shareContext);
|
||||
const includeAudio =
|
||||
canControlAudio &&
|
||||
@@ -245,16 +228,13 @@ export async function pushActiveStreamSettings(
|
||||
: shareContext === 'device'
|
||||
? VoiceSettings.getShareDeviceAudio()
|
||||
: VoiceSettings.getShareDesktopAudio());
|
||||
const {captureOptions, publishOptions} = buildScreenShareOptions({
|
||||
resolution,
|
||||
frameRate,
|
||||
const {captureOptions, publishOptions} = buildConfiguredScreenShareOptions({
|
||||
target,
|
||||
sourceDimensions,
|
||||
includeAudio,
|
||||
contentHint,
|
||||
videoCodec: ScreenShareCodecNegotiation.selectScreenShareCodec(VoiceSettings.getPreferredScreenShareCodec()),
|
||||
preferredDisplaySurface,
|
||||
});
|
||||
if (preferredScreenShareCodecPreference !== 'auto') {
|
||||
publishOptions.videoCodec = preferredVideoCodec;
|
||||
}
|
||||
const localParticipant = MediaEngine.room?.localParticipant ?? null;
|
||||
const hasActiveScreenShareAudioPublication = localParticipant?.getTrackPublication(SCREEN_SHARE_AUDIO_SOURCE) != null;
|
||||
const linuxDesktopAudioShare = isLinuxDesktopAudioShare({
|
||||
@@ -282,12 +262,13 @@ export async function pushActiveStreamSettings(
|
||||
} catch (error) {
|
||||
if (isScreenShareRollbackIncompleteError(error)) handleScreenShareError(error);
|
||||
logger.warn('Failed to restart active screen share with audio enabled', error);
|
||||
return false;
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
const activeCaptureOptions = canControlAudio
|
||||
? {...captureOptions, contentHint}
|
||||
: {contentHint, resolution: captureOptions.resolution};
|
||||
? {...captureOptions, contentHint: target.contentHint}
|
||||
: {contentHint: target.contentHint, resolution: captureOptions.resolution};
|
||||
if (
|
||||
shouldReconfigureAudioForActiveStreamSettings({
|
||||
platform: getElectronAPI()?.platform,
|
||||
@@ -318,12 +299,23 @@ export async function pushActiveStreamSettings(
|
||||
}
|
||||
}
|
||||
try {
|
||||
await MediaEngine.updateActiveScreenShareSettings(activeCaptureOptions, publishOptions);
|
||||
return await MediaEngine.updateActiveScreenShareSettings(activeCaptureOptions, publishOptions);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to push updated stream settings to the active share', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pushActiveStreamSettings(
|
||||
shareContext: StreamSettingsShareContext,
|
||||
displayShareEnvironment: DisplayShareEnvironment,
|
||||
options: PushActiveStreamSettingsOptions = {},
|
||||
): Promise<void> {
|
||||
await scheduleConfiguredScreenShareMutation(() =>
|
||||
runActiveStreamSettingsPush(shareContext, displayShareEnvironment, options),
|
||||
);
|
||||
}
|
||||
|
||||
interface StreamSettingsMenuContentProps {
|
||||
applyToLiveStream?: boolean;
|
||||
shareContext?: StreamSettingsShareContext;
|
||||
@@ -350,18 +342,19 @@ export const StreamSettingsMenuContent = observer(
|
||||
const currentMode = VoiceSettings.getStreamingMode();
|
||||
const currentResolution = VoiceSettings.getScreenshareResolution();
|
||||
const currentFrameRate = resolveScreenShareFrameRate(VoiceSettings.getVideoFrameRate());
|
||||
const effectiveMode = normaliseStreamingModeForContext(currentMode, getScreenShareContext(shareContext));
|
||||
const effectiveResolution = normaliseResolutionForContext(
|
||||
currentResolution,
|
||||
getScreenShareContext(shareContext),
|
||||
hasHigherVideoQuality,
|
||||
);
|
||||
const effectiveQuality = resolveStreamingModeSettings(
|
||||
effectiveMode,
|
||||
effectiveResolution,
|
||||
currentFrameRate,
|
||||
hasHigherVideoQuality,
|
||||
);
|
||||
const quality: ScreenShareQualityInput = {
|
||||
mode: currentMode,
|
||||
storedResolution: currentResolution,
|
||||
storedFrameRate: currentFrameRate,
|
||||
entitled: hasHigherVideoQuality,
|
||||
context: shareContext,
|
||||
};
|
||||
const target: ScreenShareTarget = resolveScreenShareTarget({
|
||||
...quality,
|
||||
sourceDimensions: null,
|
||||
hintSetting: VoiceSettings.getScreenShareContentHint(),
|
||||
});
|
||||
const presetOverriddenByContext = selectStreamSettingsPresetOverriddenByContext(currentMode, shareContext);
|
||||
const captureAudioEnabled = isAppShare
|
||||
? VoiceSettings.getShareAppAudio()
|
||||
: isDeviceShare
|
||||
@@ -428,8 +421,8 @@ export const StreamSettingsMenuContent = observer(
|
||||
value: 'gaming',
|
||||
label: i18n._(GAMING_DESCRIPTOR),
|
||||
description: hasHigherVideoQuality
|
||||
? i18n._(FLUID_MOTION_AT_1440P_60_FPS_DESCRIPTOR)
|
||||
: i18n._(FLUID_MOTION_AT_720P_30_FPS_DESCRIPTOR),
|
||||
? i18n._(SMOOTH_60_FPS_AT_UP_TO_1440P_DESCRIPTOR)
|
||||
: i18n._(SMOOTH_30_FPS_AT_UP_TO_720P_DESCRIPTOR),
|
||||
isPremium: false,
|
||||
},
|
||||
];
|
||||
@@ -438,8 +431,8 @@ export const StreamSettingsMenuContent = observer(
|
||||
value: 'screenshare',
|
||||
label: i18n._(SCREENSHARE_DESCRIPTOR),
|
||||
description: hasHigherVideoQuality
|
||||
? i18n._(RAZOR_SHARP_TEXT_AT_NATIVE_SOURCE_15_FPS_DESCRIPTOR)
|
||||
: i18n._(SHARPER_TEXT_AT_720P_30_FPS_DESCRIPTOR),
|
||||
? i18n._(SHARP_TEXT_AT_UP_TO_4K_DESCRIPTOR)
|
||||
: i18n._(SHARP_TEXT_AT_720P_DESCRIPTOR),
|
||||
isPremium: false,
|
||||
});
|
||||
}
|
||||
@@ -451,32 +444,7 @@ export const StreamSettingsMenuContent = observer(
|
||||
});
|
||||
return modes;
|
||||
}, [isDeviceShare, hasHigherVideoQuality, i18n.locale]);
|
||||
const resolutionOptions: Array<Option<ScreenshareResolution>> = useMemo(() => {
|
||||
const options: Array<Option<ScreenshareResolution>> = [
|
||||
{value: 'low_480p', label: '480p', isPremium: false},
|
||||
{value: 'medium', label: '720p', isPremium: false},
|
||||
];
|
||||
if (hasHigherVideoQuality || showPremiumFeatures) {
|
||||
options.push(
|
||||
{value: 'high', label: '1080p', isPremium: true},
|
||||
{value: 'ultra', label: '1440p', isPremium: true},
|
||||
);
|
||||
}
|
||||
if (!isDeviceShare && (hasHigherVideoQuality || showPremiumFeatures)) {
|
||||
options.push({value: 'source', label: i18n._(SOURCE_DESCRIPTOR), isPremium: true});
|
||||
}
|
||||
return options;
|
||||
}, [hasHigherVideoQuality, isDeviceShare, showPremiumFeatures, i18n.locale]);
|
||||
const frameRateOptions: Array<Option<SupportedScreenShareFrameRate>> = useMemo(
|
||||
() => [
|
||||
{value: 15, label: i18n._(MESSAGE_15_FPS_DESCRIPTOR), isPremium: false},
|
||||
{value: 30, label: i18n._(MESSAGE_30_FPS_DESCRIPTOR), isPremium: false},
|
||||
...(hasHigherVideoQuality || showPremiumFeatures
|
||||
? [{value: 60 as const, label: i18n._(MESSAGE_60_FPS_DESCRIPTOR), isPremium: true}]
|
||||
: []),
|
||||
],
|
||||
[hasHigherVideoQuality, showPremiumFeatures, i18n.locale],
|
||||
);
|
||||
const qualityMenuState = selectStreamSettingsQualityMenuState({quality, target, showPremiumFeatures});
|
||||
const audioDeviceOptions = useMemo(() => {
|
||||
const real = inputDevices.filter((d) => d.deviceId && d.deviceId !== 'default');
|
||||
return real;
|
||||
@@ -485,10 +453,22 @@ export const StreamSettingsMenuContent = observer(
|
||||
(options?: PushActiveStreamSettingsOptions) => {
|
||||
if (!applyToLiveStream) return;
|
||||
void executeScreenShareOperation(() =>
|
||||
pushActiveStreamSettings(shareContext, displayShareEnvironment, hasHigherVideoQuality, options),
|
||||
pushActiveStreamSettings(shareContext, displayShareEnvironment, options),
|
||||
).catch(() => undefined);
|
||||
},
|
||||
[applyToLiveStream, displayShareEnvironment, hasHigherVideoQuality, shareContext],
|
||||
[applyToLiveStream, displayShareEnvironment, shareContext],
|
||||
);
|
||||
const applyQualityWrite = useCallback(
|
||||
(write: StreamSettingsQualityWrite) => {
|
||||
if (write.kind === 'premium') {
|
||||
PremiumModalCommands.open();
|
||||
return;
|
||||
}
|
||||
if (write.kind === 'none') return;
|
||||
VoiceSettingsCommands.update(write.patch);
|
||||
runApply();
|
||||
},
|
||||
[runApply],
|
||||
);
|
||||
const applyAudioSourceChange = useCallback(
|
||||
(nextWindowAudioScope?: WindowShareAudioScope) => {
|
||||
@@ -520,39 +500,11 @@ export const StreamSettingsMenuContent = observer(
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (currentMode === option.value) return;
|
||||
if (target.mode === option.value) return;
|
||||
VoiceSettingsCommands.update({streamingMode: option.value});
|
||||
runApply();
|
||||
},
|
||||
[currentMode, hasHigherVideoQuality, runApply, showPremiumFeatures],
|
||||
);
|
||||
const handleResolutionSelect = useCallback(
|
||||
(option: Option<ScreenshareResolution>) => {
|
||||
if (option.isPremium && !hasHigherVideoQuality) {
|
||||
if (showPremiumFeatures) {
|
||||
PremiumModalCommands.open();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (currentResolution === option.value) return;
|
||||
VoiceSettingsCommands.update({screenshareResolution: option.value});
|
||||
runApply();
|
||||
},
|
||||
[currentResolution, hasHigherVideoQuality, runApply, showPremiumFeatures],
|
||||
);
|
||||
const handleFrameRateSelect = useCallback(
|
||||
(option: Option<SupportedScreenShareFrameRate>) => {
|
||||
if (option.isPremium && !hasHigherVideoQuality) {
|
||||
if (showPremiumFeatures) {
|
||||
PremiumModalCommands.open();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (currentFrameRate === option.value) return;
|
||||
VoiceSettingsCommands.update({videoFrameRate: option.value});
|
||||
runApply();
|
||||
},
|
||||
[currentFrameRate, hasHigherVideoQuality, runApply, showPremiumFeatures],
|
||||
[hasHigherVideoQuality, runApply, showPremiumFeatures, target.mode],
|
||||
);
|
||||
const handleCaptureAudioToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
@@ -592,36 +544,6 @@ export const StreamSettingsMenuContent = observer(
|
||||
? formatVoiceAudioDeviceLabel(i18n, selectedAudioDevice, i18n._(UNNAMED_INPUT_DESCRIPTOR))
|
||||
: i18n._(SYSTEM_DEFAULT_DESCRIPTOR);
|
||||
if (variant === 'compactLive') {
|
||||
const presetQuality =
|
||||
currentMode === 'custom'
|
||||
? null
|
||||
: resolveStreamingModeSettings(effectiveMode, currentResolution, currentFrameRate, true);
|
||||
const selectCompactResolution = (option: Option<ScreenshareResolution>) => {
|
||||
if (option.isPremium && !hasHigherVideoQuality) {
|
||||
if (showPremiumFeatures) PremiumModalCommands.open();
|
||||
return;
|
||||
}
|
||||
if (currentMode === 'custom' && effectiveQuality.resolution === option.value) return;
|
||||
VoiceSettingsCommands.update({
|
||||
streamingMode: 'custom',
|
||||
screenshareResolution: option.value,
|
||||
...(presetQuality ? {videoFrameRate: presetQuality.frameRate} : {}),
|
||||
});
|
||||
runApply();
|
||||
};
|
||||
const selectCompactFrameRate = (option: Option<SupportedScreenShareFrameRate>) => {
|
||||
if (option.isPremium && !hasHigherVideoQuality) {
|
||||
if (showPremiumFeatures) PremiumModalCommands.open();
|
||||
return;
|
||||
}
|
||||
if (currentMode === 'custom' && effectiveQuality.frameRate === option.value) return;
|
||||
VoiceSettingsCommands.update({
|
||||
streamingMode: 'custom',
|
||||
...(presetQuality ? {screenshareResolution: presetQuality.resolution} : {}),
|
||||
videoFrameRate: option.value,
|
||||
});
|
||||
runApply();
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<MenuItemSubmenu
|
||||
@@ -635,13 +557,13 @@ export const StreamSettingsMenuContent = observer(
|
||||
>
|
||||
{i18n._(FRAMERATE_DESCRIPTOR)}
|
||||
</MenuGroupLabel>
|
||||
{frameRateOptions.map((option) => {
|
||||
const isPlutoniumReserved = showPremiumFeatures && option.isPremium;
|
||||
{qualityMenuState.frameRates.map((option) => {
|
||||
const isPlutoniumReserved = showPremiumFeatures && option.premium;
|
||||
return (
|
||||
<MenuItemRadio
|
||||
key={option.value}
|
||||
selected={effectiveQuality.frameRate === option.value}
|
||||
onSelect={() => selectCompactFrameRate(option)}
|
||||
selected={option.selected}
|
||||
onSelect={() => applyQualityWrite(option.write)}
|
||||
data-flx="voice.stream-settings-menu-content.compact-live.frame-rate-option"
|
||||
>
|
||||
<span
|
||||
@@ -652,7 +574,7 @@ export const StreamSettingsMenuContent = observer(
|
||||
className={styles.rowLabel}
|
||||
data-flx="voice.stream-settings-menu-content.compact-live.frame-rate-row-label"
|
||||
>
|
||||
{option.label}
|
||||
{i18n._(FPS_DESCRIPTOR, {frameRate: option.value})}
|
||||
</span>
|
||||
{isPlutoniumReserved && (
|
||||
<PremiumBadge data-flx="voice.stream-settings-menu-content.compact-live.frame-rate-premium-badge" />
|
||||
@@ -669,13 +591,13 @@ export const StreamSettingsMenuContent = observer(
|
||||
>
|
||||
{i18n._(RESOLUTION_DESCRIPTOR)}
|
||||
</MenuGroupLabel>
|
||||
{resolutionOptions.map((option) => {
|
||||
const isPlutoniumReserved = showPremiumFeatures && option.isPremium;
|
||||
{qualityMenuState.resolutions.map((option) => {
|
||||
const isPlutoniumReserved = showPremiumFeatures && option.premium;
|
||||
return (
|
||||
<MenuItemRadio
|
||||
key={option.value}
|
||||
selected={effectiveQuality.resolution === option.value}
|
||||
onSelect={() => selectCompactResolution(option)}
|
||||
selected={option.selected}
|
||||
onSelect={() => applyQualityWrite(option.write)}
|
||||
data-flx="voice.stream-settings-menu-content.compact-live.resolution-option"
|
||||
>
|
||||
<span
|
||||
@@ -686,7 +608,7 @@ export const StreamSettingsMenuContent = observer(
|
||||
className={styles.rowLabel}
|
||||
data-flx="voice.stream-settings-menu-content.compact-live.resolution-row-label"
|
||||
>
|
||||
{option.label}
|
||||
{getScreenShareResolutionLabel(i18n, option.value)}
|
||||
</span>
|
||||
{isPlutoniumReserved && (
|
||||
<PremiumBadge data-flx="voice.stream-settings-menu-content.compact-live.resolution-premium-badge" />
|
||||
@@ -728,7 +650,7 @@ export const StreamSettingsMenuContent = observer(
|
||||
return (
|
||||
<MenuItemRadio
|
||||
key={option.value}
|
||||
selected={currentMode === option.value}
|
||||
selected={target.mode === option.value}
|
||||
onSelect={() => handleModeSelect(option)}
|
||||
data-flx="voice.stream-settings-menu-content.menu-item-radio.mode-select"
|
||||
>
|
||||
@@ -748,6 +670,14 @@ export const StreamSettingsMenuContent = observer(
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
{presetOverriddenByContext && option.value === target.mode && (
|
||||
<span
|
||||
className={styles.modeDescription}
|
||||
data-flx="voice.stream-settings-menu-content.mode-context-note"
|
||||
>
|
||||
{i18n._(CAPTURE_DEVICES_USE_THE_GAMING_PRESET_DESCRIPTOR)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{isPlutoniumReserved && <PremiumBadge data-flx="voice.stream-settings-menu-content.premium-badge" />}
|
||||
</span>
|
||||
@@ -761,18 +691,18 @@ export const StreamSettingsMenuContent = observer(
|
||||
label={i18n._(RESOLUTION_DESCRIPTOR)}
|
||||
render={() => (
|
||||
<MenuGroup data-flx="voice.stream-settings-menu-content.menu-group--3">
|
||||
{resolutionOptions.map((option) => {
|
||||
const isPlutoniumReserved = showPremiumFeatures && option.isPremium;
|
||||
{qualityMenuState.resolutions.map((option) => {
|
||||
const isPlutoniumReserved = showPremiumFeatures && option.premium;
|
||||
return (
|
||||
<MenuItemRadio
|
||||
key={option.value}
|
||||
selected={currentResolution === option.value}
|
||||
onSelect={() => handleResolutionSelect(option)}
|
||||
selected={option.selected}
|
||||
onSelect={() => applyQualityWrite(option.write)}
|
||||
data-flx="voice.stream-settings-menu-content.menu-item-radio.resolution-select"
|
||||
>
|
||||
<span className={styles.row} data-flx="voice.stream-settings-menu-content.row">
|
||||
<span className={styles.rowLabel} data-flx="voice.stream-settings-menu-content.row-label">
|
||||
{option.label}
|
||||
{getScreenShareResolutionLabel(i18n, option.value)}
|
||||
</span>
|
||||
{isPlutoniumReserved && (
|
||||
<PremiumBadge data-flx="voice.stream-settings-menu-content.premium-badge--2" />
|
||||
@@ -789,18 +719,18 @@ export const StreamSettingsMenuContent = observer(
|
||||
label={i18n._(FRAMERATE_DESCRIPTOR)}
|
||||
render={() => (
|
||||
<MenuGroup data-flx="voice.stream-settings-menu-content.menu-group--4">
|
||||
{frameRateOptions.map((option) => {
|
||||
const isPlutoniumReserved = showPremiumFeatures && option.isPremium;
|
||||
{qualityMenuState.frameRates.map((option) => {
|
||||
const isPlutoniumReserved = showPremiumFeatures && option.premium;
|
||||
return (
|
||||
<MenuItemRadio
|
||||
key={option.value}
|
||||
selected={currentFrameRate === option.value}
|
||||
onSelect={() => handleFrameRateSelect(option)}
|
||||
selected={option.selected}
|
||||
onSelect={() => applyQualityWrite(option.write)}
|
||||
data-flx="voice.stream-settings-menu-content.menu-item-radio.frame-rate-select"
|
||||
>
|
||||
<span className={styles.row} data-flx="voice.stream-settings-menu-content.row--2">
|
||||
<span className={styles.rowLabel} data-flx="voice.stream-settings-menu-content.row-label--2">
|
||||
{option.label}
|
||||
{i18n._(FPS_DESCRIPTOR, {frameRate: option.value})}
|
||||
</span>
|
||||
{isPlutoniumReserved && (
|
||||
<PremiumBadge data-flx="voice.stream-settings-menu-content.premium-badge--3" />
|
||||
@@ -919,7 +849,7 @@ const StreamSettingsAudioGroup = observer((props: StreamSettingsAudioGroupProps)
|
||||
data-flx="voice.stream-settings-menu-content.audio-group.audio-device-default"
|
||||
>
|
||||
<span className={styles.row} data-flx="voice.stream-settings-menu-content.audio-group.audio-device-row">
|
||||
<MicrophoneIcon
|
||||
<WaveformIcon
|
||||
className={styles.audioDeviceIcon}
|
||||
weight="fill"
|
||||
aria-hidden={true}
|
||||
@@ -933,13 +863,13 @@ const StreamSettingsAudioGroup = observer((props: StreamSettingsAudioGroupProps)
|
||||
className={styles.audioDeviceName}
|
||||
data-flx="voice.stream-settings-menu-content.audio-group.audio-device-name"
|
||||
>
|
||||
<Trans>Follow voice input</Trans>
|
||||
<Trans>Device audio only</Trans>
|
||||
</span>
|
||||
<span
|
||||
className={styles.audioDeviceSubtext}
|
||||
data-flx="voice.stream-settings-menu-content.audio-group.audio-device-subtext"
|
||||
>
|
||||
{selectedAudioDeviceLabel}
|
||||
<Trans>Silent when the capture device has no audio input of its own</Trans>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ScreenshareResolution, StreamingMode} from '@app/features/voice/state/VoiceSettings';
|
||||
import {
|
||||
canRestartDisplayShareWithoutPreselectedSource,
|
||||
type DisplayShareEnvironment,
|
||||
prestartAudioToggleIsPickerOwned,
|
||||
} from '@app/features/voice/utils/ScreenShareEnvironment';
|
||||
import {
|
||||
normaliseResolutionForContext,
|
||||
normaliseStreamingModeForContext,
|
||||
resolveScreenShareQualityPick,
|
||||
type ScreenShareContext,
|
||||
type ScreenShareQualityInput,
|
||||
type ScreenShareQualityPatch,
|
||||
type ScreenShareQualityPick,
|
||||
type ScreenShareTarget,
|
||||
SUPPORTED_SCREEN_SHARE_FRAME_RATES,
|
||||
type SupportedScreenShareFrameRate,
|
||||
} from '@app/features/voice/utils/ScreenShareOptions';
|
||||
import {
|
||||
canSelectManualAudioSources,
|
||||
manualAudioSourcesGovernShare,
|
||||
@@ -15,8 +28,15 @@ import {
|
||||
type WindowShareAudioScope,
|
||||
} from '@app/features/voice/utils/StreamSettingsUpdatePolicy';
|
||||
import type {NativeAudioAvailability} from '@app/types/electron.d';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {initialTransition, setup, transition} from 'xstate';
|
||||
|
||||
export const CAPTURE_DEVICES_USE_THE_GAMING_PRESET_DESCRIPTOR = msg({
|
||||
message: 'Capture devices use the Gaming preset.',
|
||||
comment:
|
||||
'Note shown beside the stored Screen share preset in the stream settings menu and the video settings tab of a capture device share, explaining why that preset is not used.',
|
||||
});
|
||||
|
||||
export type StreamSettingsAudioControlStateValue =
|
||||
| 'hidden'
|
||||
| 'unsupported'
|
||||
@@ -187,3 +207,123 @@ export function selectStreamSettingsAudioMenuState(
|
||||
showDeviceAudioMenu: signals.shareContext === 'device' && signals.captureAudioEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
export type StreamSettingsQualityWrite =
|
||||
| {kind: 'none'}
|
||||
| {kind: 'premium'}
|
||||
| {kind: 'write'; patch: ScreenShareQualityPatch};
|
||||
|
||||
export interface StreamSettingsQualitySignals {
|
||||
quality: ScreenShareQualityInput;
|
||||
pick: ScreenShareQualityPick;
|
||||
premiumOption: boolean;
|
||||
showPremiumFeatures: boolean;
|
||||
}
|
||||
|
||||
export function selectStreamSettingsQualityWrite(signals: StreamSettingsQualitySignals): StreamSettingsQualityWrite {
|
||||
if (signals.premiumOption && !signals.quality.entitled) {
|
||||
return signals.showPremiumFeatures ? {kind: 'premium'} : {kind: 'none'};
|
||||
}
|
||||
const patch = resolveScreenShareQualityPick(signals.quality, signals.pick);
|
||||
return patch === null ? {kind: 'none'} : {kind: 'write', patch};
|
||||
}
|
||||
|
||||
export const OFFERED_SCREEN_SHARE_RESOLUTIONS = [
|
||||
'low_480p',
|
||||
'medium',
|
||||
'high',
|
||||
'ultra',
|
||||
'source',
|
||||
] as const satisfies ReadonlyArray<ScreenshareResolution>;
|
||||
|
||||
export type OfferedScreenShareResolution = (typeof OFFERED_SCREEN_SHARE_RESOLUTIONS)[number];
|
||||
|
||||
export function offeredScreenShareResolution(resolution: ScreenshareResolution): OfferedScreenShareResolution {
|
||||
return resolution === 'low_240p' ? 'low_480p' : resolution;
|
||||
}
|
||||
|
||||
const STREAM_SETTINGS_FREE_RESOLUTIONS: ReadonlyArray<OfferedScreenShareResolution> = ['low_480p', 'medium'];
|
||||
const STREAM_SETTINGS_PREMIUM_RESOLUTIONS: ReadonlyArray<OfferedScreenShareResolution> = ['high', 'ultra', 'source'];
|
||||
const STREAM_SETTINGS_FREE_FRAME_RATES: ReadonlyArray<SupportedScreenShareFrameRate> = [15, 30];
|
||||
const STREAM_SETTINGS_PREMIUM_FRAME_RATES: ReadonlyArray<SupportedScreenShareFrameRate> = [60];
|
||||
|
||||
export interface StreamSettingsQualityOption<T> {
|
||||
value: T;
|
||||
premium: boolean;
|
||||
selected: boolean;
|
||||
write: StreamSettingsQualityWrite;
|
||||
}
|
||||
|
||||
export interface StreamSettingsQualityMenuViewState {
|
||||
resolutions: Array<StreamSettingsQualityOption<OfferedScreenShareResolution>>;
|
||||
frameRates: Array<StreamSettingsQualityOption<SupportedScreenShareFrameRate>>;
|
||||
}
|
||||
|
||||
export interface StreamSettingsQualityMenuSignals {
|
||||
quality: ScreenShareQualityInput;
|
||||
target: ScreenShareTarget;
|
||||
showPremiumFeatures: boolean;
|
||||
}
|
||||
|
||||
export function contextAllowsScreenShareResolution(
|
||||
resolution: OfferedScreenShareResolution,
|
||||
context: ScreenShareContext,
|
||||
): boolean {
|
||||
return normaliseResolutionForContext(resolution, context, true) === resolution;
|
||||
}
|
||||
|
||||
export function selectStreamSettingsQualityMenuState(
|
||||
signals: StreamSettingsQualityMenuSignals,
|
||||
): StreamSettingsQualityMenuViewState {
|
||||
const offersPremium = signals.quality.entitled || signals.showPremiumFeatures;
|
||||
const selectedResolution = offeredScreenShareResolution(signals.target.resolution);
|
||||
const buildOption = <T>(
|
||||
value: T,
|
||||
premium: boolean,
|
||||
selected: boolean,
|
||||
pick: ScreenShareQualityPick,
|
||||
): StreamSettingsQualityOption<T> => ({
|
||||
value,
|
||||
premium,
|
||||
selected,
|
||||
write: selectStreamSettingsQualityWrite({
|
||||
quality: signals.quality,
|
||||
pick,
|
||||
premiumOption: premium,
|
||||
showPremiumFeatures: signals.showPremiumFeatures,
|
||||
}),
|
||||
});
|
||||
return {
|
||||
resolutions: OFFERED_SCREEN_SHARE_RESOLUTIONS.filter(
|
||||
(value) =>
|
||||
value === selectedResolution ||
|
||||
STREAM_SETTINGS_FREE_RESOLUTIONS.includes(value) ||
|
||||
(offersPremium &&
|
||||
STREAM_SETTINGS_PREMIUM_RESOLUTIONS.includes(value) &&
|
||||
contextAllowsScreenShareResolution(value, signals.quality.context)),
|
||||
).map((value) =>
|
||||
buildOption(value, STREAM_SETTINGS_PREMIUM_RESOLUTIONS.includes(value), value === selectedResolution, {
|
||||
axis: 'resolution',
|
||||
resolution: value,
|
||||
}),
|
||||
),
|
||||
frameRates: SUPPORTED_SCREEN_SHARE_FRAME_RATES.filter(
|
||||
(value) =>
|
||||
value === signals.target.frameRate ||
|
||||
STREAM_SETTINGS_FREE_FRAME_RATES.includes(value) ||
|
||||
(offersPremium && STREAM_SETTINGS_PREMIUM_FRAME_RATES.includes(value)),
|
||||
).map((value) =>
|
||||
buildOption(value, !STREAM_SETTINGS_FREE_FRAME_RATES.includes(value), value === signals.target.frameRate, {
|
||||
axis: 'frameRate',
|
||||
frameRate: value,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function selectStreamSettingsPresetOverriddenByContext(
|
||||
mode: StreamingMode,
|
||||
context: ScreenShareContext,
|
||||
): boolean {
|
||||
return normaliseStreamingModeForContext(mode, context) !== mode;
|
||||
}
|
||||
|
||||
@@ -220,12 +220,6 @@
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.streamUnderperformanceSlot {
|
||||
padding: 0 0.375rem;
|
||||
color: var(--status-warning);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.avatarFlexShrink {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -40,11 +40,10 @@ import {usePoppedOutTransition} from '@app/features/voice/components/popout/useP
|
||||
import {VoicePopoutScopeContext} from '@app/features/voice/components/popout/VoicePopoutScopeContext';
|
||||
import {ScreenShareBufferingFrame} from '@app/features/voice/components/ScreenShareBufferingFrame';
|
||||
import {registerStreamAudioPrefsTouch} from '@app/features/voice/components/StreamAudioPrefsTouchScheduler';
|
||||
import {StreamInfoPill} from '@app/features/voice/components/StreamInfoPill';
|
||||
import {StreamInfoPill, type StreamInfoPillQuality} from '@app/features/voice/components/StreamInfoPill';
|
||||
import {getStreamKey} from '@app/features/voice/components/StreamKeys';
|
||||
import {StreamSpectatorsPopout} from '@app/features/voice/components/StreamSpectatorsPopout';
|
||||
import {StreamWatchHoverCard} from '@app/features/voice/components/StreamWatchHoverCard';
|
||||
import {useScreenShareUnderperformance} from '@app/features/voice/components/useScreenShareUnderperformance';
|
||||
import {useScreenShareWatchFailure} from '@app/features/voice/components/useScreenShareWatchFailure';
|
||||
import {useStreamPreview} from '@app/features/voice/components/useStreamPreview';
|
||||
import {useStreamSpectators} from '@app/features/voice/components/useStreamSpectators';
|
||||
@@ -59,6 +58,7 @@ import {
|
||||
selectVoiceParticipantTileCameraActive,
|
||||
selectVoiceParticipantTileScreenShareState,
|
||||
shouldShowCameraBuffering,
|
||||
shouldShowTileControlPill,
|
||||
shouldShowTileStreamAudioControls,
|
||||
type VoiceParticipantTileScreenShareSignals,
|
||||
} from '@app/features/voice/components/VoiceParticipantTileStateMachine';
|
||||
@@ -83,7 +83,6 @@ import {
|
||||
CONNECTION_DESCRIPTOR,
|
||||
DESKTOP_DEVICE_DESCRIPTOR,
|
||||
getSourceDataAttr,
|
||||
getStreamUnderperformanceLabel,
|
||||
isCameraSource,
|
||||
logger,
|
||||
MOBILE_DEVICE_DESCRIPTOR,
|
||||
@@ -94,7 +93,6 @@ import {
|
||||
STREAM_BUFFERING_DESCRIPTOR,
|
||||
STREAM_ENDED_DESCRIPTOR,
|
||||
STREAM_HIDDEN_DESCRIPTOR,
|
||||
STREAM_NOT_KEEPING_UP_DESCRIPTOR,
|
||||
TILE_AVATAR_BASE,
|
||||
TILE_AVATAR_MEDIA_SIZE,
|
||||
TILE_AVATAR_STYLE,
|
||||
@@ -124,6 +122,7 @@ import {
|
||||
VoiceTrackSource,
|
||||
} from '@app/features/voice/engine/VoiceTrackSource';
|
||||
import {selectVoiceEngineV2AppEffectiveSelfMuteForVoiceStatePayload} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors';
|
||||
import ActiveScreenShareSource from '@app/features/voice/state/ActiveScreenShareSource';
|
||||
import CallMediaPrefs from '@app/features/voice/state/CallMediaPrefs';
|
||||
import LocalVoiceState from '@app/features/voice/state/LocalVoiceState';
|
||||
import PopoutWindowManager, {getVoiceTilePopoutKey} from '@app/features/voice/state/PopoutWindowManager';
|
||||
@@ -163,7 +162,6 @@ import {
|
||||
PauseIcon,
|
||||
SpeakerSlashIcon,
|
||||
VideoCameraSlashIcon,
|
||||
WarningIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import type {Participant, RemoteTrackPublication, Track} from 'livekit-client';
|
||||
@@ -349,9 +347,6 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
|
||||
const streamKey = useMemo(() => getStreamKey(guildId, channelId, connectionId), [guildId, channelId, connectionId]);
|
||||
const {viewerIds, viewerUsers, spectatorEntries} = useStreamSpectators(isScreenShare ? streamKey : '', userId);
|
||||
const hasSpectatorDemand = viewerIds.length > 0;
|
||||
const streamUnderperformanceReason = useScreenShareUnderperformance(
|
||||
isOwnScreenShare && !isFocusedPlaceholderTile && viewerUsers.length > 0,
|
||||
);
|
||||
const isCameraTile = isCameraSource(trackRef.source);
|
||||
const cameraLocallyDisabled = callId !== '' && isCameraTile && CallMediaPrefs.isVideoDisabled(callId, identity);
|
||||
const screenSharePublicationMigrationVersion = ScreenSharePublicationMigration.version;
|
||||
@@ -544,11 +539,17 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
|
||||
const {previewUrl, isPreviewLoading} = useStreamPreview(previewEnabled, streamKey);
|
||||
const isStreamPlaceholder = isScreenShare && !isTrackReference(trackRef);
|
||||
const screenShareTrackSid = publication?.trackSid ?? null;
|
||||
const trackInfo = useStreamTrackInfo(isScreenShare && !isFocusPresentationTile ? trackRef : null, {
|
||||
nativeSource: isScreenShare ? VoiceTrackSource.ScreenShare : null,
|
||||
nativeTrackSid: screenShareTrackSid,
|
||||
participantIdentity: identity,
|
||||
});
|
||||
const committedTarget = isOwnScreenShare && ActiveScreenShareSource.encoding ? ActiveScreenShareSource.target : null;
|
||||
const capturedTrackInfo = useStreamTrackInfo(
|
||||
isScreenShare && !isOwnScreenShare && !isFocusPresentationTile ? trackRef : null,
|
||||
{
|
||||
nativeSource: isScreenShare ? VoiceTrackSource.ScreenShare : null,
|
||||
nativeTrackSid: screenShareTrackSid,
|
||||
participantIdentity: identity,
|
||||
},
|
||||
);
|
||||
const ownTargetInfo: StreamInfoPillQuality | null = committedTarget === null ? null : {target: committedTarget};
|
||||
const trackInfo = isOwnScreenShare ? ownTargetInfo : capturedTrackInfo;
|
||||
const isPublicationDesired = publication?.isDesired ?? publication?.isSubscribed ?? false;
|
||||
useScreenShareWatchFailure({
|
||||
enabled:
|
||||
@@ -659,12 +660,13 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
|
||||
presentation,
|
||||
});
|
||||
const showTileSpectatorPill = !isFocusPresentationTile && isScreenShare && viewerUsers.length > 0;
|
||||
const showTileControlPill =
|
||||
!isFocusedPlaceholderTile &&
|
||||
(showStreamAudioControls ||
|
||||
showTileSpectatorPill ||
|
||||
(isGridTile && groupHiddenCount > 0) ||
|
||||
showDeviceCollapseControl);
|
||||
const showTileControlPill = shouldShowTileControlPill({
|
||||
isFocusedPlaceholderTile,
|
||||
showStreamAudioControls,
|
||||
showSpectatorPill: showTileSpectatorPill,
|
||||
showGroupHiddenPill: isGridTile && groupHiddenCount > 0,
|
||||
showDeviceCollapseControl,
|
||||
});
|
||||
const viewerStreamCount = graphViewerStreamKeys.length;
|
||||
const addStreamTooltipText = plural(
|
||||
{count: viewerStreamCount},
|
||||
@@ -1326,26 +1328,6 @@ const VoiceParticipantTileInner = observer(function VoiceParticipantTileInner({
|
||||
</FocusRing>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isOwnScreenShare && viewerUsers.length > 0 && streamUnderperformanceReason && (
|
||||
<Tooltip
|
||||
text={getStreamUnderperformanceLabel(i18n, streamUnderperformanceReason)}
|
||||
position="top"
|
||||
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tooltip"
|
||||
>
|
||||
<div
|
||||
className={clsx(voiceCallStyles.tileControlPillSlot, styles.streamUnderperformanceSlot)}
|
||||
role="img"
|
||||
aria-label={i18n._(STREAM_NOT_KEEPING_UP_DESCRIPTOR)}
|
||||
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.stream-underperformance-slot"
|
||||
>
|
||||
<WarningIcon
|
||||
weight="fill"
|
||||
className={styles.tilePillIcon}
|
||||
data-flx="voice.voice-participant-tile.voice-participant-tile-inner.tile-pill-icon"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showTileSpectatorPill && (
|
||||
<StreamSpectatorsPopout
|
||||
viewerUsers={viewerUsers}
|
||||
|
||||
@@ -41,6 +41,14 @@ export interface VoiceParticipantTileStreamAudioSignals {
|
||||
presentation: VoiceParticipantTilePresentation;
|
||||
}
|
||||
|
||||
export interface VoiceParticipantTileControlPillSignals {
|
||||
isFocusedPlaceholderTile: boolean;
|
||||
showStreamAudioControls: boolean;
|
||||
showSpectatorPill: boolean;
|
||||
showGroupHiddenPill: boolean;
|
||||
showDeviceCollapseControl: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceParticipantTileCameraActiveSignals {
|
||||
isCameraTile: boolean;
|
||||
isOwnContent: boolean;
|
||||
@@ -164,6 +172,16 @@ export function shouldShowTileStreamAudioControls(signals: VoiceParticipantTileS
|
||||
return signals.presentation === 'grid' || signals.presentation === 'focus-main';
|
||||
}
|
||||
|
||||
export function shouldShowTileControlPill(signals: VoiceParticipantTileControlPillSignals): boolean {
|
||||
if (signals.isFocusedPlaceholderTile) return false;
|
||||
return (
|
||||
signals.showStreamAudioControls ||
|
||||
signals.showSpectatorPill ||
|
||||
signals.showGroupHiddenPill ||
|
||||
signals.showDeviceCollapseControl
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowCameraBuffering(signals: VoiceParticipantTileCameraBufferingSignals): boolean {
|
||||
if (signals.isScreenShare) return false;
|
||||
if (signals.isFocusedPlaceholderTile) return false;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user